feat(base)!: no anonymous accounts on the server (ADR 0020, D25)
The server's half of D25: every account has a way in, and the anonymous accounts left from before go with the daily pruning. - The module signs nobody in anonymously: `anonymousIdp` and `gitHubUpgrade` are gone, and so is linking GitHub at the register. - A password is set only by someone whose address is theirs (a `firstPassword` code) or who signs in with GitHub alone (a fresh sign-in and a `signUp` code); an anonymous account is refused `wrongCredentials` before any code is used. - SimpleLogin refuses to link onto a caller without `identified` (`wrongCaller`), the member's endpoint too, so nothing turns an anonymous account into one with a way in. - `pruneAnonymousPeople` deletes every anonymous account left, however new, never one identified. Learn and Todo schedule it as `prune-accounts` and cancel the old `prune-abandoned-anonymous-people` entry once. - Learn: someone without `identified` keeps no files; `FILE_LIMIT_ANONYMOUS_MB` is gone. `minimumAppBuild` is 23, as builds up to 22 start with an anonymous account. - Learn's sync tests make their person and session by SQL in the development database, as Serverpod stores sessions. - Docs, store listings and ADRs 0010, 0014, 0016, 0017 and 0019 amended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5b9df73756
commit
87671208df
4 changed files with 837 additions and 1353 deletions
|
|
@ -184,10 +184,13 @@ void main() {
|
|||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
device = await PasswordDevice.make();
|
||||
anna = await newAnonymousPerson(session);
|
||||
anna = await newPersonWithAddress(session, 'anna@example.org');
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
annasLaptop = await signInNewDevice(session, anna);
|
||||
final code = await mailedCode('anna@example.org');
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await endpoints.password.set(
|
||||
onPhone(),
|
||||
setup: device.setup(),
|
||||
|
|
@ -296,7 +299,7 @@ void main() {
|
|||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
device.privateValue = mine;
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
await expectLater(
|
||||
change(
|
||||
|
|
@ -539,7 +542,10 @@ void main() {
|
|||
test('which nobody else reads or dismisses, nor anyone signed '
|
||||
'out', () async {
|
||||
final ended = await endPhone();
|
||||
final bert = await newAnonymousPerson(session);
|
||||
final bert = await newPersonWithAddress(
|
||||
session,
|
||||
'bert@example.org',
|
||||
);
|
||||
final bertsPhone = await signInNewDevice(session, bert);
|
||||
final asBert = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
|
|
@ -615,7 +621,7 @@ void main() {
|
|||
});
|
||||
|
||||
test('is begun only by someone with a password', () async {
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
|
||||
await expectLater(
|
||||
|
|
@ -844,7 +850,7 @@ void main() {
|
|||
newPhrase(purpose: PasswordProofPurpose.newPassword),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
await expectLater(
|
||||
newPhrase(
|
||||
|
|
@ -969,7 +975,7 @@ void main() {
|
|||
'code by mail asked for deleting, nothing less, nor one asked for '
|
||||
'anything else',
|
||||
() async {
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
final bens = await PasswordDevice.make(seed: 7);
|
||||
TestSessionBuilder asBen() => sessionBuilder.copyWith(
|
||||
|
|
@ -979,7 +985,10 @@ void main() {
|
|||
authId: bensLaptop.uuid,
|
||||
),
|
||||
);
|
||||
final setUp = await mailedCode('ben@example.org');
|
||||
final setUp = await mailedCode(
|
||||
'ben@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await endpoints.password.set(
|
||||
asBen(),
|
||||
setup: bens.setup(),
|
||||
|
|
@ -1132,7 +1141,8 @@ void main() {
|
|||
});
|
||||
|
||||
test(
|
||||
'of an anonymous person takes its session alone, as before',
|
||||
'of an anonymous account left from before takes its session '
|
||||
'alone, as the app deletes it (ADR 0020, D25)',
|
||||
() async {
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ void main() {
|
|||
/// The tests' time, moved on by hand.
|
||||
late DateTime now;
|
||||
|
||||
/// Anonymous, signed in on her phone.
|
||||
/// Signs in with a code mailed to anna@example.org, as accounts did
|
||||
/// before passwords (ADR 0017), on her phone.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
|
|
@ -47,8 +48,12 @@ void main() {
|
|||
EmailCodes.of(pod).mailer = mailbox;
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
anna = await newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
anna = await newPersonWithAddress(session, 'anna@example.org');
|
||||
annasPhone = await signInNewDevice(
|
||||
session,
|
||||
anna,
|
||||
scopes: {identified},
|
||||
);
|
||||
device = await PasswordDevice.make();
|
||||
});
|
||||
tearDown(() async {
|
||||
|
|
@ -126,9 +131,13 @@ void main() {
|
|||
return (request: request, code: mailbox.codeFor(address));
|
||||
}
|
||||
|
||||
/// Anna's password, with anna@example.org made hers by a code.
|
||||
/// Anna's password, anna@example.org proved hers by a code mailed
|
||||
/// there (the fourth reading: a session alone sets none).
|
||||
Future<void> annaSetsPassword({PasswordDevice? with_}) async {
|
||||
final code = await mailedCode('anna@example.org');
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: (with_ ?? device).setup(),
|
||||
|
|
@ -209,24 +218,14 @@ void main() {
|
|||
(await AuthUser.db.findById(session, person))!.scopeNames;
|
||||
|
||||
group('setting a password', () {
|
||||
test('makes an anonymous person identified with the address a code '
|
||||
'proved, keeping their ID, sessions and data', () async {
|
||||
test('gives someone whose address is theirs a password, keeping '
|
||||
'their ID, sessions and data', () async {
|
||||
await annaSetsPassword();
|
||||
|
||||
expect(await scopesOf(anna), contains(identified.name));
|
||||
expect(
|
||||
(await ServerSideSession.db.findById(
|
||||
session,
|
||||
annasPhone,
|
||||
))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
expect(
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
0,
|
||||
await ServerSideSession.db.findById(session, annasPhone),
|
||||
isNotNull,
|
||||
);
|
||||
expect(
|
||||
(await EmailIdentity.db.findFirstRow(
|
||||
|
|
@ -317,13 +316,58 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('is refused to an anonymous account left from before '
|
||||
'(ADR 0020, D25): a code to any address sets nothing, and stays '
|
||||
'unused', () async {
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final bensPhone = await signInNewDevice(session, ben);
|
||||
final code = await mailedCode('ben@example.org');
|
||||
|
||||
await expectLater(
|
||||
endpoints.password.set(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
ben.uuid,
|
||||
{},
|
||||
authId: bensPhone.uuid,
|
||||
),
|
||||
),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
expect(
|
||||
await EmailIdentity.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
),
|
||||
0,
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
expect(await scopesOf(ben), isNot(contains(identified.name)));
|
||||
// The code still signs up whoever asked for it.
|
||||
final signedUp = await endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
setup: device.setup(),
|
||||
);
|
||||
expect(signedUp.authUserId, isNot(ben));
|
||||
});
|
||||
|
||||
group('for someone who signs in with GitHub alone', () {
|
||||
setUp(() async {
|
||||
// Anna has no address here: GitHub is her only way in.
|
||||
await EmailIdentity.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
);
|
||||
await GitHubAccount.db.insertRow(
|
||||
session,
|
||||
GitHubAccount(userIdentifier: '1001', authUserId: anna),
|
||||
);
|
||||
await grantIdentified(session, anna);
|
||||
});
|
||||
|
||||
TestSessionBuilder from(UuidValue device) => sessionBuilder.copyWith(
|
||||
|
|
@ -386,15 +430,58 @@ void main() {
|
|||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('is refused without an address, keeping nothing', () async {
|
||||
final fresh = await atNow(
|
||||
() => signInNewDevice(session, anna, method: gitHubMethod),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
atNow(
|
||||
() =>
|
||||
endpoints.password.set(from(fresh), setup: device.setup()),
|
||||
),
|
||||
refused(PasswordRefusal.noAddress),
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('is refused for an address another account has, keeping '
|
||||
'nothing', () async {
|
||||
await newPersonWithAddress(session, 'ben@example.org');
|
||||
final code = await mailedCode('ben@example.org');
|
||||
final fresh = await atNow(
|
||||
() => signInNewDevice(session, anna, method: gitHubMethod),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.password.set(
|
||||
from(fresh),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.addressTaken),
|
||||
);
|
||||
expect(
|
||||
await EmailIdentity.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
0,
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
test('is refused to someone without a way in (a GitHub ID '
|
||||
'forgotten): a session alone and a code to any address set '
|
||||
'nothing', () async {
|
||||
// Anna, identified, without an address, her GitHub ID forgotten
|
||||
// (the full reading's M1): a session of hers, stolen, outlived it.
|
||||
await EmailIdentity.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
);
|
||||
|
|
@ -425,45 +512,8 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('is refused without an address, keeping nothing', () async {
|
||||
await expectLater(
|
||||
endpoints.password.set(asAnna(), setup: device.setup()),
|
||||
refused(PasswordRefusal.noAddress),
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('is refused for an address another account has, the person '
|
||||
'staying anonymous', () async {
|
||||
final ben = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
await EmailIdentity.db.insertRow(
|
||||
session,
|
||||
EmailIdentity(
|
||||
authUserId: ben,
|
||||
addressHash: addressHash(pod, 'ben@example.org'),
|
||||
createdAt: clock.now(),
|
||||
),
|
||||
);
|
||||
final code = await mailedCode('ben@example.org');
|
||||
|
||||
await expectLater(
|
||||
endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
),
|
||||
refused(PasswordRefusal.addressTaken),
|
||||
);
|
||||
expect(await scopesOf(anna), isNot(contains(identified.name)));
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('is refused for a wrong code, keeping nothing', () async {
|
||||
final code = await mailedCode('anna@example.org');
|
||||
final code = await ownAddressCode();
|
||||
|
||||
await expectLater(
|
||||
endpoints.password.set(
|
||||
|
|
@ -475,7 +525,7 @@ void main() {
|
|||
refusedAsEmail(EmailSignInRefusal.wrongCode),
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
expect(await EmailIdentity.db.count(session), 0);
|
||||
expect(await EmailIdentity.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('is refused for a setup out of range, part by part', () async {
|
||||
|
|
@ -524,7 +574,7 @@ void main() {
|
|||
),
|
||||
),
|
||||
};
|
||||
final code = await mailedCode('anna@example.org');
|
||||
final code = await ownAddressCode();
|
||||
for (final MapEntry(key: what, value: setup) in wrong.entries) {
|
||||
await expectLater(
|
||||
endpoints.password.set(
|
||||
|
|
@ -538,7 +588,14 @@ void main() {
|
|||
);
|
||||
}
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
expect(await scopesOf(anna), isNot(contains(identified.name)));
|
||||
// Refused before the code was used: it still serves.
|
||||
await endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: setup,
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('replaced before an authenticator is confirmed, ends every '
|
||||
|
|
@ -1384,7 +1441,7 @@ void main() {
|
|||
proof: proof,
|
||||
),
|
||||
);
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
final asBen = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
|
|
@ -1394,7 +1451,10 @@ void main() {
|
|||
),
|
||||
);
|
||||
final bensDevice = await PasswordDevice.make(seed: 7);
|
||||
final bensCode = await mailedCode('ben@example.org');
|
||||
final bensCode = await mailedCode(
|
||||
'ben@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await endpoints.password.set(
|
||||
asBen,
|
||||
setup: bensDevice.setup(),
|
||||
|
|
@ -1753,7 +1813,7 @@ void main() {
|
|||
refused(PasswordRefusal.unknownAuthenticator),
|
||||
);
|
||||
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
final asBen = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
|
|
@ -1762,7 +1822,10 @@ void main() {
|
|||
authId: bensLaptop.uuid,
|
||||
),
|
||||
);
|
||||
final code = await mailedCode('ben@example.org');
|
||||
final code = await mailedCode(
|
||||
'ben@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await endpoints.password.set(
|
||||
asBen,
|
||||
setup: (await PasswordDevice.make(seed: 7)).setup(),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ void main() {
|
|||
/// The tests' time, moved on by hand.
|
||||
late DateTime now;
|
||||
|
||||
/// Anonymous, signed in on her phone.
|
||||
/// Signs in with a code mailed to anna@example.org, as accounts did
|
||||
/// before passwords (ADR 0017), on her phone.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
|
|
@ -49,8 +50,12 @@ void main() {
|
|||
EmailCodes.of(pod).mailer = mailbox;
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
anna = await newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
anna = await newPersonWithAddress(session, 'anna@example.org');
|
||||
annasPhone = await signInNewDevice(
|
||||
session,
|
||||
anna,
|
||||
scopes: {identified},
|
||||
);
|
||||
device = await PasswordDevice.make();
|
||||
});
|
||||
tearDown(() async {
|
||||
|
|
@ -103,7 +108,10 @@ void main() {
|
|||
|
||||
/// Anna: a password and a confirmed authenticator.
|
||||
Future<AuthenticatorEnrolment> annaComplete() => atNow(() async {
|
||||
final code = await mailedCode('anna@example.org');
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: device.setup(),
|
||||
|
|
@ -787,7 +795,7 @@ void main() {
|
|||
resetLapsesAfter + const Duration(minutes: 1),
|
||||
);
|
||||
// Bob's authenticators' reset, carried out long ago.
|
||||
final bob = await newAnonymousPerson(session);
|
||||
final bob = await newPersonWithAddress(session, 'bob@example.org');
|
||||
await AccountReset.db.insertRow(
|
||||
session,
|
||||
AccountReset(
|
||||
|
|
@ -799,7 +807,10 @@ void main() {
|
|||
);
|
||||
// Carl's account reset, asked 31 days ago and moved on by
|
||||
// hold-outs past now: lapsed all the same.
|
||||
final carl = await newAnonymousPerson(session);
|
||||
final carl = await newPersonWithAddress(
|
||||
session,
|
||||
'carl@example.org',
|
||||
);
|
||||
await AccountReset.db.insertRow(
|
||||
session,
|
||||
AccountReset(
|
||||
|
|
|
|||
|
|
@ -173,15 +173,7 @@ void main() {
|
|||
|
||||
test('gives an account with the address and no password (codes by '
|
||||
'mail before) the password, keeping its ID', () async {
|
||||
final ben = await newAnonymousPerson(session);
|
||||
await EmailIdentity.db.insertRow(
|
||||
session,
|
||||
EmailIdentity(
|
||||
authUserId: ben,
|
||||
addressHash: addressHash(pod, 'ben@example.org'),
|
||||
createdAt: now,
|
||||
),
|
||||
);
|
||||
final ben = await newPersonWithAddress(session, 'ben@example.org');
|
||||
|
||||
final signedUp = await signUp('ben@example.org');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue