Every account keeps a handle now, so removing a public profile no longer deletes it and frees the handle: it takes away what the profile shows and leaves it private, as a profile is until its person goes public (D5). - `profile.remove` and the register's `removeProfile` (members ask the register) empty the display name, bio, links with their checks and the avatar, keep the row with its handle, and return what is left, null for none. `/@handle` and `profiles.byHandle` then answer as for a handle nobody has. Once the person has published, removing is refused as before. - Learn: the profile form starts afresh from what is left, "Remove profile" shows only while something is filled in, and the dialog says the handle stays. - The module's README and ADR 0020 amended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2612 lines
85 KiB
Dart
2612 lines
85 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:clock/clock.dart';
|
|
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/register_protocol.dart';
|
|
import 'package:halkyon_base_server/src/register/remote_register.dart';
|
|
import 'package:halkyon_oidc_server/oidc.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/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;
|
|
|
|
/// Whether it offers SimpleLogin, the sign-ins begun with it, the accounts
|
|
/// it was added to, and what the next sign-in proves.
|
|
bool simpleLoginIsOffered = true;
|
|
final simpleLoginsBegun = <({String marker, UuidValue? caller})>[];
|
|
final simpleLoginLinked = <UuidValue>{};
|
|
SimpleLoginProof? simpleLoginProof;
|
|
|
|
/// The handles checked (for someone new, or signed in) and what each
|
|
/// check says, the sign-ups of people new with SimpleLogin, and the proofs
|
|
/// keys were unlocked with, as members bound them.
|
|
final handlesChecked = <String>[];
|
|
ProfileRefusal? handleRefusal;
|
|
final simpleLoginSignUps = <({UuidValue attempt, String handle})>[];
|
|
final keysProofs = <KeysProof>[];
|
|
|
|
/// The accounts with a password, and those with an authenticator too.
|
|
final passwords = <UuidValue>{};
|
|
final authenticated = <UuidValue>{};
|
|
|
|
/// The people a password was set for with a GitHub sign-in of the
|
|
/// member's made just now.
|
|
final freshGitHubSignIns = <UuidValue>{};
|
|
|
|
/// When the session dismissing a warning was made, by person.
|
|
final dismissedMadeAt = <UuidValue, DateTime?>{};
|
|
|
|
/// 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 base URLs sign-ins were bound to, as members named them.
|
|
final instanceUrls = <String>[];
|
|
|
|
/// The accounts warned that a session was ended for its wrong proofs, and
|
|
/// those whose warning was dismissed.
|
|
final warned = <UuidValue>{};
|
|
final dismissed = <UuidValue>{};
|
|
|
|
/// Thrown by [noteSessionEndedForProofs] alone while set (as [failing]
|
|
/// throws from everything): the register down as a member tells it.
|
|
Exception? notingFails;
|
|
|
|
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,
|
|
String? network,
|
|
}) => _answer('saveProfile', () {
|
|
networks.add(network!);
|
|
return 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, {
|
|
required String network,
|
|
}) => _answer('checkHandle', () {
|
|
networks.add(network);
|
|
handlesChecked.add(handle);
|
|
return handleRefusal;
|
|
});
|
|
|
|
@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<Profile?> removeProfile(Session session, UuidValue person) =>
|
|
_answer('removeProfile', () {
|
|
final profile = profiles[person];
|
|
if (profile == null) {
|
|
return null;
|
|
}
|
|
// Private again, the handle kept (ADR 0020, D27).
|
|
return profiles[person] = Profile(
|
|
handle: profile.handle,
|
|
address: profile.address,
|
|
displayName: '',
|
|
bio: '',
|
|
links: const [],
|
|
pageUrl: profile.pageUrl,
|
|
handleFixed: profile.handleFixed,
|
|
);
|
|
});
|
|
|
|
@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 EmailCodePurpose purpose,
|
|
required String network,
|
|
}) => _answer('sendSignInCode', () {
|
|
networks.add(network);
|
|
final request = const Uuid().v7obj();
|
|
codes[request] = (address: address, code: 'K7QX-M2PD');
|
|
return request;
|
|
});
|
|
|
|
@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),
|
|
simpleLogin: simpleLoginLinked.contains(person),
|
|
password: passwords.contains(person),
|
|
authenticator: authenticated.contains(person),
|
|
passwordOnly:
|
|
passwords.contains(person) && authenticated.contains(person),
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<void> noteSessionEndedForProofs(
|
|
Session session,
|
|
UuidValue person,
|
|
) async {
|
|
// Not through [_answer]: a session's tenth wrong proof comes from the
|
|
// register refusing it, while [failing] refuses everything.
|
|
asked.add('noteSessionEndedForProofs');
|
|
if (notingFails case final error?) {
|
|
throw error;
|
|
}
|
|
warned.add(person);
|
|
}
|
|
|
|
@override
|
|
Future<void> dismissSessionEndedWarning(
|
|
Session session,
|
|
UuidValue person, {
|
|
required DateTime? sessionMadeAt,
|
|
}) => _answer('dismissSessionEndedWarning', () {
|
|
dismissed.add(person);
|
|
dismissedMadeAt[person] = sessionMadeAt;
|
|
});
|
|
|
|
@override
|
|
Future<bool> passwordSignInOffered(Session session) =>
|
|
_answer('passwordSignInOffered', () => passwordOffered);
|
|
|
|
@override
|
|
Future<bool> simpleLoginOffered(Session session) =>
|
|
_answer('simpleLoginOffered', () => simpleLoginIsOffered);
|
|
|
|
@override
|
|
Future<OidcStart> beginSimpleLogin(
|
|
Session session, {
|
|
required String marker,
|
|
UuidValue? caller,
|
|
required String network,
|
|
}) => _answer('beginSimpleLogin', () {
|
|
networks.add(network);
|
|
simpleLoginsBegun.add((marker: marker, caller: caller));
|
|
return OidcStart(
|
|
attempt: const Uuid().v7obj(),
|
|
authorizationUrl: Uri.parse(
|
|
'https://app.simplelogin.io/oauth2/authorize',
|
|
),
|
|
state: '$marker.state',
|
|
);
|
|
});
|
|
|
|
@override
|
|
Future<SimpleLoginProof> completeSimpleLogin(
|
|
Session session, {
|
|
required UuidValue attempt,
|
|
required String code,
|
|
UuidValue? caller,
|
|
}) => _answer('completeSimpleLogin', () {
|
|
final proof = simpleLoginProof!;
|
|
if (proof.outcome == SimpleLoginOutcome.linked) {
|
|
simpleLoginLinked.add(caller!);
|
|
return proof.copyWith(account: caller);
|
|
}
|
|
return proof;
|
|
});
|
|
|
|
@override
|
|
Future<ProfileRefusal?> checkSimpleLoginHandle(
|
|
Session session, {
|
|
required UuidValue attempt,
|
|
required String handle,
|
|
required String network,
|
|
}) => _answer('checkSimpleLoginHandle', () {
|
|
networks.add(network);
|
|
handlesChecked.add(handle);
|
|
return handleRefusal;
|
|
});
|
|
|
|
@override
|
|
Future<UuidValue> signUpWithSimpleLogin(
|
|
Session session, {
|
|
required UuidValue attempt,
|
|
required String handle,
|
|
required KeysSetup setup,
|
|
required String network,
|
|
}) => _answer('signUpWithSimpleLogin', () {
|
|
networks.add(network);
|
|
final account = const Uuid().v7obj();
|
|
add(account);
|
|
simpleLoginSignUps.add((attempt: attempt, handle: handle));
|
|
return account;
|
|
});
|
|
|
|
/// The first keys set, by person, with the sign-in and handle they
|
|
/// came with; the changes of keys asked, by name, with the proof (as the
|
|
/// member bound it) and the session kept.
|
|
final firstKeys = <UuidValue, ({UuidValue attempt, String? handle})>{};
|
|
final keysChanges =
|
|
<({String change, KeysOwnerProof proof, UuidValue? keepSession})>[];
|
|
|
|
@override
|
|
Future<void> setFirstKeys(
|
|
Session session, {
|
|
required UuidValue person,
|
|
required UuidValue attempt,
|
|
required KeysSetup setup,
|
|
required String network,
|
|
String? handle,
|
|
}) => _answer('setFirstKeys', () {
|
|
networks.add(network);
|
|
firstKeys[person] = (attempt: attempt, handle: handle);
|
|
});
|
|
|
|
@override
|
|
Future<KeyProofChallenge> beginPhraseKeyProof(
|
|
Session session,
|
|
UuidValue person,
|
|
) => _answer(
|
|
'beginPhraseKeyProof',
|
|
() => KeyProofChallenge(
|
|
challenge: const Uuid().v7obj(),
|
|
nonce: ByteData(32),
|
|
encryptionPublicKey: ByteData(32),
|
|
signingPublicKey: ByteData(32),
|
|
recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<void> changeKeysPassword(
|
|
Session session, {
|
|
required UuidValue person,
|
|
required KeysOwnerProof proof,
|
|
required KeysPassword password,
|
|
UuidValue? keepSession,
|
|
}) => _answer('changeKeysPassword', () {
|
|
keysChanges.add((
|
|
change: 'password',
|
|
proof: proof,
|
|
keepSession: keepSession,
|
|
));
|
|
});
|
|
|
|
@override
|
|
Future<void> resetKeysPassword(
|
|
Session session, {
|
|
required UuidValue person,
|
|
required KeysOwnerProof proof,
|
|
required KeysPassword password,
|
|
UuidValue? keepSession,
|
|
}) => _answer('resetKeysPassword', () {
|
|
keysChanges.add((change: 'reset', proof: proof, keepSession: keepSession));
|
|
});
|
|
|
|
@override
|
|
Future<void> setKeysRecovery(
|
|
Session session, {
|
|
required UuidValue person,
|
|
required KeysOwnerProof proof,
|
|
required WrappedKeys recoveryKeys,
|
|
required ByteData recoveryProofKey,
|
|
}) => _answer('setKeysRecovery', () {
|
|
keysChanges.add((change: 'phrase', proof: proof, keepSession: null));
|
|
});
|
|
|
|
/// The handles mends began for, the proofs mends came with (as the
|
|
/// member bound them), and what the next mend proves.
|
|
final mendsBegun = <String>[];
|
|
final mendProofs = <KeysProof>[];
|
|
SimpleLoginProof? mended;
|
|
|
|
@override
|
|
Future<KeyProofChallenge> beginSimpleLoginMend(
|
|
Session session, {
|
|
required UuidValue attempt,
|
|
required String handle,
|
|
required String network,
|
|
}) => _answer('beginSimpleLoginMend', () {
|
|
networks.add(network);
|
|
mendsBegun.add(handle);
|
|
return KeyProofChallenge(
|
|
challenge: const Uuid().v7obj(),
|
|
nonce: ByteData(32),
|
|
encryptionPublicKey: ByteData(32),
|
|
signingPublicKey: ByteData(32),
|
|
recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
|
|
);
|
|
});
|
|
|
|
@override
|
|
Future<SimpleLoginProof> mendSimpleLogin(
|
|
Session session, {
|
|
required UuidValue attempt,
|
|
required KeysProof proof,
|
|
}) {
|
|
mendProofs.add(proof);
|
|
return _answer('mendSimpleLogin', () => mended!);
|
|
}
|
|
|
|
@override
|
|
Future<KeysChallenge> beginPasswordKeyProof(
|
|
Session session,
|
|
UuidValue person,
|
|
) => _answer(
|
|
'beginPasswordKeyProof',
|
|
() => KeysChallenge(
|
|
challenge: const Uuid().v7obj(),
|
|
nonce: ByteData(32),
|
|
salt: ByteData(16),
|
|
costs: newPasswordCosts,
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<DeviceKeys> unlockKeys(
|
|
Session session,
|
|
UuidValue person,
|
|
KeysProof proof,
|
|
) {
|
|
keysProofs.add(proof);
|
|
return _answer(
|
|
'unlockKeys',
|
|
() => DeviceKeys(
|
|
encryptionPublicKey: ByteData(32),
|
|
signingPublicKey: ByteData(32),
|
|
passwordKeys: WrappedKeys(
|
|
nonce: ByteData(24),
|
|
cipherText: ByteData(112),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> setPassword(
|
|
Session session, {
|
|
required UuidValue person,
|
|
required PasswordSetup setup,
|
|
UuidValue? request,
|
|
String? code,
|
|
String? handle,
|
|
required String network,
|
|
bool freshGitHubSignIn = false,
|
|
UuidValue? keepSession,
|
|
}) => _answer('setPassword', () {
|
|
networks.add(network);
|
|
if (handle != null) {
|
|
handlesChecked.add(handle);
|
|
}
|
|
add(person);
|
|
passwords.add(person);
|
|
if (freshGitHubSignIn) {
|
|
freshGitHubSignIns.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 instanceUrl,
|
|
String? address,
|
|
String? language,
|
|
}) {
|
|
instanceUrls.add(instanceUrl);
|
|
return _answer('completePasswordSignIn', _proven);
|
|
}
|
|
|
|
@override
|
|
Future<ProfileRefusal?> checkSignUpHandle(
|
|
Session session, {
|
|
required UuidValue request,
|
|
required String handle,
|
|
required String network,
|
|
}) => _answer('checkSignUpHandle', () {
|
|
networks.add(network);
|
|
handlesChecked.add(handle);
|
|
return handleRefusal;
|
|
});
|
|
|
|
@override
|
|
Future<UuidValue> signUpWithCode(
|
|
Session session, {
|
|
required UuidValue request,
|
|
required String code,
|
|
required PasswordSetup setup,
|
|
required String handle,
|
|
required String network,
|
|
}) => _answer('signUpWithCode', () {
|
|
networks.add(network);
|
|
handlesChecked.add(handle);
|
|
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,
|
|
OwnerProof? proof,
|
|
}) => _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,
|
|
OwnerProof? proof,
|
|
}) => _answer('removeAuthenticator', () {});
|
|
|
|
@override
|
|
Future<List<String>> newRecoveryCodes(
|
|
Session session, {
|
|
required UuidValue person,
|
|
required OwnerProof proof,
|
|
}) => _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 instanceUrl,
|
|
}) => _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 instanceUrl,
|
|
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 OwnerProof proof,
|
|
required NewPassword password,
|
|
UuidValue? keepSession,
|
|
}) => _answer('changePassword', () {
|
|
changed[person] = keepSession;
|
|
changedWith.add(proof);
|
|
});
|
|
|
|
@override
|
|
Future<void> callOffResets(
|
|
Session session, {
|
|
required UuidValue person,
|
|
required OwnerProof proof,
|
|
}) => _answer('callOffResets', () {});
|
|
|
|
@override
|
|
Future<void> setRecoveryKeys(
|
|
Session session, {
|
|
required UuidValue person,
|
|
required OwnerProof proof,
|
|
required WrappedKeys recoveryKeys,
|
|
required ByteData recoveryProofKey,
|
|
}) => _answer('setRecoveryKeys', () {});
|
|
|
|
@override
|
|
Future<void> deleteAccount(
|
|
Session session,
|
|
UuidValue person, {
|
|
DeletionProof? proof,
|
|
KeysOwnerProof? keysProof,
|
|
}) => _answer('deleteAccount', () {
|
|
deleted.add(person);
|
|
deletedWith.add(proof);
|
|
deletedWithKeys.add(keysProof);
|
|
accounts.remove(person);
|
|
});
|
|
|
|
/// The proofs deletions came with, as the member bound them.
|
|
final deletedWith = <DeletionProof?>[];
|
|
final deletedWithKeys = <KeysOwnerProof?>[];
|
|
final changedWith = <OwnerProof>[];
|
|
|
|
@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;
|
|
|
|
/// Not identified here, signed in on her phone: an anonymous account
|
|
/// left from before (ADR 0020, D25), or one the register linked whose
|
|
/// becoming identified here failed (the full reading's L7).
|
|
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',
|
|
purpose: EmailCodePurpose.signUp,
|
|
);
|
|
|
|
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',
|
|
purpose: EmailCodePurpose.signUp,
|
|
),
|
|
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',
|
|
instanceUrl: testInstanceUrl,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
);
|
|
|
|
expect(register.asked, [
|
|
'beginPasswordSignIn',
|
|
'completePasswordSignIn',
|
|
]);
|
|
expect(register.networks, hasLength(1));
|
|
// Bound to the base URL the device addressed: this member's.
|
|
expect(register.instanceUrls, [testInstanceUrl]);
|
|
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('carrying out a reset of the authenticators, end the sessions '
|
|
'here made before the register ended them, the new one staying '
|
|
'(the fourth reading)', () async {
|
|
final endedBefore = DateTime.now().toUtc();
|
|
register.proof = PasswordProof(
|
|
account: anna,
|
|
serverProof: ByteData(32),
|
|
encryptionPublicKey: ByteData(32),
|
|
signingPublicKey: ByteData(32),
|
|
passwordKeys: WrappedKeys(
|
|
nonce: ByteData(24),
|
|
cipherText: ByteData(112),
|
|
),
|
|
needsAuthenticator: true,
|
|
sessionsEndedBefore: endedBefore,
|
|
);
|
|
|
|
final challenge = await endpoints.passwordSignIn.begin(
|
|
notSignedIn(),
|
|
address: 'anna@example.org',
|
|
);
|
|
final signedIn = await endpoints.passwordSignIn.complete(
|
|
notSignedIn(),
|
|
challenge: challenge.challenge,
|
|
clientValue: ByteData(384),
|
|
clientProof: ByteData(32),
|
|
instanceUrl: testInstanceUrl,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
);
|
|
|
|
expect(signedIn.authUserId, anna);
|
|
expect(
|
|
await ServerSideSession.db.findById(session, annasPhone),
|
|
isNull,
|
|
);
|
|
final left = await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(anna),
|
|
);
|
|
expect(left, hasLength(1));
|
|
expect(left.single.createdAt.isBefore(endedBefore), isFalse);
|
|
});
|
|
|
|
test("count a proof whose answer the register couldn't give, as it "
|
|
'may have checked it (the fourth reading)', () async {
|
|
register.failing = RegisterUnavailableException('test');
|
|
|
|
await expectLater(
|
|
endpoints.authenticator.newRecoveryCodes(
|
|
asAnna(),
|
|
proof: OwnerProof(
|
|
challenge: const Uuid().v7obj(),
|
|
clientValue: ByteData(384),
|
|
clientProof: ByteData(32),
|
|
instanceUrl: testInstanceUrl,
|
|
totpCode: '123456',
|
|
),
|
|
),
|
|
throwsA(isA<RegisterUnavailableException>()),
|
|
);
|
|
|
|
expect(
|
|
(await SessionCodeFailures.db.findFirstRow(
|
|
session,
|
|
where: (t) => t.sessionId.equals(annasPhone),
|
|
))?.failures,
|
|
1,
|
|
);
|
|
});
|
|
|
|
test("checks a sign-up's handle at the register while the code "
|
|
'travels (ADR 0020, D29), the device network passed on', () async {
|
|
register.handleRefusal = ProfileRefusal.handleTaken;
|
|
|
|
expect(
|
|
await endpoints.passwordSignIn.checkHandle(
|
|
notSignedIn(),
|
|
request: const Uuid().v7obj(),
|
|
handle: 'carla',
|
|
),
|
|
ProfileRefusal.handleTaken,
|
|
);
|
|
expect(register.asked, ['checkSignUpHandle']);
|
|
expect(register.handlesChecked, ['carla']);
|
|
expect(register.networks, hasLength(1));
|
|
});
|
|
|
|
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(),
|
|
handle: 'anna',
|
|
);
|
|
|
|
expect(register.asked, ['signUpWithCode']);
|
|
expect(register.handlesChecked, ['anna']);
|
|
expect(register.networks, hasLength(1));
|
|
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),
|
|
instanceUrl: testInstanceUrl,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
),
|
|
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(register.freshGitHubSignIns, isEmpty);
|
|
expect(await scopesOf(anna), contains(identified.name));
|
|
});
|
|
|
|
test(
|
|
'tell the register whether the session asking was made by a '
|
|
'GitHub sign-in of the last ten minutes (the fourth reading)',
|
|
() async {
|
|
final device = await PasswordDevice.make();
|
|
Future<void> setFrom(UuidValue device_) => endpoints.password.set(
|
|
sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.authenticationInfo(
|
|
anna.uuid,
|
|
{},
|
|
authId: device_.uuid,
|
|
),
|
|
),
|
|
setup: device.setup(),
|
|
request: const Uuid().v7obj(),
|
|
code: 'K7QX-M2PD',
|
|
);
|
|
final old = await withClock(
|
|
Clock.fixed(DateTime.now().subtract(const Duration(minutes: 11))),
|
|
() => signInNewDevice(session, anna, method: gitHubMethod),
|
|
);
|
|
await setFrom(old);
|
|
expect(register.freshGitHubSignIns, isEmpty);
|
|
await setFrom(annasPhone);
|
|
expect(register.freshGitHubSignIns, isEmpty);
|
|
await setFrom(
|
|
await signInNewDevice(session, anna, method: gitHubMethod),
|
|
);
|
|
expect(register.freshGitHubSignIns, {anna});
|
|
},
|
|
);
|
|
|
|
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('SimpleLogin, by asking the register (ADR 0020)', () {
|
|
test('begins for this app and way back, signed out, the device '
|
|
'network passed on', () async {
|
|
final start = await endpoints.simpleLogin.begin(
|
|
notSignedIn(),
|
|
back: SimpleLoginReturn.scheme,
|
|
);
|
|
|
|
expect(register.asked, ['beginSimpleLogin']);
|
|
expect(register.simpleLoginsBegun.single, (
|
|
marker: '$testApp-scheme',
|
|
caller: null,
|
|
));
|
|
expect(register.networks, hasLength(1));
|
|
expect(start.state, startsWith('$testApp-scheme.'));
|
|
});
|
|
|
|
test('begins signed in for the caller', () async {
|
|
await endpoints.simpleLogin.begin(
|
|
asBen(),
|
|
back: SimpleLoginReturn.loopback,
|
|
);
|
|
|
|
expect(register.simpleLoginsBegun.single, (
|
|
marker: '$testApp-loopback',
|
|
caller: ben,
|
|
));
|
|
});
|
|
|
|
test('refuses a caller not identified here, asking the register '
|
|
'nothing (ADR 0020, D25)', () async {
|
|
Matcher wrongCaller() => throwsA(
|
|
isA<OidcRefusedException>().having(
|
|
(error) => error.reason,
|
|
'reason',
|
|
OidcRefusal.wrongCaller,
|
|
),
|
|
);
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.begin(
|
|
asAnna(),
|
|
back: SimpleLoginReturn.loopback,
|
|
),
|
|
wrongCaller(),
|
|
);
|
|
await expectLater(
|
|
endpoints.simpleLogin.complete(
|
|
asAnna(),
|
|
attempt: const Uuid().v7obj(),
|
|
code: 'code',
|
|
),
|
|
wrongCaller(),
|
|
);
|
|
expect(register.asked, isEmpty);
|
|
});
|
|
|
|
test('signs the account the register found in here, made a person '
|
|
'here the first time', () async {
|
|
final carla = const Uuid().v7obj();
|
|
register.simpleLoginProof = SimpleLoginProof(
|
|
outcome: SimpleLoginOutcome.signedIn,
|
|
account: carla,
|
|
hasKeys: true,
|
|
handle: 'Carla',
|
|
);
|
|
|
|
final completed = await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: const Uuid().v7obj(),
|
|
code: 'code',
|
|
);
|
|
|
|
expect(register.asked, ['completeSimpleLogin']);
|
|
expect(completed.outcome, SimpleLoginOutcome.signedIn);
|
|
expect(completed.session!.authUserId, carla);
|
|
expect((completed.hasKeys, completed.handle), (true, 'Carla'));
|
|
expect(await scopesOf(carla), {identified.name});
|
|
expect(
|
|
(await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(carla),
|
|
)).single.method,
|
|
simpleLoginMethod,
|
|
);
|
|
});
|
|
|
|
test("adds SimpleLogin at the register to the caller's account, "
|
|
'who stays identified here', () async {
|
|
register.simpleLoginProof = SimpleLoginProof(
|
|
outcome: SimpleLoginOutcome.linked,
|
|
);
|
|
|
|
final completed = await endpoints.simpleLogin.complete(
|
|
asBen(),
|
|
attempt: const Uuid().v7obj(),
|
|
code: 'code',
|
|
);
|
|
|
|
expect(completed.outcome, SimpleLoginOutcome.linked);
|
|
expect(completed.session, isNull);
|
|
expect(register.simpleLoginLinked, {ben});
|
|
expect(await scopesOf(ben), contains(identified.name));
|
|
expect(
|
|
(await endpoints.account.signInWays(asBen())).simpleLogin,
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('passes on the handle suggested to someone new, with no session '
|
|
'and no person here', () async {
|
|
final people = await AuthUser.db.count(session);
|
|
register.simpleLoginProof = SimpleLoginProof(
|
|
outcome: SimpleLoginOutcome.newcomer,
|
|
suggestedHandle: 'carla_mueller',
|
|
);
|
|
|
|
final completed = await endpoints.simpleLogin.complete(
|
|
notSignedIn(),
|
|
attempt: const Uuid().v7obj(),
|
|
code: 'code',
|
|
);
|
|
|
|
expect(completed.outcome, SimpleLoginOutcome.newcomer);
|
|
expect(completed.session, isNull);
|
|
expect(completed.suggestedHandle, 'carla_mueller');
|
|
expect(await AuthUser.db.count(session), people);
|
|
});
|
|
|
|
test('refuses as the register refuses', () async {
|
|
register.failing = OidcRefusedException(
|
|
reason: OidcRefusal.takenByAnotherPerson,
|
|
);
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.complete(
|
|
asBen(),
|
|
attempt: const Uuid().v7obj(),
|
|
code: 'code',
|
|
),
|
|
throwsA(
|
|
isA<OidcRefusedException>().having(
|
|
(error) => error.reason,
|
|
'reason',
|
|
OidcRefusal.takenByAnotherPerson,
|
|
),
|
|
),
|
|
);
|
|
expect(register.simpleLoginLinked, isEmpty);
|
|
});
|
|
|
|
test('checks a handle for someone new at the register, the device '
|
|
'network passed on', () async {
|
|
final attempt = const Uuid().v7obj();
|
|
register.handleRefusal = ProfileRefusal.handleTaken;
|
|
|
|
expect(
|
|
await endpoints.simpleLogin.checkHandle(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla',
|
|
),
|
|
ProfileRefusal.handleTaken,
|
|
);
|
|
expect(register.asked, ['checkSimpleLoginHandle']);
|
|
expect(register.handlesChecked, ['carla']);
|
|
expect(register.networks, hasLength(1));
|
|
});
|
|
|
|
test('signs up someone new at the register, a person here with a '
|
|
'session of this server', () async {
|
|
final attempt = const Uuid().v7obj();
|
|
|
|
final signedUp = await endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: attempt,
|
|
handle: 'carla',
|
|
setup: (await KeysDevice.make()).setup(),
|
|
);
|
|
|
|
final carla = signedUp.authUserId;
|
|
expect(register.asked, ['signUpWithSimpleLogin']);
|
|
expect(register.networks, hasLength(1));
|
|
expect(register.simpleLoginSignUps.single, (
|
|
attempt: attempt,
|
|
handle: 'carla',
|
|
));
|
|
expect(register.accounts, contains(carla));
|
|
expect(await scopesOf(carla), {identified.name});
|
|
expect(
|
|
(await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(carla),
|
|
)).single.method,
|
|
simpleLoginMethod,
|
|
);
|
|
});
|
|
|
|
test('signs up nobody for a signed-in caller, nor as the register '
|
|
'refuses, making no person here', () async {
|
|
final people = await AuthUser.db.count(session);
|
|
final setup = (await KeysDevice.make()).setup();
|
|
|
|
await expectLater(
|
|
endpoints.simpleLogin.signUp(
|
|
asAnna(),
|
|
attempt: const Uuid().v7obj(),
|
|
handle: 'carla',
|
|
setup: setup,
|
|
),
|
|
throwsA(
|
|
isA<OidcRefusedException>().having(
|
|
(error) => error.reason,
|
|
'reason',
|
|
OidcRefusal.wrongCaller,
|
|
),
|
|
),
|
|
);
|
|
expect(register.asked, isEmpty);
|
|
register.failing = ProfileRefusedException(
|
|
reason: ProfileRefusal.handleTaken,
|
|
);
|
|
await expectLater(
|
|
endpoints.simpleLogin.signUp(
|
|
notSignedIn(),
|
|
attempt: const Uuid().v7obj(),
|
|
handle: 'carla',
|
|
setup: setup,
|
|
),
|
|
throwsA(
|
|
isA<ProfileRefusedException>().having(
|
|
(error) => error.reason,
|
|
'reason',
|
|
ProfileRefusal.handleTaken,
|
|
),
|
|
),
|
|
);
|
|
expect(await AuthUser.db.count(session), people);
|
|
});
|
|
|
|
test('begins mending a link at the register, the device network '
|
|
'passed on, and never for a signed-in caller', () async {
|
|
await endpoints.simpleLogin.beginMend(
|
|
notSignedIn(),
|
|
attempt: const Uuid().v7obj(),
|
|
handle: 'Ben',
|
|
);
|
|
|
|
expect(register.asked, ['beginSimpleLoginMend']);
|
|
expect(register.mendsBegun, ['Ben']);
|
|
expect(register.networks, hasLength(1));
|
|
await expectLater(
|
|
endpoints.simpleLogin.beginMend(
|
|
asAnna(),
|
|
attempt: const Uuid().v7obj(),
|
|
handle: 'Ben',
|
|
),
|
|
throwsA(
|
|
isA<OidcRefusedException>().having(
|
|
(error) => error.reason,
|
|
'reason',
|
|
OidcRefusal.wrongCaller,
|
|
),
|
|
),
|
|
);
|
|
expect(register.asked, hasLength(1));
|
|
});
|
|
|
|
test('mends a link at the register, the proof bound to the base URL '
|
|
"the device addressed here, the account's sessions here made "
|
|
'before the register ended them ending too', () async {
|
|
register.mended = SimpleLoginProof(
|
|
outcome: SimpleLoginOutcome.signedIn,
|
|
account: ben,
|
|
hasKeys: true,
|
|
handle: 'Ben',
|
|
endedBefore: DateTime.now().toUtc(),
|
|
);
|
|
|
|
final completed = await endpoints.simpleLogin.mend(
|
|
notSignedIn(),
|
|
attempt: const Uuid().v7obj(),
|
|
proof: KeysProof(
|
|
challenge: const Uuid().v7obj(),
|
|
signature: ByteData(64),
|
|
instanceUrl: 'HTTP://LOCALHOST:0',
|
|
),
|
|
);
|
|
|
|
expect(register.mendProofs.single.instanceUrl, testInstanceUrl);
|
|
expect(completed.session!.authUserId, ben);
|
|
expect((completed.hasKeys, completed.handle), (true, 'Ben'));
|
|
expect(
|
|
await ServerSideSession.db.findById(session, bensLaptop),
|
|
isNull,
|
|
);
|
|
expect(
|
|
(await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
)).single.method,
|
|
simpleLoginMethod,
|
|
);
|
|
});
|
|
|
|
test('is offered as the register offers it, and not while it is out '
|
|
'of reach', () async {
|
|
expect(
|
|
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
|
|
isTrue,
|
|
);
|
|
register.simpleLoginIsOffered = false;
|
|
expect(
|
|
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
|
|
isFalse,
|
|
);
|
|
register
|
|
..simpleLoginIsOffered = true
|
|
..failing = RegisterUnavailableException('test');
|
|
expect(
|
|
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
|
|
isFalse,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('keys without a password that signs in, by asking the register '
|
|
'(ADR 0020, D19)', () {
|
|
KeysProof wrongProof() => KeysProof(
|
|
challenge: const Uuid().v7obj(),
|
|
signature: ByteData(64),
|
|
instanceUrl: testInstanceUrl,
|
|
);
|
|
|
|
test('are begun and unlocked at the register, the proof bound to '
|
|
'the base URL the device addressed here', () async {
|
|
final challenge = await endpoints.keys.begin(asBen());
|
|
final keys = await endpoints.keys.unlock(
|
|
asBen(),
|
|
KeysProof(
|
|
challenge: challenge.challenge,
|
|
signature: ByteData(64),
|
|
instanceUrl: testInstanceUrl,
|
|
),
|
|
);
|
|
|
|
expect(register.asked, ['beginPasswordKeyProof', 'unlockKeys']);
|
|
expect(keys.signingPublicKey.lengthInBytes, 32);
|
|
expect(register.keysProofs.single.challenge, challenge.challenge);
|
|
expect(register.keysProofs.single.instanceUrl, testInstanceUrl);
|
|
});
|
|
|
|
test('refuse a proof for another instance without asking the '
|
|
'register', () async {
|
|
await expectLater(
|
|
endpoints.keys.unlock(
|
|
asBen(),
|
|
wrongProof().copyWith(instanceUrl: 'https://evil.example/'),
|
|
),
|
|
throwsA(
|
|
isA<PasswordRefusedException>().having(
|
|
(refusal) => refusal.reason,
|
|
'reason',
|
|
PasswordRefusal.otherInstance,
|
|
),
|
|
),
|
|
);
|
|
expect(register.asked, isEmpty);
|
|
});
|
|
|
|
test('are set first at the register with the sign-in and handle, '
|
|
'for the caller', () async {
|
|
final attempt = const Uuid().v7obj();
|
|
|
|
await endpoints.keys.set(
|
|
asBen(),
|
|
attempt: attempt,
|
|
setup: (await KeysDevice.make()).setup(),
|
|
handle: 'ben',
|
|
);
|
|
|
|
expect(register.asked, ['setFirstKeys']);
|
|
expect(register.networks, hasLength(1));
|
|
expect(register.firstKeys, {ben: (attempt: attempt, handle: 'ben')});
|
|
});
|
|
|
|
test('are changed at the register, each proof bound to the base URL '
|
|
'the device addressed here, this session kept where every other '
|
|
'ends', () async {
|
|
final proof = KeysOwnerProof(
|
|
challenge: const Uuid().v7obj(),
|
|
signature: ByteData(64),
|
|
instanceUrl: testInstanceUrl,
|
|
attempt: const Uuid().v7obj(),
|
|
);
|
|
final password = KeysPassword(
|
|
salt: ByteData(16),
|
|
costs: newPasswordCosts,
|
|
passwordProofKey: ByteData(32),
|
|
passwordKeys: WrappedKeys(
|
|
nonce: ByteData(24),
|
|
cipherText: ByteData(112),
|
|
),
|
|
);
|
|
|
|
await endpoints.keys.changePassword(
|
|
asBen(),
|
|
proof: proof,
|
|
password: password,
|
|
);
|
|
await endpoints.keys.beginPhrase(asBen());
|
|
await endpoints.keys.resetPassword(
|
|
asBen(),
|
|
proof: proof,
|
|
password: password,
|
|
);
|
|
await endpoints.keys.setRecoveryKeys(
|
|
asBen(),
|
|
proof: proof,
|
|
recoveryKeys: WrappedKeys(
|
|
nonce: ByteData(24),
|
|
cipherText: ByteData(112),
|
|
),
|
|
recoveryProofKey: ByteData(32),
|
|
);
|
|
|
|
expect(register.asked, [
|
|
'changeKeysPassword',
|
|
'beginPhraseKeyProof',
|
|
'resetKeysPassword',
|
|
'setKeysRecovery',
|
|
]);
|
|
expect(
|
|
[
|
|
for (final change in register.keysChanges)
|
|
(change.change, change.keepSession),
|
|
],
|
|
[
|
|
('password', bensLaptop),
|
|
('reset', bensLaptop),
|
|
('phrase', null),
|
|
],
|
|
);
|
|
expect(
|
|
register.keysChanges.map((change) => change.proof.instanceUrl),
|
|
everyElement(testInstanceUrl),
|
|
);
|
|
await expectLater(
|
|
endpoints.keys.changePassword(
|
|
asBen(),
|
|
proof: proof.copyWith(instanceUrl: 'https://evil.example/'),
|
|
password: password,
|
|
),
|
|
throwsA(
|
|
isA<PasswordRefusedException>().having(
|
|
(refusal) => refusal.reason,
|
|
'reason',
|
|
PasswordRefusal.otherInstance,
|
|
),
|
|
),
|
|
);
|
|
expect(register.asked, hasLength(4));
|
|
});
|
|
|
|
test("count the session's own wrong proofs, which the register "
|
|
'refuses: the tenth in a row ends the session here', () async {
|
|
register.failing = PasswordRefusedException(
|
|
reason: PasswordRefusal.wrongCredentials,
|
|
);
|
|
for (var i = 0; i < 10; i++) {
|
|
await expectLater(
|
|
endpoints.keys.unlock(asBen(), wrongProof()),
|
|
throwsA(
|
|
isA<PasswordRefusedException>().having(
|
|
(refusal) => refusal.reason,
|
|
'reason',
|
|
i < 9
|
|
? PasswordRefusal.wrongCredentials
|
|
: PasswordRefusal.sessionEnded,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
expect(
|
|
await ServerSideSession.db.findById(session, bensLaptop),
|
|
isNull,
|
|
);
|
|
expect(register.warned, {ben});
|
|
});
|
|
});
|
|
|
|
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(register.networks, hasLength(1));
|
|
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('are removed at the register, which says what is left: the '
|
|
'handle (ADR 0020, D27)', () async {
|
|
await endpoints.profile.save(asBen(), 'Ben', 'Ben', '', const []);
|
|
|
|
final removed = (await endpoints.profile.remove(asBen()))!;
|
|
|
|
expect((removed.handle, removed.displayName), ('Ben', ''));
|
|
expect(register.asked.last, 'removeProfile');
|
|
expect(register.profiles[ben]!.toJson(), removed.toJson());
|
|
});
|
|
|
|
test('have their handles checked at the register, the device '
|
|
'network passed on to be counted there', () async {
|
|
register.handleRefusal = ProfileRefusal.tooManyHandleChecks;
|
|
|
|
expect(
|
|
await endpoints.profile.checkHandle(asBen(), 'Ben'),
|
|
ProfileRefusal.tooManyHandleChecks,
|
|
);
|
|
expect(register.asked, ['checkHandle']);
|
|
expect(register.handlesChecked, ['Ben']);
|
|
expect(register.networks, hasLength(1));
|
|
});
|
|
|
|
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("passes the password's proof on to the register, bound to "
|
|
'the base URL the device addressed here', () async {
|
|
await endpoints.account.delete(
|
|
asBen(),
|
|
proof: DeletionProof(
|
|
challenge: const Uuid().v7obj(),
|
|
clientValue: ByteData(384),
|
|
clientProof: ByteData(32),
|
|
totpCode: '123456',
|
|
instanceUrl: 'HTTP://LOCALHOST:0',
|
|
),
|
|
);
|
|
|
|
expect(register.deleted, [ben]);
|
|
expect(register.deletedWith.single?.instanceUrl, testInstanceUrl);
|
|
expect(register.deletedWith.single?.totpCode, '123456');
|
|
});
|
|
|
|
test('passes the proof of keys without SRP on to the register, with '
|
|
'its SimpleLogin sign-in, bound alike', () async {
|
|
final attempt = const Uuid().v7obj();
|
|
|
|
await endpoints.account.delete(
|
|
asBen(),
|
|
keysProof: KeysOwnerProof(
|
|
challenge: const Uuid().v7obj(),
|
|
signature: ByteData(64),
|
|
instanceUrl: 'HTTP://LOCALHOST:0',
|
|
attempt: attempt,
|
|
),
|
|
);
|
|
|
|
expect(register.deleted, [ben]);
|
|
expect(register.deletedWith.single, isNull);
|
|
expect(
|
|
register.deletedWithKeys.single?.instanceUrl,
|
|
testInstanceUrl,
|
|
);
|
|
expect(register.deletedWithKeys.single?.attempt, attempt);
|
|
});
|
|
|
|
test('of an anonymous account left from before deletes it here '
|
|
'alone, once the register says it has no such account', () async {
|
|
await endpoints.account.delete(asAnna());
|
|
|
|
expect(register.asked, ['knownAccounts']);
|
|
expect(await AuthUser.db.findById(session, anna), isNull);
|
|
expect(hostDeleted, [anna]);
|
|
});
|
|
|
|
test(
|
|
'of a person the register knows deletes it at the register, '
|
|
"whatever this session's scopes say (the full reading's L7)",
|
|
() async {
|
|
// Linked at the register, her becoming identified here failed.
|
|
register.accounts.add(anna);
|
|
await endpoints.account.delete(asAnna());
|
|
|
|
expect(register.deleted, [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);
|
|
});
|
|
|
|
test('asks the register first: tries while it is out of reach count '
|
|
"for nothing, as no proof left here (the reading of the fixes' "
|
|
'B1)', () async {
|
|
register.failing = RegisterUnavailableException('test');
|
|
DeletionProof proof() => DeletionProof(
|
|
challenge: const Uuid().v7obj(),
|
|
clientValue: ByteData(384),
|
|
clientProof: ByteData(32),
|
|
totpCode: '123456',
|
|
instanceUrl: testInstanceUrl,
|
|
);
|
|
for (var i = 0; i < 11; i++) {
|
|
await expectLater(
|
|
endpoints.account.delete(asBen(), proof: proof()),
|
|
throwsA(isA<RegisterUnavailableException>()),
|
|
);
|
|
}
|
|
expect(register.asked.toSet(), {'knownAccounts'});
|
|
expect(
|
|
await ServerSideSession.db.findById(session, bensLaptop),
|
|
isNotNull,
|
|
);
|
|
});
|
|
|
|
test('of a person anonymous here is refused too while the register '
|
|
'is out of reach, deleting nothing here: the register may have '
|
|
'linked her while her becoming identified here failed (the '
|
|
'short reading, L2)', () async {
|
|
register.failing = RegisterUnavailableException('test');
|
|
|
|
await expectLater(
|
|
endpoints.account.delete(asAnna()),
|
|
throwsA(isA<RegisterUnavailableException>()),
|
|
);
|
|
expect(await AuthUser.db.findById(session, anna), isNotNull);
|
|
expect(
|
|
await ServerSideSession.db.findById(session, annasPhone),
|
|
isNotNull,
|
|
);
|
|
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(
|
|
"a moment sent with the register's clock is taken by this "
|
|
"server's own: a clock ahead ends the sessions made here in its "
|
|
"gap, those made since stay (the reading of the fixes' C1)",
|
|
() async {
|
|
// This server runs 60 s ahead of the register. By its clock, a
|
|
// session was made 20 s ago, the register ended the account's
|
|
// sessions 10 s ago, and another was made 5 s ago.
|
|
final now = DateTime.now().toUtc();
|
|
Future<UuidValue> madeAgo(Duration ago) async {
|
|
final id = await signInNewDevice(session, ben);
|
|
final row = (await ServerSideSession.db.findById(session, id))!;
|
|
await ServerSideSession.db.updateRow(
|
|
session,
|
|
row.copyWith(createdAt: now.subtract(ago)),
|
|
);
|
|
return id;
|
|
}
|
|
|
|
final inTheGap = await madeAgo(const Duration(seconds: 20));
|
|
final since = await madeAgo(const Duration(seconds: 5));
|
|
const ahead = Duration(seconds: 60);
|
|
final endedBefore = now
|
|
.subtract(const Duration(seconds: 10))
|
|
.subtract(ahead);
|
|
|
|
final answer = await tell({
|
|
'account': ben.uuid,
|
|
'endedBefore': endedBefore.toIso8601String(),
|
|
'sentAt': now.subtract(ahead).toIso8601String(),
|
|
});
|
|
|
|
expect(answer.status, 200);
|
|
expect(
|
|
await ServerSideSession.db.findById(session, inTheGap),
|
|
isNull,
|
|
);
|
|
expect(
|
|
await ServerSideSession.db.findById(session, since),
|
|
isNotNull,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('a moment sent before it was taken (a clock set back) ends '
|
|
'every session made here until now, rather than none', () async {
|
|
final now = DateTime.now().toUtc();
|
|
final bensPhone = await signInNewDevice(session, ben);
|
|
final answer = await tell({
|
|
'account': ben.uuid,
|
|
'endedBefore': now
|
|
.subtract(const Duration(hours: 1))
|
|
.toIso8601String(),
|
|
'sentAt': now.subtract(const Duration(hours: 2)).toIso8601String(),
|
|
});
|
|
expect(answer.status, 200);
|
|
expect(
|
|
await ServerSideSession.db.findById(session, bensPhone),
|
|
isNull,
|
|
);
|
|
expect(
|
|
(await tell({
|
|
'account': ben.uuid,
|
|
'endedBefore': now.toIso8601String(),
|
|
'sentAt': 'then',
|
|
})).status,
|
|
400,
|
|
);
|
|
});
|
|
|
|
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(),
|
|
proof: OwnerProof(
|
|
challenge: challenge.challenge,
|
|
clientValue: ByteData(384),
|
|
clientProof: ByteData(32),
|
|
instanceUrl: 'HTTP://LOCALHOST:0',
|
|
totpCode: '123456',
|
|
),
|
|
password: NewPassword(
|
|
salt: ByteData(16),
|
|
costs: newPasswordCosts,
|
|
verifier: ByteData(384),
|
|
passwordKeys: WrappedKeys(
|
|
nonce: ByteData(24),
|
|
cipherText: ByteData(112),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(register.asked, ['beginPasswordChange', 'changePassword']);
|
|
expect(register.changed, {ben: bensLaptop});
|
|
// Bound to the base URL the device addressed here.
|
|
expect(register.changedWith.single.instanceUrl, testInstanceUrl);
|
|
expect(register.changedWith.single.totpCode, '123456');
|
|
});
|
|
|
|
test("counts its own session's wrong proofs, which the register "
|
|
'refuses: the tenth in a row ends the session here', () async {
|
|
register.failing = PasswordRefusedException(
|
|
reason: PasswordRefusal.wrongCredentials,
|
|
);
|
|
Future<List<String>> newCodes() =>
|
|
endpoints.authenticator.newRecoveryCodes(
|
|
asBen(),
|
|
proof: OwnerProof(
|
|
challenge: const Uuid().v7obj(),
|
|
clientValue: ByteData(384),
|
|
clientProof: ByteData(32),
|
|
instanceUrl: testInstanceUrl,
|
|
totpCode: '000000',
|
|
),
|
|
);
|
|
for (var i = 0; i < 9; i++) {
|
|
await expectLater(
|
|
newCodes(),
|
|
throwsA(
|
|
isA<PasswordRefusedException>().having(
|
|
(refusal) => refusal.reason,
|
|
'reason',
|
|
PasswordRefusal.wrongCredentials,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
expect(register.warned, isEmpty);
|
|
await expectLater(
|
|
newCodes(),
|
|
throwsA(
|
|
isA<PasswordRefusedException>().having(
|
|
(refusal) => refusal.reason,
|
|
'reason',
|
|
PasswordRefusal.sessionEnded,
|
|
),
|
|
),
|
|
);
|
|
expect(
|
|
await ServerSideSession.db.findById(session, bensLaptop),
|
|
isNull,
|
|
);
|
|
// The owner's warning is the register's to keep.
|
|
expect(register.warned, {ben});
|
|
});
|
|
|
|
test('ends the session all the same when the register, down, '
|
|
"can't be told to warn its owner", () async {
|
|
register
|
|
..failing = PasswordRefusedException(
|
|
reason: PasswordRefusal.wrongCredentials,
|
|
)
|
|
..notingFails = RegisterUnavailableException('test');
|
|
for (var i = 0; i < 10; i++) {
|
|
await expectLater(
|
|
endpoints.authenticator.newRecoveryCodes(
|
|
asBen(),
|
|
proof: OwnerProof(
|
|
challenge: const Uuid().v7obj(),
|
|
clientValue: ByteData(384),
|
|
clientProof: ByteData(32),
|
|
instanceUrl: testInstanceUrl,
|
|
totpCode: '000000',
|
|
),
|
|
),
|
|
throwsA(
|
|
isA<PasswordRefusedException>().having(
|
|
(refusal) => refusal.reason,
|
|
'reason',
|
|
i < 9
|
|
? PasswordRefusal.wrongCredentials
|
|
: PasswordRefusal.sessionEnded,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
expect(
|
|
await ServerSideSession.db.findById(session, bensLaptop),
|
|
isNull,
|
|
);
|
|
expect(register.asked.last, 'noteSessionEndedForProofs');
|
|
expect(register.warned, isEmpty);
|
|
});
|
|
|
|
test("passes its person's calling off of resets to the register, "
|
|
'never one signed out', () async {
|
|
await endpoints.password.callOffResets(
|
|
asBen(),
|
|
proof: OwnerProof(
|
|
challenge: const Uuid().v7obj(),
|
|
clientValue: ByteData(384),
|
|
clientProof: ByteData(32),
|
|
totpCode: '123456',
|
|
instanceUrl: testInstanceUrl,
|
|
),
|
|
);
|
|
expect(register.asked, contains('callOffResets'));
|
|
register.asked.clear();
|
|
await expectLater(
|
|
endpoints.password.callOffResets(
|
|
sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
|
),
|
|
proof: OwnerProof(
|
|
challenge: const Uuid().v7obj(),
|
|
clientValue: ByteData(384),
|
|
clientProof: ByteData(32),
|
|
instanceUrl: testInstanceUrl,
|
|
),
|
|
),
|
|
throwsA(isA<ServerpodUnauthenticatedException>()),
|
|
);
|
|
expect(register.asked, isNot(contains('callOffResets')));
|
|
});
|
|
|
|
test("passes its person's dismissing of the warning to the register, "
|
|
"never another's", () async {
|
|
await endpoints.password.dismissSessionEndedWarning(asBen());
|
|
expect(register.dismissed, {ben});
|
|
// With when Ben's session here was made (the full reading's L1).
|
|
expect(register.dismissedMadeAt[ben], isNotNull);
|
|
await expectLater(
|
|
endpoints.password.dismissSessionEndedWarning(
|
|
sessionBuilder.copyWith(
|
|
authentication: AuthenticationOverride.unauthenticated(),
|
|
),
|
|
),
|
|
throwsA(isA<ServerpodUnauthenticatedException>()),
|
|
);
|
|
expect(register.dismissed, {ben});
|
|
});
|
|
|
|
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',
|
|
instanceUrl: testInstanceUrl,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
);
|
|
|
|
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,
|
|
);
|
|
});
|
|
|
|
PasswordProof bensProof() => PasswordProof(
|
|
account: ben,
|
|
serverProof: ByteData(32),
|
|
encryptionPublicKey: ByteData(32),
|
|
signingPublicKey: ByteData(32),
|
|
passwordKeys: WrappedKeys(
|
|
nonce: ByteData(24),
|
|
cipherText: ByteData(112),
|
|
),
|
|
needsAuthenticator: false,
|
|
);
|
|
|
|
Future<AuthSuccess> resetBensPassword() =>
|
|
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',
|
|
instanceUrl: testInstanceUrl,
|
|
address: 'anna@example.org',
|
|
language: 'en',
|
|
);
|
|
|
|
test("a clock behind the register's neither waits nor refuses: the "
|
|
"sessions here end, and the new one stays when the register's "
|
|
'notice, sent with its clock, arrives', () async {
|
|
final endedBefore = DateTime.now().toUtc().add(
|
|
const Duration(minutes: 1),
|
|
);
|
|
register
|
|
..proof = bensProof()
|
|
..endedBefore = endedBefore;
|
|
final watch = Stopwatch()..start();
|
|
|
|
final signedIn = await resetBensPassword();
|
|
|
|
expect(watch.elapsed, lessThan(const Duration(seconds: 5)));
|
|
final left = await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
);
|
|
expect(left.single.id, isNot(bensLaptop));
|
|
expect(signedIn.authUserId, ben);
|
|
final answer = await answerSessionsEnded(
|
|
session,
|
|
jsonEncode({
|
|
'account': ben.uuid,
|
|
'endedBefore': endedBefore.toIso8601String(),
|
|
'sentAt': endedBefore
|
|
.add(const Duration(seconds: 1))
|
|
.toIso8601String(),
|
|
}),
|
|
authorization: 'Bearer $_secret',
|
|
);
|
|
expect(answer.status, 200);
|
|
expect(
|
|
await ServerSideSession.db.findById(session, left.single.id!),
|
|
isNotNull,
|
|
);
|
|
});
|
|
|
|
test("the register's notice of a reset, however long it took to "
|
|
'arrive, leaves the session made here for it; a notice of a '
|
|
'later moment ends it (the short reading, M2)', () async {
|
|
final endedBefore = DateTime.now().toUtc();
|
|
register
|
|
..proof = bensProof()
|
|
..endedBefore = endedBefore;
|
|
final signedIn = await resetBensPassword();
|
|
final made = (await ServerSideSession.db.find(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
)).single.id!;
|
|
expect(signedIn.authUserId, ben);
|
|
// Its way here took longer than the reset's answer: sent at once,
|
|
// it arrives after the session was made.
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
final answer = await answerSessionsEnded(
|
|
session,
|
|
jsonEncode({
|
|
'account': ben.uuid,
|
|
'endedBefore': endedBefore.toIso8601String(),
|
|
'sentAt': endedBefore.toIso8601String(),
|
|
}),
|
|
authorization: 'Bearer $_secret',
|
|
);
|
|
expect(answer.status, 200);
|
|
expect(await ServerSideSession.db.findById(session, made), isNotNull);
|
|
|
|
final later = DateTime.now().toUtc();
|
|
await answerSessionsEnded(
|
|
session,
|
|
jsonEncode({
|
|
'account': ben.uuid,
|
|
'endedBefore': later.toIso8601String(),
|
|
'sentAt': later.toIso8601String(),
|
|
}),
|
|
authorization: 'Bearer $_secret',
|
|
);
|
|
expect(await ServerSideSession.db.findById(session, made), isNull);
|
|
});
|
|
|
|
test("a notice of the reset's moment for another account keeps "
|
|
"nothing of Ben's", () async {
|
|
final endedBefore = DateTime.now().toUtc();
|
|
register
|
|
..proof = bensProof()
|
|
..endedBefore = endedBefore;
|
|
await resetBensPassword();
|
|
final annasTablet = await signInNewDevice(session, anna);
|
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
|
await answerSessionsEnded(
|
|
session,
|
|
jsonEncode({
|
|
'account': anna.uuid,
|
|
'endedBefore': endedBefore.toIso8601String(),
|
|
'sentAt': endedBefore.toIso8601String(),
|
|
}),
|
|
authorization: 'Bearer $_secret',
|
|
);
|
|
expect(
|
|
await ServerSideSession.db.findById(session, annasTablet),
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
test("a clock ahead of the register's ends the sessions made here in "
|
|
"its gap too (the reading of the fixes' C1)", () async {
|
|
final now = DateTime.now().toUtc();
|
|
register
|
|
..proof = bensProof()
|
|
..endedBefore = now.subtract(const Duration(minutes: 1));
|
|
// Whoever knew the old password signed in here 20 s before the
|
|
// reset: by this server's clock, 40 s after the register's moment.
|
|
final inTheGap = await signInNewDevice(
|
|
session,
|
|
ben,
|
|
scopes: {identified},
|
|
);
|
|
final row = (await ServerSideSession.db.findById(session, inTheGap))!;
|
|
await ServerSideSession.db.updateRow(
|
|
session,
|
|
row.copyWith(createdAt: now.subtract(const Duration(seconds: 20))),
|
|
);
|
|
|
|
final signedIn = await resetBensPassword();
|
|
|
|
expect(signedIn.authUserId, ben);
|
|
expect(
|
|
await ServerSideSession.db.findById(session, inTheGap),
|
|
isNull,
|
|
);
|
|
expect(
|
|
await ServerSideSession.db.count(
|
|
session,
|
|
where: (t) => t.authUserId.equals(ben),
|
|
),
|
|
1,
|
|
);
|
|
});
|
|
});
|
|
|
|
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));
|
|
}
|