feat(auth): a password with TOTP on the register, and resets
The register's side of signing in with an address, a password that never leaves the device (SRP-6a) and a code from an authenticator (ADR 0017), with members forwarding every step through AccountRegister: - password.set: salt, Argon2id's costs, SRP's verifier, the public keys and the private keys wrapped twice, all made on the device; with the address a mailed code proves, an anonymous person becomes identified under their ID. It signs in once an authenticator is confirmed. - passwordSignIn.begin and complete: SRP's two steps with a TOTP or recovery code. An address nobody has is answered from a decoy key as an account would be; every wrong credential is refused alike. - authenticator: secrets sealed under totpKey (XChaCha20-Poly1305, bound to their row), each code accepted once, ten recovery codes with the first, the last one kept. - accountRecovery: a lost authenticator reset 72 hours after the password and a mailed code or the recovery phrase ask, a lost password set anew with the phrase's signature, or the account reset with new keys 72 hours after a mailed code asks; a mail at each start, a sign-in calling it off. - An account with a password no longer signs in by a mailed code or GitHub; no new GitHub account is linked where passwords are offered, and from GITHUB_SIGN_IN_ENDS GitHub signs nobody in and its IDs go. Five tables, added by the module's migration; deletePerson deletes their rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
200a2df32f
commit
a3a8ed3890
1 changed files with 22557 additions and 60 deletions
|
|
@ -11,6 +11,7 @@ import 'package:serverpod_auth_idp_server/core.dart';
|
|||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
import '../support/password_device.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
const _secret = 'a shared secret only the instance knows';
|
||||
|
|
@ -35,6 +36,16 @@ final class _Register implements AccountRegister {
|
|||
final askedAbout = <Set<UuidValue>>[];
|
||||
bool offered = true;
|
||||
|
||||
/// Whether it offers password sign-in.
|
||||
bool passwordOffered = true;
|
||||
|
||||
/// The accounts with a password, and those with an authenticator too.
|
||||
final passwords = <UuidValue>{};
|
||||
final authenticated = <UuidValue>{};
|
||||
|
||||
/// What a password sign-in proves; null refuses it.
|
||||
PasswordProof? proof;
|
||||
|
||||
/// Thrown by every call while set: the register refusing, or down.
|
||||
Exception? failing;
|
||||
|
||||
|
|
@ -206,9 +217,168 @@ final class _Register implements AccountRegister {
|
|||
() => SignInWays(
|
||||
gitHub: gitHub.containsValue(person),
|
||||
email: addresses.containsValue(person),
|
||||
password: passwords.contains(person),
|
||||
authenticator: authenticated.contains(person),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<bool> passwordSignInOffered(Session session) =>
|
||||
_answer('passwordSignInOffered', () => passwordOffered);
|
||||
|
||||
@override
|
||||
Future<void> setPassword(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required PasswordSetup setup,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
}) => _answer('setPassword', () {
|
||||
add(person);
|
||||
passwords.add(person);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<PasswordChallenge> beginPasswordSignIn(
|
||||
Session session, {
|
||||
required String address,
|
||||
required String network,
|
||||
}) => _answer('beginPasswordSignIn', () {
|
||||
networks.add(network);
|
||||
return PasswordChallenge(
|
||||
challenge: const Uuid().v7obj(),
|
||||
salt: ByteData(16),
|
||||
costs: newPasswordCosts,
|
||||
serverValue: ByteData(384),
|
||||
);
|
||||
});
|
||||
|
||||
PasswordProof _proven() =>
|
||||
proof ??
|
||||
(throw PasswordRefusedException(
|
||||
reason: PasswordRefusal.wrongCredentials,
|
||||
));
|
||||
|
||||
@override
|
||||
Future<PasswordProof> completePasswordSignIn(
|
||||
Session session, {
|
||||
required UuidValue challenge,
|
||||
required ByteData clientValue,
|
||||
required ByteData clientProof,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
}) => _answer('completePasswordSignIn', _proven);
|
||||
|
||||
@override
|
||||
Future<AuthenticatorEnrolment> enrolAuthenticator(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required String name,
|
||||
required String label,
|
||||
}) => _answer(
|
||||
'enrolAuthenticator',
|
||||
() => AuthenticatorEnrolment(
|
||||
authenticator: const Uuid().v7obj(),
|
||||
uri: 'otpauth://totp/learn.test:$label',
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<String>> confirmAuthenticator(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required UuidValue authenticator,
|
||||
required String code,
|
||||
}) => _answer('confirmAuthenticator', () {
|
||||
authenticated.add(person);
|
||||
return const ['K7QX-M2PD'];
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<Authenticator>> authenticatorsOf(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
) => _answer('authenticatorsOf', () => const <Authenticator>[]);
|
||||
|
||||
@override
|
||||
Future<void> removeAuthenticator(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required UuidValue authenticator,
|
||||
String? code,
|
||||
}) => _answer('removeAuthenticator', () {});
|
||||
|
||||
@override
|
||||
Future<List<String>> newRecoveryCodes(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required String code,
|
||||
}) => _answer('newRecoveryCodes', () => const ['K7QX-M2PD']);
|
||||
|
||||
@override
|
||||
Future<ResetPending> resetAuthenticator(
|
||||
Session session, {
|
||||
required UuidValue challenge,
|
||||
required ByteData clientValue,
|
||||
required ByteData clientProof,
|
||||
required String address,
|
||||
required String language,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
UuidValue? keyChallenge,
|
||||
ByteData? signature,
|
||||
}) => _answer(
|
||||
'resetAuthenticator',
|
||||
() => ResetPending(takesEffectAt: DateTime.utc(2026, 10, 23)),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<KeyProofChallenge> beginKeyProof(
|
||||
Session session, {
|
||||
required String address,
|
||||
required String network,
|
||||
}) => _answer('beginKeyProof', () {
|
||||
networks.add(network);
|
||||
return KeyProofChallenge(
|
||||
challenge: const Uuid().v7obj(),
|
||||
nonce: ByteData(32),
|
||||
encryptionPublicKey: ByteData(32),
|
||||
signingPublicKey: ByteData(32),
|
||||
recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
|
||||
);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<UuidValue> resetPasswordWithPhrase(
|
||||
Session session, {
|
||||
required UuidValue challenge,
|
||||
required ByteData signature,
|
||||
required NewPassword password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
}) => _answer('resetPasswordWithPhrase', () => _proven().account);
|
||||
|
||||
@override
|
||||
Future<ResetPending> startAccountReset(
|
||||
Session session, {
|
||||
required String address,
|
||||
required String language,
|
||||
required UuidValue request,
|
||||
required String code,
|
||||
}) => _answer(
|
||||
'startAccountReset',
|
||||
() => ResetPending(takesEffectAt: DateTime.utc(2026, 10, 23)),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<UuidValue> completeAccountReset(
|
||||
Session session, {
|
||||
required String address,
|
||||
required UuidValue request,
|
||||
required String code,
|
||||
required PasswordSetup setup,
|
||||
}) => _answer('completeAccountReset', () => _proven().account);
|
||||
|
||||
@override
|
||||
Future<void> deleteAccount(Session session, UuidValue person) =>
|
||||
_answer('deleteAccount', () {
|
||||
|
|
@ -491,6 +661,135 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('passwords, by asking the register (ADR 0017)', () {
|
||||
test('sign the account the register proved in here, made a person '
|
||||
'here the first time, the device network passed on', () async {
|
||||
final carla = const Uuid().v7obj();
|
||||
register.proof = PasswordProof(
|
||||
account: carla,
|
||||
serverProof: ByteData(32),
|
||||
encryptionPublicKey: ByteData(32),
|
||||
signingPublicKey: ByteData(32),
|
||||
passwordKeys: WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(112),
|
||||
),
|
||||
needsAuthenticator: false,
|
||||
);
|
||||
|
||||
final challenge = await endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: 'carla@example.org',
|
||||
);
|
||||
final signedIn = await endpoints.passwordSignIn.complete(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: ByteData(384),
|
||||
clientProof: ByteData(32),
|
||||
totpCode: '123456',
|
||||
);
|
||||
|
||||
expect(register.asked, [
|
||||
'beginPasswordSignIn',
|
||||
'completePasswordSignIn',
|
||||
]);
|
||||
expect(register.networks, hasLength(1));
|
||||
expect(signedIn.authUserId, carla);
|
||||
expect(await scopesOf(carla), {identified.name});
|
||||
expect(
|
||||
(await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(carla),
|
||||
)).single.method,
|
||||
passwordMethod,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuse as the register refuses, signing nobody in', () async {
|
||||
final people = await AuthUser.db.count(session);
|
||||
|
||||
await expectLater(
|
||||
endpoints.passwordSignIn.complete(
|
||||
notSignedIn(),
|
||||
challenge: const Uuid().v7obj(),
|
||||
clientValue: ByteData(384),
|
||||
clientProof: ByteData(32),
|
||||
),
|
||||
throwsA(
|
||||
isA<PasswordRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
PasswordRefusal.wrongCredentials,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(await AuthUser.db.count(session), people);
|
||||
});
|
||||
|
||||
test('set one at the register, the person becoming identified '
|
||||
'here', () async {
|
||||
final device = await PasswordDevice.make();
|
||||
|
||||
await endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: device.setup(),
|
||||
request: const Uuid().v7obj(),
|
||||
code: 'K7QX-M2PD',
|
||||
);
|
||||
|
||||
expect(register.passwords, {anna});
|
||||
expect(await scopesOf(anna), contains(identified.name));
|
||||
expect(
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('are offered as the register offers them, and not while it is '
|
||||
'out of reach', () async {
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).passwordSignIn,
|
||||
isTrue,
|
||||
);
|
||||
register.passwordOffered = false;
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).passwordSignIn,
|
||||
isFalse,
|
||||
);
|
||||
register
|
||||
..passwordOffered = true
|
||||
..failing = RegisterUnavailableException('test');
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).passwordSignIn,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('keep GitHub from signing into an account that has one, as '
|
||||
'the register knows it', () async {
|
||||
register.gitHub['1001'] = ben;
|
||||
expect(await gitHubAccountToSignIn(session, '1001'), ben);
|
||||
|
||||
register
|
||||
..passwords.add(ben)
|
||||
..authenticated.add(ben);
|
||||
|
||||
await expectLater(
|
||||
gitHubAccountToSignIn(session, '1001'),
|
||||
throwsA(
|
||||
isA<GitHubSignInRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
GitHubSignInRefusal.usePassword,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('profiles', () {
|
||||
test('are saved and found at the register', () async {
|
||||
final saved = await endpoints.profile.save(
|
||||
|
|
|
|||
Loading…
Reference in a new issue