feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the old password's SRP proof and a TOTP or recovery code, and the same keys wrapped under the new password; a session alone changes nothing, and an account without a confirmed authenticator is told to set one up first. `password.setRecoveryKeys` replaces the keys' wrapping under the recovery phrase for a TOTP code, so a device holding the keys can make a new phrase. A change of password ends every other session of the account in every service of the instance; resets of the password and of the account end them all. The register ends its own and keeps a notice per member (`sessions_ended_notice`, a migration that only creates it), told at once and hourly until it confirms, as deletions are told: it names a moment, so sessions made since stay, and the session that asked for a change. A member answering one ends its sessions made before that moment; a member signing someone in after a reset ends its own first and makes the new session only once its clock is past the moment. A reset not due says when it takes effect (`takesEffectAt` on `PasswordRefusedException`), across members too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f8076dcacf
commit
d00da1f24a
2 changed files with 43506 additions and 126 deletions
|
|
@ -0,0 +1,459 @@
|
|||
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';
|
||||
|
||||
final _todo = Uri.parse('http://todo.test:8082');
|
||||
|
||||
/// The members of the tests' instance, as the register tells them.
|
||||
final class _Members implements MemberNotifier {
|
||||
final sessionsTold = <(Uri, UuidValue, DateTime, UuidValue?)>[];
|
||||
bool answering = true;
|
||||
|
||||
@override
|
||||
Future<bool> accountGone(Uri member, UuidValue account) async => answering;
|
||||
|
||||
@override
|
||||
Future<bool> sessionsEnded(
|
||||
Uri member,
|
||||
UuidValue account, {
|
||||
required DateTime endedBefore,
|
||||
UuidValue? keepSession,
|
||||
}) async {
|
||||
sessionsTold.add((member, account, endedBefore, keepSession));
|
||||
return answering;
|
||||
}
|
||||
}
|
||||
|
||||
/// Changing what keeps an account while signed in (ADR 0017, step 3): the
|
||||
/// password, with the old one and a code, and the recovery phrase's
|
||||
/// wrapping, with a code; and the sessions every change or reset of the
|
||||
/// password ends, in every service of the instance.
|
||||
void main() {
|
||||
withServerpod(
|
||||
'Changing the password',
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late Serverpod pod;
|
||||
late Mailbox mailbox;
|
||||
late _Members members;
|
||||
late PasswordDevice device;
|
||||
late DateTime now;
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
late UuidValue annasLaptop;
|
||||
late AuthenticatorEnrolment enrolment;
|
||||
|
||||
Future<T> atNow<T>(Future<T> Function() body) =>
|
||||
withClock(Clock(() => now), body);
|
||||
void nextStep() => now = now.add(totpPeriod);
|
||||
|
||||
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
TestSessionBuilder onPhone() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{identified},
|
||||
authId: annasPhone.uuid,
|
||||
),
|
||||
);
|
||||
|
||||
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<PasswordSignedIn> signIn({
|
||||
BigInt? privateValue,
|
||||
String? totpCode,
|
||||
}) => atNow(() async {
|
||||
final challenge = await endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
final answer = device.answer(challenge, privateValue: privateValue);
|
||||
return await endpoints.passwordSignIn.complete(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: totpCode ?? codeOf(enrolment, at: now),
|
||||
);
|
||||
});
|
||||
|
||||
/// Changes Anna's password from her phone, the old one proved by
|
||||
/// [privateValue] (hers by default) with [totpCode] (her
|
||||
/// authenticator's now by default).
|
||||
Future<void> change({
|
||||
BigInt? privateValue,
|
||||
String? totpCode,
|
||||
bool withoutCode = false,
|
||||
NewPassword? password,
|
||||
TestSessionBuilder? as,
|
||||
}) => atNow(() async {
|
||||
final challenge = await endpoints.password.beginChange(onPhone());
|
||||
final answer = device.answer(challenge, privateValue: privateValue);
|
||||
await endpoints.password.change(
|
||||
as ?? onPhone(),
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: withoutCode ? null : totpCode ?? codeOf(enrolment, at: now),
|
||||
password: password ?? device.newPassword(),
|
||||
);
|
||||
});
|
||||
|
||||
Future<Set<UuidValue>> sessionsOf(UuidValue person) async => {
|
||||
for (final row in await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
))
|
||||
row.id!,
|
||||
};
|
||||
|
||||
setUp(() async {
|
||||
useBase(sessionBuilder, accounts: HoldsRegister(members: [_todo]));
|
||||
session = sessionBuilder.build();
|
||||
pod = session.serverpod;
|
||||
await deleteAll(session);
|
||||
await SessionsEndedNotice.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id > 0,
|
||||
);
|
||||
mailbox = Mailbox();
|
||||
EmailCodes.of(pod).mailer = mailbox;
|
||||
members = _Members();
|
||||
MemberNotifier.use(pod, members);
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
now = DateTime.utc(2026, 10, 20, 12);
|
||||
device = await PasswordDevice.make();
|
||||
anna = await newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
annasLaptop = await signInNewDevice(session, anna);
|
||||
final code = await mailedCode('anna@example.org');
|
||||
await endpoints.password.set(
|
||||
onPhone(),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
);
|
||||
enrolment = await endpoints.authenticator.enrol(
|
||||
onPhone(),
|
||||
name: 'Phone',
|
||||
label: 'anna@example.org',
|
||||
);
|
||||
await atNow(
|
||||
() => endpoints.authenticator.confirm(
|
||||
onPhone(),
|
||||
authenticator: enrolment.authenticator,
|
||||
code: codeOf(enrolment, at: now),
|
||||
),
|
||||
);
|
||||
nextStep();
|
||||
});
|
||||
tearDown(() async {
|
||||
EmailCodes.of(pod).mailer = null;
|
||||
MemberNotifier.use(pod, null);
|
||||
usePasswordKeys(pod, null);
|
||||
final cleanup = sessionBuilder.build();
|
||||
await SessionsEndedNotice.db.deleteWhere(
|
||||
cleanup,
|
||||
where: (t) => t.id > 0,
|
||||
);
|
||||
await deleteAll(cleanup);
|
||||
});
|
||||
|
||||
group('the password', () {
|
||||
test('changes with the old one and a code, the keys staying, every '
|
||||
'other session ending here and at the members', () async {
|
||||
final before = (await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!;
|
||||
final old = device.privateValue;
|
||||
|
||||
await change();
|
||||
|
||||
nextStep();
|
||||
final signedIn = await signIn();
|
||||
expect(signedIn.authUserId, anna);
|
||||
nextStep();
|
||||
await expectLater(
|
||||
signIn(privateValue: old),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
final after = (await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!;
|
||||
expect(
|
||||
Uint8List.sublistView(after.encryptionPublicKey),
|
||||
Uint8List.sublistView(before.encryptionPublicKey),
|
||||
);
|
||||
expect(
|
||||
Uint8List.sublistView(after.recoveryKeysCipherText),
|
||||
Uint8List.sublistView(before.recoveryKeysCipherText),
|
||||
);
|
||||
// The phone that changed it stays; the laptop is signed out; the
|
||||
// sign-in since is a session of its own.
|
||||
final sessions = await sessionsOf(anna);
|
||||
expect(sessions, contains(annasPhone));
|
||||
expect(sessions, isNot(contains(annasLaptop)));
|
||||
final (member, account, endedBefore, kept) =
|
||||
members.sessionsTold.first;
|
||||
expect(member, _todo);
|
||||
expect(account, anna);
|
||||
expect(endedBefore, DateTime.utc(2026, 10, 20, 12, 0, 30));
|
||||
expect(kept, annasPhone);
|
||||
expect(await SessionsEndedNotice.db.count(session), 0);
|
||||
});
|
||||
|
||||
test('is refused alike for a wrong old password, a missing or wrong '
|
||||
"code, and another person's proof, nothing changing", () async {
|
||||
final kept = Uint8List.sublistView(
|
||||
(await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!.verifier,
|
||||
);
|
||||
final mine = device.privateValue;
|
||||
|
||||
await expectLater(
|
||||
change(privateValue: BigInt.from(4242)),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
device.privateValue = mine;
|
||||
await expectLater(
|
||||
change(withoutCode: true),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
device.privateValue = mine;
|
||||
await expectLater(
|
||||
change(totpCode: '000000'),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
device.privateValue = mine;
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
await expectLater(
|
||||
change(
|
||||
as: sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
ben.uuid,
|
||||
{identified},
|
||||
authId: bensLaptop.uuid,
|
||||
),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
device.privateValue = mine;
|
||||
await expectLater(
|
||||
change(
|
||||
password: device.newPassword().copyWith(salt: ByteData(3)),
|
||||
),
|
||||
refused(PasswordRefusal.invalidSetup),
|
||||
);
|
||||
|
||||
expect(
|
||||
Uint8List.sublistView(
|
||||
(await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!.verifier,
|
||||
),
|
||||
kept,
|
||||
);
|
||||
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
|
||||
expect(members.sessionsTold, isEmpty);
|
||||
});
|
||||
|
||||
test('is begun only by someone with a password', () async {
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
|
||||
await expectLater(
|
||||
endpoints.password.beginChange(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
ben.uuid,
|
||||
{},
|
||||
authId: bensLaptop.uuid,
|
||||
),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.noPassword),
|
||||
);
|
||||
});
|
||||
|
||||
test('tells a member that missed it again, hourly, until it '
|
||||
'confirms', () async {
|
||||
members.answering = false;
|
||||
await change();
|
||||
expect(await SessionsEndedNotice.db.count(session), 1);
|
||||
|
||||
members.answering = true;
|
||||
expect(await deliverSessionNotices(session), 1);
|
||||
expect(await SessionsEndedNotice.db.count(session), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('the recovery phrase', () {
|
||||
test("takes new wrapped keys for a code: the old phrase's wrapping "
|
||||
'goes', () async {
|
||||
final fresh = WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(wrappedKeysLength),
|
||||
);
|
||||
|
||||
await atNow(
|
||||
() => endpoints.password.setRecoveryKeys(
|
||||
onPhone(),
|
||||
code: codeOf(enrolment, at: now),
|
||||
recoveryKeys: fresh,
|
||||
),
|
||||
);
|
||||
|
||||
final proof = await endpoints.accountRecovery.beginKeyProof(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
expect(proof.recoveryKeys.toJson(), fresh.toJson());
|
||||
});
|
||||
|
||||
test('is refused for a wrong code, keys out of range, and without a '
|
||||
'password, keeping the wrapping', () async {
|
||||
final fresh = WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(wrappedKeysLength),
|
||||
);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.password.setRecoveryKeys(
|
||||
onPhone(),
|
||||
code: '000000',
|
||||
recoveryKeys: fresh,
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.wrongCode),
|
||||
);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.password.setRecoveryKeys(
|
||||
onPhone(),
|
||||
code: codeOf(enrolment, at: now),
|
||||
recoveryKeys: WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.invalidSetup),
|
||||
);
|
||||
final ben = await newAnonymousPerson(session);
|
||||
await expectLater(
|
||||
endpoints.password.setRecoveryKeys(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
ben.uuid,
|
||||
{},
|
||||
),
|
||||
),
|
||||
code: '123456',
|
||||
recoveryKeys: fresh,
|
||||
),
|
||||
refused(PasswordRefusal.noPassword),
|
||||
);
|
||||
final proof = await endpoints.accountRecovery.beginKeyProof(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
expect(
|
||||
proof.recoveryKeys.toJson(),
|
||||
device.recoveryKeys.toJson(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('a reset', () {
|
||||
test('of the password with the phrase ends the sessions at the '
|
||||
'members too, the new session made after', () async {
|
||||
final proof = await endpoints.accountRecovery.beginKeyProof(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
);
|
||||
final signature = await device.sign(proof);
|
||||
final signedIn = await atNow(
|
||||
() => endpoints.accountRecovery.resetPassword(
|
||||
notSignedIn(),
|
||||
challenge: proof.challenge,
|
||||
signature: signature,
|
||||
password: device.newPassword(),
|
||||
totpCode: codeOf(enrolment, at: now),
|
||||
),
|
||||
);
|
||||
|
||||
expect(signedIn.authUserId, anna);
|
||||
final sessions = await sessionsOf(anna);
|
||||
expect(sessions, hasLength(1));
|
||||
expect(sessions, isNot(contains(annasPhone)));
|
||||
expect(sessions, isNot(contains(annasLaptop)));
|
||||
expect(members.sessionsTold.single.$2, anna);
|
||||
expect(members.sessionsTold.single.$4, isNull);
|
||||
});
|
||||
|
||||
test('not due says when it takes effect', () async {
|
||||
final start = await mailedCode('anna@example.org');
|
||||
final pending = await atNow(
|
||||
() => endpoints.accountRecovery.startAccountReset(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
request: start.request,
|
||||
code: start.code,
|
||||
),
|
||||
);
|
||||
final complete = await mailedCode('anna@example.org');
|
||||
final fresh = await PasswordDevice.make(seed: 99);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.accountRecovery.completeAccountReset(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
request: complete.request,
|
||||
code: complete.code,
|
||||
setup: fresh.setup(),
|
||||
),
|
||||
),
|
||||
throwsA(
|
||||
isA<PasswordRefusedException>()
|
||||
.having((e) => e.reason, 'reason', PasswordRefusal.notDue)
|
||||
.having(
|
||||
(e) => e.takesEffectAt,
|
||||
'takesEffectAt',
|
||||
pending.takesEffectAt,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -193,8 +193,8 @@ void main() {
|
|||
where: (t) => t.authUserId.equals(anna),
|
||||
))!;
|
||||
expect(
|
||||
after.salt.buffer.asUint8List(),
|
||||
before.salt.buffer.asUint8List(),
|
||||
Uint8List.sublistView(after.salt),
|
||||
Uint8List.sublistView(before.salt),
|
||||
);
|
||||
expect(await AuthUser.db.count(session), 1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue