fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone, so whoever took one made a GitHub-only account, or an address account without a password, theirs for good (their address or password, their authenticator; GitHub then refused the owner). - An account with an address proves it anew: a code mailed to that address for this (EmailCodePurpose.firstPassword, its own mail), never the session alone, a code asked for anything else, or another address. - An account that signs in with GitHub alone does it within 10 minutes of a GitHub sign-in (the session's method and creation time, where the session lives; members tell the register), refused signInAgain before any code is used; the address it adds is proved as before. - Anonymous people still become identified with a code to the address they add. The app mails the code to the person's own address (asking for it where the device doesn't know it), offers a password right after a GitHub sign-in (D2's "at their next sign-in") before the person's app, and asks for a GitHub sign-in anew when the instance refuses an older one, keeping the code typed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1ada2e27f6
commit
f0413531ff
16 changed files with 762 additions and 66 deletions
|
|
@ -30,6 +30,7 @@ export 'src/password/key_proof.dart'
|
|||
export 'src/password/password_config.dart';
|
||||
export 'src/password/passwords.dart'
|
||||
show
|
||||
gitHubSignInFreshFor,
|
||||
newPasswordCosts,
|
||||
passwordGroup,
|
||||
passwordMethod,
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ final class LogCodeMailer implements CodeMailer {
|
|||
EmailCodePurpose.authenticatorReset => 'um deine Authenticator-App abzuschalten',
|
||||
EmailCodePurpose.accountReset => 'um dein Konto zurückzusetzen',
|
||||
EmailCodePurpose.deletion => 'um dein Konto zu löschen',
|
||||
EmailCodePurpose.firstPassword => 'um deinem Konto ein Passwort zu geben',
|
||||
}}',
|
||||
body:
|
||||
'$code\n'
|
||||
|
|
@ -117,6 +118,7 @@ final class LogCodeMailer implements CodeMailer {
|
|||
'und neue Schlüssel; was mit den alten verschlüsselt war, ist '
|
||||
'dann verloren',
|
||||
EmailCodePurpose.deletion => 'um dein Konto auf $instance mit allem darin zu löschen',
|
||||
EmailCodePurpose.firstPassword => 'um deinem Konto auf $instance sein erstes Passwort zu geben',
|
||||
}}. Er gilt 15 Minuten.\n'
|
||||
'\n'
|
||||
'Gib ihn nur in $app auf $instance ein, nirgends sonst, auch wenn '
|
||||
|
|
@ -136,6 +138,7 @@ final class LogCodeMailer implements CodeMailer {
|
|||
EmailCodePurpose.authenticatorReset => 'switch off your authenticator',
|
||||
EmailCodePurpose.accountReset => 'reset your account',
|
||||
EmailCodePurpose.deletion => 'delete your account',
|
||||
EmailCodePurpose.firstPassword => 'give your account a password',
|
||||
}}',
|
||||
body:
|
||||
'$code\n'
|
||||
|
|
@ -149,6 +152,7 @@ final class LogCodeMailer implements CodeMailer {
|
|||
EmailCodePurpose.accountReset => 'to reset your account on $instance: a new password and new '
|
||||
'keys, and what was encrypted with the old ones is lost',
|
||||
EmailCodePurpose.deletion => 'to delete your account on $instance with everything in it',
|
||||
EmailCodePurpose.firstPassword => 'to give your account on $instance its first password',
|
||||
}}. It is valid for 15 minutes.\n'
|
||||
'\n'
|
||||
'Type it only in $app on $instance, nowhere else, even if a page or '
|
||||
|
|
|
|||
|
|
@ -17,3 +17,6 @@ values:
|
|||
- accountReset
|
||||
### Deleting an account whose setup never finished, with the password.
|
||||
- deletion
|
||||
### Proving, signed in, the account's own address to give it its first
|
||||
### password (the fourth reading: a session alone sets none).
|
||||
- firstPassword
|
||||
|
|
|
|||
|
|
@ -32,7 +32,11 @@ enum EmailCodePurpose implements _is.SerializableModel {
|
|||
accountReset,
|
||||
|
||||
/// Deleting an account whose setup never finished, with the password.
|
||||
deletion;
|
||||
deletion,
|
||||
|
||||
/// Proving, signed in, the account's own address to give it its first
|
||||
/// password (the fourth reading: a session alone sets none).
|
||||
firstPassword;
|
||||
|
||||
static EmailCodePurpose fromJson(String name) {
|
||||
switch (name) {
|
||||
|
|
@ -46,6 +50,8 @@ enum EmailCodePurpose implements _is.SerializableModel {
|
|||
return EmailCodePurpose.accountReset;
|
||||
case 'deletion':
|
||||
return EmailCodePurpose.deletion;
|
||||
case 'firstPassword':
|
||||
return EmailCodePurpose.firstPassword;
|
||||
default:
|
||||
throw ArgumentError(
|
||||
'Value "$name" cannot be converted to "EmailCodePurpose"',
|
||||
|
|
|
|||
|
|
@ -88,7 +88,12 @@ enum PasswordRefusal implements _is.SerializableModel {
|
|||
/// couldn't open a session for it yet (its clock is behind the
|
||||
/// register's): sign in again with the new password. Never "this
|
||||
/// instance has no password sign-in" (the re-review's tenth finding).
|
||||
passwordChanged;
|
||||
passwordChanged,
|
||||
|
||||
/// A first password for an account that signs in with GitHub alone takes
|
||||
/// a GitHub sign-in of the last 10 minutes (the fourth reading: a session
|
||||
/// alone sets none): sign in with GitHub again, then set it.
|
||||
signInAgain;
|
||||
|
||||
static PasswordRefusal fromJson(String name) {
|
||||
switch (name) {
|
||||
|
|
@ -132,6 +137,8 @@ enum PasswordRefusal implements _is.SerializableModel {
|
|||
return PasswordRefusal.sessionEnded;
|
||||
case 'passwordChanged':
|
||||
return PasswordRefusal.passwordChanged;
|
||||
case 'signInAgain':
|
||||
return PasswordRefusal.signInAgain;
|
||||
default:
|
||||
throw ArgumentError(
|
||||
'Value "$name" cannot be converted to "PasswordRefusal"',
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/src/auth/github.dart';
|
||||
import 'package:halkyon_base_server/src/auth/membership.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/owner_proof_tries.dart';
|
||||
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/people.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
|
@ -19,9 +22,12 @@ class PasswordEndpoint extends Endpoint {
|
|||
|
||||
/// Gives the caller the password [setup] describes (all of it made on the
|
||||
/// device), with the address [code] proves for [request] (mailed with
|
||||
/// `emailCode.sendCode`) unless the caller has one; refusals as
|
||||
/// `setPassword`'s. It signs in once an authenticator is confirmed
|
||||
/// (`authenticator.confirm`).
|
||||
/// `emailCode.sendCode`): the caller's own where they have one
|
||||
/// (`EmailCodePurpose.firstPassword`), else the one they add
|
||||
/// (`EmailCodePurpose.signUp`); someone who signs in with GitHub alone
|
||||
/// does it within [gitHubSignInFreshFor] of signing in with GitHub on this
|
||||
/// session. Refusals as `setPassword`'s. It signs in once an authenticator
|
||||
/// is confirmed (`authenticator.confirm`).
|
||||
Future<void> set(
|
||||
Session session, {
|
||||
required PasswordSetup setup,
|
||||
|
|
@ -35,6 +41,7 @@ class PasswordEndpoint extends Endpoint {
|
|||
setup: setup,
|
||||
request: request,
|
||||
code: code,
|
||||
freshGitHubSignIn: await _signedInWithGitHubJustNow(session),
|
||||
);
|
||||
if (isMember(session.serverpod)) {
|
||||
await becomeIdentified(session, person);
|
||||
|
|
@ -120,6 +127,23 @@ class PasswordEndpoint extends Endpoint {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether [session]'s own session (this server's) was made by a GitHub
|
||||
/// sign-in within [gitHubSignInFreshFor] (the fourth reading).
|
||||
Future<bool> _signedInWithGitHubJustNow(Session session) async {
|
||||
final authId = session.authenticated!.authId;
|
||||
if (!Uuid.isValidUUID(fromString: authId)) {
|
||||
return false;
|
||||
}
|
||||
// SELECT * FROM "serverpod_auth_core_session" WHERE "id" = $1
|
||||
final row = await ServerSideSession.db.findById(
|
||||
session,
|
||||
UuidValue.fromString(authId),
|
||||
);
|
||||
return row != null &&
|
||||
row.method == gitHubMethod &&
|
||||
!clock.now().isAfter(row.createdAt.add(gitHubSignInFreshFor));
|
||||
}
|
||||
|
||||
/// How many proofs of the password one signed-in session begins in an
|
||||
/// hour: a bound on the work it can ask for, holding back that session
|
||||
/// alone.
|
||||
|
|
|
|||
|
|
@ -58,3 +58,7 @@ values:
|
|||
### register's): sign in again with the new password. Never "this
|
||||
### instance has no password sign-in" (the re-review's tenth finding).
|
||||
- passwordChanged
|
||||
### A first password for an account that signs in with GitHub alone takes
|
||||
### a GitHub sign-in of the last 10 minutes (the fourth reading: a session
|
||||
### alone sets none): sign in with GitHub again, then set it.
|
||||
- signInAgain
|
||||
|
|
|
|||
|
|
@ -309,6 +309,10 @@ AccountPassword passwordRowOf(
|
|||
updatedAt: now,
|
||||
);
|
||||
|
||||
/// How recent a GitHub sign-in must be for an account that signs in with
|
||||
/// GitHub alone to take its first password ([setPassword]).
|
||||
const gitHubSignInFreshFor = Duration(minutes: 10);
|
||||
|
||||
/// Gives [person] a password ([setup], ADR 0017). A password needs an
|
||||
/// address, for resets: the person's own, or the one [code] proves for
|
||||
/// [request] (mailed with `sendSignInCode`), which becomes theirs as email
|
||||
|
|
@ -319,31 +323,78 @@ AccountPassword passwordRowOf(
|
|||
/// ([hadConfirmedAuthenticator]: after a reset, a new password comes through
|
||||
/// the recovery phrase or the account's reset alone).
|
||||
///
|
||||
/// A session alone sets no first password (the fourth reading: whoever
|
||||
/// took one would make the account theirs): an account with an address
|
||||
/// proves it anew, by a code mailed to that address for this
|
||||
/// ([EmailCodePurpose.firstPassword]); one that signs in with GitHub alone
|
||||
/// by a GitHub sign-in of the last [gitHubSignInFreshFor]
|
||||
/// ([freshGitHubSignIn], which the server holding the session tells), with
|
||||
/// the address it adds proved as an anonymous person proves theirs
|
||||
/// ([EmailCodePurpose.signUp]).
|
||||
///
|
||||
/// Refused ([PasswordRefusedException]) where the instance has no password
|
||||
/// sign-in, for a setup out of range, without an address, for an address
|
||||
/// another account has, and once the account has a password and had a
|
||||
/// confirmed authenticator ([PasswordRefusal.passwordExists]); a code's
|
||||
/// refusals as `proveAddressCode`'s, and a second address as email sign-in
|
||||
/// refuses it ([EmailSignInRefusedException]). Nothing changes then.
|
||||
/// another account has, once the account has a password and had a
|
||||
/// confirmed authenticator ([PasswordRefusal.passwordExists]), without the
|
||||
/// code its own address takes ([PasswordRefusal.wrongCredentials]), and
|
||||
/// for GitHub without a recent sign-in ([PasswordRefusal.signInAgain],
|
||||
/// before any code is used); a code's refusals as `proveAddressCode`'s, and
|
||||
/// another address than the account's as email sign-in refuses it
|
||||
/// ([EmailSignInRefusedException]). Nothing changes then.
|
||||
Future<void> setPassword(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required PasswordSetup setup,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
bool freshGitHubSignIn = false,
|
||||
}) async {
|
||||
requirePasswordKeys(session.serverpod);
|
||||
if (!isPasswordSetup(setup)) {
|
||||
throw refusedFor(PasswordRefusal.invalidSetup);
|
||||
}
|
||||
final proven = request != null && code != null
|
||||
? await proveAddressCode(
|
||||
// SELECT * FROM "email_identity" WHERE "authUserId" = $1
|
||||
final own = await EmailIdentity.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
);
|
||||
final String? proven;
|
||||
if (own != null) {
|
||||
if (request == null || code == null) {
|
||||
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||
}
|
||||
proven = await proveAddressCode(
|
||||
session,
|
||||
request: request,
|
||||
code: code,
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
if (proven != own.addressHash) {
|
||||
throw EmailSignInRefusedException(
|
||||
reason: EmailSignInRefusal.otherAddressLinked,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// SELECT count(*) FROM "serverpod_auth_idp_github_account"
|
||||
// WHERE "authUserId" = $1
|
||||
final gitHub =
|
||||
await GitHubAccount.db.count(
|
||||
session,
|
||||
request: request,
|
||||
code: code,
|
||||
purpose: EmailCodePurpose.signUp,
|
||||
)
|
||||
: null;
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
) >
|
||||
0;
|
||||
if (gitHub && !freshGitHubSignIn) {
|
||||
throw refusedFor(PasswordRefusal.signInAgain);
|
||||
}
|
||||
proven = request != null && code != null
|
||||
? await proveAddressCode(
|
||||
session,
|
||||
request: request,
|
||||
code: code,
|
||||
purpose: EmailCodePurpose.signUp,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
try {
|
||||
await session.db.transaction((transaction) async {
|
||||
// SELECT * FROM "email_identity"
|
||||
|
|
@ -358,7 +409,7 @@ Future<void> setPassword(
|
|||
final mine = identities
|
||||
.where((identity) => identity.authUserId == person)
|
||||
.firstOrNull;
|
||||
if (proven != null) {
|
||||
if (proven case final proven?) {
|
||||
if (identities.any(
|
||||
(identity) =>
|
||||
identity.addressHash == proven && identity.authUserId != person,
|
||||
|
|
@ -392,8 +443,14 @@ Future<void> setPassword(
|
|||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
} else if (mine == null) {
|
||||
throw refusedFor(PasswordRefusal.noAddress);
|
||||
} else {
|
||||
// No address proved: none at all, or one that came meanwhile, whose
|
||||
// code wasn't given.
|
||||
throw refusedFor(
|
||||
mine == null
|
||||
? PasswordRefusal.noAddress
|
||||
: PasswordRefusal.wrongCredentials,
|
||||
);
|
||||
}
|
||||
// SELECT * FROM "account_password" WHERE "authUserId" = $1
|
||||
final existing = await AccountPassword.db.findFirstRow(
|
||||
|
|
|
|||
|
|
@ -141,13 +141,17 @@ abstract interface class AccountRegister {
|
|||
Future<bool> passwordSignInOffered(Session session);
|
||||
|
||||
/// Gives [person] the password [setup] describes, with the address [code]
|
||||
/// proves for [request] unless they have one. Refusals as `setPassword`'s.
|
||||
/// proves for [request] (their own where they have one), and for one who
|
||||
/// signs in with GitHub alone a GitHub sign-in of theirs made recently
|
||||
/// ([freshGitHubSignIn], of the session asking). Refusals as
|
||||
/// `setPassword`'s.
|
||||
Future<void> setPassword(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required PasswordSetup setup,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
bool freshGitHubSignIn = false,
|
||||
});
|
||||
|
||||
/// A password sign-in's first step for [address], the sign-ins begun from
|
||||
|
|
|
|||
|
|
@ -224,6 +224,8 @@ Future<Map<String, Object?>?> _answer(
|
|||
setup: arguments.model('setup', PasswordSetup.fromJson),
|
||||
request: arguments.optionalId('request'),
|
||||
code: arguments.optionalString('code'),
|
||||
// The member tells it of its own session (the fourth reading).
|
||||
freshGitHubSignIn: arguments.flag('freshGitHubSignIn'),
|
||||
);
|
||||
return const {};
|
||||
case RegisterOperation.beginPasswordSignIn:
|
||||
|
|
@ -503,6 +505,13 @@ extension type const _Arguments(Map<String, Object?> _map) {
|
|||
/// Whether the argument [name] holds anything (not null).
|
||||
bool has(String name) => _map[name] != null;
|
||||
|
||||
/// The flag [name]: false where it is missing.
|
||||
bool flag(String name) => switch (_map[name]) {
|
||||
null => false,
|
||||
final bool value => value,
|
||||
_ => throw FormatException('$name is no flag'),
|
||||
};
|
||||
|
||||
/// The model [read] makes of the object [name]; a [FormatException] for
|
||||
/// what isn't one.
|
||||
T model<T>(String name, T Function(Map<String, dynamic> json) read) {
|
||||
|
|
|
|||
|
|
@ -404,12 +404,14 @@ final class LocalRegister implements AccountRegister {
|
|||
required PasswordSetup setup,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
bool freshGitHubSignIn = false,
|
||||
}) => passwords.setPassword(
|
||||
session,
|
||||
person: person,
|
||||
setup: setup,
|
||||
request: request,
|
||||
code: code,
|
||||
freshGitHubSignIn: freshGitHubSignIn,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -291,11 +291,13 @@ final class RemoteRegister implements AccountRegister {
|
|||
required PasswordSetup setup,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
bool freshGitHubSignIn = false,
|
||||
}) => _ask(RegisterOperation.setPassword, {
|
||||
'person': person.uuid,
|
||||
'setup': setup.toJson(),
|
||||
'request': request?.uuid,
|
||||
'code': code,
|
||||
'freshGitHubSignIn': freshGitHubSignIn,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -117,6 +117,25 @@ void main() {
|
|||
);
|
||||
}
|
||||
|
||||
/// A code mailed to Anna's own address, for a password set while
|
||||
/// signed in (the fourth reading: a session alone sets none).
|
||||
Future<({UuidValue request, String code})> ownAddressCode() => mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
|
||||
/// Anna's password set anew from her phone (until an authenticator is
|
||||
/// confirmed), with a code to her own address.
|
||||
Future<void> annaSetsAgain(PasswordDevice with_) async {
|
||||
final code = await ownAddressCode();
|
||||
await endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: with_.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
);
|
||||
}
|
||||
|
||||
/// Anna's password and a confirmed authenticator: its enrolment, and
|
||||
/// the recovery codes it brought.
|
||||
Future<({AuthenticatorEnrolment enrolment, List<String> recoveryCodes})>
|
||||
|
|
@ -208,14 +227,15 @@ void main() {
|
|||
expect(ways.authenticator, isFalse);
|
||||
});
|
||||
|
||||
test('needs no code from someone whose address is theirs', () async {
|
||||
test('takes, from someone whose address is theirs, a code mailed to '
|
||||
'that address for it', () async {
|
||||
await annaSetsPassword();
|
||||
await AccountPassword.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
);
|
||||
|
||||
await endpoints.password.set(asAnna(), setup: device.setup());
|
||||
await annaSetsAgain(device);
|
||||
|
||||
expect(
|
||||
await AccountPassword.db.count(
|
||||
|
|
@ -224,6 +244,127 @@ void main() {
|
|||
),
|
||||
1,
|
||||
);
|
||||
expect(mailbox.codes.last.purpose, EmailCodePurpose.firstPassword);
|
||||
});
|
||||
|
||||
test('is refused to a session alone where the account has an '
|
||||
'address, and for a code asked for anything else or another '
|
||||
'address (the fourth reading)', () async {
|
||||
// An account with an address and no password, as codes by mail
|
||||
// left them: whoever took its session sets nothing.
|
||||
await annaSetsPassword();
|
||||
await AccountPassword.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
);
|
||||
final thief = await PasswordDevice.make(seed: 99);
|
||||
|
||||
await expectLater(
|
||||
endpoints.password.set(asAnna(), setup: thief.setup()),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
final signUp = await mailedCode('anna@example.org');
|
||||
await expectLater(
|
||||
endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: thief.setup(),
|
||||
request: signUp.request,
|
||||
code: signUp.code,
|
||||
),
|
||||
refusedAsEmail(EmailSignInRefusal.codeExpired),
|
||||
);
|
||||
final theirs = await mailedCode(
|
||||
'thief@example.org',
|
||||
purpose: EmailCodePurpose.firstPassword,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.password.set(
|
||||
asAnna(),
|
||||
setup: thief.setup(),
|
||||
request: theirs.request,
|
||||
code: theirs.code,
|
||||
),
|
||||
refusedAsEmail(EmailSignInRefusal.otherAddressLinked),
|
||||
);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
expect(
|
||||
(await EmailIdentity.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
))!.addressHash,
|
||||
addressHash(pod, 'anna@example.org'),
|
||||
);
|
||||
});
|
||||
|
||||
group('for someone who signs in with GitHub alone', () {
|
||||
setUp(() async {
|
||||
await GitHubAccount.db.insertRow(
|
||||
session,
|
||||
GitHubAccount(userIdentifier: '1001', authUserId: anna),
|
||||
);
|
||||
await grantIdentified(session, anna);
|
||||
});
|
||||
|
||||
TestSessionBuilder from(UuidValue device) => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{identified},
|
||||
authId: device.uuid,
|
||||
),
|
||||
);
|
||||
|
||||
test('is refused to a session not made by a GitHub sign-in of the '
|
||||
'last ten minutes, before the code is used, so a stolen '
|
||||
'session claims nothing (the fourth reading)', () async {
|
||||
final thief = await PasswordDevice.make(seed: 99);
|
||||
final code = await mailedCode('thief@example.org');
|
||||
final stale = await atNow(
|
||||
() => signInNewDevice(session, anna, method: gitHubMethod),
|
||||
);
|
||||
now = now.add(gitHubSignInFreshFor + const Duration(seconds: 1));
|
||||
for (final device in [annasPhone, stale]) {
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.password.set(
|
||||
from(device),
|
||||
setup: thief.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
),
|
||||
),
|
||||
refused(PasswordRefusal.signInAgain),
|
||||
);
|
||||
}
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
expect(
|
||||
await EmailIdentity.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
0,
|
||||
);
|
||||
expect(await gitHubAccountToSignIn(session, '1001'), anna);
|
||||
|
||||
// Signed in with GitHub just now, the same code still serves.
|
||||
final fresh = await atNow(
|
||||
() => signInNewDevice(session, anna, method: gitHubMethod),
|
||||
);
|
||||
await atNow(
|
||||
() => endpoints.password.set(
|
||||
from(fresh),
|
||||
setup: device.setup(),
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await AccountPassword.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('is refused without an address, keeping nothing', () async {
|
||||
|
|
@ -346,7 +487,7 @@ void main() {
|
|||
'then is refused', () async {
|
||||
await annaSetsPassword();
|
||||
final other = await PasswordDevice.make(seed: 99);
|
||||
await endpoints.password.set(asAnna(), setup: other.setup());
|
||||
await annaSetsAgain(other);
|
||||
expect(
|
||||
Uint8List.sublistView(
|
||||
(await AccountPassword.db.findFirstRow(
|
||||
|
|
@ -370,7 +511,7 @@ void main() {
|
|||
});
|
||||
|
||||
await expectLater(
|
||||
endpoints.password.set(asAnna(), setup: device.setup()),
|
||||
annaSetsAgain(device),
|
||||
refused(PasswordRefusal.passwordExists),
|
||||
);
|
||||
});
|
||||
|
|
@ -1866,7 +2007,7 @@ void main() {
|
|||
|
||||
final other = await PasswordDevice.make(seed: 99);
|
||||
await expectLater(
|
||||
endpoints.password.set(asAnna(), setup: other.setup()),
|
||||
annaSetsAgain(other),
|
||||
refused(PasswordRefusal.passwordExists),
|
||||
);
|
||||
expect(
|
||||
|
|
@ -2647,10 +2788,10 @@ void main() {
|
|||
fresh.signingPublicKey,
|
||||
);
|
||||
await expectLater(finish(fresh), refused(PasswordRefusal.noReset));
|
||||
// A session alone replaces neither, before an authenticator is
|
||||
// confirmed again.
|
||||
// A session (even with a code to the address) replaces neither,
|
||||
// before an authenticator is confirmed again.
|
||||
await expectLater(
|
||||
endpoints.password.set(asAnna(), setup: device.setup()),
|
||||
annaSetsAgain(device),
|
||||
refused(PasswordRefusal.passwordExists),
|
||||
);
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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';
|
||||
|
|
@ -43,6 +44,10 @@ final class _Register implements AccountRegister {
|
|||
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>{};
|
||||
|
||||
/// What a password sign-in proves; null refuses it.
|
||||
PasswordProof? proof;
|
||||
|
||||
|
|
@ -205,9 +210,13 @@ final class _Register implements AccountRegister {
|
|||
required PasswordSetup setup,
|
||||
UuidValue? request,
|
||||
String? code,
|
||||
bool freshGitHubSignIn = false,
|
||||
}) => _answer('setPassword', () {
|
||||
add(person);
|
||||
passwords.add(person);
|
||||
if (freshGitHubSignIn) {
|
||||
freshGitHubSignIns.add(person);
|
||||
}
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -733,6 +742,7 @@ void main() {
|
|||
);
|
||||
|
||||
expect(register.passwords, {anna});
|
||||
expect(register.freshGitHubSignIns, isEmpty);
|
||||
expect(await scopesOf(anna), contains(identified.name));
|
||||
expect(
|
||||
await AnonymousAccount.db.count(
|
||||
|
|
@ -743,6 +753,38 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ Future<UuidValue> signInNewDevice(
|
|||
Session session,
|
||||
UuidValue person, {
|
||||
Set<Scope> scopes = const {},
|
||||
String method = 'test',
|
||||
}) async {
|
||||
Future<Set<UuidValue>> sessionIds() async => {
|
||||
for (final serverSideSession in await ServerSideSession.db.find(
|
||||
|
|
@ -68,7 +69,7 @@ Future<UuidValue> signInNewDevice(
|
|||
await AuthServices.instance.tokenManager.createToken(
|
||||
session,
|
||||
authUserId: person,
|
||||
method: 'test',
|
||||
method: method,
|
||||
scopes: scopes,
|
||||
);
|
||||
return (await sessionIds()).difference(before).single;
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ void main() {
|
|||
"to switch off your authenticator app's codes",
|
||||
EmailCodePurpose.accountReset: 'to reset your account',
|
||||
EmailCodePurpose.deletion: 'to delete your account',
|
||||
EmailCodePurpose.firstPassword:
|
||||
'to give your account on learn.halkyon.eu its first password',
|
||||
};
|
||||
const german = {
|
||||
EmailCodePurpose.signUp: 'um dein Konto auf learn.halkyon.eu anzulegen',
|
||||
|
|
@ -135,6 +137,9 @@ void main() {
|
|||
'um die Codes deiner Authenticator-App',
|
||||
EmailCodePurpose.accountReset: 'zurückzusetzen',
|
||||
EmailCodePurpose.deletion: 'mit allem darin zu löschen',
|
||||
EmailCodePurpose.firstPassword:
|
||||
'um deinem Konto auf learn.halkyon.eu sein erstes Passwort zu '
|
||||
'geben',
|
||||
};
|
||||
for (final purpose in EmailCodePurpose.values) {
|
||||
final en = mailFor(purpose, 'en').body;
|
||||
|
|
@ -179,6 +184,9 @@ void main() {
|
|||
'K7QX-M2PD: your Halkyon Learn code to reset your account',
|
||||
EmailCodePurpose.deletion:
|
||||
'K7QX-M2PD: your Halkyon Learn code to delete your account',
|
||||
EmailCodePurpose.firstPassword:
|
||||
'K7QX-M2PD: your Halkyon Learn code to give your account a '
|
||||
'password',
|
||||
};
|
||||
const german = {
|
||||
EmailCodePurpose.signUp:
|
||||
|
|
@ -193,6 +201,9 @@ void main() {
|
|||
'K7QX-M2PD: dein Halkyon-Learn-Code, um dein Konto zurückzusetzen',
|
||||
EmailCodePurpose.deletion:
|
||||
'K7QX-M2PD: dein Halkyon-Learn-Code, um dein Konto zu löschen',
|
||||
EmailCodePurpose.firstPassword:
|
||||
'K7QX-M2PD: dein Halkyon-Learn-Code, um deinem Konto ein Passwort '
|
||||
'zu geben',
|
||||
};
|
||||
for (final purpose in EmailCodePurpose.values) {
|
||||
expect(subjectOf(purpose, 'en'), english[purpose]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue