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
|
|
@ -168,8 +168,8 @@ void main() {
|
|||
(await RateLimitedRequestAttempt.db.insertRow(
|
||||
session,
|
||||
RateLimitedRequestAttempt(
|
||||
domain: 'anonymous',
|
||||
source: 'account_creation',
|
||||
domain: 'password_sign_in',
|
||||
source: 'proofs_per_address',
|
||||
key: '203.0.113.7',
|
||||
ipAddress: '203.0.113.7',
|
||||
attemptedAt: now.subtract(ago),
|
||||
|
|
@ -198,7 +198,10 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
withServerpod('Pruning abandoned anonymous people', (sessionBuilder, _) {
|
||||
withServerpod('Pruning anonymous people (ADR 0020, D25)', (
|
||||
sessionBuilder,
|
||||
_,
|
||||
) {
|
||||
late Session session;
|
||||
final now = DateTime.now().toUtc();
|
||||
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
|
||||
|
|
@ -220,45 +223,70 @@ void main() {
|
|||
session = sessionBuilder.build();
|
||||
});
|
||||
|
||||
test('the module signs nobody in anonymously', () {
|
||||
expect(
|
||||
session.serverpod.endpoints.getConnectorByName('anonymousIdp'),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'deletes anonymous people unused for 90 days, and only them, with '
|
||||
"the host's data of theirs",
|
||||
'deletes every anonymous account left, however new or lately used, '
|
||||
"with the host's data of theirs",
|
||||
() async {
|
||||
final unused = await newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(100),
|
||||
);
|
||||
await _lastUsed(session, unused, daysAgo(95));
|
||||
final neverSignedIn = await newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(100),
|
||||
);
|
||||
final old = await newAnonymousPerson(session, createdAt: daysAgo(100));
|
||||
final recentlyUsed = await newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(100),
|
||||
);
|
||||
await _lastUsed(session, recentlyUsed, daysAgo(10));
|
||||
final newcomer = await newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(5),
|
||||
);
|
||||
final identifiedPerson = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
await _lastUsed(session, identifiedPerson, daysAgo(365));
|
||||
await _lastUsed(session, recentlyUsed, daysAgo(1));
|
||||
final newcomer = await newAnonymousPerson(session);
|
||||
await signInNewDevice(session, newcomer);
|
||||
|
||||
final pruned = await pruneAbandonedAnonymousPeople(session, now: now);
|
||||
// Others may have been left by earlier runs.
|
||||
expect(await pruneAnonymousPeople(session), greaterThanOrEqualTo(3));
|
||||
|
||||
expect(pruned, 2);
|
||||
expect(await AuthUser.db.findById(session, unused), isNull);
|
||||
expect(await AuthUser.db.findById(session, neverSignedIn), isNull);
|
||||
for (final kept in [recentlyUsed, newcomer, identifiedPerson]) {
|
||||
expect(await AuthUser.db.findById(session, kept), isNotNull);
|
||||
for (final gone in [old, recentlyUsed, newcomer]) {
|
||||
expect(await AuthUser.db.findById(session, gone), isNull);
|
||||
expect(await _sessionsOf(session, gone), 0);
|
||||
}
|
||||
expect(deletedByHost, unorderedEquals([unused, neverSignedIn]));
|
||||
expect(deletedByHost, containsAll([old, recentlyUsed, newcomer]));
|
||||
},
|
||||
);
|
||||
|
||||
test('deletes nobody but anonymous accounts: never someone identified, '
|
||||
"even with an anonymous account's row left, nor anyone without "
|
||||
'one', () async {
|
||||
final identifiedPerson = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
// INSERT INTO "serverpod_auth_idp_anonymous_account" (…) VALUES (…)
|
||||
await AnonymousAccount.db.insertRow(
|
||||
session,
|
||||
AnonymousAccount(authUserId: identifiedPerson, createdAt: daysAgo(400)),
|
||||
);
|
||||
await _lastUsed(session, identifiedPerson, daysAgo(365));
|
||||
final noAnonymousAccount = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
)).id;
|
||||
|
||||
await pruneAnonymousPeople(session);
|
||||
|
||||
for (final kept in [identifiedPerson, noAnonymousAccount]) {
|
||||
expect(await AuthUser.db.findById(session, kept), isNotNull);
|
||||
expect(deletedByHost, isNot(contains(kept)));
|
||||
}
|
||||
expect(await _sessionsOf(session, identifiedPerson), 1);
|
||||
});
|
||||
|
||||
test('deletes at most as many as it is given at once', () async {
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await newAnonymousPerson(session);
|
||||
}
|
||||
|
||||
expect(await pruneAnonymousPeople(session, limit: 2), 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Anonymous sign-in',
|
||||
// Signing in runs concurrent transactions (the rate limit and the new
|
||||
// account), which the test's database rollback can't wrap. So these tests
|
||||
// don't roll back; they delete what they create instead.
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
setUp(() async {
|
||||
useBase(sessionBuilder);
|
||||
await _deleteAll(sessionBuilder.build());
|
||||
});
|
||||
tearDown(() => _deleteAll(sessionBuilder.build()));
|
||||
|
||||
test('creates a person', () async {
|
||||
final authSuccess = await endpoints.anonymousIdp.login(sessionBuilder);
|
||||
|
||||
final authUser = await AuthServices.instance.authUsers.get(
|
||||
sessionBuilder.build(),
|
||||
authUserId: authSuccess.authUserId,
|
||||
);
|
||||
expect(authUser.id, authSuccess.authUserId);
|
||||
});
|
||||
|
||||
test("counts a new account once, by the visitor's network, not as "
|
||||
"Serverpod's own limit counts the connection's", () async {
|
||||
await endpoints.anonymousIdp.login(sessionBuilder);
|
||||
|
||||
final notes = await RateLimitedRequestAttempt.db.find(
|
||||
sessionBuilder.build(),
|
||||
);
|
||||
expect(
|
||||
notes.map((note) => (note.domain, note.source, note.key)),
|
||||
// `networkOf`'s key for a test's session, which has no request.
|
||||
[('anonymous', 'accounts_per_network', 'null')],
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'refuses the 31st account from one IP address within an hour',
|
||||
() async {
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await endpoints.anonymousIdp.login(sessionBuilder);
|
||||
}
|
||||
|
||||
await expectLater(
|
||||
endpoints.anonymousIdp.login(sessionBuilder),
|
||||
throwsA(
|
||||
isA<AnonymousAccountBlockedException>().having(
|
||||
(e) => e.reason,
|
||||
'reason',
|
||||
AnonymousAccountBlockedExceptionReason.tooManyAttempts,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes every rate-limit record and every person; their anonymous accounts
|
||||
/// and sessions go with them (`ON DELETE CASCADE`).
|
||||
Future<void> _deleteAll(Session session) async {
|
||||
await RateLimitedRequestAttempt.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
||||
}
|
||||
|
|
@ -86,7 +86,8 @@ void main() {
|
|||
late Session session;
|
||||
late _Mailbox mailbox;
|
||||
|
||||
/// Anonymous, signed in on her phone.
|
||||
/// Signed in on her phone without an address: an anonymous account
|
||||
/// left from before (ADR 0020, D25), asking for codes as anyone may.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
|
|
@ -103,11 +104,7 @@ void main() {
|
|||
mailbox = _Mailbox();
|
||||
EmailCodes.of(session.serverpod).mailer = mailbox;
|
||||
|
||||
anna = (await AuthServices.instance.authUsers.create(session)).id;
|
||||
await AnonymousAccount.db.insertRow(
|
||||
session,
|
||||
AnonymousAccount(authUserId: anna),
|
||||
);
|
||||
anna = await newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
ben = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -12,18 +12,12 @@ void main() {
|
|||
withServerpod('GitHub sign-in', (sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
|
||||
/// Anonymous, signed in on her phone.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
/// Identified, with the GitHub account 1001.
|
||||
late UuidValue ben;
|
||||
|
||||
setUp(() async {
|
||||
useBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
anna = await _newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
ben = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
|
|
@ -39,35 +33,8 @@ void main() {
|
|||
);
|
||||
|
||||
group('refuses', () {
|
||||
test('upgrading when not signed in', () async {
|
||||
await expectLater(
|
||||
endpoints.gitHubUpgrade.upgrade(
|
||||
notSignedIn(),
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: _redirectUri,
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('both ways on an instance without a GitHub OAuth app', () async {
|
||||
test('signing in on an instance without a GitHub OAuth app', () async {
|
||||
// The test configuration has no githubClientId/githubClientSecret.
|
||||
await expectLater(
|
||||
endpoints.gitHubUpgrade.upgrade(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{},
|
||||
authId: annasPhone.uuid,
|
||||
),
|
||||
),
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: _redirectUri,
|
||||
),
|
||||
throwsA(_refusal(GitHubSignInRefusal.unavailable)),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.gitHubSignIn.signIn(
|
||||
notSignedIn(),
|
||||
|
|
@ -79,37 +46,6 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('linking a GitHub account that belongs to someone else; nothing '
|
||||
'changes', () async {
|
||||
await expectLater(
|
||||
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '1001'),
|
||||
throwsA(_refusal(GitHubSignInRefusal.takenByAnotherPerson)),
|
||||
);
|
||||
|
||||
expect(await _gitHubAccountsOf(session, anna), isEmpty);
|
||||
expect(await _isAnonymous(session, anna), isTrue);
|
||||
expect(
|
||||
await _scopesOf(session, anna),
|
||||
isNot(contains(identified.name)),
|
||||
);
|
||||
expect(
|
||||
[
|
||||
for (final account in await _gitHubAccountsOf(session, ben))
|
||||
account.userIdentifier,
|
||||
],
|
||||
['1001'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a second GitHub account for the same person', () async {
|
||||
await expectLater(
|
||||
linkGitHubAccount(session, authUserId: ben, gitHubUserId: '2002'),
|
||||
throwsA(_refusal(GitHubSignInRefusal.otherGitHubAccountLinked)),
|
||||
);
|
||||
|
||||
expect(await _gitHubAccount(session, '2002'), isNull);
|
||||
});
|
||||
|
||||
test('signing in with a GitHub account nobody has; nobody is '
|
||||
'created', () async {
|
||||
final people = await AuthUser.db.count(session);
|
||||
|
|
@ -124,33 +60,12 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
test('linking keeps the person and makes them identified, on every '
|
||||
'session', () async {
|
||||
await linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002');
|
||||
test('adds GitHub to nobody: the module has no way to (ADR 0020, '
|
||||
'D25)', () {
|
||||
final pod = session.serverpod;
|
||||
|
||||
final account = await _gitHubAccount(session, '2002');
|
||||
// The same person, so everything they have stays where it is.
|
||||
expect(account?.authUserId, anna);
|
||||
// Only the GitHub ID is kept.
|
||||
expect(account?.email, isNull);
|
||||
expect(await _scopesOf(session, anna), contains(identified.name));
|
||||
expect(
|
||||
(await ServerSideSession.db.findById(session, annasPhone))?.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
expect(await _isAnonymous(session, anna), isFalse);
|
||||
});
|
||||
|
||||
test('linking the account a person already has changes nothing', () async {
|
||||
await linkGitHubAccount(session, authUserId: ben, gitHubUserId: '1001');
|
||||
|
||||
expect(
|
||||
[
|
||||
for (final account in await _gitHubAccountsOf(session, ben))
|
||||
account.userIdentifier,
|
||||
],
|
||||
['1001'],
|
||||
);
|
||||
expect(pod.endpoints.getConnectorByName('gitHubUpgrade'), isNull);
|
||||
expect(pod.endpoints.getConnectorByName('gitHubSignIn'), isNotNull);
|
||||
});
|
||||
|
||||
test('signing in with a known GitHub account starts a session for that '
|
||||
|
|
@ -179,38 +94,15 @@ void main() {
|
|||
useGitHubSignInEnds(session.serverpod, null);
|
||||
});
|
||||
|
||||
test('links no new GitHub account where people sign in with a '
|
||||
'password, and still signs in those who have one', () async {
|
||||
test('still signs in those who have it where people sign in with a '
|
||||
'password', () async {
|
||||
usePasswordKeys(session.serverpod, testPasswordKeys);
|
||||
EmailCodes.of(session.serverpod).mailer = const LogCodeMailer();
|
||||
|
||||
await expectLater(
|
||||
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002'),
|
||||
throwsA(_refusal(GitHubSignInRefusal.deprecated)),
|
||||
);
|
||||
expect(await _isAnonymous(session, anna), isTrue);
|
||||
expect(await _gitHubAccount(session, '2002'), isNull);
|
||||
await linkGitHubAccount(session, authUserId: ben, gitHubUserId: '1001');
|
||||
expect(await gitHubAccountToSignIn(session, '1001'), ben);
|
||||
});
|
||||
|
||||
test(
|
||||
"tells an anonymous person whose GitHub account is another's so, "
|
||||
'as linking nothing, for the app to switch to that account',
|
||||
() async {
|
||||
usePasswordKeys(session.serverpod, testPasswordKeys);
|
||||
EmailCodes.of(session.serverpod).mailer = const LogCodeMailer();
|
||||
|
||||
await expectLater(
|
||||
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '1001'),
|
||||
throwsA(_refusal(GitHubSignInRefusal.takenByAnotherPerson)),
|
||||
);
|
||||
expect(await _isAnonymous(session, anna), isTrue);
|
||||
expect(await gitHubAccountToSignIn(session, '1001'), ben);
|
||||
},
|
||||
);
|
||||
|
||||
test('signs nobody in and links nothing from the day it ends, after '
|
||||
test('signs nobody in from the day it ends, after '
|
||||
'which pruning forgets every GitHub ID', () async {
|
||||
final ends = DateTime.utc(2027, 1, 22);
|
||||
useGitHubSignInEnds(session.serverpod, ends);
|
||||
|
|
@ -232,21 +124,6 @@ void main() {
|
|||
),
|
||||
throwsA(_refusal(GitHubSignInRefusal.ended)),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.gitHubUpgrade.upgrade(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{},
|
||||
authId: annasPhone.uuid,
|
||||
),
|
||||
),
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: _redirectUri,
|
||||
),
|
||||
throwsA(_refusal(GitHubSignInRefusal.ended)),
|
||||
);
|
||||
});
|
||||
// For 30 days after, nothing goes: a date set wrong can be put
|
||||
// right meanwhile.
|
||||
|
|
@ -431,34 +308,6 @@ Matcher _refusal(GitHubSignInRefusal reason) =>
|
|||
reason,
|
||||
);
|
||||
|
||||
/// A person as the app's first launch creates them: anonymous.
|
||||
Future<UuidValue> _newAnonymousPerson(Session session) async {
|
||||
final person = (await AuthServices.instance.authUsers.create(session)).id;
|
||||
await AnonymousAccount.db.insertRow(
|
||||
session,
|
||||
AnonymousAccount(authUserId: person),
|
||||
);
|
||||
return person;
|
||||
}
|
||||
|
||||
Future<bool> _isAnonymous(Session session, UuidValue person) async =>
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
) >
|
||||
0;
|
||||
|
||||
Future<Set<String>> _scopesOf(Session session, UuidValue person) async =>
|
||||
(await AuthServices.instance.authUsers.get(
|
||||
session,
|
||||
authUserId: person,
|
||||
)).scopeNames;
|
||||
|
||||
Future<List<GitHubAccount>> _gitHubAccountsOf(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
) => GitHubAccount.db.find(session, where: (t) => t.authUserId.equals(person));
|
||||
|
||||
Future<GitHubAccount?> _gitHubAccount(Session session, String gitHubUserId) =>
|
||||
GitHubAccount.db.findFirstRow(
|
||||
session,
|
||||
|
|
|
|||
Loading…
Reference in a new issue