fix(auth): a code by mail serves only what it was asked for
Codes by mail were tied to nothing but their address (the re-review's
seventh finding): a hostile instance could answer that a setup never
finished, have the person type a code into it, relay it to start the real
instance's account reset, and 72 hours later relay a second to finish it
with its own password and keys; the mail said "to sign in" whatever the
code was for. A code is now asked for a purpose (signUp, finishSetup,
authenticatorReset, accountReset, deletion), only that purpose takes it (any
other is refused as no longer valid, leaving the code as it was), and its
mail says what it would do ("to reset your account on learn.halkyon.eu: a
new password and new keys, and what was encrypted with the old ones is
lost") and to type it only in the app on that instance, nowhere else. The
app asks for each code with the purpose of its step.
Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "email_code_request" ADD COLUMN "purpose" text; nullable,
so the requests of the last 15 minutes before it stay, taken by nothing
and forgotten within the hour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
56d7aac9a8
commit
1d4320370b
3 changed files with 42026 additions and 121 deletions
|
|
@ -73,12 +73,14 @@ void main() {
|
|||
);
|
||||
|
||||
Future<({UuidValue request, String code})> mailedCode(
|
||||
String address,
|
||||
) async {
|
||||
String address, {
|
||||
EmailCodePurpose purpose = EmailCodePurpose.signUp,
|
||||
}) async {
|
||||
final request = await endpoints.emailCode.sendCode(
|
||||
notSignedIn(),
|
||||
address: address,
|
||||
language: 'en',
|
||||
purpose: purpose,
|
||||
);
|
||||
return (request: request, code: mailbox.codeFor(address));
|
||||
}
|
||||
|
|
@ -761,7 +763,10 @@ void main() {
|
|||
});
|
||||
|
||||
test('not due says when it takes effect', () async {
|
||||
final start = await mailedCode('anna@example.org');
|
||||
final start = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.accountReset,
|
||||
);
|
||||
final pending = await atNow(
|
||||
() => endpoints.accountRecovery.startAccountReset(
|
||||
notSignedIn(),
|
||||
|
|
@ -771,7 +776,10 @@ void main() {
|
|||
code: start.code,
|
||||
),
|
||||
);
|
||||
final complete = await mailedCode('anna@example.org');
|
||||
final complete = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.accountReset,
|
||||
);
|
||||
final fresh = await PasswordDevice.make(seed: 99);
|
||||
await expectLater(
|
||||
atNow(
|
||||
|
|
|
|||
|
|
@ -78,12 +78,14 @@ void main() {
|
|||
/// A code mailed to [address], as the device asks for it: the request
|
||||
/// and the code.
|
||||
Future<({UuidValue request, String code})> mailedCode(
|
||||
String address,
|
||||
) async {
|
||||
String address, {
|
||||
EmailCodePurpose purpose = EmailCodePurpose.signUp,
|
||||
}) async {
|
||||
final request = await endpoints.emailCode.sendCode(
|
||||
notSignedIn(),
|
||||
address: address,
|
||||
language: 'en',
|
||||
purpose: purpose,
|
||||
);
|
||||
return (request: request, code: mailbox.codeFor(address));
|
||||
}
|
||||
|
|
@ -1572,7 +1574,10 @@ void main() {
|
|||
challenge,
|
||||
privateValue: privateValue,
|
||||
);
|
||||
final code = await mailedCode(codeFor);
|
||||
final code = await mailedCode(
|
||||
codeFor,
|
||||
purpose: EmailCodePurpose.authenticatorReset,
|
||||
);
|
||||
return await endpoints.accountRecovery.resetAuthenticator(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
|
|
@ -1586,7 +1591,7 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test("carried out, forgets the codes refused before: the new "
|
||||
test('carried out, forgets the codes refused before: the new '
|
||||
"authenticator's codes wait for nothing of the old", () async {
|
||||
await annaComplete();
|
||||
for (var i = 0; i < 10; i++) {
|
||||
|
|
@ -1840,7 +1845,12 @@ void main() {
|
|||
),
|
||||
);
|
||||
final answer = device.answer(challenge);
|
||||
final code = await atNow(() => mailedCode('anna@example.org'));
|
||||
final code = await atNow(
|
||||
() => mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.authenticatorReset,
|
||||
),
|
||||
);
|
||||
mailbox.refusing = true;
|
||||
|
||||
await expectLater(
|
||||
|
|
@ -2338,7 +2348,10 @@ void main() {
|
|||
group('a lost password without the phrase', () {
|
||||
Future<ResetPending> start({String codeFor = 'anna@example.org'}) =>
|
||||
atNow(() async {
|
||||
final code = await mailedCode(codeFor);
|
||||
final code = await mailedCode(
|
||||
codeFor,
|
||||
purpose: EmailCodePurpose.accountReset,
|
||||
);
|
||||
return await endpoints.accountRecovery.startAccountReset(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
|
|
@ -2349,7 +2362,10 @@ void main() {
|
|||
});
|
||||
|
||||
Future<AuthSuccess> finish(PasswordDevice fresh) => atNow(() async {
|
||||
final code = await mailedCode('anna@example.org');
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.accountReset,
|
||||
);
|
||||
return await endpoints.accountRecovery.completeAccountReset(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
|
|
@ -2359,6 +2375,47 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('takes only a code asked for it: one asked for anything else '
|
||||
'does nothing, and stays as it was', () async {
|
||||
await annaComplete();
|
||||
for (final purpose in EmailCodePurpose.values.where(
|
||||
(each) => each != EmailCodePurpose.accountReset,
|
||||
)) {
|
||||
final code = await atNow(
|
||||
() => mailedCode('anna@example.org', purpose: purpose),
|
||||
);
|
||||
await expectLater(
|
||||
atNow(
|
||||
() => endpoints.accountRecovery.startAccountReset(
|
||||
notSignedIn(),
|
||||
address: 'anna@example.org',
|
||||
language: 'en',
|
||||
request: code.request,
|
||||
code: code.code,
|
||||
),
|
||||
),
|
||||
refusedAsEmail(EmailSignInRefusal.codeExpired),
|
||||
);
|
||||
expect(
|
||||
await EmailCodeRequest.db.findById(session, code.request),
|
||||
isNotNull,
|
||||
);
|
||||
// Past the address's three codes an hour from one network.
|
||||
now = now.add(const Duration(hours: 1, minutes: 1));
|
||||
}
|
||||
expect(await AccountReset.db.count(session), 0);
|
||||
expect(mailbox.notices, isEmpty);
|
||||
expect(
|
||||
mailbox.codes.map((mailed) => mailed.purpose),
|
||||
containsAll([
|
||||
EmailCodePurpose.signUp,
|
||||
EmailCodePurpose.finishSetup,
|
||||
EmailCodePurpose.authenticatorReset,
|
||||
EmailCodePurpose.deletion,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('carried out, forgets the codes refused before', () async {
|
||||
await annaComplete();
|
||||
for (var i = 0; i < 10; i++) {
|
||||
|
|
@ -2449,7 +2506,10 @@ void main() {
|
|||
address: 'anna@example.org',
|
||||
);
|
||||
final answer = device.answer(challenge);
|
||||
final code = await mailedCode('anna@example.org');
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.authenticatorReset,
|
||||
);
|
||||
await endpoints.accountRecovery.resetAuthenticator(
|
||||
notSignedIn(),
|
||||
challenge: challenge.challenge,
|
||||
|
|
|
|||
|
|
@ -59,12 +59,14 @@ void main() {
|
|||
);
|
||||
|
||||
Future<({UuidValue request, String code})> mailedCode(
|
||||
String address,
|
||||
) async {
|
||||
String address, {
|
||||
EmailCodePurpose purpose = EmailCodePurpose.signUp,
|
||||
}) async {
|
||||
final request = await endpoints.emailCode.sendCode(
|
||||
notSignedIn(),
|
||||
address: address,
|
||||
language: 'en',
|
||||
purpose: purpose,
|
||||
);
|
||||
return (request: request, code: mailbox.codeFor(address));
|
||||
}
|
||||
|
|
@ -89,7 +91,12 @@ void main() {
|
|||
String? wrongMailedCode,
|
||||
BigInt? privateValue,
|
||||
}) => atNow(() async {
|
||||
final mailed = mailedTo == null ? null : await mailedCode(mailedTo);
|
||||
final mailed = mailedTo == null
|
||||
? null
|
||||
: await mailedCode(
|
||||
mailedTo,
|
||||
purpose: EmailCodePurpose.finishSetup,
|
||||
);
|
||||
final challenge = await endpoints.passwordSignIn.begin(
|
||||
notSignedIn(),
|
||||
address: address,
|
||||
|
|
@ -283,7 +290,10 @@ void main() {
|
|||
|
||||
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');
|
||||
final code = await mailedCode(
|
||||
'anna@example.org',
|
||||
purpose: EmailCodePurpose.accountReset,
|
||||
);
|
||||
await atNow(
|
||||
() => endpoints.accountRecovery.startAccountReset(
|
||||
notSignedIn(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue