feat(base): SimpleLogin accounts' keys without SRP, signing up in one call (ADR 0020, step 3)
An account that signs in with SimpleLogin keeps its keys under a password that signs nobody in (D19): `account_password` keeps the password's own proof key (Ed25519, `passwordProofKey`) in place of SRP's verifier, which becomes nullable. The migration adds the column and drops the NOT NULL, nothing else. - `simpleLogin.signUp(attempt, handle, setup)`: someone new whose identity waits under the attempt gets, in one transaction, an account (identified), the link, a profile with the handle and the keys; then a session (D18). Refused, the identity still waiting, for a setup out of range and a handle refused as saving a profile refuses it. - `simpleLogin.checkHandle(attempt, handle)`: whether someone new could take a handle, at most 30 an hour per attempt and 60 per network. - `simpleLogin.complete` says, for an account, whether it has its keys and its handle, and keeps the sign-in ten minutes under its attempt, the account as its caller: D20's confirmation for the changes to come. - `keys.begin`, `keys.unlock`: the keys for a signed-in device once the password is proven with its proof key, for a nonce, bound to the base URL the device addressed; wrong proofs counted per session as ADR 0017's. - An authenticator is enrolled only for a password that signs in. - Members ask the register for all of it; `RemoteRegister` and the instance API carry the four new operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
c6831b0b7b
commit
090d901e92
1 changed files with 46254 additions and 78 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_oidc_server/oidc.dart';
|
||||
|
|
@ -7,6 +9,7 @@ import 'package:serverpod_auth_idp_server/core.dart';
|
|||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
import '../support/keys_device.dart';
|
||||
import '../support/mailbox.dart';
|
||||
import '../support/password_device.dart';
|
||||
import '../support/password_support.dart';
|
||||
|
|
@ -533,6 +536,425 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('a sign-in stands for ten minutes (D20)', () {
|
||||
test(
|
||||
'signed out or in, under its attempt, for the account that '
|
||||
'signed in alone, and says whether it has keys and its handle',
|
||||
() async {
|
||||
await giveKeys(session, ben, await KeysDevice.make());
|
||||
await AccountRegister.of(pod).saveProfile(
|
||||
session,
|
||||
ben,
|
||||
handle: 'Ben',
|
||||
displayName: '',
|
||||
bio: '',
|
||||
links: const [],
|
||||
);
|
||||
final idp = AuthServices.instance.oidcIdp;
|
||||
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
|
||||
final annas = await allowed(asAnna(), subject: 'sl-anna');
|
||||
|
||||
final signedIn = await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: bens.attempt,
|
||||
code: bens.code,
|
||||
);
|
||||
final linked = await endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: annas.attempt,
|
||||
code: annas.code,
|
||||
);
|
||||
|
||||
expect((signedIn.hasKeys, signedIn.handle), (true, 'Ben'));
|
||||
expect((linked.hasKeys, linked.handle), (false, null));
|
||||
for (final (attempt, person) in [
|
||||
(bens.attempt, ben),
|
||||
(annas.attempt, anna),
|
||||
]) {
|
||||
expect(
|
||||
await idp.isKept(session, attempt: attempt, caller: person),
|
||||
isTrue,
|
||||
);
|
||||
expect(await idp.isKept(session, attempt: attempt), isFalse);
|
||||
expect(
|
||||
await withClock(
|
||||
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
||||
() => idp.isKept(session, attempt: attempt, caller: person),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('signing up someone new (D18)', () {
|
||||
late KeysDevice device;
|
||||
|
||||
setUp(() async => device = await KeysDevice.make());
|
||||
|
||||
/// Carla, new here, allowed at SimpleLogin: the attempt her identity
|
||||
/// waits under.
|
||||
Future<UuidValue> newcomer({String subject = 'sl-carla'}) async {
|
||||
final (:attempt, :code) = await allowed(
|
||||
notSignedIn(),
|
||||
subject: subject,
|
||||
name: 'Carla Müller',
|
||||
);
|
||||
await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
);
|
||||
return attempt;
|
||||
}
|
||||
|
||||
Matcher refusedAsProfile(ProfileRefusal reason) => throwsA(
|
||||
isA<ProfileRefusedException>().having(
|
||||
(e) => e.reason,
|
||||
'reason',
|
||||
reason,
|
||||
),
|
||||
);
|
||||
|
||||
test('makes the account with its handle and keys, identified, signed '
|
||||
'in with SimpleLogin, its identity taken once', () async {
|
||||
final attempt = await newcomer();
|
||||
|
||||
final signedUp = await endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'Carla_M',
|
||||
setup: device.setup(),
|
||||
);
|
||||
|
||||
final carla = signedUp.authUserId;
|
||||
expect(
|
||||
(await AuthUser.db.findById(session, carla))!.scopeNames,
|
||||
{identified.name},
|
||||
);
|
||||
expect((await identitiesOf(carla)).single.subject, 'sl-carla');
|
||||
final profile = (await PublicProfile.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(carla),
|
||||
))!;
|
||||
expect((profile.handle, profile.handleKey), ('Carla_M', 'carla_m'));
|
||||
expect(profile.displayName, isEmpty);
|
||||
final keys = (await AccountPassword.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(carla),
|
||||
))!;
|
||||
expect(keys.verifier, isNull);
|
||||
expect(
|
||||
Uint8List.sublistView(keys.passwordProofKey!),
|
||||
device.passwordProofKey,
|
||||
);
|
||||
expect(
|
||||
(await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(carla),
|
||||
)).single.method,
|
||||
simpleLoginMethod,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'Carla_N',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
|
||||
final again = await allowed(notSignedIn(), subject: 'sl-carla');
|
||||
final signedIn = await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: again.attempt,
|
||||
code: again.code,
|
||||
);
|
||||
expect(signedIn.session!.authUserId, carla);
|
||||
expect((signedIn.hasKeys, signedIn.handle), (true, 'Carla_M'));
|
||||
});
|
||||
|
||||
test('refuses a handle someone has, whatever its case, and one nobody '
|
||||
'may have, the identity still waiting', () async {
|
||||
await AccountRegister.of(pod).saveProfile(
|
||||
session,
|
||||
ben,
|
||||
handle: 'Ben',
|
||||
displayName: '',
|
||||
bio: '',
|
||||
links: const [],
|
||||
);
|
||||
final attempt = await newcomer();
|
||||
|
||||
for (final (handle, reason) in [
|
||||
('BEN', ProfileRefusal.handleTaken),
|
||||
('admin', ProfileRefusal.handleReserved),
|
||||
('carla müller', ProfileRefusal.handleInvalid),
|
||||
]) {
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: handle,
|
||||
setup: device.setup(),
|
||||
),
|
||||
refusedAsProfile(reason),
|
||||
reason: handle,
|
||||
);
|
||||
}
|
||||
await endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses keys out of range, the identity still waiting', () async {
|
||||
final attempt = await newcomer();
|
||||
final setup = device.setup();
|
||||
|
||||
for (final wrong in [
|
||||
setup.copyWith(
|
||||
costs: setup.costs.copyWith(opsLimit: 1),
|
||||
),
|
||||
setup.copyWith(salt: ByteData(15)),
|
||||
setup.copyWith(passwordProofKey: ByteData(31)),
|
||||
setup.copyWith(
|
||||
passwordKeys: setup.passwordKeys.copyWith(nonce: ByteData(12)),
|
||||
),
|
||||
]) {
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: wrong,
|
||||
),
|
||||
throwsA(
|
||||
isA<PasswordRefusedException>().having(
|
||||
(e) => e.reason,
|
||||
'reason',
|
||||
PasswordRefusal.invalidSetup,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
await endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: setup,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses an attempt nobody new waits under: unknown, past its '
|
||||
"half hour, or an account's sign-in", () async {
|
||||
final late = await newcomer();
|
||||
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
|
||||
await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: bens.attempt,
|
||||
code: bens.code,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: const Uuid().v4obj(),
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
await expectLater(
|
||||
withClock(
|
||||
Clock.fixed(DateTime.now().add(const Duration(minutes: 31))),
|
||||
() => endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: late,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
),
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: bens.attempt,
|
||||
handle: 'ben2',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
expect(await identitiesOf(ben), hasLength(1));
|
||||
});
|
||||
|
||||
test('refuses a signed-in caller, whose session it would leave for '
|
||||
"another person's", () async {
|
||||
final attempt = await newcomer();
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
asAnna(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
await endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
);
|
||||
});
|
||||
|
||||
test('makes one account of an identity waiting twice', () async {
|
||||
final first = await newcomer();
|
||||
final second = await newcomer();
|
||||
final carla = (await endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: first,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
)).authUserId;
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: second,
|
||||
handle: 'carla2',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.takenByAnotherPerson),
|
||||
);
|
||||
expect(await identitiesOf(carla), hasLength(1));
|
||||
expect(
|
||||
await PublicProfile.db.count(
|
||||
session,
|
||||
where: (t) => t.handleKey.equals('carla2'),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('signs up nobody where SimpleLogin is not offered', () async {
|
||||
final attempt = await newcomer();
|
||||
useSimpleLogin(sessionBuilder, null);
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
),
|
||||
refused(OidcRefusal.unknownProvider),
|
||||
);
|
||||
});
|
||||
|
||||
group('checking a handle', () {
|
||||
test('says whether it is free as saving a profile would, whatever '
|
||||
'its case, taking nothing', () async {
|
||||
await AccountRegister.of(pod).saveProfile(
|
||||
session,
|
||||
ben,
|
||||
handle: 'Ben',
|
||||
displayName: '',
|
||||
bio: '',
|
||||
links: const [],
|
||||
);
|
||||
final attempt = await newcomer();
|
||||
|
||||
for (final (handle, reason) in [
|
||||
('carla', null),
|
||||
('bEn', ProfileRefusal.handleTaken),
|
||||
('support', ProfileRefusal.handleReserved),
|
||||
('carla!', ProfileRefusal.handleInvalid),
|
||||
]) {
|
||||
expect(
|
||||
await endpoints.simpleLogin.checkHandle(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: handle,
|
||||
),
|
||||
reason,
|
||||
reason: handle,
|
||||
);
|
||||
}
|
||||
await endpoints.simpleLogin.signUp(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
setup: device.setup(),
|
||||
);
|
||||
});
|
||||
|
||||
test('only for an attempt someone new waits under', () async {
|
||||
final bens = await allowed(notSignedIn(), subject: 'sl-ben');
|
||||
await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: bens.attempt,
|
||||
code: bens.code,
|
||||
);
|
||||
|
||||
for (final attempt in [const Uuid().v4obj(), bens.attempt]) {
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.checkHandle(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
handle: 'carla',
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('at most 30 times an hour for an attempt, and 60 from one '
|
||||
'network', () async {
|
||||
final first = await newcomer();
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await endpoints.simpleLogin.checkHandle(
|
||||
notSignedIn(),
|
||||
attempt: first,
|
||||
handle: 'carla$i',
|
||||
);
|
||||
}
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.checkHandle(
|
||||
notSignedIn(),
|
||||
attempt: first,
|
||||
handle: 'carla',
|
||||
),
|
||||
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
|
||||
);
|
||||
// A check refused counts for neither: the network has 30.
|
||||
final second = await newcomer(subject: 'sl-dora');
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await endpoints.simpleLogin.checkHandle(
|
||||
notSignedIn(),
|
||||
attempt: second,
|
||||
handle: 'dora$i',
|
||||
);
|
||||
}
|
||||
final third = await newcomer(subject: 'sl-emil');
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.checkHandle(
|
||||
notSignedIn(),
|
||||
attempt: third,
|
||||
handle: 'emil',
|
||||
),
|
||||
refusedAsProfile(ProfileRefusal.tooManyHandleChecks),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('the identity goes with the account', () async {
|
||||
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-anna');
|
||||
await endpoints.simpleLogin.complete(
|
||||
|
|
|
|||
Loading…
Reference in a new issue