feat(auth): ways in for accounts without a working password
ADR 0017, step 3. Signing up takes an address a mailed code proves and a password's setup, without a session (`passwordSignIn.signUp`): an address nobody has becomes a new identified account, and an account with the address but no password (codes by mail before; no keys exist, so nothing waits) takes the password, keeping its ID. A setup never finished signs in with its password and a code mailed to the address it began with, until an authenticator is confirmed, which also calls off a reset of the account waiting; any other account takes no code by mail in place of its second factor and leaves the code unused. A reset of the whole account waiting no longer counts as having had an authenticator, so its owner isn't locked out until it completes. Members forward both through the register; the tests' shared helpers move into `support/password_support.dart`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
ce2c00c7ea
commit
f8076dcacf
19 changed files with 849 additions and 80 deletions
|
|
@ -497,8 +497,9 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
|
||||||
);
|
);
|
||||||
|
|
||||||
/// The second step: the device's A ([clientValue]) and proof M1
|
/// The second step: the device's A ([clientValue]) and proof M1
|
||||||
/// ([clientProof]) for [challenge], and a [totpCode] or a [recoveryCode].
|
/// ([clientProof]) for [challenge], and a [totpCode] or a [recoveryCode]
|
||||||
/// Signed in, SRP's M2 (which the device checks before trusting anything
|
/// (or, for a setup never finished, a [code] mailed for [request] to the
|
||||||
|
/// address the sign-in began with). Signed in, SRP's M2 (which the device checks before trusting anything
|
||||||
/// else), the account's keys as the register keeps them, and the session.
|
/// else), the account's keys as the register keeps them, and the session.
|
||||||
/// Refusals as `completePasswordSignIn`'s.
|
/// Refusals as `completePasswordSignIn`'s.
|
||||||
_ida.Future<_ix90myvx.PasswordSignedIn> complete({
|
_ida.Future<_ix90myvx.PasswordSignedIn> complete({
|
||||||
|
|
@ -507,6 +508,8 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
|
||||||
required _idt.ByteData clientProof,
|
required _idt.ByteData clientProof,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
|
_isc.UuidValue? request,
|
||||||
|
String? code,
|
||||||
}) => caller.callServerEndpoint<_ix90myvx.PasswordSignedIn>(
|
}) => caller.callServerEndpoint<_ix90myvx.PasswordSignedIn>(
|
||||||
'halkyon_base.passwordSignIn',
|
'halkyon_base.passwordSignIn',
|
||||||
'complete',
|
'complete',
|
||||||
|
|
@ -516,6 +519,28 @@ class EndpointPasswordSignIn extends _isc.EndpointRef {
|
||||||
'clientProof': clientProof,
|
'clientProof': clientProof,
|
||||||
'totpCode': totpCode,
|
'totpCode': totpCode,
|
||||||
'recoveryCode': recoveryCode,
|
'recoveryCode': recoveryCode,
|
||||||
|
'request': request,
|
||||||
|
'code': code,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Signs up with the address a [code] mailed for [request]
|
||||||
|
/// (`emailCode.sendCode`) proves and the password [setup] describes (all
|
||||||
|
/// of it made on the device): a new account, or the account with that
|
||||||
|
/// address that has no password yet. Signed in, the device sets up an
|
||||||
|
/// authenticator next, before the password signs in elsewhere. Refusals as
|
||||||
|
/// `signUpWithCode`'s.
|
||||||
|
_ida.Future<_iacc.AuthSuccess> signUp({
|
||||||
|
required _isc.UuidValue request,
|
||||||
|
required String code,
|
||||||
|
required _ipn3lqh9.PasswordSetup setup,
|
||||||
|
}) => caller.callServerEndpoint<_iacc.AuthSuccess>(
|
||||||
|
'halkyon_base.passwordSignIn',
|
||||||
|
'signUp',
|
||||||
|
{
|
||||||
|
'request': request,
|
||||||
|
'code': code,
|
||||||
|
'setup': setup,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,8 @@ enum PasswordRefusal implements _isc.SerializableModel {
|
||||||
/// The step began too long ago, or was finished already: begin again.
|
/// The step began too long ago, or was finished already: begin again.
|
||||||
challengeExpired,
|
challengeExpired,
|
||||||
|
|
||||||
/// The password is right, but no authenticator was confirmed yet: finish
|
/// The password is right, but no authenticator was confirmed yet: sign in
|
||||||
/// setting one up where the password was set (or, while email codes still
|
/// with the password and a code mailed to the address, then set one up.
|
||||||
/// sign in, signed in with a code by mail).
|
|
||||||
setupIncomplete,
|
setupIncomplete,
|
||||||
|
|
||||||
/// What the device sent isn't a password's setup: a verifier, a key, a
|
/// What the device sent isn't a password's setup: a verifier, a key, a
|
||||||
|
|
|
||||||
|
|
@ -805,6 +805,16 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
type: _is.getType<String?>(),
|
type: _is.getType<String?>(),
|
||||||
nullable: true,
|
nullable: true,
|
||||||
),
|
),
|
||||||
|
'request': _is.ParameterDescription(
|
||||||
|
name: 'request',
|
||||||
|
type: _is.getType<_is.UuidValue?>(),
|
||||||
|
nullable: true,
|
||||||
|
),
|
||||||
|
'code': _is.ParameterDescription(
|
||||||
|
name: 'code',
|
||||||
|
type: _is.getType<String?>(),
|
||||||
|
nullable: true,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
call:
|
call:
|
||||||
(
|
(
|
||||||
|
|
@ -820,6 +830,41 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
clientProof: params['clientProof'],
|
clientProof: params['clientProof'],
|
||||||
totpCode: params['totpCode'],
|
totpCode: params['totpCode'],
|
||||||
recoveryCode: params['recoveryCode'],
|
recoveryCode: params['recoveryCode'],
|
||||||
|
request: params['request'],
|
||||||
|
code: params['code'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'signUp': _is.MethodConnector(
|
||||||
|
name: 'signUp',
|
||||||
|
params: {
|
||||||
|
'request': _is.ParameterDescription(
|
||||||
|
name: 'request',
|
||||||
|
type: _is.getType<_is.UuidValue>(),
|
||||||
|
nullable: false,
|
||||||
|
),
|
||||||
|
'code': _is.ParameterDescription(
|
||||||
|
name: 'code',
|
||||||
|
type: _is.getType<String>(),
|
||||||
|
nullable: false,
|
||||||
|
),
|
||||||
|
'setup': _is.ParameterDescription(
|
||||||
|
name: 'setup',
|
||||||
|
type: _is.getType<_ikhyee47.PasswordSetup>(),
|
||||||
|
nullable: false,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
call:
|
||||||
|
(
|
||||||
|
_is.Session session,
|
||||||
|
Map<String, dynamic> params,
|
||||||
|
) async =>
|
||||||
|
(endpoints['passwordSignIn']
|
||||||
|
as _ik13m562.PasswordSignInEndpoint)
|
||||||
|
.signUp(
|
||||||
|
session,
|
||||||
|
request: params['request'],
|
||||||
|
code: params['code'],
|
||||||
|
setup: params['setup'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,8 @@ enum PasswordRefusal implements _is.SerializableModel {
|
||||||
/// The step began too long ago, or was finished already: begin again.
|
/// The step began too long ago, or was finished already: begin again.
|
||||||
challengeExpired,
|
challengeExpired,
|
||||||
|
|
||||||
/// The password is right, but no authenticator was confirmed yet: finish
|
/// The password is right, but no authenticator was confirmed yet: sign in
|
||||||
/// setting one up where the password was set (or, while email codes still
|
/// with the password and a code mailed to the address, then set one up.
|
||||||
/// sign in, signed in with a code by mail).
|
|
||||||
setupIncomplete,
|
setupIncomplete,
|
||||||
|
|
||||||
/// What the device sent isn't a password's setup: a verifier, a key, a
|
/// What the device sent isn't a password's setup: a verifier, a key, a
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ password:
|
||||||
passwordSignIn:
|
passwordSignIn:
|
||||||
- begin:
|
- begin:
|
||||||
- complete:
|
- complete:
|
||||||
|
- signUp:
|
||||||
profile:
|
profile:
|
||||||
- mine:
|
- mine:
|
||||||
- save:
|
- save:
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,8 @@ values:
|
||||||
- tooManyAttempts
|
- tooManyAttempts
|
||||||
### The step began too long ago, or was finished already: begin again.
|
### The step began too long ago, or was finished already: begin again.
|
||||||
- challengeExpired
|
- challengeExpired
|
||||||
### The password is right, but no authenticator was confirmed yet: finish
|
### The password is right, but no authenticator was confirmed yet: sign in
|
||||||
### setting one up where the password was set (or, while email codes still
|
### with the password and a code mailed to the address, then set one up.
|
||||||
### sign in, signed in with a code by mail).
|
|
||||||
- setupIncomplete
|
- setupIncomplete
|
||||||
### What the device sent isn't a password's setup: a verifier, a key, a
|
### What the device sent isn't a password's setup: a verifier, a key, a
|
||||||
### salt or costs out of range.
|
### salt or costs out of range.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_base_server/src/password/passwords.dart';
|
||||||
import 'package:halkyon_base_server/src/register/account_register.dart';
|
import 'package:halkyon_base_server/src/register/account_register.dart';
|
||||||
import 'package:halkyon_base_server/src/register/people.dart';
|
import 'package:halkyon_base_server/src/register/people.dart';
|
||||||
import 'package:serverpod/serverpod.dart';
|
import 'package:serverpod/serverpod.dart';
|
||||||
|
import 'package:serverpod_auth_idp_server/core.dart';
|
||||||
|
|
||||||
/// Signing in with an address, a password and a code from an authenticator
|
/// Signing in with an address, a password and a code from an authenticator
|
||||||
/// (ADR 0017), in SRP-6a's two steps. Called without a session: Serverpod
|
/// (ADR 0017), in SRP-6a's two steps. Called without a session: Serverpod
|
||||||
|
|
@ -26,8 +27,9 @@ class PasswordSignInEndpoint extends Endpoint {
|
||||||
);
|
);
|
||||||
|
|
||||||
/// The second step: the device's A ([clientValue]) and proof M1
|
/// The second step: the device's A ([clientValue]) and proof M1
|
||||||
/// ([clientProof]) for [challenge], and a [totpCode] or a [recoveryCode].
|
/// ([clientProof]) for [challenge], and a [totpCode] or a [recoveryCode]
|
||||||
/// Signed in, SRP's M2 (which the device checks before trusting anything
|
/// (or, for a setup never finished, a [code] mailed for [request] to the
|
||||||
|
/// address the sign-in began with). Signed in, SRP's M2 (which the device checks before trusting anything
|
||||||
/// else), the account's keys as the register keeps them, and the session.
|
/// else), the account's keys as the register keeps them, and the session.
|
||||||
/// Refusals as `completePasswordSignIn`'s.
|
/// Refusals as `completePasswordSignIn`'s.
|
||||||
Future<PasswordSignedIn> complete(
|
Future<PasswordSignedIn> complete(
|
||||||
|
|
@ -37,6 +39,8 @@ class PasswordSignInEndpoint extends Endpoint {
|
||||||
required ByteData clientProof,
|
required ByteData clientProof,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
|
UuidValue? request,
|
||||||
|
String? code,
|
||||||
}) async {
|
}) async {
|
||||||
final proof = await AccountRegister.of(session.serverpod)
|
final proof = await AccountRegister.of(session.serverpod)
|
||||||
.completePasswordSignIn(
|
.completePasswordSignIn(
|
||||||
|
|
@ -46,6 +50,8 @@ class PasswordSignInEndpoint extends Endpoint {
|
||||||
clientProof: clientProof,
|
clientProof: clientProof,
|
||||||
totpCode: totpCode,
|
totpCode: totpCode,
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
|
request: request,
|
||||||
|
code: code,
|
||||||
);
|
);
|
||||||
final signedIn = await issueSession(
|
final signedIn = await issueSession(
|
||||||
session,
|
session,
|
||||||
|
|
@ -66,4 +72,22 @@ class PasswordSignInEndpoint extends Endpoint {
|
||||||
scopeNames: signedIn.scopeNames,
|
scopeNames: signedIn.scopeNames,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signs up with the address a [code] mailed for [request]
|
||||||
|
/// (`emailCode.sendCode`) proves and the password [setup] describes (all
|
||||||
|
/// of it made on the device): a new account, or the account with that
|
||||||
|
/// address that has no password yet. Signed in, the device sets up an
|
||||||
|
/// authenticator next, before the password signs in elsewhere. Refusals as
|
||||||
|
/// `signUpWithCode`'s.
|
||||||
|
Future<AuthSuccess> signUp(
|
||||||
|
Session session, {
|
||||||
|
required UuidValue request,
|
||||||
|
required String code,
|
||||||
|
required PasswordSetup setup,
|
||||||
|
}) async {
|
||||||
|
final account = await AccountRegister.of(
|
||||||
|
session.serverpod,
|
||||||
|
).signUpWithCode(session, request: request, code: code, setup: setup);
|
||||||
|
return await issueSession(session, account, method: passwordMethod);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:clock/clock.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:halkyon_base_server/src/auth/email.dart';
|
import 'package:halkyon_base_server/src/auth/email.dart';
|
||||||
import 'package:halkyon_base_server/src/auth/identified.dart';
|
import 'package:halkyon_base_server/src/auth/identified.dart';
|
||||||
|
import 'package:halkyon_base_server/src/auth/scopes.dart';
|
||||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||||
import 'package:halkyon_base_server/src/password/key_proof.dart';
|
import 'package:halkyon_base_server/src/password/key_proof.dart';
|
||||||
import 'package:halkyon_base_server/src/password/password_config.dart';
|
import 'package:halkyon_base_server/src/password/password_config.dart';
|
||||||
|
|
@ -139,11 +140,15 @@ Future<bool> signsInWithPassword(
|
||||||
await hadConfirmedAuthenticator(session, person, transaction: transaction);
|
await hadConfirmedAuthenticator(session, person, transaction: transaction);
|
||||||
|
|
||||||
/// Whether [person]'s account had a confirmed authenticator: it has one, or
|
/// Whether [person]'s account had a confirmed authenticator: it has one, or
|
||||||
/// a reset of it or its authenticators stands (waiting, or carried out with
|
/// a reset of its authenticators stands (waiting, or carried out with none
|
||||||
/// none confirmed since, which leaves an `account_reset` row until one is).
|
/// confirmed since, which leaves its `account_reset` row until one is; the
|
||||||
/// Such an account's password is set up for good: a session alone never
|
/// account's reset carried out leaves one too). Such an account's password
|
||||||
/// replaces it ([setPassword]), as the 72 hours of a reset would otherwise
|
/// is set up for good: a session alone never replaces it ([setPassword]),
|
||||||
/// be skipped by whoever signs in another way.
|
/// and a code by mail never signs in in place of its second factor
|
||||||
|
/// ([completePasswordSignIn]), as the 72 hours of a reset would otherwise
|
||||||
|
/// be skipped by whoever has the mailbox. A reset of the whole account
|
||||||
|
/// waiting doesn't count: it may wait on a setup never finished, whose
|
||||||
|
/// owner still signs in with the password and a code by mail.
|
||||||
Future<bool> hadConfirmedAuthenticator(
|
Future<bool> hadConfirmedAuthenticator(
|
||||||
Session session,
|
Session session,
|
||||||
UuidValue person, {
|
UuidValue person, {
|
||||||
|
|
@ -154,10 +159,13 @@ Future<bool> hadConfirmedAuthenticator(
|
||||||
person,
|
person,
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
) ||
|
) ||
|
||||||
// SELECT count(*) FROM "account_reset" WHERE "authUserId" = $1
|
// SELECT count(*) FROM "account_reset"
|
||||||
|
// WHERE "authUserId" = $1 AND "kind" = 'authenticator'
|
||||||
await AccountReset.db.count(
|
await AccountReset.db.count(
|
||||||
session,
|
session,
|
||||||
where: (t) => t.authUserId.equals(person),
|
where: (t) =>
|
||||||
|
t.authUserId.equals(person) &
|
||||||
|
t.kind.equals(AccountResetKind.authenticator),
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
) >
|
) >
|
||||||
0;
|
0;
|
||||||
|
|
@ -363,6 +371,100 @@ Future<void> setPassword(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signs up with the address [code] proves for [request] (mailed with
|
||||||
|
/// `sendSignInCode`) and the password [setup] describes, without a session
|
||||||
|
/// (ADR 0017): an address nobody has becomes a new account, identified,
|
||||||
|
/// whose ID is returned; an account with the address and no password (one
|
||||||
|
/// that signed in with codes by mail; no keys exist, so nothing waits)
|
||||||
|
/// takes the password, keeping its ID and everything it has. Either way
|
||||||
|
/// the password signs in once an authenticator is confirmed, which the
|
||||||
|
/// session the caller issues sets up.
|
||||||
|
///
|
||||||
|
/// Refused ([PasswordRefusedException]) where the instance has no password
|
||||||
|
/// sign-in, for a setup out of range, and for an address whose account has
|
||||||
|
/// a password already ([PasswordRefusal.passwordExists]: it signs in with
|
||||||
|
/// it, or resets it); a code's refusals as `proveAddressCode`'s
|
||||||
|
/// ([EmailSignInRefusedException]). Nothing changes then.
|
||||||
|
Future<UuidValue> signUpWithCode(
|
||||||
|
Session session, {
|
||||||
|
required UuidValue request,
|
||||||
|
required String code,
|
||||||
|
required PasswordSetup setup,
|
||||||
|
}) async {
|
||||||
|
requirePasswordKeys(session.serverpod);
|
||||||
|
if (!isPasswordSetup(setup)) {
|
||||||
|
throw refusedFor(PasswordRefusal.invalidSetup);
|
||||||
|
}
|
||||||
|
final proven = await proveAddressCode(session, request: request, code: code);
|
||||||
|
try {
|
||||||
|
return await session.db.transaction((transaction) async {
|
||||||
|
// SELECT * FROM "email_identity" WHERE "addressHash" = $1
|
||||||
|
final identity = await EmailIdentity.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.addressHash.equals(proven),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
final now = clock.now().toUtc();
|
||||||
|
final UuidValue person;
|
||||||
|
if (identity == null) {
|
||||||
|
person = const Uuid().v7obj();
|
||||||
|
await ensurePerson(
|
||||||
|
session,
|
||||||
|
person,
|
||||||
|
scopes: {identified},
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
// INSERT INTO "email_identity" ("authUserId", "addressHash",
|
||||||
|
// "createdAt") VALUES ($1, $2, $3)
|
||||||
|
await EmailIdentity.db.insertRow(
|
||||||
|
session,
|
||||||
|
EmailIdentity(
|
||||||
|
authUserId: person,
|
||||||
|
addressHash: proven,
|
||||||
|
createdAt: now,
|
||||||
|
),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
person = identity.authUserId;
|
||||||
|
// SELECT count(*) FROM "account_password" WHERE "authUserId" = $1
|
||||||
|
if (await AccountPassword.db.count(
|
||||||
|
session,
|
||||||
|
where: (t) => t.authUserId.equals(person),
|
||||||
|
transaction: transaction,
|
||||||
|
) >
|
||||||
|
0) {
|
||||||
|
throw refusedFor(PasswordRefusal.passwordExists);
|
||||||
|
}
|
||||||
|
// An address's person is identified already, and no longer
|
||||||
|
// anonymous; as email sign-in left them.
|
||||||
|
await grantIdentified(session, person, transaction: transaction);
|
||||||
|
// DELETE FROM "serverpod_auth_idp_anonymous_account"
|
||||||
|
// WHERE "authUserId" = $1
|
||||||
|
await AnonymousAccount.db.deleteWhere(
|
||||||
|
session,
|
||||||
|
where: (t) => t.authUserId.equals(person),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// INSERT INTO "account_password" (…) VALUES (…)
|
||||||
|
await AccountPassword.db.insertRow(
|
||||||
|
session,
|
||||||
|
passwordRowOf(person, setup, now: now),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
return person;
|
||||||
|
});
|
||||||
|
} on DatabaseQueryException catch (error) {
|
||||||
|
// The same address signed up at the same moment: the unique index
|
||||||
|
// refused the second (unique_violation).
|
||||||
|
if (error.code == '23505') {
|
||||||
|
throw refusedFor(PasswordRefusal.passwordExists);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The first step of signing in with a password: SRP's B for the account
|
/// The first step of signing in with a password: SRP's B for the account
|
||||||
/// with [address], with its salt and costs, all alike for an address nobody
|
/// with [address], with its salt and costs, all alike for an address nobody
|
||||||
/// has (see above). At most 10 sign-ins begin for an address and 30 from a
|
/// has (see above). At most 10 sign-ins begin for an address and 30 from a
|
||||||
|
|
@ -416,12 +518,21 @@ Future<PasswordChallenge> beginPasswordSignIn(
|
||||||
/// account with SRP's M2 for the device and its keys as the register keeps
|
/// account with SRP's M2 for the device and its keys as the register keeps
|
||||||
/// them; the caller issues the session.
|
/// them; the caller issues the session.
|
||||||
///
|
///
|
||||||
|
/// A setup never finished (a password, never a confirmed authenticator:
|
||||||
|
/// [hadConfirmedAuthenticator]) signs in with a [code] mailed for
|
||||||
|
/// [request] to the address the sign-in began with in place of the second
|
||||||
|
/// factor, until an authenticator is confirmed; it calls off a reset of the
|
||||||
|
/// account waiting, as any sign-in with the password does. For any other
|
||||||
|
/// account a code by mail is no second factor, and is left unused.
|
||||||
|
///
|
||||||
/// Refused ([PasswordRefusedException]) for a challenge unknown, used or
|
/// Refused ([PasswordRefusedException]) for a challenge unknown, used or
|
||||||
/// older than [signInChallengeLifetime]; for a wrong password, an address
|
/// older than [signInChallengeLifetime]; for a wrong password, an address
|
||||||
/// nobody has, a wrong or used code, all alike
|
/// nobody has, a wrong or used code, a code by mail to another address, all
|
||||||
/// ([PasswordRefusal.wrongCredentials]); and only once the password proved
|
/// alike ([PasswordRefusal.wrongCredentials]); and only once the password
|
||||||
/// right, for an account whose authenticator isn't confirmed yet or whose
|
/// proved right, for an account whose authenticator isn't confirmed yet
|
||||||
/// reset hasn't taken effect when no code came ([secondFactor]).
|
/// (without a code by mail) or whose reset hasn't taken effect when no code
|
||||||
|
/// came ([secondFactor]); a code by mail's own refusals as
|
||||||
|
/// `proveAddressCode`'s ([EmailSignInRefusedException]).
|
||||||
Future<PasswordProof> completePasswordSignIn(
|
Future<PasswordProof> completePasswordSignIn(
|
||||||
Session session, {
|
Session session, {
|
||||||
required UuidValue challenge,
|
required UuidValue challenge,
|
||||||
|
|
@ -429,6 +540,8 @@ Future<PasswordProof> completePasswordSignIn(
|
||||||
required ByteData clientProof,
|
required ByteData clientProof,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
|
UuidValue? request,
|
||||||
|
String? code,
|
||||||
}) async {
|
}) async {
|
||||||
final keys = requirePasswordKeys(session.serverpod);
|
final keys = requirePasswordKeys(session.serverpod);
|
||||||
final proved = await provePassword(
|
final proved = await provePassword(
|
||||||
|
|
@ -438,13 +551,30 @@ Future<PasswordProof> completePasswordSignIn(
|
||||||
clientValue: clientValue,
|
clientValue: clientValue,
|
||||||
clientProof: clientProof,
|
clientProof: clientProof,
|
||||||
);
|
);
|
||||||
final needsAuthenticator = await secondFactor(
|
final account = proved.password.authUserId;
|
||||||
session,
|
final bool needsAuthenticator;
|
||||||
keys,
|
if (request != null &&
|
||||||
proved.password.authUserId,
|
code != null &&
|
||||||
totpCode: totpCode,
|
!await hadConfirmedAuthenticator(session, account)) {
|
||||||
recoveryCode: recoveryCode,
|
final proven = await proveAddressCode(
|
||||||
);
|
session,
|
||||||
|
request: request,
|
||||||
|
code: code,
|
||||||
|
);
|
||||||
|
if (proven != proved.addressHash) {
|
||||||
|
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||||
|
}
|
||||||
|
await _callOffResets(session, account);
|
||||||
|
needsAuthenticator = true;
|
||||||
|
} else {
|
||||||
|
needsAuthenticator = await secondFactor(
|
||||||
|
session,
|
||||||
|
keys,
|
||||||
|
account,
|
||||||
|
totpCode: totpCode,
|
||||||
|
recoveryCode: recoveryCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
final password = proved.password;
|
final password = proved.password;
|
||||||
return PasswordProof(
|
return PasswordProof(
|
||||||
account: password.authUserId,
|
account: password.authUserId,
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,9 @@ abstract interface class AccountRegister {
|
||||||
});
|
});
|
||||||
|
|
||||||
/// A password sign-in's second step: the account it proved, for the
|
/// A password sign-in's second step: the account it proved, for the
|
||||||
/// caller's session. Refusals as `completePasswordSignIn`'s.
|
/// caller's session; a [code] mailed for [request] in place of the second
|
||||||
|
/// factor of a setup never finished. Refusals as
|
||||||
|
/// `completePasswordSignIn`'s.
|
||||||
Future<PasswordProof> completePasswordSignIn(
|
Future<PasswordProof> completePasswordSignIn(
|
||||||
Session session, {
|
Session session, {
|
||||||
required UuidValue challenge,
|
required UuidValue challenge,
|
||||||
|
|
@ -183,6 +185,18 @@ abstract interface class AccountRegister {
|
||||||
required ByteData clientProof,
|
required ByteData clientProof,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
|
UuidValue? request,
|
||||||
|
String? code,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Signs up with the address [code] proves for [request] and the password
|
||||||
|
/// [setup] describes: the account, new or one with the address and no
|
||||||
|
/// password, for the caller's session. Refusals as `signUpWithCode`'s.
|
||||||
|
Future<UuidValue> signUpWithCode(
|
||||||
|
Session session, {
|
||||||
|
required UuidValue request,
|
||||||
|
required String code,
|
||||||
|
required PasswordSetup setup,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// A new authenticator of [person]. Refusals as `enrolAuthenticator`'s.
|
/// A new authenticator of [person]. Refusals as `enrolAuthenticator`'s.
|
||||||
|
|
|
||||||
|
|
@ -247,8 +247,18 @@ Future<Map<String, Object?>?> _answer(
|
||||||
clientProof: arguments.bytes('clientProof'),
|
clientProof: arguments.bytes('clientProof'),
|
||||||
totpCode: arguments.optionalString('totpCode'),
|
totpCode: arguments.optionalString('totpCode'),
|
||||||
recoveryCode: arguments.optionalString('recoveryCode'),
|
recoveryCode: arguments.optionalString('recoveryCode'),
|
||||||
|
request: arguments.optionalId('request'),
|
||||||
|
code: arguments.optionalString('code'),
|
||||||
);
|
);
|
||||||
return {'proof': proof.toJson()};
|
return {'proof': proof.toJson()};
|
||||||
|
case RegisterOperation.signUpWithCode:
|
||||||
|
final account = await register.signUpWithCode(
|
||||||
|
session,
|
||||||
|
request: arguments.id('request'),
|
||||||
|
code: arguments.string('code'),
|
||||||
|
setup: arguments.model('setup', PasswordSetup.fromJson),
|
||||||
|
);
|
||||||
|
return {'account': account.uuid};
|
||||||
case RegisterOperation.enrolAuthenticator:
|
case RegisterOperation.enrolAuthenticator:
|
||||||
final enrolment = await register.enrolAuthenticator(
|
final enrolment = await register.enrolAuthenticator(
|
||||||
session,
|
session,
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,8 @@ final class LocalRegister implements AccountRegister {
|
||||||
required ByteData clientProof,
|
required ByteData clientProof,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
|
UuidValue? request,
|
||||||
|
String? code,
|
||||||
}) => passwords.completePasswordSignIn(
|
}) => passwords.completePasswordSignIn(
|
||||||
session,
|
session,
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
|
|
@ -451,6 +453,21 @@ final class LocalRegister implements AccountRegister {
|
||||||
clientProof: clientProof,
|
clientProof: clientProof,
|
||||||
totpCode: totpCode,
|
totpCode: totpCode,
|
||||||
recoveryCode: recoveryCode,
|
recoveryCode: recoveryCode,
|
||||||
|
request: request,
|
||||||
|
code: code,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UuidValue> signUpWithCode(
|
||||||
|
Session session, {
|
||||||
|
required UuidValue request,
|
||||||
|
required String code,
|
||||||
|
required PasswordSetup setup,
|
||||||
|
}) => passwords.signUpWithCode(
|
||||||
|
session,
|
||||||
|
request: request,
|
||||||
|
code: code,
|
||||||
|
setup: setup,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ abstract final class RegisterOperation {
|
||||||
static const setPassword = 'set-password';
|
static const setPassword = 'set-password';
|
||||||
static const beginPasswordSignIn = 'begin-password-sign-in';
|
static const beginPasswordSignIn = 'begin-password-sign-in';
|
||||||
static const completePasswordSignIn = 'complete-password-sign-in';
|
static const completePasswordSignIn = 'complete-password-sign-in';
|
||||||
|
static const signUpWithCode = 'sign-up-with-code';
|
||||||
static const enrolAuthenticator = 'enrol-authenticator';
|
static const enrolAuthenticator = 'enrol-authenticator';
|
||||||
static const confirmAuthenticator = 'confirm-authenticator';
|
static const confirmAuthenticator = 'confirm-authenticator';
|
||||||
static const authenticatorsOf = 'authenticators-of';
|
static const authenticatorsOf = 'authenticators-of';
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,8 @@ final class RemoteRegister implements AccountRegister {
|
||||||
required ByteData clientProof,
|
required ByteData clientProof,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
|
UuidValue? request,
|
||||||
|
String? code,
|
||||||
}) async => _model<PasswordProof>(
|
}) async => _model<PasswordProof>(
|
||||||
RegisterOperation.completePasswordSignIn,
|
RegisterOperation.completePasswordSignIn,
|
||||||
await _ask(RegisterOperation.completePasswordSignIn, {
|
await _ask(RegisterOperation.completePasswordSignIn, {
|
||||||
|
|
@ -353,11 +355,31 @@ final class RemoteRegister implements AccountRegister {
|
||||||
'clientProof': _bytes(clientProof),
|
'clientProof': _bytes(clientProof),
|
||||||
'totpCode': totpCode,
|
'totpCode': totpCode,
|
||||||
'recoveryCode': recoveryCode,
|
'recoveryCode': recoveryCode,
|
||||||
|
'request': request?.uuid,
|
||||||
|
'code': code,
|
||||||
}),
|
}),
|
||||||
'proof',
|
'proof',
|
||||||
PasswordProof.fromJson,
|
PasswordProof.fromJson,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UuidValue> signUpWithCode(
|
||||||
|
Session session, {
|
||||||
|
required UuidValue request,
|
||||||
|
required String code,
|
||||||
|
required PasswordSetup setup,
|
||||||
|
}) async =>
|
||||||
|
_id(
|
||||||
|
(await _ask(RegisterOperation.signUpWithCode, {
|
||||||
|
'request': request.uuid,
|
||||||
|
'code': code,
|
||||||
|
'setup': setup.toJson(),
|
||||||
|
}))['account'],
|
||||||
|
) ??
|
||||||
|
(throw RegisterUnavailableException(
|
||||||
|
'${RegisterOperation.signUpWithCode}: no answer',
|
||||||
|
));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AuthenticatorEnrolment> enrolAuthenticator(
|
Future<AuthenticatorEnrolment> enrolAuthenticator(
|
||||||
Session session, {
|
Session session, {
|
||||||
|
|
|
||||||
|
|
@ -10,24 +10,9 @@ import 'package:test/test.dart';
|
||||||
import '../support/fixtures.dart';
|
import '../support/fixtures.dart';
|
||||||
import '../support/mailbox.dart';
|
import '../support/mailbox.dart';
|
||||||
import '../support/password_device.dart';
|
import '../support/password_device.dart';
|
||||||
|
import '../support/password_support.dart';
|
||||||
import '../test_tools/serverpod_test_tools.dart';
|
import '../test_tools/serverpod_test_tools.dart';
|
||||||
|
|
||||||
Matcher refused(PasswordRefusal reason) => throwsA(
|
|
||||||
isA<PasswordRefusedException>().having(
|
|
||||||
(error) => error.reason,
|
|
||||||
'reason',
|
|
||||||
reason,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Matcher refusedAsEmail(EmailSignInRefusal reason) => throwsA(
|
|
||||||
isA<EmailSignInRefusedException>().having(
|
|
||||||
(error) => error.reason,
|
|
||||||
'reason',
|
|
||||||
reason,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
withServerpod(
|
withServerpod(
|
||||||
'A password and an authenticator',
|
'A password and an authenticator',
|
||||||
|
|
@ -1797,35 +1782,3 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes everything the tests make.
|
|
||||||
Future<void> deleteAll(Session session) async {
|
|
||||||
await SignInChallenge.db.deleteWhere(
|
|
||||||
session,
|
|
||||||
where: (t) => t.id.notEquals(null),
|
|
||||||
);
|
|
||||||
await AccountReset.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
||||||
await RecoveryCode.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
||||||
await TotpAuthenticator.db.deleteWhere(
|
|
||||||
session,
|
|
||||||
where: (t) => t.id.notEquals(null),
|
|
||||||
);
|
|
||||||
await AccountPassword.db.deleteWhere(session, where: (t) => t.id > 0);
|
|
||||||
await RateLimitedRequestAttempt.db.deleteWhere(
|
|
||||||
session,
|
|
||||||
where: (t) => t.id.notEquals(null),
|
|
||||||
);
|
|
||||||
await EmailCodeRequest.db.deleteWhere(
|
|
||||||
session,
|
|
||||||
where: (t) => t.id.notEquals(null),
|
|
||||||
);
|
|
||||||
await SecretChallenge.db.deleteWhere(
|
|
||||||
session,
|
|
||||||
where: (t) => t.id.notEquals(null),
|
|
||||||
);
|
|
||||||
await EmailIdentity.db.deleteWhere(
|
|
||||||
session,
|
|
||||||
where: (t) => t.id.notEquals(null),
|
|
||||||
);
|
|
||||||
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:halkyon_base_server/base.dart';
|
||||||
|
import 'package:serverpod/serverpod.dart';
|
||||||
|
import 'package:serverpod_auth_idp_server/core.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../support/fixtures.dart';
|
||||||
|
import '../support/mailbox.dart';
|
||||||
|
import '../support/password_device.dart';
|
||||||
|
import '../support/password_support.dart';
|
||||||
|
import '../test_tools/serverpod_test_tools.dart';
|
||||||
|
|
||||||
|
/// The ways into an account that has no working password yet (ADR 0017,
|
||||||
|
/// step 3): signing up with a code by mail, and a setup never finished
|
||||||
|
/// signing in with the password and a code by mail.
|
||||||
|
void main() {
|
||||||
|
withServerpod(
|
||||||
|
'Ways in without a working password',
|
||||||
|
// Rate limits, codes and taken challenges write in transactions of their
|
||||||
|
// own: these tests delete what they make instead.
|
||||||
|
rollbackDatabase: RollbackDatabase.disabled,
|
||||||
|
(sessionBuilder, endpoints) {
|
||||||
|
late Session session;
|
||||||
|
late Serverpod pod;
|
||||||
|
late Mailbox mailbox;
|
||||||
|
late PasswordDevice device;
|
||||||
|
late DateTime now;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
useBase(sessionBuilder);
|
||||||
|
session = sessionBuilder.build();
|
||||||
|
pod = session.serverpod;
|
||||||
|
await deleteAll(session);
|
||||||
|
mailbox = Mailbox();
|
||||||
|
EmailCodes.of(pod).mailer = mailbox;
|
||||||
|
usePasswordKeys(pod, testPasswordKeys);
|
||||||
|
now = DateTime.utc(2026, 10, 20, 12);
|
||||||
|
device = await PasswordDevice.make();
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
EmailCodes.of(pod).mailer = null;
|
||||||
|
usePasswordKeys(pod, null);
|
||||||
|
await deleteAll(sessionBuilder.build());
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<T> atNow<T>(Future<T> Function() body) =>
|
||||||
|
withClock(Clock(() => now), body);
|
||||||
|
|
||||||
|
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
|
||||||
|
authentication: AuthenticationOverride.unauthenticated(),
|
||||||
|
);
|
||||||
|
TestSessionBuilder as(UuidValue person) => sessionBuilder.copyWith(
|
||||||
|
authentication: AuthenticationOverride.authenticationInfo(
|
||||||
|
person.uuid,
|
||||||
|
{identified},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<({UuidValue request, String code})> mailedCode(
|
||||||
|
String address,
|
||||||
|
) async {
|
||||||
|
final request = await endpoints.accountRecovery.sendCode(
|
||||||
|
notSignedIn(),
|
||||||
|
address: address,
|
||||||
|
language: 'en',
|
||||||
|
);
|
||||||
|
return (request: request, code: mailbox.codeFor(address));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AuthSuccess> signUp(String address, {PasswordDevice? with_}) =>
|
||||||
|
atNow(() async {
|
||||||
|
final code = await mailedCode(address);
|
||||||
|
return await endpoints.passwordSignIn.signUp(
|
||||||
|
notSignedIn(),
|
||||||
|
request: code.request,
|
||||||
|
code: code.code,
|
||||||
|
setup: (with_ ?? device).setup(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Signs in as a device would, both steps, with a code by mail where
|
||||||
|
/// [mailedTo] names its address.
|
||||||
|
Future<PasswordSignedIn> signIn({
|
||||||
|
String address = 'anna@example.org',
|
||||||
|
String? totpCode,
|
||||||
|
String? mailedTo,
|
||||||
|
String? wrongMailedCode,
|
||||||
|
BigInt? privateValue,
|
||||||
|
}) => atNow(() async {
|
||||||
|
final mailed = mailedTo == null ? null : await mailedCode(mailedTo);
|
||||||
|
final challenge = await endpoints.passwordSignIn.begin(
|
||||||
|
notSignedIn(),
|
||||||
|
address: address,
|
||||||
|
);
|
||||||
|
final answer = device.answer(challenge, privateValue: privateValue);
|
||||||
|
return await endpoints.passwordSignIn.complete(
|
||||||
|
notSignedIn(),
|
||||||
|
challenge: challenge.challenge,
|
||||||
|
clientValue: answer.clientValue,
|
||||||
|
clientProof: answer.clientProof,
|
||||||
|
totpCode: totpCode,
|
||||||
|
request: mailed?.request,
|
||||||
|
code: wrongMailedCode ?? mailed?.code,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<int> countOf(UuidValue person) => AccountPassword.db.count(
|
||||||
|
session,
|
||||||
|
where: (t) => t.authUserId.equals(person),
|
||||||
|
);
|
||||||
|
|
||||||
|
group('signing up with a code by mail', () {
|
||||||
|
test('makes an address nobody has a new identified account with the '
|
||||||
|
'password, signed in, its authenticator next', () async {
|
||||||
|
final signedUp = await signUp('anna@example.org');
|
||||||
|
final anna = signedUp.authUserId;
|
||||||
|
|
||||||
|
expect(signedUp.scopeNames, contains(identified.name));
|
||||||
|
expect(
|
||||||
|
(await AuthUser.db.findById(session, anna))!.scopeNames,
|
||||||
|
contains(identified.name),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await EmailIdentity.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.authUserId.equals(anna),
|
||||||
|
))!.addressHash,
|
||||||
|
addressHash(pod, 'anna@example.org'),
|
||||||
|
);
|
||||||
|
expect(await countOf(anna), 1);
|
||||||
|
final ways = await endpoints.account.signInWays(as(anna));
|
||||||
|
expect(ways.email, isTrue);
|
||||||
|
expect(ways.password, isTrue);
|
||||||
|
expect(ways.authenticator, isFalse);
|
||||||
|
expect(ways.passwordOnly, isFalse);
|
||||||
|
|
||||||
|
// The session it gave sets up the authenticator.
|
||||||
|
final enrolment = await endpoints.authenticator.enrol(
|
||||||
|
as(anna),
|
||||||
|
name: 'Phone',
|
||||||
|
label: 'anna@example.org',
|
||||||
|
);
|
||||||
|
final codes = await atNow(
|
||||||
|
() => endpoints.authenticator.confirm(
|
||||||
|
as(anna),
|
||||||
|
authenticator: enrolment.authenticator,
|
||||||
|
code: codeOf(enrolment, at: now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(codes, hasLength(10));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gives an account with the address and no password (codes by '
|
||||||
|
'mail before) the password, keeping its ID', () async {
|
||||||
|
final ben = await newAnonymousPerson(session);
|
||||||
|
await EmailIdentity.db.insertRow(
|
||||||
|
session,
|
||||||
|
EmailIdentity(
|
||||||
|
authUserId: ben,
|
||||||
|
addressHash: addressHash(pod, 'ben@example.org'),
|
||||||
|
createdAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final signedUp = await signUp('ben@example.org');
|
||||||
|
|
||||||
|
expect(signedUp.authUserId, ben);
|
||||||
|
expect(await countOf(ben), 1);
|
||||||
|
expect(
|
||||||
|
(await AuthUser.db.findById(session, ben))!.scopeNames,
|
||||||
|
contains(identified.name),
|
||||||
|
);
|
||||||
|
expect(await AuthUser.db.count(session), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is refused for an address whose account has a password, '
|
||||||
|
'finished or not, keeping it as it was', () async {
|
||||||
|
final anna = (await signUp('anna@example.org')).authUserId;
|
||||||
|
final before = (await AccountPassword.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.authUserId.equals(anna),
|
||||||
|
))!;
|
||||||
|
final other = await PasswordDevice.make(seed: 99);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
signUp('anna@example.org', with_: other),
|
||||||
|
refused(PasswordRefusal.passwordExists),
|
||||||
|
);
|
||||||
|
final after = (await AccountPassword.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.authUserId.equals(anna),
|
||||||
|
))!;
|
||||||
|
expect(
|
||||||
|
after.salt.buffer.asUint8List(),
|
||||||
|
before.salt.buffer.asUint8List(),
|
||||||
|
);
|
||||||
|
expect(await AuthUser.db.count(session), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is refused for a wrong code, a setup out of range, and where '
|
||||||
|
'the instance has no keys, making nobody', () async {
|
||||||
|
final code = await mailedCode('anna@example.org');
|
||||||
|
await expectLater(
|
||||||
|
endpoints.passwordSignIn.signUp(
|
||||||
|
notSignedIn(),
|
||||||
|
request: code.request,
|
||||||
|
code: 'WRONGCOD',
|
||||||
|
setup: device.setup(),
|
||||||
|
),
|
||||||
|
refusedAsEmail(EmailSignInRefusal.wrongCode),
|
||||||
|
);
|
||||||
|
final short = device.setup().copyWith(
|
||||||
|
salt: ByteData(8),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
endpoints.passwordSignIn.signUp(
|
||||||
|
notSignedIn(),
|
||||||
|
request: code.request,
|
||||||
|
code: code.code,
|
||||||
|
setup: short,
|
||||||
|
),
|
||||||
|
refused(PasswordRefusal.invalidSetup),
|
||||||
|
);
|
||||||
|
usePasswordKeys(pod, null);
|
||||||
|
await expectLater(
|
||||||
|
endpoints.passwordSignIn.signUp(
|
||||||
|
notSignedIn(),
|
||||||
|
request: code.request,
|
||||||
|
code: code.code,
|
||||||
|
setup: device.setup(),
|
||||||
|
),
|
||||||
|
refused(PasswordRefusal.unavailable),
|
||||||
|
);
|
||||||
|
expect(await AuthUser.db.count(session), 0);
|
||||||
|
expect(await AccountPassword.db.count(session), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('a setup never finished', () {
|
||||||
|
late UuidValue anna;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
anna = (await signUp('anna@example.org')).authUserId;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signs in with the password and a code mailed to its address, '
|
||||||
|
'the authenticator still to set up', () async {
|
||||||
|
await expectLater(
|
||||||
|
signIn(),
|
||||||
|
refused(PasswordRefusal.setupIncomplete),
|
||||||
|
);
|
||||||
|
|
||||||
|
final signedIn = await signIn(mailedTo: 'anna@example.org');
|
||||||
|
|
||||||
|
expect(signedIn.authUserId, anna);
|
||||||
|
expect(signedIn.needsAuthenticator, isTrue);
|
||||||
|
expect(signedIn.scopeNames, contains(identified.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is refused alike for a wrong password, and for a code to '
|
||||||
|
'another address; a wrong code as email refuses it', () async {
|
||||||
|
await expectLater(
|
||||||
|
signIn(
|
||||||
|
mailedTo: 'anna@example.org',
|
||||||
|
privateValue: BigInt.from(4242),
|
||||||
|
),
|
||||||
|
refused(PasswordRefusal.wrongCredentials),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
signIn(mailedTo: 'ben@example.org'),
|
||||||
|
refused(PasswordRefusal.wrongCredentials),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
signIn(mailedTo: 'anna@example.org', wrongMailedCode: 'WRONGCOD'),
|
||||||
|
refusedAsEmail(EmailSignInRefusal.wrongCode),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with an account reset waiting, still signs in with the '
|
||||||
|
'password and a code, which calls the reset off', () async {
|
||||||
|
final code = await mailedCode('anna@example.org');
|
||||||
|
await atNow(
|
||||||
|
() => endpoints.accountRecovery.startAccountReset(
|
||||||
|
notSignedIn(),
|
||||||
|
address: 'anna@example.org',
|
||||||
|
language: 'en',
|
||||||
|
request: code.request,
|
||||||
|
code: code.code,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await endpoints.account.signInWays(as(anna))).passwordOnly,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
final signedIn = await signIn(mailedTo: 'anna@example.org');
|
||||||
|
|
||||||
|
expect(signedIn.needsAuthenticator, isTrue);
|
||||||
|
expect(
|
||||||
|
await AccountReset.db.count(
|
||||||
|
session,
|
||||||
|
where: (t) => t.authUserId.equals(anna),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('takes no code by mail in place of the second factor once an '
|
||||||
|
'authenticator was confirmed, and leaves it unused', () async {
|
||||||
|
final enrolment = await endpoints.authenticator.enrol(
|
||||||
|
as(anna),
|
||||||
|
name: 'Phone',
|
||||||
|
label: 'anna@example.org',
|
||||||
|
);
|
||||||
|
await atNow(
|
||||||
|
() => endpoints.authenticator.confirm(
|
||||||
|
as(anna),
|
||||||
|
authenticator: enrolment.authenticator,
|
||||||
|
code: codeOf(enrolment, at: now),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
now = now.add(totpPeriod);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
signIn(mailedTo: 'anna@example.org'),
|
||||||
|
refused(PasswordRefusal.wrongCredentials),
|
||||||
|
);
|
||||||
|
// The code mailed is still valid for what it was asked for.
|
||||||
|
expect(await EmailCodeRequest.db.count(session), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -269,8 +269,23 @@ final class _Register implements AccountRegister {
|
||||||
required ByteData clientProof,
|
required ByteData clientProof,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
|
UuidValue? request,
|
||||||
|
String? code,
|
||||||
}) => _answer('completePasswordSignIn', _proven);
|
}) => _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
|
@override
|
||||||
Future<AuthenticatorEnrolment> enrolAuthenticator(
|
Future<AuthenticatorEnrolment> enrolAuthenticator(
|
||||||
Session session, {
|
Session session, {
|
||||||
|
|
@ -708,6 +723,29 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
test('refuse as the register refuses, signing nobody in', () async {
|
||||||
final people = await AuthUser.db.count(session);
|
final people = await AuthUser.db.count(session);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -685,6 +685,67 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ways in without a working password (ADR 0017)', () {
|
||||||
|
late DateTime now;
|
||||||
|
Future<T> atNow<T>(Future<T> Function() body) =>
|
||||||
|
withClock(Clock(() => now), body);
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
usePasswordKeys(pod, testPasswordKeys);
|
||||||
|
now = DateTime.utc(2026, 10, 20, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cross for a member's device: signing up with a code, and a "
|
||||||
|
'setup never finished signing in with the password and a code, '
|
||||||
|
'with what the register refuses', () async {
|
||||||
|
final device = await PasswordDevice.make();
|
||||||
|
await atNow(() async {
|
||||||
|
Future<UuidValue> code() => remote.sendSignInCode(
|
||||||
|
session,
|
||||||
|
address: 'carla@example.org',
|
||||||
|
language: 'en',
|
||||||
|
network: '203.0.113.7',
|
||||||
|
);
|
||||||
|
final carla = await remote.signUpWithCode(
|
||||||
|
session,
|
||||||
|
request: await code(),
|
||||||
|
code: mailbox.codeFor('carla@example.org'),
|
||||||
|
setup: device.setup(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await AuthUser.db.findById(session, carla))!.scopeNames,
|
||||||
|
contains(identified.name),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
remote.signUpWithCode(
|
||||||
|
session,
|
||||||
|
request: await code(),
|
||||||
|
code: mailbox.codeFor('carla@example.org'),
|
||||||
|
setup: device.setup(),
|
||||||
|
),
|
||||||
|
refused(PasswordRefusal.passwordExists),
|
||||||
|
);
|
||||||
|
|
||||||
|
final challenge = await remote.beginPasswordSignIn(
|
||||||
|
session,
|
||||||
|
address: 'carla@example.org',
|
||||||
|
network: '203.0.113.7',
|
||||||
|
);
|
||||||
|
final answer = device.answer(challenge);
|
||||||
|
final proof = await remote.completePasswordSignIn(
|
||||||
|
session,
|
||||||
|
challenge: challenge.challenge,
|
||||||
|
clientValue: answer.clientValue,
|
||||||
|
clientProof: answer.clientProof,
|
||||||
|
request: await code(),
|
||||||
|
code: mailbox.codeFor('carla@example.org'),
|
||||||
|
);
|
||||||
|
expect(proof.account, carla);
|
||||||
|
expect(proof.needsAuthenticator, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('deleting an account', () {
|
group('deleting an account', () {
|
||||||
test('deletes it here and tells every member, keeping no notice '
|
test('deletes it here and tells every member, keeping no notice '
|
||||||
'once they confirmed', () async {
|
'once they confirmed', () async {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import 'package:halkyon_base_server/base.dart';
|
||||||
|
import 'package:serverpod/serverpod.dart';
|
||||||
|
import 'package:serverpod_auth_idp_server/core.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// Throws a [PasswordRefusedException] for [reason].
|
||||||
|
Matcher refused(PasswordRefusal reason) => throwsA(
|
||||||
|
isA<PasswordRefusedException>().having(
|
||||||
|
(error) => error.reason,
|
||||||
|
'reason',
|
||||||
|
reason,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Throws an [EmailSignInRefusedException] for [reason].
|
||||||
|
Matcher refusedAsEmail(EmailSignInRefusal reason) => throwsA(
|
||||||
|
isA<EmailSignInRefusedException>().having(
|
||||||
|
(error) => error.reason,
|
||||||
|
'reason',
|
||||||
|
reason,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Deletes everything the tests make.
|
||||||
|
Future<void> deleteAll(Session session) async {
|
||||||
|
await SignInChallenge.db.deleteWhere(
|
||||||
|
session,
|
||||||
|
where: (t) => t.id.notEquals(null),
|
||||||
|
);
|
||||||
|
await AccountReset.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||||
|
await RecoveryCode.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||||
|
await TotpAuthenticator.db.deleteWhere(
|
||||||
|
session,
|
||||||
|
where: (t) => t.id.notEquals(null),
|
||||||
|
);
|
||||||
|
await AccountPassword.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||||
|
await RateLimitedRequestAttempt.db.deleteWhere(
|
||||||
|
session,
|
||||||
|
where: (t) => t.id.notEquals(null),
|
||||||
|
);
|
||||||
|
await EmailCodeRequest.db.deleteWhere(
|
||||||
|
session,
|
||||||
|
where: (t) => t.id.notEquals(null),
|
||||||
|
);
|
||||||
|
await SecretChallenge.db.deleteWhere(
|
||||||
|
session,
|
||||||
|
where: (t) => t.id.notEquals(null),
|
||||||
|
);
|
||||||
|
await EmailIdentity.db.deleteWhere(
|
||||||
|
session,
|
||||||
|
where: (t) => t.id.notEquals(null),
|
||||||
|
);
|
||||||
|
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
||||||
|
}
|
||||||
|
|
@ -1157,6 +1157,8 @@ class _PasswordSignInEndpoint {
|
||||||
required _idt.ByteData clientProof,
|
required _idt.ByteData clientProof,
|
||||||
String? totpCode,
|
String? totpCode,
|
||||||
String? recoveryCode,
|
String? recoveryCode,
|
||||||
|
_is.UuidValue? request,
|
||||||
|
String? code,
|
||||||
}) async {
|
}) async {
|
||||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||||
var _localUniqueSession =
|
var _localUniqueSession =
|
||||||
|
|
@ -1175,6 +1177,8 @@ class _PasswordSignInEndpoint {
|
||||||
'clientProof': clientProof,
|
'clientProof': clientProof,
|
||||||
'totpCode': totpCode,
|
'totpCode': totpCode,
|
||||||
'recoveryCode': recoveryCode,
|
'recoveryCode': recoveryCode,
|
||||||
|
'request': request,
|
||||||
|
'code': code,
|
||||||
}),
|
}),
|
||||||
serializationManager: _serializationManager,
|
serializationManager: _serializationManager,
|
||||||
);
|
);
|
||||||
|
|
@ -1190,6 +1194,43 @@ class _PasswordSignInEndpoint {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ida.Future<_iacs.AuthSuccess> signUp(
|
||||||
|
_ist.TestSessionBuilder sessionBuilder, {
|
||||||
|
required _is.UuidValue request,
|
||||||
|
required String code,
|
||||||
|
required _ikhyee47.PasswordSetup setup,
|
||||||
|
}) async {
|
||||||
|
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||||
|
var _localUniqueSession =
|
||||||
|
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||||
|
endpoint: 'passwordSignIn',
|
||||||
|
method: 'signUp',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||||
|
createSessionCallback: (_) => _localUniqueSession,
|
||||||
|
endpointPath: 'passwordSignIn',
|
||||||
|
methodName: 'signUp',
|
||||||
|
parameters: _ist.testObjectToJson({
|
||||||
|
'request': request,
|
||||||
|
'code': code,
|
||||||
|
'setup': setup,
|
||||||
|
}),
|
||||||
|
serializationManager: _serializationManager,
|
||||||
|
);
|
||||||
|
var _localReturnValue =
|
||||||
|
await (_localCallContext.method.call(
|
||||||
|
_localUniqueSession,
|
||||||
|
_localCallContext.arguments,
|
||||||
|
)
|
||||||
|
as _ida.Future<_iacs.AuthSuccess>);
|
||||||
|
return _localReturnValue;
|
||||||
|
} finally {
|
||||||
|
await _localUniqueSession.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProfileEndpoint {
|
class _ProfileEndpoint {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue