feat(auth): ways in for accounts without a working password
ADR 0017, step 3. Signing up takes an address a mailed code proves and a password's setup, without a session (`passwordSignIn.signUp`): an address nobody has becomes a new identified account, and an account with the address but no password (codes by mail before; no keys exist, so nothing waits) takes the password, keeping its ID. A setup never finished signs in with its password and a code mailed to the address it began with, until an authenticator is confirmed, which also calls off a reset of the account waiting; any other account takes no code by mail in place of its second factor and leaves the code unused. A reset of the whole account waiting no longer counts as having had an authenticator, so its owner isn't locked out until it completes. Members forward both through the register; the tests' shared helpers move into `support/password_support.dart`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
ce2c00c7ea
commit
f8076dcacf
2 changed files with 849 additions and 80 deletions
|
|
@ -10,24 +10,9 @@ import 'package:test/test.dart';
|
|||
import '../support/fixtures.dart';
|
||||
import '../support/mailbox.dart';
|
||||
import '../support/password_device.dart';
|
||||
import '../support/password_support.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
Matcher refused(PasswordRefusal reason) => throwsA(
|
||||
isA<PasswordRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
reason,
|
||||
),
|
||||
);
|
||||
|
||||
Matcher refusedAsEmail(EmailSignInRefusal reason) => throwsA(
|
||||
isA<EmailSignInRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
reason,
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
withServerpod(
|
||||
'A password and an authenticator',
|
||||
|
|
@ -1797,35 +1782,3 @@ void main() {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes everything the tests make.
|
||||
Future<void> deleteAll(Session session) async {
|
||||
await SignInChallenge.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await AccountReset.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await RecoveryCode.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await TotpAuthenticator.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await AccountPassword.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await RateLimitedRequestAttempt.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await EmailCodeRequest.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await SecretChallenge.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await EmailIdentity.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,336 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
import '../support/mailbox.dart';
|
||||
import '../support/password_device.dart';
|
||||
import '../support/password_support.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
/// The ways into an account that has no working password yet (ADR 0017,
|
||||
/// step 3): signing up with a code by mail, and a setup never finished
|
||||
/// signing in with the password and a code by mail.
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Ways in without a working password',
|
||||
// Rate limits, codes and taken challenges write in transactions of their
|
||||
// own: these tests delete what they make instead.
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late Serverpod pod;
|
||||
late Mailbox mailbox;
|
||||
late PasswordDevice device;
|
||||
late DateTime now;
|
||||
|
||||
setUp(() async {
|
||||
useBase(sessionBuilder);
|
||||
session = sessionBuilder.build();
|
||||
pod = session.serverpod;
|
||||
await deleteAll(session);
|
||||
mailbox = Mailbox();
|
||||
EmailCodes.of(pod).mailer = mailbox;
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
device = await PasswordDevice.make();
|
||||
});
|
||||
tearDown(() async {
|
||||
EmailCodes.of(pod).mailer = null;
|
||||
usePasswordKeys(pod, null);
|
||||
await deleteAll(sessionBuilder.build());
|
||||
});
|
||||
|
||||
Future<T> atNow<T>(Future<T> Function() body) =>
|
||||
withClock(Clock(() => now), body);
|
||||
|
||||
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
TestSessionBuilder as(UuidValue person) => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
{identified},
|
||||
),
|
||||
);
|
||||
|
||||
Future<({UuidValue request, String code})> mailedCode(
|
||||
String address,
|
||||
) async {
|
||||
final request = await endpoints.accountRecovery.sendCode(
|
||||
notSignedIn(),
|
||||
address: address,
|
||||
language: 'en',
|
||||
);
|
||||
return (request: request, code: mailbox.codeFor(address));
|
||||
}
|
||||
|
||||
Future<AuthSuccess> signUp(String address, {PasswordDevice? with_}) =>
|
||||
atNow(() async {
|
||||
final code = await mailedCode(address);
|
||||
return await endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
setup: (with_ ?? device).setup(),
|
||||
);
|
||||
});
|
||||
|
||||
/// Signs in as a device would, both steps, with a code by mail where
|
||||
/// [mailedTo] names its address.
|
||||
Future<PasswordSignedIn> signIn({
|
||||
String address = 'anna@example.org',
|
||||
String? totpCode,
|
||||
String? mailedTo,
|
||||
String? wrongMailedCode,
|
||||
BigInt? privateValue,
|
||||
}) => atNow(() async {
|
||||
final mailed = mailedTo == null ? null : await mailedCode(mailedTo);
|
||||
final challenge = await endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: address,
|
||||
);
|
||||
final answer = device.answer(challenge, privateValue: privateValue);
|
||||
return await endpoints.passwordSignIn.complete(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: totpCode,
|
||||
request: mailed?.request,
|
||||
code: wrongMailedCode ?? mailed?.code,
|
||||
);
|
||||
});
|
||||
|
||||
Future<int> countOf(UuidValue person) => AccountPassword.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
);
|
||||
|
||||
group('signing up with a code by mail', () {
|
||||
test('makes an address nobody has a new identified account with the '
|
||||
'password, signed in, its authenticator next', () async {
|
||||
final signedUp = await signUp('anna@example.org');
|
||||
final anna = signedUp.authUserId;
|
||||
|
||||
expect(signedUp.scopeNames, contains(identified.name));
|
||||
expect(
|
||||
(await AuthUser.db.findById(session, anna))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
expect(
|
||||
(await EmailIdentity.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!.addressHash,
|
||||
addressHash(pod, 'anna@example.org'),
|
||||
);
|
||||
expect(await countOf(anna), 1);
|
||||
final ways = await endpoints.account.signInWays(as(anna));
|
||||
expect(ways.email, isTrue);
|
||||
expect(ways.password, isTrue);
|
||||
expect(ways.authenticator, isFalse);
|
||||
expect(ways.passwordOnly, isFalse);
|
||||
|
||||
// The session it gave sets up the authenticator.
|
||||
final enrolment = await endpoints.authenticator.enrol(
|
||||
as(anna),
|
||||
name: 'Phone',
|
||||
label: 'anna@example.org',
|
||||
);
|
||||
final codes = await atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
as(anna),
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment, at: now),
|
||||
),
|
||||
);
|
||||
expect(codes, hasLength(10));
|
||||
});
|
||||
|
||||
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 signedUp = await signUp('ben@example.org');
|
||||
|
||||
expect(signedUp.authUserId, ben);
|
||||
expect(await countOf(ben), 1);
|
||||
expect(
|
||||
(await AuthUser.db.findById(session, ben))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
expect(await AuthUser.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('is refused for an address whose account has a password, '
|
||||
'finished or not, keeping it as it was', () async {
|
||||
final anna = (await signUp('anna@example.org')).authUserId;
|
||||
final before = (await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!;
|
||||
final other = await PasswordDevice.make(seed: 99);
|
||||
|
||||
await expectLater(
|
||||
signUp('anna@example.org', with_: other),
|
||||
refused(PasswordRefusal.passwordExists),
|
||||
);
|
||||
final after = (await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!;
|
||||
expect(
|
||||
after.salt.buffer.asUint8List(),
|
||||
before.salt.buffer.asUint8List(),
|
||||
);
|
||||
expect(await AuthUser.db.count(session), 1);
|
||||
});
|
||||
|
||||
test('is refused for a wrong code, a setup out of range, and where '
|
||||
'the instance has no keys, making nobody', () async {
|
||||
final code = await mailedCode('anna@example.org');
|
||||
await expectLater(
|
||||
endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: code.request,
|
||||
code: 'WRONGCOD',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refusedAsEmail(EmailSignInRefusal.wrongCode),
|
||||
);
|
||||
final short = device.setup().copyWith(
|
||||
salt: ByteData(8),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
setup: short,
|
||||
),
|
||||
refused(PasswordRefusal.invalidSetup),
|
||||
);
|
||||
usePasswordKeys(pod, null);
|
||||
await expectLater(
|
||||
endpoints.passwordSignIn.signUp(
|
||||
notSignedIn(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(PasswordRefusal.unavailable),
|
||||
);
|
||||
expect(await AuthUser.db.count(session), 0);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('a setup never finished', () {
|
||||
late UuidValue anna;
|
||||
|
||||
setUp(() async {
|
||||
anna = (await signUp('anna@example.org')).authUserId;
|
||||
});
|
||||
|
||||
test('signs in with the password and a code mailed to its address, '
|
||||
'the authenticator still to set up', () async {
|
||||
await expectLater(
|
||||
signIn(),
|
||||
refused(PasswordRefusal.setupIncomplete),
|
||||
);
|
||||
|
||||
final signedIn = await signIn(mailedTo: 'anna@example.org');
|
||||
|
||||
expect(signedIn.authUserId, anna);
|
||||
expect(signedIn.needsAuthenticator, isTrue);
|
||||
expect(signedIn.scopeNames, contains(identified.name));
|
||||
});
|
||||
|
||||
test('is refused alike for a wrong password, and for a code to '
|
||||
'another address; a wrong code as email refuses it', () async {
|
||||
await expectLater(
|
||||
signIn(
|
||||
mailedTo: 'anna@example.org',
|
||||
privateValue: BigInt.from(4242),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
signIn(mailedTo: 'ben@example.org'),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
signIn(mailedTo: 'anna@example.org', wrongMailedCode: 'WRONGCOD'),
|
||||
refusedAsEmail(EmailSignInRefusal.wrongCode),
|
||||
);
|
||||
});
|
||||
|
||||
test('with an account reset waiting, still signs in with the '
|
||||
'password and a code, which calls the reset off', () async {
|
||||
final code = await mailedCode('anna@example.org');
|
||||
await atNow(
|
||||
() => endpoints.accountRecovery.startAccountReset(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(await endpoints.account.signInWays(as(anna))).passwordOnly,
|
||||
isFalse,
|
||||
);
|
||||
|
||||
final signedIn = await signIn(mailedTo: 'anna@example.org');
|
||||
|
||||
expect(signedIn.needsAuthenticator, isTrue);
|
||||
expect(
|
||||
await AccountReset.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('takes no code by mail in place of the second factor once an '
|
||||
'authenticator was confirmed, and leaves it unused', () async {
|
||||
final enrolment = await endpoints.authenticator.enrol(
|
||||
as(anna),
|
||||
name: 'Phone',
|
||||
label: 'anna@example.org',
|
||||
);
|
||||
await atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
as(anna),
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment, at: now),
|
||||
),
|
||||
);
|
||||
now = now.add(totpPeriod);
|
||||
|
||||
await expectLater(
|
||||
signIn(mailedTo: 'anna@example.org'),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
// The code mailed is still valid for what it was asked for.
|
||||
expect(await EmailCodeRequest.db.count(session), 1);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue