Whoever knew the password could guess codes at 10 an hour forever,
unnoticed, each guess checked against every authenticator (the second
reading's M4). Every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in
device (removing an authenticator, new recovery codes, the code from one
confirmed before) now counts, per account, from every network; after
every tenth in a row no code is taken (codesWait, saying until when), an
hour, then twice as long each time, a day at the most (longestCodeWait,
second_factor_waits.dart): at a day, ten guesses a day still have about a
1 % chance a year against one authenticator, so the first wait mails the
owner that someone knows their password. The instance keeps no address,
so sign-in and the phrase's reset now send the address the device typed
(and its language), used only for that mail after its hash is checked
against the account's. A code accepted clears it all.
Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits"
bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT
NULL) with a unique index on "authUserId": a new table, nothing
touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
1240 lines
39 KiB
Dart
1240 lines
39 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:halkyon_base_server/base.dart';
|
|
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
|
|
import 'package:halkyon_base_server/src/register/instance_api.dart';
|
|
import 'package:halkyon_base_server/src/register/people.dart' show maxClockSkew;
|
|
import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
|
import 'package:halkyon_base_server/src/register/remote_register.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/password_device.dart';
|
|
import '../test_tools/serverpod_test_tools.dart';
|
|
|
|
const _secret = 'a shared secret only the instance knows';
|
|
final _learn = Uri.parse('http://learn.test:8082');
|
|
|
|
/// The instance's register, in memory: what a member asks it, and what it
|
|
/// answers.
|
|
final class _Register implements AccountRegister {
|
|
final accounts = <UuidValue>{};
|
|
final addresses = <String, UuidValue>{};
|
|
final gitHub = <String, UuidValue>{};
|
|
final profiles = <UuidValue, Profile>{};
|
|
final codes = <UuidValue, ({String address, String code})>{};
|
|
|
|
/// The calls asked, by name, in their order.
|
|
final asked = <String>[];
|
|
final networks = <String>[];
|
|
final deleted = <UuidValue>[];
|
|
|
|
/// What [knownAccounts] was asked about, call by call.
|
|
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;
|
|
|
|
/// The hosts sign-ins were bound to, as members named them.
|
|
final hosts = <String>[];
|
|
|
|
Future<T> _answer<T>(String call, T Function() answer) async {
|
|
asked.add(call);
|
|
if (failing case final error?) {
|
|
throw error;
|
|
}
|
|
return answer();
|
|
}
|
|
|
|
/// [person] as an account with the address [address].
|
|
void add(UuidValue person, {String? address}) {
|
|
accounts.add(person);
|
|
if (address != null) {
|
|
addresses[address] = person;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<Profile?> profileOf(Session session, UuidValue person) =>
|
|
_answer('profileOf', () => profiles[person]);
|
|
|
|
@override
|
|
Future<Map<UuidValue, Profile>> profilesOf(
|
|
Session session,
|
|
Set<UuidValue> people,
|
|
) => _answer(
|
|
'profilesOf',
|
|
() => {
|
|
for (final person in people) person: ?profiles[person],
|
|
},
|
|
);
|
|
|
|
@override
|
|
Future<Profile?> profileByHandle(Session session, String handle) => _answer(
|
|
'profileByHandle',
|
|
() => profiles.values
|
|
.where((profile) => profile.handle.toLowerCase() == handle)
|
|
.firstOrNull,
|
|
);
|
|
|
|
@override
|
|
Future<UuidValue?> personWithHandle(Session session, String handle) =>
|
|
_answer(
|
|
'personWithHandle',
|
|
() => profiles.entries
|
|
.where((entry) => entry.value.handle.toLowerCase() == handle)
|
|
.firstOrNull
|
|
?.key,
|
|
);
|
|
|
|
@override
|
|
Future<Profile> saveProfile(
|
|
Session session,
|
|
UuidValue person, {
|
|
required String handle,
|
|
required String displayName,
|
|
required String bio,
|
|
required List<String> links,
|
|
}) => _answer(
|
|
'saveProfile',
|
|
() => profiles[person] = Profile(
|
|
handle: handle,
|
|
address: '$handle@learn.test',
|
|
displayName: displayName,
|
|
bio: bio,
|
|
links: links,
|
|
pageUrl: 'https://learn.test/@$handle',
|
|
handleFixed: false,
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<ProfileRefusal?> checkHandle(
|
|
Session session,
|
|
UuidValue person,
|
|
String handle,
|
|
) => _answer('checkHandle', () => null);
|
|
|
|
@override
|
|
Future<Profile> checkLinks(Session session, UuidValue person) =>
|
|
_answer('checkLinks', () => profiles[person]!);
|
|
|
|
@override
|
|
Future<Profile> setAvatar(Session session, UuidValue person, ByteData png) =>
|
|
_answer('setAvatar', () => profiles[person]!);
|
|
|
|
@override
|
|
Future<Profile> removeAvatar(Session session, UuidValue person) =>
|
|
_answer('removeAvatar', () => profiles[person]!);
|
|
|
|
@override
|
|
Future<void> removeProfile(Session session, UuidValue person) =>
|
|
_answer('removeProfile', () => profiles.remove(person));
|
|
|
|
@override
|
|
Future<void> fixHandle(Session session, UuidValue person) =>
|
|
_answer('fixHandle', () {});
|
|
|
|
@override
|
|
Future<bool> emailSignInOffered(Session session) =>
|
|
_answer('emailSignInOffered', () => offered);
|
|
|
|
@override
|
|
Future<UuidValue> sendSignInCode(
|
|
Session session, {
|
|
required String address,
|
|
required String language,
|
|
required String network,
|
|
}) => _answer('sendSignInCode', () {
|
|
networks.add(network);
|
|
final request = const Uuid().v7obj();
|
|
codes[request] = (address: address, code: 'K7QX-M2PD');
|
|
return request;
|
|
});
|
|
|
|
@override
|
|
Future<void> linkGitHub(
|
|
Session session, {
|
|
required UuidValue person,
|
|
required String gitHubUserId,
|
|
}) => _answer('linkGitHub', () {
|
|
accounts.add(person);
|
|
gitHub[gitHubUserId] = person;
|
|
});
|
|
|
|
@override
|
|
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId) =>
|
|
_answer('accountWithGitHub', () => gitHub[gitHubUserId]);
|
|
|
|
@override
|
|
Future<SignInWays> signInWaysOf(Session session, UuidValue person) => _answer(
|
|
'signInWaysOf',
|
|
() => SignInWays(
|
|
gitHub: gitHub.containsValue(person),
|
|
email: addresses.containsValue(person),
|
|
password: passwords.contains(person),
|
|
authenticator: authenticated.contains(person),
|
|
passwordOnly:
|
|
passwords.contains(person) && 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,
|
|
UuidValue? request,
|
|
String? code,
|
|
required String host,
|
|
String? address,
|
|
String? language,
|
|
}) {
|
|
hosts.add(host);
|
|
return _answer('completePasswordSignIn', _proven);
|
|
}
|
|
|
|
@override
|
|
Future<UuidValue> signUpWithCode(
|
|
Session session, {
|
|
required UuidValue request,
|
|
required String code,
|
|
required PasswordSetup setup,
|
|
}) => _answer('signUpWithCode', () {
|
|
final account = const Uuid().v7obj();
|
|
add(account);
|
|
passwords.add(account);
|
|
return account;
|
|
});
|
|
|
|
@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,
|
|
String? existingCode,
|
|
}) => _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,
|
|
required String host,
|
|
}) => _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)),
|
|
);
|
|
});
|
|
|
|
/// The moment a reset ended the account's sessions at the register.
|
|
DateTime endedBefore = DateTime.utc(2026, 10, 20, 12);
|
|
|
|
@override
|
|
Future<AccountSessionsEnded> resetPasswordWithPhrase(
|
|
Session session, {
|
|
required UuidValue challenge,
|
|
required ByteData signature,
|
|
required NewPassword password,
|
|
String? totpCode,
|
|
String? recoveryCode,
|
|
required String host,
|
|
String? address,
|
|
String? language,
|
|
}) => _answer(
|
|
'resetPasswordWithPhrase',
|
|
() => (account: _proven().account, endedBefore: endedBefore),
|
|
);
|
|
|
|
@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<AccountSessionsEnded> completeAccountReset(
|
|
Session session, {
|
|
required String address,
|
|
required UuidValue request,
|
|
required String code,
|
|
required PasswordSetup setup,
|
|
}) => _answer(
|
|
'completeAccountReset',
|
|
() => (account: _proven().account, endedBefore: endedBefore),
|
|
);
|
|
|
|
/// The sessions kept by the password changes asked, by person.
|
|
final changed = <UuidValue, UuidValue?>{};
|
|
|
|
@override
|
|
Future<PasswordChallenge> beginPasswordChange(
|
|
Session session, {
|
|
required UuidValue person,
|
|
}) => _answer(
|
|
'beginPasswordChange',
|
|
() => PasswordChallenge(
|
|
challenge: const Uuid().v7obj(),
|
|
salt: ByteData(16),
|
|
costs: newPasswordCosts,
|
|
serverValue: ByteData(384),
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<void> changePassword(
|
|
Session session, {
|
|
required UuidValue person,
|
|
required UuidValue challenge,
|
|
required ByteData clientValue,
|
|
required ByteData clientProof,
|
|
String? totpCode,
|
|
String? recoveryCode,
|
|
required NewPassword password,
|
|
UuidValue? keepSession,
|
|
required String host,
|
|
}) => _answer('changePassword', () {
|
|
changed[person] = keepSession;
|
|
});
|
|
|
|
@override
|
|
Future<void> setRecoveryKeys(
|
|
Session session, {
|
|
required UuidValue person,
|
|
required UuidValue challenge,
|
|
required ByteData clientValue,
|
|
required ByteData clientProof,
|
|
String? totpCode,
|
|
String? recoveryCode,
|
|
required WrappedKeys recoveryKeys,
|
|
required ByteData recoveryProofKey,
|
|
required String host,
|
|
}) => _answer('setRecoveryKeys', () {});
|
|
|
|
@override
|
|
Future<void> deleteAccount(Session session, UuidValue person) =>
|
|
_answer('deleteAccount', () {
|
|
deleted.add(person);
|
|
accounts.remove(person);
|
|
});
|
|
|
|
@override
|
|
Future<Set<UuidValue>> knownAccounts(
|
|
Session session,
|
|
Set<UuidValue> people,
|
|
) => _answer('knownAccounts', () {
|
|
askedAbout.add(people);
|
|
return people.intersection(accounts);
|
|
});
|
|
}
|
|
|
|
void main() {
|
|
withServerpod(
|
|
'A member of an instance',
|
|
// Sessions issued and revoked, and deletions in transactions of their
|
|
// own: these tests delete what they make instead.
|
|
rollbackDatabase: RollbackDatabase.disabled,
|
|
(sessionBuilder, endpoints) {
|
|
late Session session;
|
|
late Serverpod pod;
|
|
late _Register register;
|
|
|
|
/// Anonymous here, signed in on her phone.
|
|
late UuidValue anna;
|
|
late UuidValue annasPhone;
|
|
|
|
/// An account of the register, identified here, signed in on his
|
|
/// laptop.
|
|
late UuidValue ben;
|
|
late UuidValue bensLaptop;
|
|
|
|
/// Whose data the host deleted.
|
|
late List<UuidValue> hostDeleted;
|
|
|
|
setUp(() async {
|
|
hostDeleted = [];
|
|
useBase(
|
|
sessionBuilder,
|
|
accounts: MemberOfRegister(_learn),
|
|
deletePersonData: [
|
|
(session, person, transaction) async {
|
|
hostDeleted.add(person);
|
|
return null;
|
|
},
|
|
],
|
|
);
|
|
session = sessionBuilder.build();
|
|
pod = session.serverpod;
|
|
await _deleteAll(session);
|
|
useInstanceSecret(pod, _secret);
|
|
register = _Register();
|
|
AccountRegister.use(pod, register);
|
|
anna = await newAnonymousPerson(session);
|
|
annasPhone = await signInNewDevice(session, anna);
|
|
ben = (await AuthServices.instance.authUsers.create(
|
|
session,
|
|
scopes: {identified},
|
|
)).id;
|
|
register.add(ben, address: 'ben@example.org');
|
|
bensLaptop = await signInNewDevice(session, ben, scopes: {identified});
|
|
});
|
|
tearDown(() async {
|
|
AccountRegister.use(pod, null);
|
|
useInstanceSecret(pod, null);
|
|
await _deleteAll(sessionBuilder.build());
|
|
});
|
|
|
|
TestSessionBuilder asAnna() => sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
|
anna.uuid,
|
|
{},
|
|
authId: annasPhone.uuid,
|
|
),
|
|
);
|
|
TestSessionBuilder asBen() => sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
|
ben.uuid,
|
|
{identified},
|
|
authId: bensLaptop.uuid,
|
|
),
|
|
);
|
|
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
|
);
|
|
|
|
Future<Set<String>> scopesOf(UuidValue person) async =>
|
|
(await AuthUser.db.findById(session, person))!.scopeNames;
|
|
|
|
group('signs in by asking the register', () {
|
|
test('which mails the code, its device network passed on', () async {
|
|
await endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: 'anna@example.org',
|
|
language: 'de',
|
|
);
|
|
|
|
expect(register.asked, ['sendSignInCode']);
|
|
expect(register.codes.values.single.address, 'anna@example.org');
|
|
expect(register.networks, hasLength(1));
|
|
});
|
|
|
|
test('and refuses as the register refuses', () async {
|
|
register.failing = EmailSignInRefusedException(
|
|
reason: EmailSignInRefusal.tooManyCodes,
|
|
);
|
|
|
|
await expectLater(
|
|
endpoints.emailCode.sendCode(
|
|
asAnna(),
|
|
address: 'anna@example.org',
|
|
language: 'de',
|
|
),
|
|
throwsA(
|
|
isA<EmailSignInRefusedException>().having(
|
|
(error) => error.reason,
|
|
'reason',
|
|
EmailSignInRefusal.tooManyCodes,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('an account signing in again is the same person here', () async {
|
|
await issueSession(session, ben, method: 'test');
|
|
await issueSession(session, ben, method: 'test');
|
|
|
|
expect(
|
|
await AuthUser.db.count(session, where: (t) => t.id.equals(ben)),
|
|
1,
|
|
);
|
|
expect(
|
|
await ServerSideSession.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
),
|
|
3,
|
|
);
|
|
});
|
|
|
|
test('an account the register proved is identified here even where '
|
|
'linking its way in was cut off', () async {
|
|
final success = await issueSession(session, anna, method: 'test');
|
|
|
|
expect(success.authUserId, anna);
|
|
expect(await scopesOf(anna), contains(identified.name));
|
|
expect(
|
|
(await ServerSideSession.db.findById(
|
|
session,
|
|
annasPhone,
|
|
))!.scopeNames,
|
|
contains(identified.name),
|
|
);
|
|
});
|
|
|
|
test('email sign-in is offered as the register offers it', () async {
|
|
expect(
|
|
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
|
|
isTrue,
|
|
);
|
|
register.offered = false;
|
|
expect(
|
|
(await endpoints.instance.basics(notSignedIn())).emailSignIn,
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('the basics still answer while the register is out of reach, '
|
|
'without email sign-in', () async {
|
|
register.failing = RegisterUnavailableException('test');
|
|
|
|
final basics = await endpoints.instance.basics(notSignedIn());
|
|
|
|
expect(basics.emailSignIn, isFalse);
|
|
expect(basics.app, testApp);
|
|
});
|
|
|
|
test('the ways in are those the register knows', () async {
|
|
register.gitHub['1001'] = ben;
|
|
|
|
final ways = await endpoints.account.signInWays(asBen());
|
|
|
|
expect(ways.toJson(), SignInWays(gitHub: true, email: true).toJson());
|
|
});
|
|
});
|
|
|
|
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',
|
|
host: testHost,
|
|
);
|
|
|
|
expect(register.asked, [
|
|
'beginPasswordSignIn',
|
|
'completePasswordSignIn',
|
|
]);
|
|
expect(register.networks, hasLength(1));
|
|
// Bound to the host the device addressed: this member's.
|
|
expect(register.hosts, [testHost]);
|
|
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('sign up at the register, the new account signed in here, '
|
|
'identified', () async {
|
|
final device = await PasswordDevice.make();
|
|
|
|
final signedUp = await endpoints.passwordSignIn.signUp(
|
|
notSignedIn(),
|
|
request: const Uuid().v7obj(),
|
|
code: 'K7QX-M2PD',
|
|
setup: device.setup(),
|
|
);
|
|
|
|
expect(register.asked, ['signUpWithCode']);
|
|
expect(register.passwords, {signedUp.authUserId});
|
|
expect(await scopesOf(signedUp.authUserId), {identified.name});
|
|
expect(
|
|
(await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(signedUp.authUserId),
|
|
)).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),
|
|
host: testHost,
|
|
),
|
|
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(
|
|
asBen(),
|
|
'Ben',
|
|
'Ben',
|
|
'',
|
|
const [],
|
|
);
|
|
|
|
expect(register.profiles[ben]!.toJson(), saved.toJson());
|
|
expect(
|
|
(await endpoints.profiles.byHandle(asAnna(), 'ben')).toJson(),
|
|
saved.toJson(),
|
|
);
|
|
expect((await profileOf(session, ben))!.toJson(), saved.toJson());
|
|
expect(await PublicProfile.db.count(session), 0);
|
|
});
|
|
|
|
test("have their page at the register's address, where this "
|
|
"server's sends its visitors", () async {
|
|
await endpoints.profile.save(asBen(), 'Ben', 'Ben', '', const []);
|
|
|
|
final page = await profilePage(
|
|
session,
|
|
'@ben',
|
|
host: 'todo.test',
|
|
);
|
|
|
|
expect(page.status, 308);
|
|
expect(page.location, 'https://learn.test/@Ben');
|
|
});
|
|
});
|
|
|
|
group('deleting the account', () {
|
|
test('of an identified person deletes it at the register, which '
|
|
'tells every member, and here at once', () async {
|
|
await endpoints.account.delete(asBen());
|
|
|
|
expect(register.deleted, [ben]);
|
|
expect(await AuthUser.db.findById(session, ben), isNull);
|
|
expect(
|
|
await ServerSideSession.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
),
|
|
0,
|
|
);
|
|
expect(hostDeleted, [ben]);
|
|
expect(await AuthUser.db.findById(session, anna), isNotNull);
|
|
});
|
|
|
|
test('of an anonymous person deletes it here alone', () async {
|
|
await endpoints.account.delete(asAnna());
|
|
|
|
expect(register.asked, isEmpty);
|
|
expect(await AuthUser.db.findById(session, anna), isNull);
|
|
expect(hostDeleted, [anna]);
|
|
});
|
|
|
|
test('is refused while the register is out of reach, deleting '
|
|
'nothing here', () async {
|
|
register.failing = RegisterUnavailableException('test');
|
|
|
|
await expectLater(
|
|
endpoints.account.delete(asBen()),
|
|
throwsA(isA<RegisterUnavailableException>()),
|
|
);
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
expect(
|
|
await ServerSideSession.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
),
|
|
1,
|
|
);
|
|
expect(hostDeleted, isEmpty);
|
|
});
|
|
});
|
|
|
|
group('told by the register that an account is gone', () {
|
|
Future<InstanceAnswer> tell(
|
|
Object? account, {
|
|
String? authorization = 'Bearer $_secret',
|
|
}) => answerRegister(
|
|
session,
|
|
jsonEncode({'account': account}),
|
|
authorization: authorization,
|
|
);
|
|
|
|
test('deletes that person here with their sessions, and nobody '
|
|
'else', () async {
|
|
final answer = await tell(ben.uuid);
|
|
|
|
expect(answer.status, 200);
|
|
expect(await AuthUser.db.findById(session, ben), isNull);
|
|
expect(
|
|
await ServerSideSession.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
),
|
|
0,
|
|
);
|
|
expect(hostDeleted, [ben]);
|
|
expect(await AuthUser.db.findById(session, anna), isNotNull);
|
|
expect(register.asked, isEmpty);
|
|
});
|
|
|
|
test('confirms an account it never had', () async {
|
|
expect((await tell(const Uuid().v7())).status, 200);
|
|
expect(hostDeleted, isEmpty);
|
|
});
|
|
|
|
test('refuses without the instance secret, or with a wrong one, '
|
|
'deleting nobody', () async {
|
|
for (final authorization in [
|
|
null,
|
|
'Bearer wrong',
|
|
'Basic $_secret',
|
|
]) {
|
|
expect(
|
|
(await tell(ben.uuid, authorization: authorization)).status,
|
|
401,
|
|
);
|
|
}
|
|
useInstanceSecret(pod, null);
|
|
expect((await tell(ben.uuid)).status, 401);
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
expect(hostDeleted, isEmpty);
|
|
});
|
|
|
|
test('refuses what it cannot read', () async {
|
|
for (final account in [null, 7, 'not an id']) {
|
|
expect((await tell(account)).status, 400, reason: '$account');
|
|
}
|
|
expect(
|
|
(await answerRegister(
|
|
session,
|
|
'not JSON',
|
|
authorization: 'Bearer $_secret',
|
|
)).status,
|
|
400,
|
|
);
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
});
|
|
|
|
test('is answered by no register', () async {
|
|
useBase(sessionBuilder);
|
|
|
|
expect((await tell(ben.uuid)).status, 404);
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
});
|
|
});
|
|
|
|
group('told by the register that sessions ended (ADR 0017)', () {
|
|
Future<InstanceAnswer> tell(
|
|
Map<String, Object?> body, {
|
|
String? authorization = 'Bearer $_secret',
|
|
}) => answerSessionsEnded(
|
|
session,
|
|
jsonEncode(body),
|
|
authorization: authorization,
|
|
);
|
|
|
|
Future<DateTime> createdAt(UuidValue id) async =>
|
|
(await ServerSideSession.db.findById(session, id))!.createdAt;
|
|
|
|
test('ends those of the account made before the moment, but the one '
|
|
'kept and those made since, and nobody else', () async {
|
|
final bensPhone = await signInNewDevice(session, ben);
|
|
final endedBefore = (await createdAt(
|
|
bensPhone,
|
|
)).add(const Duration(microseconds: 1));
|
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
|
final bensTablet = await signInNewDevice(session, ben);
|
|
|
|
final answer = await tell({
|
|
'account': ben.uuid,
|
|
'endedBefore': endedBefore.toIso8601String(),
|
|
'keepSession': bensLaptop.uuid,
|
|
});
|
|
|
|
expect(answer.status, 200);
|
|
final left = {
|
|
for (final row in await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
))
|
|
row.id!,
|
|
};
|
|
expect(left, {bensLaptop, bensTablet});
|
|
expect(
|
|
await ServerSideSession.db.findById(session, annasPhone),
|
|
isNotNull,
|
|
);
|
|
expect(register.asked, isEmpty);
|
|
});
|
|
|
|
test('refuses without the secret and what it cannot read, and is '
|
|
'answered by no register, ending nothing', () async {
|
|
final everything = {
|
|
'account': ben.uuid,
|
|
'endedBefore': DateTime.now().toUtc().toIso8601String(),
|
|
};
|
|
expect((await tell(everything, authorization: null)).status, 401);
|
|
expect(
|
|
(await tell(everything, authorization: 'Bearer wrong')).status,
|
|
401,
|
|
);
|
|
expect(
|
|
(await tell({'account': ben.uuid, 'endedBefore': 'soon'})).status,
|
|
400,
|
|
);
|
|
expect((await tell({'account': ben.uuid})).status, 400);
|
|
useBase(sessionBuilder);
|
|
expect((await tell(everything)).status, 404);
|
|
expect(
|
|
await ServerSideSession.db.findById(session, bensLaptop),
|
|
isNotNull,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('changing a password, by asking the register (ADR 0017)', () {
|
|
test('keeps the session asking: its ID goes to the register, which '
|
|
'tells every service', () async {
|
|
final challenge = await endpoints.password.beginChange(asBen());
|
|
|
|
await endpoints.password.change(
|
|
asBen(),
|
|
challenge: challenge.challenge,
|
|
clientValue: ByteData(384),
|
|
clientProof: ByteData(32),
|
|
totpCode: '123456',
|
|
password: NewPassword(
|
|
salt: ByteData(16),
|
|
costs: newPasswordCosts,
|
|
verifier: ByteData(384),
|
|
passwordKeys: WrappedKeys(
|
|
nonce: ByteData(24),
|
|
cipherText: ByteData(112),
|
|
),
|
|
),
|
|
host: testHost,
|
|
);
|
|
|
|
expect(register.asked, ['beginPasswordChange', 'changePassword']);
|
|
expect(register.changed, {ben: bensLaptop});
|
|
});
|
|
|
|
test('a reset ends the sessions here made before the register ended '
|
|
'them, the new one made after', () async {
|
|
register
|
|
..proof = PasswordProof(
|
|
account: ben,
|
|
serverProof: ByteData(32),
|
|
encryptionPublicKey: ByteData(32),
|
|
signingPublicKey: ByteData(32),
|
|
passwordKeys: WrappedKeys(
|
|
nonce: ByteData(24),
|
|
cipherText: ByteData(112),
|
|
),
|
|
needsAuthenticator: false,
|
|
)
|
|
..endedBefore = DateTime.now().toUtc();
|
|
|
|
final signedIn = await endpoints.accountRecovery.resetPassword(
|
|
notSignedIn(),
|
|
challenge: const Uuid().v7obj(),
|
|
signature: ByteData(64),
|
|
password: NewPassword(
|
|
salt: ByteData(16),
|
|
costs: newPasswordCosts,
|
|
verifier: ByteData(384),
|
|
passwordKeys: WrappedKeys(
|
|
nonce: ByteData(24),
|
|
cipherText: ByteData(112),
|
|
),
|
|
),
|
|
totpCode: '123456',
|
|
host: testHost,
|
|
);
|
|
|
|
expect(signedIn.authUserId, ben);
|
|
final left = await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
);
|
|
expect(left.single.id, isNot(bensLaptop));
|
|
expect(
|
|
left.single.createdAt.isBefore(register.endedBefore),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test("waits a few seconds at most for the register's moment: a "
|
|
'clock further behind is refused at once, and signs nobody in, '
|
|
'rather than holding the request', () async {
|
|
register
|
|
..proof = PasswordProof(
|
|
account: ben,
|
|
serverProof: ByteData(32),
|
|
encryptionPublicKey: ByteData(32),
|
|
signingPublicKey: ByteData(32),
|
|
passwordKeys: WrappedKeys(
|
|
nonce: ByteData(24),
|
|
cipherText: ByteData(112),
|
|
),
|
|
needsAuthenticator: false,
|
|
)
|
|
..endedBefore = DateTime.now().toUtc().add(
|
|
const Duration(minutes: 1),
|
|
);
|
|
final watch = Stopwatch()..start();
|
|
|
|
await expectLater(
|
|
endpoints.accountRecovery.resetPassword(
|
|
notSignedIn(),
|
|
challenge: const Uuid().v7obj(),
|
|
signature: ByteData(64),
|
|
password: NewPassword(
|
|
salt: ByteData(16),
|
|
costs: newPasswordCosts,
|
|
verifier: ByteData(384),
|
|
passwordKeys: WrappedKeys(
|
|
nonce: ByteData(24),
|
|
cipherText: ByteData(112),
|
|
),
|
|
),
|
|
totpCode: '123456',
|
|
host: testHost,
|
|
),
|
|
throwsA(
|
|
isA<PasswordRefusedException>().having(
|
|
(error) => error.reason,
|
|
'reason',
|
|
PasswordRefusal.unavailable,
|
|
),
|
|
),
|
|
);
|
|
expect(watch.elapsed, lessThan(maxClockSkew));
|
|
expect(
|
|
await ServerSideSession.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
),
|
|
0,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('pruning', () {
|
|
late UuidValue carla;
|
|
|
|
setUp(() async {
|
|
carla = (await AuthServices.instance.authUsers.create(
|
|
session,
|
|
scopes: {identified},
|
|
)).id;
|
|
});
|
|
|
|
test('deletes the identified people the register no longer knows, '
|
|
'asking only about those', () async {
|
|
final forgotten = await forgetPeopleTheRegisterForgot(
|
|
session,
|
|
batch: 1,
|
|
);
|
|
|
|
expect(forgotten, 1);
|
|
expect(await AuthUser.db.findById(session, carla), isNull);
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
expect(await AuthUser.db.findById(session, anna), isNotNull);
|
|
expect(register.askedAbout.expand((people) => people), {ben, carla});
|
|
});
|
|
|
|
test('deletes nobody while the register knows none of those asked '
|
|
'about, as a register misnamed or emptied would say', () async {
|
|
register.accounts.clear();
|
|
|
|
expect(await forgetPeopleTheRegisterForgot(session), 0);
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
expect(await AuthUser.db.findById(session, carla), isNotNull);
|
|
});
|
|
|
|
test('deletes nobody while the register is out of reach', () async {
|
|
register.failing = RegisterUnavailableException('test');
|
|
|
|
await expectLater(
|
|
forgetPeopleTheRegisterForgot(session),
|
|
throwsA(isA<RegisterUnavailableException>()),
|
|
);
|
|
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
|
expect(await AuthUser.db.findById(session, carla), isNotNull);
|
|
});
|
|
|
|
test('does nothing on the register', () async {
|
|
useBase(sessionBuilder);
|
|
|
|
expect(await forgetPeopleTheRegisterForgot(session), 0);
|
|
expect(register.asked, isEmpty);
|
|
expect(await AuthUser.db.findById(session, carla), isNotNull);
|
|
});
|
|
});
|
|
|
|
test('does not start without the secret where it would need it, and '
|
|
'needs none alone', () {
|
|
expect(() => checkInstanceSecret(pod), returnsNormally);
|
|
useInstanceSecret(pod, null);
|
|
expect(() => checkInstanceSecret(pod), throwsStateError);
|
|
|
|
useBase(sessionBuilder, accounts: HoldsRegister(members: [_learn]));
|
|
expect(() => checkInstanceSecret(pod), throwsStateError);
|
|
useBase(sessionBuilder);
|
|
expect(() => checkInstanceSecret(pod), returnsNormally);
|
|
});
|
|
|
|
test('does not start with a secret shorter than 32 characters, alone '
|
|
'or not: it gates every account of the instance', () {
|
|
for (final accounts in [
|
|
const HoldsRegister(),
|
|
HoldsRegister(members: [_learn]),
|
|
MemberOfRegister(_learn),
|
|
]) {
|
|
useBase(sessionBuilder, accounts: accounts);
|
|
useInstanceSecret(pod, 'x' * 31);
|
|
expect(() => checkInstanceSecret(pod), throwsStateError);
|
|
useInstanceSecret(pod, 'x' * 32);
|
|
expect(() => checkInstanceSecret(pod), returnsNormally);
|
|
}
|
|
});
|
|
|
|
test('asks the register at its address with the secret, and asks '
|
|
'nothing without one', () {
|
|
AccountRegister.use(pod, null);
|
|
|
|
expect(
|
|
AccountRegister.of(pod),
|
|
isA<RemoteRegister>()
|
|
.having((remote) => remote.register, 'register', _learn)
|
|
.having((remote) => remote.secret, 'secret', _secret),
|
|
);
|
|
useInstanceSecret(pod, null);
|
|
expect(() => AccountRegister.of(pod), throwsStateError);
|
|
expect(
|
|
MemberNotifier.of(pod),
|
|
isA<HttpMemberNotifier>().having(
|
|
(notifier) => notifier.secret,
|
|
'secret',
|
|
isNull,
|
|
),
|
|
);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _deleteAll(Session session) async {
|
|
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
|
}
|