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
4 changed files with 762 additions and 66 deletions
|
|
@ -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