fix(auth): GitHub-only accounts go at GitHub's end; only the anonymous add an address
The full reading's M1 and L6: once GitHub IDs were forgotten at GITHUB_SIGN_IN_ENDS, an account that had signed in with GitHub alone counted as anonymous, so a stolen session and any mailbox gave it a first password and authenticator; and such accounts, with no way in left, were never pruned. Now: - at the end, the daily run that forgets the GitHub IDs first deletes the accounts that sign in with GitHub alone (no address, so never a password), with everything they have, members told (deletePerson); the app tells them for the three months before that their account goes then unless they set up a password (passwordSectionGitHubEndsDeletes, en/de); - password.set adds an address without GitHub only for someone anonymous where the session lives (anonymousHere, which a member tells the register, as it tells freshGitHubSignIn); anyone else is refused before any code is used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
9d0d037c86
commit
5eb74639b6
11 changed files with 202 additions and 13 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/src/auth/account.dart';
|
||||
import 'package:halkyon_base_server/src/auth/identified.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/password/password_config.dart';
|
||||
|
|
@ -72,6 +73,13 @@ final _endsOf = Expando<DateTime>('GitHub sign-in ends');
|
|||
/// at [now] ([gitHubSignInEnds]), and returns how many: nobody signs in with
|
||||
/// them any more, so nothing is kept of them. Daily, with the pruning; on the
|
||||
/// register, which keeps them (ADR 0016).
|
||||
///
|
||||
/// First the accounts that sign in with GitHub alone (no address, so never
|
||||
/// a password) go, with everything they have, every member told
|
||||
/// (`deletePerson`): no way in is left to them, and an account nobody can
|
||||
/// enter would stay for good, or be claimed by whoever holds a session of
|
||||
/// it (the full reading's M1 and L6). The app tells them for the three
|
||||
/// months before (ADR 0017, D2).
|
||||
Future<int> forgetGitHubAccounts(
|
||||
Session session, {
|
||||
required DateTime now,
|
||||
|
|
@ -80,12 +88,36 @@ Future<int> forgetGitHubAccounts(
|
|||
if (ends == null || now.isBefore(ends)) {
|
||||
return 0;
|
||||
}
|
||||
// SELECT * FROM "serverpod_auth_idp_github_account"
|
||||
final gitHubAccounts = await GitHubAccount.db.find(session);
|
||||
final gitHubPeople = {
|
||||
for (final account in gitHubAccounts) account.authUserId,
|
||||
};
|
||||
if (gitHubPeople.isNotEmpty) {
|
||||
// SELECT * FROM "email_identity" WHERE "authUserId" = ANY($1)
|
||||
final withAddress = {
|
||||
for (final identity in await EmailIdentity.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.inSet(gitHubPeople),
|
||||
))
|
||||
identity.authUserId,
|
||||
};
|
||||
final gitHubOnly = gitHubPeople.difference(withAddress);
|
||||
for (final person in gitHubOnly) {
|
||||
await deletePerson(session, person);
|
||||
}
|
||||
session.log(
|
||||
'Deleted ${gitHubOnly.length} accounts that signed in with GitHub '
|
||||
'alone, at its end.',
|
||||
);
|
||||
}
|
||||
// DELETE FROM "serverpod_auth_idp_github_account"
|
||||
final forgotten = await GitHubAccount.db.deleteWhere(
|
||||
await GitHubAccount.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
return forgotten.length;
|
||||
// Those deleted with their accounts counted too.
|
||||
return gitHubAccounts.length;
|
||||
}
|
||||
|
||||
/// The numeric GitHub ID that [code], from the app's GitHub sign-in, proves.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@ class PasswordEndpoint extends Endpoint {
|
|||
request: request,
|
||||
code: code,
|
||||
freshGitHubSignIn: await _signedInWithGitHubJustNow(session),
|
||||
// SELECT count(*) FROM "serverpod_auth_idp_anonymous_account"
|
||||
// WHERE "authUserId" = $1
|
||||
anonymousHere:
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
) >
|
||||
0,
|
||||
);
|
||||
if (isMember(session.serverpod)) {
|
||||
await becomeIdentified(session, person);
|
||||
|
|
|
|||
|
|
@ -349,7 +349,11 @@ const gitHubSignInFreshFor = Duration(minutes: 10);
|
|||
/// by a GitHub sign-in of the last [gitHubSignInFreshFor]
|
||||
/// ([freshGitHubSignIn], which the server holding the session tells), with
|
||||
/// the address it adds proved as an anonymous person proves theirs
|
||||
/// ([EmailCodePurpose.signUp]).
|
||||
/// ([EmailCodePurpose.signUp]). Only someone anonymous where the session
|
||||
/// lives ([anonymousHere], which that server tells) adds an address so
|
||||
/// without either: anyone else has no way in left to prove (a GitHub ID
|
||||
/// forgotten at its end, the full reading's M1), and a session of theirs
|
||||
/// alone would make the account a thief's.
|
||||
///
|
||||
/// Refused ([PasswordRefusedException]) where the instance has no password
|
||||
/// sign-in, for a setup out of range, without an address, for an address
|
||||
|
|
@ -357,7 +361,9 @@ const gitHubSignInFreshFor = Duration(minutes: 10);
|
|||
/// confirmed authenticator ([PasswordRefusal.passwordExists]), without the
|
||||
/// code its own address takes ([PasswordRefusal.wrongCredentials]), and
|
||||
/// for GitHub without a recent sign-in ([PasswordRefusal.signInAgain],
|
||||
/// before any code is used); a code's refusals as `proveAddressCode`'s, and
|
||||
/// before any code is used), and for someone neither anonymous nor with a
|
||||
/// way in ([PasswordRefusal.wrongCredentials], before any code is used); a
|
||||
/// code's refusals as `proveAddressCode`'s, and
|
||||
/// another address than the account's as email sign-in refuses it
|
||||
/// ([EmailSignInRefusedException]). Nothing changes then.
|
||||
Future<void> setPassword(
|
||||
|
|
@ -367,6 +373,7 @@ Future<void> setPassword(
|
|||
UuidValue? request,
|
||||
String? code,
|
||||
bool freshGitHubSignIn = false,
|
||||
bool anonymousHere = false,
|
||||
}) async {
|
||||
requirePasswordKeys(session.serverpod);
|
||||
if (!isPasswordSetup(setup)) {
|
||||
|
|
@ -405,6 +412,9 @@ Future<void> setPassword(
|
|||
if (gitHub && !freshGitHubSignIn) {
|
||||
throw refusedFor(PasswordRefusal.signInAgain);
|
||||
}
|
||||
if (!gitHub && !anonymousHere) {
|
||||
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||
}
|
||||
proven = request != null && code != null
|
||||
? await proveAddressCode(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -152,8 +152,9 @@ abstract interface class AccountRegister {
|
|||
/// Gives [person] the password [setup] describes, with the address [code]
|
||||
/// proves for [request] (their own where they have one), and for one who
|
||||
/// signs in with GitHub alone a GitHub sign-in of theirs made recently
|
||||
/// ([freshGitHubSignIn], of the session asking). Refusals as
|
||||
/// `setPassword`'s.
|
||||
/// ([freshGitHubSignIn], of the session asking); someone with neither
|
||||
/// only while anonymous where the session lives ([anonymousHere]).
|
||||
/// Refusals as `setPassword`'s.
|
||||
Future<void> setPassword(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
|
|
@ -161,6 +162,7 @@ abstract interface class AccountRegister {
|
|||
UuidValue? request,
|
||||
String? code,
|
||||
bool freshGitHubSignIn = false,
|
||||
bool anonymousHere = false,
|
||||
});
|
||||
|
||||
/// A password sign-in's first step for [address], the sign-ins begun from
|
||||
|
|
|
|||
|
|
@ -233,8 +233,11 @@ Future<Map<String, Object?>?> _answer(
|
|||
setup: arguments.model('setup', PasswordSetup.fromJson),
|
||||
request: arguments.optionalId('request'),
|
||||
code: arguments.optionalString('code'),
|
||||
// The member tells it of its own session (the fourth reading).
|
||||
// The member tells it of its own session (the fourth reading) and
|
||||
// person (the full reading's M1): the register keeps no anonymous
|
||||
// person of a member's.
|
||||
freshGitHubSignIn: arguments.flag('freshGitHubSignIn'),
|
||||
anonymousHere: arguments.flag('anonymousHere'),
|
||||
);
|
||||
return const {};
|
||||
case RegisterOperation.beginPasswordSignIn:
|
||||
|
|
|
|||
|
|
@ -423,6 +423,7 @@ final class LocalRegister implements AccountRegister {
|
|||
UuidValue? request,
|
||||
String? code,
|
||||
bool freshGitHubSignIn = false,
|
||||
bool anonymousHere = false,
|
||||
}) => passwords.setPassword(
|
||||
session,
|
||||
person: person,
|
||||
|
|
@ -430,6 +431,7 @@ final class LocalRegister implements AccountRegister {
|
|||
request: request,
|
||||
code: code,
|
||||
freshGitHubSignIn: freshGitHubSignIn,
|
||||
anonymousHere: anonymousHere,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -306,12 +306,14 @@ final class RemoteRegister implements AccountRegister {
|
|||
UuidValue? request,
|
||||
String? code,
|
||||
bool freshGitHubSignIn = false,
|
||||
bool anonymousHere = false,
|
||||
}) => _ask(RegisterOperation.setPassword, {
|
||||
'person': person.uuid,
|
||||
'setup': setup.toJson(),
|
||||
'request': request?.uuid,
|
||||
'code': code,
|
||||
'freshGitHubSignIn': freshGitHubSignIn,
|
||||
'anonymousHere': anonymousHere,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -234,6 +234,35 @@ void main() {
|
|||
});
|
||||
expect(await forgetGitHubAccounts(session, now: ends), 1);
|
||||
expect(await GitHubAccount.db.count(session), 0);
|
||||
// Ben signed in with GitHub alone and never set a password up: his
|
||||
// account goes with it (the full reading's M1), no way in left.
|
||||
expect(await AuthUser.db.findById(session, ben), isNull);
|
||||
});
|
||||
|
||||
test('at its end keeps the account of someone with an address too, '
|
||||
'forgetting only the GitHub ID', () async {
|
||||
final carl = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
await GitHubAccount.db.insertRow(
|
||||
session,
|
||||
GitHubAccount(userIdentifier: '3003', authUserId: carl),
|
||||
);
|
||||
await EmailIdentity.db.insertRow(
|
||||
session,
|
||||
EmailIdentity(
|
||||
authUserId: carl,
|
||||
addressHash: addressHash(session.serverpod, 'carl@example.org'),
|
||||
createdAt: DateTime.utc(2026, 10),
|
||||
),
|
||||
);
|
||||
final ends = DateTime.utc(2027, 1, 22);
|
||||
useGitHubSignInEnds(session.serverpod, ends);
|
||||
expect(await forgetGitHubAccounts(session, now: ends), 2);
|
||||
expect(await AuthUser.db.findById(session, carl), isNotNull);
|
||||
expect(await AuthUser.db.findById(session, ben), isNull);
|
||||
expect(await GitHubAccount.db.count(session), 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -380,6 +380,43 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
test('is refused to someone neither anonymous nor with a way in (a '
|
||||
'GitHub ID forgotten): a session alone and a code to any address '
|
||||
'set nothing', () async {
|
||||
// Anna, identified, her GitHub ID forgotten (the full reading's
|
||||
// M1): a session of hers, stolen, outlived it.
|
||||
await grantIdentified(session, anna);
|
||||
await AnonymousAccount.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
);
|
||||
final thief = await PasswordDevice.make(seed: 99);
|
||||
final code = await mailedCode('thief@example.org');
|
||||
await expectLater(
|
||||
endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: thief.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
expect(
|
||||
await EmailIdentity.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await AccountPassword.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('is refused without an address, keeping nothing', () async {
|
||||
await expectLater(
|
||||
endpoints.password.set(asAnna(), setup: device.setup()),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ final class _Register implements AccountRegister {
|
|||
/// member's made just now.
|
||||
final freshGitHubSignIns = <UuidValue>{};
|
||||
|
||||
/// Those [setPassword] was told are anonymous on the member.
|
||||
final anonymousHere_ = <UuidValue>{};
|
||||
|
||||
/// What a password sign-in proves; null refuses it.
|
||||
PasswordProof? proof;
|
||||
|
||||
|
|
@ -240,12 +243,16 @@ final class _Register implements AccountRegister {
|
|||
UuidValue? request,
|
||||
String? code,
|
||||
bool freshGitHubSignIn = false,
|
||||
bool anonymousHere = false,
|
||||
}) => _answer('setPassword', () {
|
||||
add(person);
|
||||
passwords.add(person);
|
||||
if (freshGitHubSignIn) {
|
||||
freshGitHubSignIns.add(person);
|
||||
}
|
||||
if (anonymousHere) {
|
||||
anonymousHere_.add(person);
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -850,6 +857,9 @@ void main() {
|
|||
|
||||
expect(register.passwords, {anna});
|
||||
expect(register.freshGitHubSignIns, isEmpty);
|
||||
// Anna was anonymous here, which the register can't know (the
|
||||
// full reading's M1).
|
||||
expect(register.anonymousHere_, {anna});
|
||||
expect(await scopesOf(anna), contains(identified.name));
|
||||
expect(
|
||||
await AnonymousAccount.db.count(
|
||||
|
|
|
|||
|
|
@ -384,6 +384,7 @@ void main() {
|
|||
remote.setPassword(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
anonymousHere: true,
|
||||
setup: device.setup(),
|
||||
request: request,
|
||||
code: 'AAAA-AAAA',
|
||||
|
|
@ -391,9 +392,22 @@ void main() {
|
|||
refused(EmailSignInRefusal.wrongCode),
|
||||
);
|
||||
expect(await AuthUser.db.findById(session, annaOnTodo), isNull);
|
||||
// Without the member saying Anna is anonymous there, refused
|
||||
// before her code is used (the full reading's M1).
|
||||
await expectLater(
|
||||
remote.setPassword(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
setup: device.setup(),
|
||||
request: request,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await remote.setPassword(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
anonymousHere: true,
|
||||
setup: device.setup(),
|
||||
request: request,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
|
|
@ -486,6 +500,7 @@ void main() {
|
|||
await remote.setPassword(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
anonymousHere: true,
|
||||
setup: device.setup(),
|
||||
request: request,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
|
|
@ -755,6 +770,7 @@ void main() {
|
|||
await remote.setPassword(
|
||||
session,
|
||||
person: annaOnTodo,
|
||||
anonymousHere: true,
|
||||
setup: device.setup(),
|
||||
request: request,
|
||||
code: mailbox.codeFor('anna@example.org'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue