An account that signs in with SimpleLogin alone, its password and its recovery phrase both lost, asks for new keys with a SimpleLogin sign-in (`keys.startReset`); another sign-in carries it out 72 hours later (`keys.completeReset`, `notDue` with when before), with the same keys rewrapped where the device still holds them (D33). No mail: every signed-in device shows it, and any right proof of the password's or the phrase's key, or a mend, calls it off; `keys.callOffResets` takes the password's proof alone (D32). The row is ADR 0017's account reset of kind `account`, so no migration; ADR 0017's reset by mail now takes only an account whose password signs in. An account with ADR 0017's password that added SimpleLogin had keys but no proof key, so no new device signed in with SimpleLogin could open them: `password.setProofKey` sets the one that same password makes, with the owner's proof binding it (D34), and ADR 0017's new password takes it away. Only an account that signs in with SimpleLogin alone changes through `keys`. `simpleLogin.complete` says whether the password signs in, whether the keys have the proof key, and when a reset takes effect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
1341 lines
47 KiB
Dart
1341 lines
47 KiB
Dart
import 'dart:typed_data';
|
||
|
||
import 'package:clock/clock.dart';
|
||
import 'package:cryptography/cryptography.dart';
|
||
import 'package:halkyon_base_server/base.dart';
|
||
import 'package:halkyon_base_server/src/password/passwords.dart'
|
||
show passwordRowOf;
|
||
import 'package:halkyon_oidc_server/oidc.dart';
|
||
import 'package:halkyon_oidc_server/testing.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/keys_device.dart';
|
||
import '../support/mailbox.dart';
|
||
import '../support/password_device.dart';
|
||
import '../support/password_support.dart';
|
||
import '../test_tools/serverpod_test_tools.dart';
|
||
|
||
// The keys of an account that signs in with SimpleLogin on a signed-in
|
||
// device (Halkyon Learn's ADR 0020, 7, D19 and D20): the password proven
|
||
// with its own proof key, a signature over the register's nonce, before the
|
||
// device gets the keys wrapped under it; the changes, each with that proof
|
||
// (the phrase's for a lost password) and a SimpleLogin sign-in of the last
|
||
// ten minutes, against a fake SimpleLogin (halkyon_oidc's testing.dart).
|
||
// Every way a proof is refused, and a session's wrong proofs ending it as
|
||
// ADR 0017's do.
|
||
|
||
void main() {
|
||
late TestKey key;
|
||
|
||
setUpAll(() => key = TestKey.generate('sl', seed: 13));
|
||
|
||
withServerpod(
|
||
'The keys of an account that signs in with SimpleLogin',
|
||
// Sessions ended for wrong proofs, in transactions of their own: these
|
||
// tests delete what they make instead.
|
||
rollbackDatabase: RollbackDatabase.disabled,
|
||
(sessionBuilder, endpoints) {
|
||
late Session session;
|
||
late KeysDevice device;
|
||
late FakeProvider simpleLogin;
|
||
|
||
/// With keys, signed in on her phone and her laptop; SimpleLogin's
|
||
/// `sl-anna`.
|
||
late UuidValue anna;
|
||
late UuidValue annasPhone;
|
||
late UuidValue annasLaptop;
|
||
|
||
/// Without keys, signed in on his laptop; SimpleLogin's `sl-ben`.
|
||
late UuidValue ben;
|
||
late UuidValue bensLaptop;
|
||
|
||
setUp(() async {
|
||
useBase(sessionBuilder, app: 'learn');
|
||
session = sessionBuilder.build();
|
||
await _deleteAll(session);
|
||
simpleLogin = FakeProvider.simpleLogin(
|
||
key: key,
|
||
clientId: 'halkyon-learn',
|
||
clientSecret: 'a secret of the app',
|
||
callbackUrl: Uri.parse('https://learn.test/simplelogin-callback'),
|
||
);
|
||
useSimpleLogin(sessionBuilder, simpleLogin);
|
||
device = await KeysDevice.make();
|
||
anna = (await AuthServices.instance.authUsers.create(
|
||
session,
|
||
scopes: {identified},
|
||
)).id;
|
||
await giveKeys(session, anna, device);
|
||
annasPhone = await signInNewDevice(
|
||
session,
|
||
anna,
|
||
method: 'simplelogin',
|
||
);
|
||
annasLaptop = await signInNewDevice(
|
||
session,
|
||
anna,
|
||
method: 'simplelogin',
|
||
);
|
||
ben = (await AuthServices.instance.authUsers.create(
|
||
session,
|
||
scopes: {identified},
|
||
)).id;
|
||
bensLaptop = await signInNewDevice(session, ben, method: 'simplelogin');
|
||
for (final (person, subject) in [(anna, 'sl-anna'), (ben, 'sl-ben')]) {
|
||
await OidcIdentity.db.insertRow(
|
||
session,
|
||
OidcIdentity(
|
||
authUserId: person,
|
||
provider: simpleLoginProvider,
|
||
subject: subject,
|
||
),
|
||
);
|
||
}
|
||
});
|
||
tearDown(() async {
|
||
useSimpleLoginApp(session.serverpod, null);
|
||
await _deleteAll(sessionBuilder.build());
|
||
});
|
||
|
||
TestSessionBuilder signedIn(UuidValue person, UuidValue on) =>
|
||
sessionBuilder.copyWith(
|
||
authentication: AuthenticationOverride.authenticationInfo(
|
||
person.uuid,
|
||
{identified},
|
||
authId: on.uuid,
|
||
),
|
||
);
|
||
TestSessionBuilder onPhone() => signedIn(anna, annasPhone);
|
||
|
||
OidcIdp idp() => AuthServices.instance.oidcIdp;
|
||
|
||
Matcher refusedAsOidc(OidcRefusal reason) => throwsA(
|
||
isA<OidcRefusedException>().having((e) => e.reason, 'reason', reason),
|
||
);
|
||
Matcher refusedAsProfile(ProfileRefusal reason) => throwsA(
|
||
isA<ProfileRefusedException>().having(
|
||
(e) => e.reason,
|
||
'reason',
|
||
reason,
|
||
),
|
||
);
|
||
|
||
/// A SimpleLogin sign-in on [as], as [subject], just now: its attempt,
|
||
/// which stands for it ten minutes (D20).
|
||
Future<UuidValue> signInAgain(
|
||
TestSessionBuilder as,
|
||
String subject,
|
||
) async {
|
||
final start = await endpoints.simpleLogin.begin(
|
||
as,
|
||
back: SimpleLoginReturn.loopback,
|
||
);
|
||
await endpoints.simpleLogin.complete(
|
||
as,
|
||
attempt: start.attempt,
|
||
code: simpleLogin.authorize(start.authorizationUrl, subject: subject),
|
||
);
|
||
return start.attempt;
|
||
}
|
||
|
||
Future<Set<UuidValue>> sessionsOf(UuidValue person) async => {
|
||
for (final row in await ServerSideSession.db.find(
|
||
session,
|
||
where: (t) => t.authUserId.equals(person),
|
||
))
|
||
row.id!,
|
||
};
|
||
|
||
group('unlocking them', () {
|
||
test('gives the salt and costs, then the keys once the password '
|
||
'proved right', () async {
|
||
final challenge = await endpoints.keys.begin(onPhone());
|
||
|
||
expect(Uint8List.sublistView(challenge.salt), device.salt);
|
||
expect(challenge.costs.toJson(), newPasswordCosts.toJson());
|
||
expect(challenge.nonce.lengthInBytes, 32);
|
||
|
||
final keys = await endpoints.keys.unlock(
|
||
onPhone(),
|
||
await device.prove(challenge),
|
||
);
|
||
|
||
expect(
|
||
Uint8List.sublistView(keys.signingPublicKey),
|
||
device.signingPublicKey,
|
||
);
|
||
expect(
|
||
Uint8List.sublistView(keys.encryptionPublicKey),
|
||
device.encryptionPublicKey,
|
||
);
|
||
expect(keys.passwordKeys.toJson(), device.passwordKeys.toJson());
|
||
});
|
||
|
||
test('refuses a signature by another key: the recovery phrase, or '
|
||
"another password's", () async {
|
||
for (final keys in [
|
||
device.recoveryProofKeys,
|
||
(await KeysDevice.make(seed: 5)).passwordProofKeys,
|
||
]) {
|
||
final challenge = await endpoints.keys.begin(onPhone());
|
||
await expectLater(
|
||
endpoints.keys.unlock(
|
||
onPhone(),
|
||
await device.prove(challenge, keys: keys),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
}
|
||
});
|
||
|
||
test('refuses a proof made for another purpose or change', () async {
|
||
for (final (purpose, digest) in [
|
||
(KeyProofPurpose.deleteAccount, const <int>[]),
|
||
(KeyProofPurpose.unlockKeys, const [1]),
|
||
]) {
|
||
final challenge = await endpoints.keys.begin(onPhone());
|
||
await expectLater(
|
||
endpoints.keys.unlock(
|
||
onPhone(),
|
||
await device.prove(challenge, purpose: purpose, digest: digest),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
}
|
||
});
|
||
|
||
test('refuses a proof made for another instance, which a hostile '
|
||
'one could relay here', () async {
|
||
final challenge = await endpoints.keys.begin(onPhone());
|
||
|
||
await expectLater(
|
||
endpoints.keys.unlock(
|
||
onPhone(),
|
||
await device.prove(
|
||
challenge,
|
||
instanceUrl: 'https://evil.example/',
|
||
claimedUrl: testInstanceUrl,
|
||
),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
await expectLater(
|
||
endpoints.keys.unlock(
|
||
onPhone(),
|
||
await device.prove(
|
||
await endpoints.keys.begin(onPhone()),
|
||
instanceUrl: 'https://evil.example/',
|
||
),
|
||
),
|
||
refused(PasswordRefusal.otherInstance),
|
||
);
|
||
});
|
||
|
||
test('takes each challenge once, and only from whom it was begun '
|
||
'for', () async {
|
||
final challenge = await endpoints.keys.begin(onPhone());
|
||
await endpoints.keys.unlock(
|
||
onPhone(),
|
||
await device.prove(challenge),
|
||
);
|
||
|
||
await expectLater(
|
||
endpoints.keys.unlock(onPhone(), await device.prove(challenge)),
|
||
refused(PasswordRefusal.challengeExpired),
|
||
);
|
||
final annas = await endpoints.keys.begin(onPhone());
|
||
await giveKeys(session, ben, await KeysDevice.make(seed: 3));
|
||
await expectLater(
|
||
endpoints.keys.unlock(
|
||
signedIn(ben, bensLaptop),
|
||
await device.prove(annas),
|
||
),
|
||
refused(PasswordRefusal.challengeExpired),
|
||
);
|
||
});
|
||
|
||
test('refuses an account without such keys: none, or a password '
|
||
'that signs in (ADR 0017)', () async {
|
||
await expectLater(
|
||
endpoints.keys.begin(signedIn(ben, bensLaptop)),
|
||
refused(PasswordRefusal.noPassword),
|
||
);
|
||
await AccountPassword.db.insertRow(
|
||
session,
|
||
passwordRowOf(
|
||
ben,
|
||
(await PasswordDevice.make()).setup(),
|
||
now: DateTime.now().toUtc(),
|
||
),
|
||
);
|
||
await expectLater(
|
||
endpoints.keys.begin(signedIn(ben, bensLaptop)),
|
||
refused(PasswordRefusal.noPassword),
|
||
);
|
||
});
|
||
|
||
test('takes no session at all', () async {
|
||
final notSignedIn = sessionBuilder.copyWith(
|
||
authentication: AuthenticationOverride.unauthenticated(),
|
||
);
|
||
await expectLater(
|
||
endpoints.keys.begin(notSignedIn),
|
||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||
);
|
||
});
|
||
|
||
test("a session's wrong proofs count for it alone: the tenth in a "
|
||
"row ends it, the owner's other session unlocking as before, and "
|
||
'the owner is warned', () async {
|
||
final other = await KeysDevice.make(seed: 5);
|
||
for (var i = 0; i < 9; i++) {
|
||
await expectLater(
|
||
endpoints.keys.unlock(
|
||
onPhone(),
|
||
await other.prove(await endpoints.keys.begin(onPhone())),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
}
|
||
await expectLater(
|
||
endpoints.keys.unlock(
|
||
onPhone(),
|
||
await other.prove(await endpoints.keys.begin(onPhone())),
|
||
),
|
||
refused(PasswordRefusal.sessionEnded),
|
||
);
|
||
|
||
expect(await sessionsOf(anna), {annasLaptop});
|
||
expect(
|
||
(await endpoints.account.signInWays(
|
||
signedIn(anna, annasLaptop),
|
||
)).sessionEndedForProofsAt,
|
||
isNotNull,
|
||
);
|
||
final laptop = signedIn(anna, annasLaptop);
|
||
await endpoints.keys.unlock(
|
||
laptop,
|
||
await device.prove(await endpoints.keys.begin(laptop)),
|
||
);
|
||
});
|
||
|
||
test('a right proof clears the wrong ones before it', () async {
|
||
final other = await KeysDevice.make(seed: 5);
|
||
for (var round = 0; round < 2; round++) {
|
||
for (var i = 0; i < 9; i++) {
|
||
await expectLater(
|
||
endpoints.keys.unlock(
|
||
onPhone(),
|
||
await other.prove(await endpoints.keys.begin(onPhone())),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
}
|
||
await endpoints.keys.unlock(
|
||
onPhone(),
|
||
await device.prove(await endpoints.keys.begin(onPhone())),
|
||
);
|
||
}
|
||
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
|
||
});
|
||
});
|
||
|
||
group('first keys, for an account SimpleLogin was added to (D20)', () {
|
||
late KeysDevice bens;
|
||
|
||
setUp(() async => bens = await KeysDevice.make(seed: 3));
|
||
|
||
TestSessionBuilder onBensLaptop() => signedIn(ben, bensLaptop);
|
||
|
||
Future<void> setWith(
|
||
UuidValue attempt, {
|
||
KeysSetup? setup,
|
||
String? handle = 'ben',
|
||
}) => endpoints.keys.set(
|
||
onBensLaptop(),
|
||
attempt: attempt,
|
||
setup: setup ?? bens.setup(),
|
||
handle: handle,
|
||
);
|
||
|
||
test('are set with a SimpleLogin sign-in of the last ten minutes, '
|
||
'taken, with a handle for an account without one, and then '
|
||
'unlock', () async {
|
||
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
|
||
|
||
await setWith(attempt, handle: 'Ben');
|
||
|
||
final row = (await AccountPassword.db.findFirstRow(
|
||
session,
|
||
where: (t) => t.authUserId.equals(ben),
|
||
))!;
|
||
expect(row.verifier, isNull);
|
||
expect(
|
||
Uint8List.sublistView(row.passwordProofKey!),
|
||
bens.passwordProofKey,
|
||
);
|
||
expect(
|
||
(await PublicProfile.db.findFirstRow(
|
||
session,
|
||
where: (t) => t.authUserId.equals(ben),
|
||
))!.handle,
|
||
'Ben',
|
||
);
|
||
expect(
|
||
(await endpoints.account.signInWays(onBensLaptop())).keys,
|
||
isTrue,
|
||
);
|
||
expect(
|
||
await idp().isKept(session, attempt: attempt, caller: ben),
|
||
isFalse,
|
||
);
|
||
await endpoints.keys.unlock(
|
||
onBensLaptop(),
|
||
await bens.prove(await endpoints.keys.begin(onBensLaptop())),
|
||
);
|
||
});
|
||
|
||
test('keep the handle an account has, refusing another', () async {
|
||
await AccountRegister.of(session.serverpod).saveProfile(
|
||
session,
|
||
ben,
|
||
handle: 'Ben',
|
||
displayName: '',
|
||
bio: '',
|
||
links: const [],
|
||
);
|
||
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
|
||
|
||
await expectLater(
|
||
setWith(attempt, handle: 'Benjamin'),
|
||
refusedAsProfile(ProfileRefusal.handleInvalid),
|
||
);
|
||
await setWith(attempt, handle: null);
|
||
expect(
|
||
(await PublicProfile.db.findFirstRow(
|
||
session,
|
||
where: (t) => t.authUserId.equals(ben),
|
||
))!.handle,
|
||
'Ben',
|
||
);
|
||
});
|
||
|
||
test(
|
||
'refuse a handle missing or refused as saving a profile refuses '
|
||
'it, and a setup out of range, the sign-in still standing',
|
||
() async {
|
||
await AccountRegister.of(session.serverpod).saveProfile(
|
||
session,
|
||
anna,
|
||
handle: 'Anna',
|
||
displayName: '',
|
||
bio: '',
|
||
links: const [],
|
||
);
|
||
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
|
||
|
||
for (final (handle, reason) in [
|
||
(null, ProfileRefusal.handleInvalid),
|
||
('ANNA', ProfileRefusal.handleTaken),
|
||
('admin', ProfileRefusal.handleReserved),
|
||
('ben!', ProfileRefusal.handleInvalid),
|
||
]) {
|
||
await expectLater(
|
||
setWith(attempt, handle: handle),
|
||
refusedAsProfile(reason),
|
||
reason: '$handle',
|
||
);
|
||
}
|
||
await expectLater(
|
||
setWith(
|
||
attempt,
|
||
setup: bens.setup().copyWith(salt: ByteData(15)),
|
||
),
|
||
refused(PasswordRefusal.invalidSetup),
|
||
);
|
||
await setWith(attempt);
|
||
},
|
||
);
|
||
|
||
test("count a new handle among the person's answers about handles "
|
||
'(30 an hour), the sign-in still standing when refused', () async {
|
||
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
|
||
for (var i = 0; i < handleChecksPerAsker; i++) {
|
||
await countHandleCheck(session, asker: ben.uuid, network: null);
|
||
}
|
||
|
||
await expectLater(
|
||
setWith(attempt),
|
||
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
|
||
);
|
||
expect(
|
||
await idp().isKept(session, attempt: attempt, caller: ben),
|
||
isTrue,
|
||
);
|
||
});
|
||
|
||
test('refuse an account with keys already, under either kind of '
|
||
'password', () async {
|
||
final annas = await signInAgain(onPhone(), 'sl-anna');
|
||
await expectLater(
|
||
endpoints.keys.set(onPhone(), attempt: annas, setup: bens.setup()),
|
||
refused(PasswordRefusal.passwordExists),
|
||
);
|
||
await AccountPassword.db.insertRow(
|
||
session,
|
||
passwordRowOf(
|
||
ben,
|
||
(await PasswordDevice.make()).setup(),
|
||
now: DateTime.now().toUtc(),
|
||
),
|
||
);
|
||
await expectLater(
|
||
setWith(await signInAgain(onBensLaptop(), 'sl-ben')),
|
||
refused(PasswordRefusal.passwordExists),
|
||
);
|
||
});
|
||
|
||
test("refuse without a SimpleLogin sign-in of the account's: none, "
|
||
"past its ten minutes, or another account's, which stands for "
|
||
'it still', () async {
|
||
final annas = await signInAgain(onPhone(), 'sl-anna');
|
||
final attempt = await signInAgain(onBensLaptop(), 'sl-ben');
|
||
|
||
await expectLater(
|
||
setWith(const Uuid().v4obj()),
|
||
refusedAsOidc(OidcRefusal.unknownAttempt),
|
||
);
|
||
await expectLater(
|
||
withClock(
|
||
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
||
() => setWith(attempt),
|
||
),
|
||
refusedAsOidc(OidcRefusal.unknownAttempt),
|
||
);
|
||
await expectLater(
|
||
setWith(annas),
|
||
refusedAsOidc(OidcRefusal.wrongCaller),
|
||
);
|
||
expect(
|
||
await idp().isKept(session, attempt: annas, caller: anna),
|
||
isTrue,
|
||
);
|
||
await setWith(attempt);
|
||
});
|
||
});
|
||
|
||
group('a new password (D20)', () {
|
||
late KeysDevice next;
|
||
|
||
setUp(() async => next = await KeysDevice.make(seed: 7));
|
||
|
||
/// The proof for a new password ([binding], else [next]'s) by
|
||
/// [keys] (the password's proof key unless given), with the
|
||
/// sign-in under [attempt].
|
||
Future<KeysOwnerProof> changeProof(
|
||
UuidValue attempt, {
|
||
KeysPassword? binding,
|
||
SimpleKeyPair? keys,
|
||
KeyProofPurpose purpose = KeyProofPurpose.newPassword,
|
||
KeysChallenge? challenge,
|
||
}) async => await device.ownerProof(
|
||
attempt,
|
||
challenge: challenge ?? await endpoints.keys.begin(onPhone()),
|
||
purpose: purpose,
|
||
digest: newKeysPasswordDigest(binding ?? next.password()),
|
||
keys: keys,
|
||
);
|
||
|
||
Future<void> changeWith(KeysOwnerProof proof, {KeysPassword? to}) =>
|
||
endpoints.keys.changePassword(
|
||
onPhone(),
|
||
proof: proof,
|
||
password: to ?? next.password(),
|
||
);
|
||
|
||
test("takes the old password's proof and a SimpleLogin sign-in: the "
|
||
'keys open with the new one alone, and every other session '
|
||
'ends', () async {
|
||
final attempt = await signInAgain(onPhone(), 'sl-anna');
|
||
|
||
await changeWith(await changeProof(attempt));
|
||
|
||
expect(await sessionsOf(anna), {annasPhone});
|
||
final challenge = await endpoints.keys.begin(onPhone());
|
||
expect(Uint8List.sublistView(challenge.salt), next.salt);
|
||
final keys = await endpoints.keys.unlock(
|
||
onPhone(),
|
||
await next.prove(challenge),
|
||
);
|
||
expect(keys.passwordKeys.toJson(), next.passwordKeys.toJson());
|
||
await expectLater(
|
||
endpoints.keys.unlock(
|
||
onPhone(),
|
||
await device.prove(await endpoints.keys.begin(onPhone())),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
});
|
||
|
||
test('refuses a proof by another key, for another password or '
|
||
'purpose, and a password out of range, the sign-in still '
|
||
'standing', () async {
|
||
final attempt = await signInAgain(onPhone(), 'sl-anna');
|
||
|
||
for (final proof in [
|
||
await changeProof(attempt, keys: device.recoveryProofKeys),
|
||
await changeProof(
|
||
attempt,
|
||
binding: (await KeysDevice.make(seed: 9)).password(),
|
||
),
|
||
await changeProof(attempt, purpose: KeyProofPurpose.newPhrase),
|
||
]) {
|
||
await expectLater(
|
||
changeWith(proof),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
}
|
||
await expectLater(
|
||
changeWith(
|
||
await changeProof(attempt),
|
||
to: next.password().copyWith(passwordProofKey: ByteData(31)),
|
||
),
|
||
refused(PasswordRefusal.invalidSetup),
|
||
);
|
||
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
|
||
await changeWith(await changeProof(attempt));
|
||
});
|
||
|
||
test('refuses without a SimpleLogin sign-in of hers, checking no '
|
||
"proof: none, another account's, past its ten minutes, or one a "
|
||
'change took', () async {
|
||
final bens = await signInAgain(signedIn(ben, bensLaptop), 'sl-ben');
|
||
final challenge = await endpoints.keys.begin(onPhone());
|
||
|
||
for (final attempt in [const Uuid().v4obj(), bens]) {
|
||
await expectLater(
|
||
changeWith(await changeProof(attempt, challenge: challenge)),
|
||
refusedAsOidc(OidcRefusal.unknownAttempt),
|
||
);
|
||
}
|
||
final annas = await signInAgain(onPhone(), 'sl-anna');
|
||
await expectLater(
|
||
withClock(
|
||
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
||
() async => await changeWith(
|
||
await changeProof(annas, challenge: challenge),
|
||
),
|
||
),
|
||
refusedAsOidc(OidcRefusal.unknownAttempt),
|
||
);
|
||
// The challenge still answers: no proof was taken.
|
||
await changeWith(await changeProof(annas, challenge: challenge));
|
||
// One change for each sign-in.
|
||
await expectLater(
|
||
changeWith(
|
||
await next.ownerProof(
|
||
annas,
|
||
challenge: await endpoints.keys.begin(onPhone()),
|
||
purpose: KeyProofPurpose.newPassword,
|
||
digest: newKeysPasswordDigest(device.password()),
|
||
),
|
||
to: device.password(),
|
||
),
|
||
refusedAsOidc(OidcRefusal.unknownAttempt),
|
||
);
|
||
});
|
||
|
||
test("counts the session's wrong proofs, as unlocking does: the "
|
||
'tenth in a row ends it', () async {
|
||
final attempt = await signInAgain(onPhone(), 'sl-anna');
|
||
|
||
for (var i = 0; i < 10; i++) {
|
||
await expectLater(
|
||
changeWith(
|
||
await changeProof(attempt, keys: device.recoveryProofKeys),
|
||
),
|
||
refused(
|
||
i < 9
|
||
? PasswordRefusal.wrongCredentials
|
||
: PasswordRefusal.sessionEnded,
|
||
),
|
||
);
|
||
}
|
||
expect(await sessionsOf(anna), {annasLaptop});
|
||
});
|
||
});
|
||
|
||
group('a lost password (D20)', () {
|
||
late KeysDevice next;
|
||
|
||
setUp(() async => next = await KeysDevice.make(seed: 7));
|
||
|
||
Future<void> resetWith(KeysOwnerProof proof) => endpoints.keys
|
||
.resetPassword(onPhone(), proof: proof, password: next.password());
|
||
|
||
test("is set anew with the recovery phrase's proof and a SimpleLogin "
|
||
'sign-in, every other session ending', () async {
|
||
final phrase = await endpoints.keys.beginPhrase(onPhone());
|
||
expect(phrase.recoveryKeys.toJson(), device.recoveryKeys.toJson());
|
||
expect(
|
||
Uint8List.sublistView(phrase.signingPublicKey),
|
||
device.signingPublicKey,
|
||
);
|
||
expect(phrase.nonce.lengthInBytes, 32);
|
||
final attempt = await signInAgain(onPhone(), 'sl-anna');
|
||
|
||
await resetWith(
|
||
await device.ownerProof(
|
||
attempt,
|
||
phrase: phrase,
|
||
purpose: KeyProofPurpose.passwordReset,
|
||
digest: newKeysPasswordDigest(next.password()),
|
||
),
|
||
);
|
||
|
||
expect(await sessionsOf(anna), {annasPhone});
|
||
await endpoints.keys.unlock(
|
||
onPhone(),
|
||
await next.prove(await endpoints.keys.begin(onPhone())),
|
||
);
|
||
});
|
||
|
||
test("refuses the password's proof key, a challenge of keys.begin, "
|
||
'and a proof for another purpose', () async {
|
||
final attempt = await signInAgain(onPhone(), 'sl-anna');
|
||
final digest = newKeysPasswordDigest(next.password());
|
||
|
||
await expectLater(
|
||
resetWith(
|
||
await device.ownerProof(
|
||
attempt,
|
||
phrase: await endpoints.keys.beginPhrase(onPhone()),
|
||
keys: device.passwordProofKeys,
|
||
purpose: KeyProofPurpose.passwordReset,
|
||
digest: digest,
|
||
),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
await expectLater(
|
||
resetWith(
|
||
await device.ownerProof(
|
||
attempt,
|
||
challenge: await endpoints.keys.begin(onPhone()),
|
||
keys: device.recoveryProofKeys,
|
||
purpose: KeyProofPurpose.passwordReset,
|
||
digest: digest,
|
||
),
|
||
),
|
||
refused(PasswordRefusal.challengeExpired),
|
||
);
|
||
await expectLater(
|
||
resetWith(
|
||
await device.ownerProof(
|
||
attempt,
|
||
phrase: await endpoints.keys.beginPhrase(onPhone()),
|
||
purpose: KeyProofPurpose.newPassword,
|
||
digest: digest,
|
||
),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
await resetWith(
|
||
await device.ownerProof(
|
||
attempt,
|
||
phrase: await endpoints.keys.beginPhrase(onPhone()),
|
||
purpose: KeyProofPurpose.passwordReset,
|
||
digest: digest,
|
||
),
|
||
);
|
||
});
|
||
|
||
test('is for an account with such keys alone', () async {
|
||
final bens = signedIn(ben, bensLaptop);
|
||
await expectLater(
|
||
endpoints.keys.beginPhrase(bens),
|
||
refused(PasswordRefusal.noPassword),
|
||
);
|
||
await AccountPassword.db.insertRow(
|
||
session,
|
||
passwordRowOf(
|
||
ben,
|
||
(await PasswordDevice.make()).setup(),
|
||
now: DateTime.now().toUtc(),
|
||
),
|
||
);
|
||
await expectLater(
|
||
endpoints.keys.beginPhrase(bens),
|
||
refused(PasswordRefusal.noPassword),
|
||
);
|
||
});
|
||
});
|
||
|
||
group('a new recovery phrase (D20)', () {
|
||
late KeysDevice next;
|
||
|
||
setUp(() async => next = await KeysDevice.make(seed: 7));
|
||
|
||
Future<void> setPhrase(
|
||
KeysOwnerProof proof, {
|
||
WrappedKeys? recoveryKeys,
|
||
}) => endpoints.keys.setRecoveryKeys(
|
||
onPhone(),
|
||
proof: proof,
|
||
recoveryKeys: recoveryKeys ?? next.recoveryKeys,
|
||
recoveryProofKey: ByteData.sublistView(next.recoveryProofKey),
|
||
);
|
||
|
||
Future<KeysOwnerProof> phraseProof(UuidValue attempt) async =>
|
||
await device.ownerProof(
|
||
attempt,
|
||
challenge: await endpoints.keys.begin(onPhone()),
|
||
purpose: KeyProofPurpose.newPhrase,
|
||
digest: newPhraseDigest(
|
||
next.recoveryKeys,
|
||
ByteData.sublistView(next.recoveryProofKey),
|
||
),
|
||
);
|
||
|
||
test("takes the password's proof and a SimpleLogin sign-in, the old "
|
||
'phrase proving nothing then, the sessions staying', () async {
|
||
await setPhrase(
|
||
await phraseProof(await signInAgain(onPhone(), 'sl-anna')),
|
||
);
|
||
|
||
final phrase = await endpoints.keys.beginPhrase(onPhone());
|
||
expect(phrase.recoveryKeys.toJson(), next.recoveryKeys.toJson());
|
||
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
|
||
final again = await signInAgain(onPhone(), 'sl-anna');
|
||
final digest = newKeysPasswordDigest(next.password());
|
||
await expectLater(
|
||
endpoints.keys.resetPassword(
|
||
onPhone(),
|
||
proof: await device.ownerProof(
|
||
again,
|
||
phrase: phrase,
|
||
purpose: KeyProofPurpose.passwordReset,
|
||
digest: digest,
|
||
),
|
||
password: next.password(),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
await endpoints.keys.resetPassword(
|
||
onPhone(),
|
||
proof: await next.ownerProof(
|
||
again,
|
||
phrase: await endpoints.keys.beginPhrase(onPhone()),
|
||
purpose: KeyProofPurpose.passwordReset,
|
||
digest: digest,
|
||
),
|
||
password: next.password(),
|
||
);
|
||
});
|
||
|
||
test('refuses a phrase out of range, or a proof for another '
|
||
'one', () async {
|
||
final attempt = await signInAgain(onPhone(), 'sl-anna');
|
||
|
||
await expectLater(
|
||
setPhrase(
|
||
await phraseProof(attempt),
|
||
recoveryKeys: next.recoveryKeys.copyWith(nonce: ByteData(12)),
|
||
),
|
||
refused(PasswordRefusal.invalidSetup),
|
||
);
|
||
await expectLater(
|
||
setPhrase(
|
||
await phraseProof(attempt),
|
||
recoveryKeys: device.recoveryKeys,
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
await setPhrase(await phraseProof(attempt));
|
||
});
|
||
});
|
||
|
||
group('deleting the account (D20)', () {
|
||
Future<KeysOwnerProof> deletionProof(
|
||
UuidValue attempt, {
|
||
KeyProofPurpose purpose = KeyProofPurpose.deleteAccount,
|
||
}) async => await device.ownerProof(
|
||
attempt,
|
||
challenge: await endpoints.keys.begin(onPhone()),
|
||
purpose: purpose,
|
||
);
|
||
|
||
test("takes the password's proof and a SimpleLogin sign-in", () async {
|
||
await endpoints.account.delete(
|
||
onPhone(),
|
||
keysProof: await deletionProof(
|
||
await signInAgain(onPhone(), 'sl-anna'),
|
||
),
|
||
);
|
||
|
||
expect(await AuthUser.db.findById(session, anna), isNull);
|
||
});
|
||
|
||
test('refuses a session alone, the proof of a password that signs '
|
||
'in, a proof for another purpose, and one without a sign-in, '
|
||
'keeping the account', () async {
|
||
await expectLater(
|
||
endpoints.account.delete(onPhone()),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
await expectLater(
|
||
endpoints.account.delete(
|
||
onPhone(),
|
||
proof: DeletionProof(
|
||
challenge: const Uuid().v4obj(),
|
||
clientValue: ByteData(384),
|
||
clientProof: ByteData(32),
|
||
totpCode: '000000',
|
||
instanceUrl: testInstanceUrl,
|
||
),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
await expectLater(
|
||
endpoints.account.delete(
|
||
onPhone(),
|
||
keysProof: await deletionProof(const Uuid().v4obj()),
|
||
),
|
||
refusedAsOidc(OidcRefusal.unknownAttempt),
|
||
);
|
||
await expectLater(
|
||
endpoints.account.delete(
|
||
onPhone(),
|
||
keysProof: await deletionProof(
|
||
await signInAgain(onPhone(), 'sl-anna'),
|
||
purpose: KeyProofPurpose.newPassword,
|
||
),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
expect(await AuthUser.db.findById(session, anna), isNotNull);
|
||
});
|
||
});
|
||
|
||
group('a reset, the password and the phrase lost (D31–D33)', () {
|
||
late KeysDevice next;
|
||
|
||
/// The tests' time, moved on by hand.
|
||
late DateTime now;
|
||
|
||
/// Anna's tablet, signed in with SimpleLogin a moment ago, without
|
||
/// her keys.
|
||
late UuidValue annasTablet;
|
||
|
||
setUp(() async {
|
||
next = await KeysDevice.make(seed: 11);
|
||
now = DateTime.now().toUtc();
|
||
annasTablet = await signInNewDevice(
|
||
session,
|
||
anna,
|
||
method: 'simplelogin',
|
||
);
|
||
});
|
||
|
||
/// [body] at the tests' time.
|
||
Future<T> atNow<T>(Future<T> Function() body) =>
|
||
withClock(Clock(() => now), body);
|
||
|
||
TestSessionBuilder onTablet() => signedIn(anna, annasTablet);
|
||
TestSessionBuilder onLaptop() => signedIn(anna, annasLaptop);
|
||
|
||
/// Asks for the reset on [as] (her tablet unless given), with a
|
||
/// SimpleLogin sign-in of hers just now.
|
||
Future<ResetPending> start({TestSessionBuilder? as}) => atNow(() async {
|
||
final device = as ?? onTablet();
|
||
return await endpoints.keys.startReset(
|
||
device,
|
||
attempt: await signInAgain(device, 'sl-anna'),
|
||
);
|
||
});
|
||
|
||
/// Carries it out on [as] (her tablet unless given) with [setup]
|
||
/// (the next keys unless given) and a SimpleLogin sign-in of hers
|
||
/// just now.
|
||
Future<void> complete({TestSessionBuilder? as, KeysSetup? setup}) =>
|
||
atNow(() async {
|
||
final device = as ?? onTablet();
|
||
await endpoints.keys.completeReset(
|
||
device,
|
||
attempt: await signInAgain(device, 'sl-anna'),
|
||
setup: setup ?? next.setup(),
|
||
);
|
||
});
|
||
|
||
Future<List<AccountReset>> resetsOf(UuidValue person) =>
|
||
AccountReset.db.find(
|
||
session,
|
||
where: (t) => t.authUserId.equals(person),
|
||
);
|
||
|
||
Future<AccountPassword> keysOf(UuidValue person) async =>
|
||
(await AccountPassword.db.findFirstRow(
|
||
session,
|
||
where: (t) => t.authUserId.equals(person),
|
||
))!;
|
||
|
||
test('is asked with a SimpleLogin sign-in alone, taken, and takes '
|
||
'effect 72 hours later; asked again meanwhile, it keeps its '
|
||
'time, which her devices and the next sign-in are told', () async {
|
||
final attempt = await atNow(() => signInAgain(onTablet(), 'sl-anna'));
|
||
final pending = await atNow(
|
||
() => endpoints.keys.startReset(onTablet(), attempt: attempt),
|
||
);
|
||
|
||
expect(pending.takesEffectAt, now.add(resetDelay));
|
||
expect(
|
||
(await resetsOf(anna)).single.kind,
|
||
AccountResetKind.account,
|
||
);
|
||
expect(
|
||
await idp().isKept(session, attempt: attempt, caller: anna),
|
||
isFalse,
|
||
);
|
||
now = now.add(const Duration(hours: 1));
|
||
expect((await start()).takesEffectAt, pending.takesEffectAt);
|
||
expect(
|
||
(await atNow(
|
||
() => endpoints.account.signInWays(onPhone()),
|
||
)).accountResetAt,
|
||
pending.takesEffectAt,
|
||
);
|
||
final signedOut = sessionBuilder.copyWith(
|
||
authentication: AuthenticationOverride.unauthenticated(),
|
||
);
|
||
final completed = await atNow(() async {
|
||
final begun = await endpoints.simpleLogin.begin(
|
||
signedOut,
|
||
back: SimpleLoginReturn.loopback,
|
||
);
|
||
return await endpoints.simpleLogin.complete(
|
||
signedOut,
|
||
attempt: begun.attempt,
|
||
code: simpleLogin.authorize(
|
||
begun.authorizationUrl,
|
||
subject: 'sl-anna',
|
||
),
|
||
);
|
||
});
|
||
expect(completed.accountResetAt, pending.takesEffectAt);
|
||
// Her keys stay as they were until it is carried out.
|
||
expect(
|
||
Uint8List.sublistView((await keysOf(anna)).passwordProofKey!),
|
||
device.passwordProofKey,
|
||
);
|
||
});
|
||
|
||
test('is carried out once due, with a new SimpleLogin sign-in: the '
|
||
'new keys in place of the old, her link staying, the reset gone '
|
||
'and every other session ended', () async {
|
||
final before = await keysOf(anna);
|
||
await start();
|
||
now = now.add(resetDelay);
|
||
|
||
await complete();
|
||
|
||
final row = await keysOf(anna);
|
||
expect(row.id, before.id);
|
||
expect(row.verifier, isNull);
|
||
expect(
|
||
Uint8List.sublistView(row.passwordProofKey!),
|
||
next.passwordProofKey,
|
||
);
|
||
expect(
|
||
Uint8List.sublistView(row.recoveryProofKey!),
|
||
next.recoveryProofKey,
|
||
);
|
||
expect(
|
||
Uint8List.sublistView(row.signingPublicKey),
|
||
next.signingPublicKey,
|
||
);
|
||
expect(await resetsOf(anna), isEmpty);
|
||
expect(await sessionsOf(anna), {annasTablet});
|
||
expect(
|
||
(await OidcIdentity.db.find(
|
||
session,
|
||
where: (t) => t.authUserId.equals(anna),
|
||
)).single.subject,
|
||
'sl-anna',
|
||
);
|
||
await expectLater(
|
||
endpoints.keys.unlock(
|
||
onTablet(),
|
||
await device.prove(await endpoints.keys.begin(onTablet())),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
final keys = await endpoints.keys.unlock(
|
||
onTablet(),
|
||
await next.prove(await endpoints.keys.begin(onTablet())),
|
||
);
|
||
expect(
|
||
Uint8List.sublistView(keys.signingPublicKey),
|
||
next.signingPublicKey,
|
||
);
|
||
});
|
||
|
||
test('keeps the keys a device of hers still holds, wrapped under the '
|
||
'new password and phrase (D33)', () async {
|
||
await start(as: onPhone());
|
||
now = now.add(resetDelay);
|
||
final rewrapped = next.setup().copyWith(
|
||
encryptionPublicKey: ByteData.sublistView(
|
||
device.encryptionPublicKey,
|
||
),
|
||
signingPublicKey: ByteData.sublistView(device.signingPublicKey),
|
||
);
|
||
|
||
await complete(as: onPhone(), setup: rewrapped);
|
||
|
||
final row = await keysOf(anna);
|
||
expect(
|
||
Uint8List.sublistView(row.signingPublicKey),
|
||
device.signingPublicKey,
|
||
);
|
||
expect(
|
||
Uint8List.sublistView(row.passwordProofKey!),
|
||
next.passwordProofKey,
|
||
);
|
||
expect(await sessionsOf(anna), {annasPhone});
|
||
});
|
||
|
||
test('refuses before its time, saying when; keys out of range; and '
|
||
'once it lapsed, or without one, nothing changing', () async {
|
||
final pending = await start();
|
||
now = pending.takesEffectAt.subtract(const Duration(minutes: 1));
|
||
await expectLater(
|
||
complete(),
|
||
throwsA(
|
||
isA<PasswordRefusedException>()
|
||
.having((e) => e.reason, 'reason', PasswordRefusal.notDue)
|
||
.having(
|
||
(e) => e.takesEffectAt,
|
||
'takesEffectAt',
|
||
pending.takesEffectAt,
|
||
),
|
||
),
|
||
);
|
||
now = pending.takesEffectAt;
|
||
await expectLater(
|
||
complete(
|
||
setup: next.setup().copyWith(salt: ByteData(3)),
|
||
),
|
||
refused(PasswordRefusal.invalidSetup),
|
||
);
|
||
expect(await resetsOf(anna), hasLength(1));
|
||
now = now.add(resetLapsesAfter);
|
||
await expectLater(complete(), refused(PasswordRefusal.noReset));
|
||
await AccountReset.db.deleteWhere(
|
||
session,
|
||
where: (t) => t.authUserId.equals(anna),
|
||
);
|
||
await expectLater(complete(), refused(PasswordRefusal.noReset));
|
||
expect(
|
||
Uint8List.sublistView((await keysOf(anna)).passwordProofKey!),
|
||
device.passwordProofKey,
|
||
);
|
||
expect(
|
||
await sessionsOf(anna),
|
||
containsAll([annasPhone, annasLaptop]),
|
||
);
|
||
});
|
||
|
||
test("refuses without a SimpleLogin sign-in of hers: none, another's, "
|
||
'one run out or taken, asking for nothing then', () async {
|
||
final bens = await atNow(
|
||
() => signInAgain(signedIn(ben, bensLaptop), 'sl-ben'),
|
||
);
|
||
final ranOut = await atNow(() => signInAgain(onTablet(), 'sl-anna'));
|
||
now = now.add(const Duration(minutes: 11));
|
||
for (final attempt in [const Uuid().v4obj(), bens, ranOut]) {
|
||
await expectLater(
|
||
atNow(
|
||
() => endpoints.keys.startReset(onTablet(), attempt: attempt),
|
||
),
|
||
refusedAsOidc(OidcRefusal.unknownAttempt),
|
||
);
|
||
}
|
||
expect(await resetsOf(anna), isEmpty);
|
||
|
||
await start();
|
||
now = now.add(resetDelay);
|
||
final taken = await atNow(() => signInAgain(onTablet(), 'sl-anna'));
|
||
await atNow(
|
||
() => endpoints.keys.completeReset(
|
||
onTablet(),
|
||
attempt: taken,
|
||
setup: next.setup(),
|
||
),
|
||
);
|
||
await start();
|
||
now = now.add(resetDelay);
|
||
final other = (await KeysDevice.make(seed: 12)).setup();
|
||
await expectLater(
|
||
atNow(
|
||
() => endpoints.keys.completeReset(
|
||
onTablet(),
|
||
attempt: taken,
|
||
setup: other,
|
||
),
|
||
),
|
||
refusedAsOidc(OidcRefusal.unknownAttempt),
|
||
);
|
||
expect(await resetsOf(anna), hasLength(1));
|
||
});
|
||
|
||
test('is for an account with such keys alone: none, or a password '
|
||
'that signs in (ADR 0017, its reset by mail)', () async {
|
||
final bens = signedIn(ben, bensLaptop);
|
||
Future<void> startAndComplete() => atNow(() async {
|
||
await expectLater(
|
||
endpoints.keys.startReset(
|
||
bens,
|
||
attempt: await signInAgain(bens, 'sl-ben'),
|
||
),
|
||
refused(PasswordRefusal.noPassword),
|
||
);
|
||
await expectLater(
|
||
endpoints.keys.completeReset(
|
||
bens,
|
||
attempt: await signInAgain(bens, 'sl-ben'),
|
||
setup: next.setup(),
|
||
),
|
||
refused(PasswordRefusal.noPassword),
|
||
);
|
||
});
|
||
|
||
await startAndComplete();
|
||
await AccountPassword.db.insertRow(
|
||
session,
|
||
passwordRowOf(
|
||
ben,
|
||
(await PasswordDevice.make()).setup(),
|
||
now: DateTime.now().toUtc(),
|
||
),
|
||
);
|
||
await startAndComplete();
|
||
expect(await resetsOf(ben), isEmpty);
|
||
});
|
||
|
||
test("is called off by her password's proof on a signed-in device "
|
||
'(D32), never by another key, nor a proof for another '
|
||
'purpose', () async {
|
||
await start();
|
||
Future<void> callOff(KeysProof proof) =>
|
||
endpoints.keys.callOffResets(onPhone(), proof: proof);
|
||
|
||
for (final keys in [
|
||
next.passwordProofKeys,
|
||
device.recoveryProofKeys,
|
||
]) {
|
||
await expectLater(
|
||
callOff(
|
||
await device.prove(
|
||
await endpoints.keys.begin(onPhone()),
|
||
purpose: KeyProofPurpose.callOffResets,
|
||
keys: keys,
|
||
),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
}
|
||
await expectLater(
|
||
callOff(
|
||
await device.prove(
|
||
await endpoints.keys.begin(onPhone()),
|
||
purpose: KeyProofPurpose.newPhrase,
|
||
),
|
||
),
|
||
refused(PasswordRefusal.wrongCredentials),
|
||
);
|
||
expect(await resetsOf(anna), hasLength(1));
|
||
|
||
await callOff(
|
||
await device.prove(
|
||
await endpoints.keys.begin(onPhone()),
|
||
purpose: KeyProofPurpose.callOffResets,
|
||
),
|
||
);
|
||
expect(await resetsOf(anna), isEmpty);
|
||
await expectLater(
|
||
endpoints.keys.callOffResets(
|
||
sessionBuilder.copyWith(
|
||
authentication: AuthenticationOverride.unauthenticated(),
|
||
),
|
||
proof: await device.prove(
|
||
await endpoints.keys.begin(onPhone()),
|
||
purpose: KeyProofPurpose.callOffResets,
|
||
),
|
||
),
|
||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||
);
|
||
});
|
||
|
||
test('is called off by any proof of her password or her phrase: '
|
||
'opening her keys on a device, or a lost password set '
|
||
'anew', () async {
|
||
await start();
|
||
await endpoints.keys.unlock(
|
||
onLaptop(),
|
||
await device.prove(await endpoints.keys.begin(onLaptop())),
|
||
);
|
||
expect(await resetsOf(anna), isEmpty);
|
||
|
||
await start();
|
||
final attempt = await atNow(() => signInAgain(onPhone(), 'sl-anna'));
|
||
await atNow(
|
||
() async => await endpoints.keys.resetPassword(
|
||
onPhone(),
|
||
proof: await device.ownerProof(
|
||
attempt,
|
||
phrase: await endpoints.keys.beginPhrase(onPhone()),
|
||
purpose: KeyProofPurpose.passwordReset,
|
||
digest: newKeysPasswordDigest(next.password()),
|
||
),
|
||
password: next.password(),
|
||
),
|
||
);
|
||
expect(await resetsOf(anna), isEmpty);
|
||
});
|
||
});
|
||
|
||
group('the ways in', () {
|
||
test('say the account has keys, and no password that signs in, nor '
|
||
'an authenticator to add', () async {
|
||
// Where password sign-in is offered, which takes mail too.
|
||
final pod = session.serverpod;
|
||
usePasswordKeys(pod, testPasswordKeys);
|
||
EmailCodes.of(pod).mailer = Mailbox();
|
||
addTearDown(() => EmailCodes.of(pod).mailer = null);
|
||
final ways = await endpoints.account.signInWays(onPhone());
|
||
|
||
expect(ways.keys, isTrue);
|
||
expect(ways.password, isFalse);
|
||
expect(ways.passwordOnly, isFalse);
|
||
await expectLater(
|
||
endpoints.authenticator.enrol(
|
||
onPhone(),
|
||
name: 'Phone',
|
||
label: 'anna',
|
||
),
|
||
refused(PasswordRefusal.noPassword),
|
||
);
|
||
});
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
Future<void> _deleteAll(Session session) async {
|
||
await OidcAttempt.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
||
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
|
||
await deleteAll(session);
|
||
}
|