fix(auth): codes after the recovery phrase count apart from the password's

The full reading's M3: whoever knows the password made the account's codes
wait, and that wait refused the phrase's owner their reset too, so they were
held out of both without end. Codes now count per account and per way they
came (SecondFactorPath: after the password, after the phrase), each with
its own waits; a code accepted either way clears both.

Migration (module, Learn's and Todo's servers; Learn's device schema
rebuilds the server-only table):
  DROP INDEX "second_factor_failures_person_idx";
  ALTER TABLE "second_factor_failures" ADD COLUMN "path" text NOT NULL
    DEFAULT 'password'::text;
  CREATE UNIQUE INDEX "second_factor_failures_person_idx" ON
    "second_factor_failures" USING btree ("authUserId", "path");
Existing rows are the password's, unique per account, so unique per
account and way too.

Also the hosts' generated future calls, for the pruning's doc comment the
lapsed resets' commit changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 13:06:26 +02:00
commit 4d1cd2045c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 41930 additions and 17 deletions

View file

@ -0,0 +1,45 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// Which way codes reach an account's second factor without a session
/// (ADR 0017; the full reading's M3): after the password proved right, or
/// after the recovery phrase did. Each counts its own refused codes, so
/// whoever knows the password and makes codes wait never holds back the
/// phrase's owner from their reset.
enum SecondFactorPath implements _isc.SerializableModel {
/// A sign-in, or a reset of the authenticators asked with the password.
password,
/// A new password set with the recovery phrase.
phrase;
static SecondFactorPath fromJson(String name) {
switch (name) {
case 'password':
return SecondFactorPath.password;
case 'phrase':
return SecondFactorPath.phrase;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "SecondFactorPath"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -39,6 +39,7 @@ import 'password/password_refused_exception.dart' as _i4froq5y;
import 'password/password_setup.dart' as _igpggy1y;
import 'password/password_signed_in.dart' as _ipk7gvzy;
import 'password/reset_pending.dart' as _i3y6qzi3;
import 'password/second_factor_path.dart' as _i4ivswjj;
import 'password/sign_in_challenge_purpose.dart' as _izijxzng;
import 'password/wrapped_keys.dart' as _i6v10ugm;
import 'profile/profile.dart' as _ia5j8fch;
@ -66,6 +67,7 @@ export 'password/password_refused_exception.dart';
export 'password/password_setup.dart';
export 'password/password_signed_in.dart';
export 'password/reset_pending.dart';
export 'password/second_factor_path.dart';
export 'password/sign_in_challenge_purpose.dart';
export 'password/wrapped_keys.dart';
export 'profile/profile.dart';
@ -182,6 +184,9 @@ class Protocol extends _isc.SerializationManager {
if (t == _i3y6qzi3.ResetPending) {
return _i3y6qzi3.ResetPending.fromJson(data) as T;
}
if (t == _i4ivswjj.SecondFactorPath) {
return _i4ivswjj.SecondFactorPath.fromJson(data) as T;
}
if (t == _izijxzng.SignInChallengePurpose) {
return _izijxzng.SignInChallengePurpose.fromJson(data) as T;
}
@ -290,6 +295,10 @@ class Protocol extends _isc.SerializationManager {
if (t == _isc.getType<_i3y6qzi3.ResetPending?>()) {
return (data != null ? _i3y6qzi3.ResetPending.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i4ivswjj.SecondFactorPath?>()) {
return (data != null ? _i4ivswjj.SecondFactorPath.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_izijxzng.SignInChallengePurpose?>()) {
return (data != null
? _izijxzng.SignInChallengePurpose.fromJson(data)
@ -371,6 +380,7 @@ class Protocol extends _isc.SerializationManager {
_igpggy1y.PasswordSetup => 'PasswordSetup',
_ipk7gvzy.PasswordSignedIn => 'PasswordSignedIn',
_i3y6qzi3.ResetPending => 'ResetPending',
_i4ivswjj.SecondFactorPath => 'SecondFactorPath',
_izijxzng.SignInChallengePurpose => 'SignInChallengePurpose',
_i6v10ugm.WrappedKeys => 'WrappedKeys',
_ia5j8fch.Profile => 'Profile',
@ -436,6 +446,8 @@ class Protocol extends _isc.SerializationManager {
return 'PasswordSignedIn';
case _i3y6qzi3.ResetPending():
return 'ResetPending';
case _i4ivswjj.SecondFactorPath():
return 'SecondFactorPath';
case _izijxzng.SignInChallengePurpose():
return 'SignInChallengePurpose';
case _i6v10ugm.WrappedKeys():
@ -521,6 +533,9 @@ class Protocol extends _isc.SerializationManager {
if (dataClassName == 'ResetPending') {
return deserialize<_i3y6qzi3.ResetPending>(data['data']);
}
if (dataClassName == 'SecondFactorPath') {
return deserialize<_i4ivswjj.SecondFactorPath>(data['data']);
}
if (dataClassName == 'SignInChallengePurpose') {
return deserialize<_izijxzng.SignInChallengePurpose>(data['data']);
}