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:
parent
43fcc8aac2
commit
4d1cd2045c
8 changed files with 41930 additions and 17 deletions
|
|
@ -11,25 +11,30 @@
|
|||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import '../password/second_factor_path.dart' as _iqz50kjy;
|
||||
|
||||
/// The codes an account's second factor refused in a row (ADR 0017; the
|
||||
/// second reading's M4): whoever knows the password guesses codes, and
|
||||
/// after every tenth refused in a row codes wait, an hour, then twice as
|
||||
/// long each time. The row goes once a code is accepted.
|
||||
/// long each time. One row per account and way the codes came
|
||||
/// (`SecondFactorPath`, the full reading's M3); the rows go once a code is
|
||||
/// accepted.
|
||||
abstract class SecondFactorFailures
|
||||
implements _is.TableRow<int?>, _is.ProtocolSerialization {
|
||||
SecondFactorFailures._({
|
||||
this.id,
|
||||
required this.authUserId,
|
||||
_iqz50kjy.SecondFactorPath? path,
|
||||
required this.failures,
|
||||
required this.waits,
|
||||
this.waitUntil,
|
||||
required this.updatedAt,
|
||||
});
|
||||
}) : path = path ?? _iqz50kjy.SecondFactorPath.password;
|
||||
|
||||
factory SecondFactorFailures({
|
||||
int? id,
|
||||
required _is.UuidValue authUserId,
|
||||
_iqz50kjy.SecondFactorPath? path,
|
||||
required int failures,
|
||||
required int waits,
|
||||
DateTime? waitUntil,
|
||||
|
|
@ -44,6 +49,11 @@ abstract class SecondFactorFailures
|
|||
authUserId: _is.UuidValueJsonExtension.fromJson(
|
||||
jsonSerialization['authUserId'],
|
||||
),
|
||||
path: jsonSerialization['path'] == null
|
||||
? null
|
||||
: _iqz50kjy.SecondFactorPath.fromJson(
|
||||
(jsonSerialization['path'] as String),
|
||||
),
|
||||
failures: jsonSerialization['failures'] as int,
|
||||
waits: jsonSerialization['waits'] as int,
|
||||
waitUntil: jsonSerialization['waitUntil'] == null
|
||||
|
|
@ -65,6 +75,9 @@ abstract class SecondFactorFailures
|
|||
/// The account: its person's ID (deleted with them, `deletePerson`).
|
||||
_is.UuidValue authUserId;
|
||||
|
||||
/// Which way the codes came: after the password, or the phrase.
|
||||
_iqz50kjy.SecondFactorPath path;
|
||||
|
||||
/// Codes refused in a row since the last accepted.
|
||||
int failures;
|
||||
|
||||
|
|
@ -86,6 +99,7 @@ abstract class SecondFactorFailures
|
|||
SecondFactorFailures copyWith({
|
||||
int? id,
|
||||
_is.UuidValue? authUserId,
|
||||
_iqz50kjy.SecondFactorPath? path,
|
||||
int? failures,
|
||||
int? waits,
|
||||
DateTime? waitUntil,
|
||||
|
|
@ -97,6 +111,7 @@ abstract class SecondFactorFailures
|
|||
'__className__': 'halkyon_base.SecondFactorFailures',
|
||||
if (id != null) 'id': id,
|
||||
'authUserId': authUserId.toJson(),
|
||||
'path': path.toJson(),
|
||||
'failures': failures,
|
||||
'waits': waits,
|
||||
if (waitUntil != null) 'waitUntil': waitUntil?.toJson(),
|
||||
|
|
@ -143,6 +158,7 @@ class _SecondFactorFailuresImpl extends SecondFactorFailures {
|
|||
_SecondFactorFailuresImpl({
|
||||
int? id,
|
||||
required _is.UuidValue authUserId,
|
||||
_iqz50kjy.SecondFactorPath? path,
|
||||
required int failures,
|
||||
required int waits,
|
||||
DateTime? waitUntil,
|
||||
|
|
@ -150,6 +166,7 @@ class _SecondFactorFailuresImpl extends SecondFactorFailures {
|
|||
}) : super._(
|
||||
id: id,
|
||||
authUserId: authUserId,
|
||||
path: path,
|
||||
failures: failures,
|
||||
waits: waits,
|
||||
waitUntil: waitUntil,
|
||||
|
|
@ -163,6 +180,7 @@ class _SecondFactorFailuresImpl extends SecondFactorFailures {
|
|||
SecondFactorFailures copyWith({
|
||||
Object? id = _Undefined,
|
||||
_is.UuidValue? authUserId,
|
||||
_iqz50kjy.SecondFactorPath? path,
|
||||
int? failures,
|
||||
int? waits,
|
||||
Object? waitUntil = _Undefined,
|
||||
|
|
@ -171,6 +189,7 @@ class _SecondFactorFailuresImpl extends SecondFactorFailures {
|
|||
return SecondFactorFailures(
|
||||
id: id is int? ? id : this.id,
|
||||
authUserId: authUserId ?? this.authUserId,
|
||||
path: path ?? this.path,
|
||||
failures: failures ?? this.failures,
|
||||
waits: waits ?? this.waits,
|
||||
waitUntil: waitUntil is DateTime? ? waitUntil : this.waitUntil,
|
||||
|
|
@ -190,6 +209,13 @@ class SecondFactorFailuresUpdateTable
|
|||
value,
|
||||
);
|
||||
|
||||
_is.ColumnValue<_iqz50kjy.SecondFactorPath, _iqz50kjy.SecondFactorPath> path(
|
||||
_iqz50kjy.SecondFactorPath value,
|
||||
) => _is.ColumnValue(
|
||||
table.path,
|
||||
value,
|
||||
);
|
||||
|
||||
_is.ColumnValue<int, int> failures(int value) => _is.ColumnValue(
|
||||
table.failures,
|
||||
value,
|
||||
|
|
@ -221,6 +247,12 @@ class SecondFactorFailuresTable extends _is.Table<int?> {
|
|||
'authUserId',
|
||||
this,
|
||||
);
|
||||
path = _is.ColumnEnum(
|
||||
'path',
|
||||
this,
|
||||
_is.EnumSerialization.byName,
|
||||
hasDefault: true,
|
||||
);
|
||||
failures = _is.ColumnInt(
|
||||
'failures',
|
||||
this,
|
||||
|
|
@ -244,6 +276,9 @@ class SecondFactorFailuresTable extends _is.Table<int?> {
|
|||
/// The account: its person's ID (deleted with them, `deletePerson`).
|
||||
late final _is.ColumnUuid authUserId;
|
||||
|
||||
/// Which way the codes came: after the password, or the phrase.
|
||||
late final _is.ColumnEnum<_iqz50kjy.SecondFactorPath> path;
|
||||
|
||||
/// Codes refused in a row since the last accepted.
|
||||
late final _is.ColumnInt failures;
|
||||
|
||||
|
|
@ -260,6 +295,7 @@ class SecondFactorFailuresTable extends _is.Table<int?> {
|
|||
List<_is.Column> get columns => [
|
||||
id,
|
||||
authUserId,
|
||||
path,
|
||||
failures,
|
||||
waits,
|
||||
waitUntil,
|
||||
|
|
|
|||
|
|
@ -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/serverpod.dart' as _is;
|
||||
|
||||
/// 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 _is.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;
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ import 'password/password_signed_in.dart' as _ipk7gvzy;
|
|||
import 'password/recovery_code.dart' as _iart5x0c;
|
||||
import 'password/reset_pending.dart' as _i3y6qzi3;
|
||||
import 'password/second_factor_failures.dart' as _i9cg650q;
|
||||
import 'password/second_factor_path.dart' as _i4ivswjj;
|
||||
import 'password/session_code_failures.dart' as _iqr11ieo;
|
||||
import 'password/sign_in_challenge.dart' as _i9rg59hu;
|
||||
import 'password/sign_in_challenge_purpose.dart' as _izijxzng;
|
||||
|
|
@ -87,6 +88,7 @@ export 'password/password_signed_in.dart';
|
|||
export 'password/recovery_code.dart';
|
||||
export 'password/reset_pending.dart';
|
||||
export 'password/second_factor_failures.dart';
|
||||
export 'password/second_factor_path.dart';
|
||||
export 'password/session_code_failures.dart';
|
||||
export 'password/sign_in_challenge.dart';
|
||||
export 'password/sign_in_challenge_purpose.dart';
|
||||
|
|
@ -678,6 +680,13 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
isNullable: false,
|
||||
dartType: 'UuidValue',
|
||||
),
|
||||
_isp.ColumnDefinition(
|
||||
name: 'path',
|
||||
columnType: _isp.ColumnType.text,
|
||||
isNullable: false,
|
||||
dartType: 'protocol:SecondFactorPath',
|
||||
columnDefault: '\'password\'',
|
||||
),
|
||||
_isp.ColumnDefinition(
|
||||
name: 'failures',
|
||||
columnType: _isp.ColumnType.bigint,
|
||||
|
|
@ -713,6 +722,10 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
type: _isp.IndexElementDefinitionType.column,
|
||||
definition: 'authUserId',
|
||||
),
|
||||
_isp.IndexElementDefinition(
|
||||
type: _isp.IndexElementDefinitionType.column,
|
||||
definition: 'path',
|
||||
),
|
||||
],
|
||||
type: 'btree',
|
||||
isUnique: true,
|
||||
|
|
@ -1109,6 +1122,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _i9cg650q.SecondFactorFailures) {
|
||||
return _i9cg650q.SecondFactorFailures.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i4ivswjj.SecondFactorPath) {
|
||||
return _i4ivswjj.SecondFactorPath.fromJson(data) as T;
|
||||
}
|
||||
if (t == _iqr11ieo.SessionCodeFailures) {
|
||||
return _iqr11ieo.SessionCodeFailures.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -1263,6 +1279,10 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
: null)
|
||||
as T;
|
||||
}
|
||||
if (t == _is.getType<_i4ivswjj.SecondFactorPath?>()) {
|
||||
return (data != null ? _i4ivswjj.SecondFactorPath.fromJson(data) : null)
|
||||
as T;
|
||||
}
|
||||
if (t == _is.getType<_iqr11ieo.SessionCodeFailures?>()) {
|
||||
return (data != null
|
||||
? _iqr11ieo.SessionCodeFailures.fromJson(data)
|
||||
|
|
@ -1384,6 +1404,7 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
_iart5x0c.RecoveryCode => 'RecoveryCode',
|
||||
_i3y6qzi3.ResetPending => 'ResetPending',
|
||||
_i9cg650q.SecondFactorFailures => 'SecondFactorFailures',
|
||||
_i4ivswjj.SecondFactorPath => 'SecondFactorPath',
|
||||
_iqr11ieo.SessionCodeFailures => 'SessionCodeFailures',
|
||||
_i9rg59hu.SignInChallenge => 'SignInChallenge',
|
||||
_izijxzng.SignInChallengePurpose => 'SignInChallengePurpose',
|
||||
|
|
@ -1469,6 +1490,8 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
return 'ResetPending';
|
||||
case _i9cg650q.SecondFactorFailures():
|
||||
return 'SecondFactorFailures';
|
||||
case _i4ivswjj.SecondFactorPath():
|
||||
return 'SecondFactorPath';
|
||||
case _iqr11ieo.SessionCodeFailures():
|
||||
return 'SessionCodeFailures';
|
||||
case _i9rg59hu.SignInChallenge():
|
||||
|
|
@ -1591,6 +1614,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (dataClassName == 'SecondFactorFailures') {
|
||||
return deserialize<_i9cg650q.SecondFactorFailures>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'SecondFactorPath') {
|
||||
return deserialize<_i4ivswjj.SecondFactorPath>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'SessionCodeFailures') {
|
||||
return deserialize<_iqr11ieo.SessionCodeFailures>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
|
|||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
notice: notice,
|
||||
// Its own count (the full reading's M3): whoever knows the password
|
||||
// and makes its codes wait never holds the phrase's owner back.
|
||||
path: SecondFactorPath.phrase,
|
||||
);
|
||||
final endedBefore = await session.db.transaction((transaction) async {
|
||||
await writeNewPassword(session, row, password, transaction: transaction);
|
||||
|
|
|
|||
|
|
@ -950,7 +950,9 @@ typedef SecondFactorPassed = ({
|
|||
/// no code while a reset waits, [PasswordRefusal.notDue]; while codes
|
||||
/// wait, [PasswordRefusal.codesWait]; anything else,
|
||||
/// [PasswordRefusal.wrongCredentials]. Every code is counted as it is
|
||||
/// tried ([takeCodeAttempt]); [notice] tells the owner as a wait begins.
|
||||
/// tried ([takeCodeAttempt]), apart for each [path] it came by (after the
|
||||
/// password, or the recovery phrase: the full reading's M3); [notice] tells
|
||||
/// the owner as a wait begins.
|
||||
Future<SecondFactorPassed> secondFactor(
|
||||
Session session,
|
||||
PasswordKeys keys,
|
||||
|
|
@ -958,6 +960,7 @@ Future<SecondFactorPassed> secondFactor(
|
|||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required CodesWaitNotice notice,
|
||||
SecondFactorPath path = SecondFactorPath.password,
|
||||
}) async {
|
||||
final now = clock.now().toUtc();
|
||||
final confirmed = await confirmedAuthenticatorsOf(session, account);
|
||||
|
|
@ -988,7 +991,7 @@ Future<SecondFactorPassed> secondFactor(
|
|||
throw refusedFor(PasswordRefusal.setupIncomplete);
|
||||
}
|
||||
if (totp.isNotEmpty || recovery.isNotEmpty) {
|
||||
final waitBegan = await takeCodeAttempt(session, account);
|
||||
final waitBegan = await takeCodeAttempt(session, account, path: path);
|
||||
if (totp.isNotEmpty
|
||||
? await acceptTotpCode(session, keys, confirmed, totp)
|
||||
: await _useRecoveryCode(session, keys, account, recovery)) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
### The codes an account's second factor refused in a row (ADR 0017; the
|
||||
### second reading's M4): whoever knows the password guesses codes, and
|
||||
### after every tenth refused in a row codes wait, an hour, then twice as
|
||||
### long each time. The row goes once a code is accepted.
|
||||
### long each time. One row per account and way the codes came
|
||||
### (`SecondFactorPath`, the full reading's M3); the rows go once a code is
|
||||
### accepted.
|
||||
class: SecondFactorFailures
|
||||
serverOnly: true
|
||||
table: second_factor_failures
|
||||
fields:
|
||||
### The account: its person's ID (deleted with them, `deletePerson`).
|
||||
authUserId: UuidValue
|
||||
### Which way the codes came: after the password, or the phrase.
|
||||
path: SecondFactorPath, default=password
|
||||
### Codes refused in a row since the last accepted.
|
||||
failures: int
|
||||
### How many waits began since the last accepted code: the next lasts an
|
||||
|
|
@ -17,7 +21,7 @@ fields:
|
|||
waitUntil: DateTime?
|
||||
updatedAt: DateTime
|
||||
indexes:
|
||||
### One row per account.
|
||||
### One row per account and way.
|
||||
second_factor_failures_person_idx:
|
||||
fields: authUserId
|
||||
fields: authUserId, path
|
||||
unique: true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
### 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
|
||||
serialized: byName
|
||||
values:
|
||||
### A sign-in, or a reset of the authenticators asked with the password.
|
||||
- password
|
||||
### A new password set with the recovery phrase.
|
||||
- phrase
|
||||
|
|
@ -10,7 +10,10 @@ import 'package:serverpod/serverpod.dart';
|
|||
// password in a sign-in or a proof of the recovery phrase, and every
|
||||
// recovery code there, counts as it is
|
||||
// tried (before it is checked, so codes sent at once can't slip past), per
|
||||
// account, from every network. After every [codeFailuresBeforeWait] tried
|
||||
// account and way the code came (`SecondFactorPath`: after the password, or
|
||||
// after the recovery phrase; the full reading's M3: whoever knows the
|
||||
// password made codes wait, and the phrase's owner was held back from their
|
||||
// reset too), from every network. After every [codeFailuresBeforeWait] tried
|
||||
// in a row no code is taken for a while, an hour the first time and twice
|
||||
// as long each time after, [longestCodeWait] at the most, and the owner is
|
||||
// mailed as each wait begins: every call carrying such a code takes the
|
||||
|
|
@ -51,22 +54,24 @@ const longestCodeWait = Duration(days: 1);
|
|||
/// before a wait are checked.
|
||||
Future<DateTime?> takeCodeAttempt(
|
||||
Session session,
|
||||
UuidValue account,
|
||||
) {
|
||||
UuidValue account, {
|
||||
SecondFactorPath path = SecondFactorPath.password,
|
||||
}) {
|
||||
final now = clock.now().toUtc();
|
||||
return session.db.transaction((transaction) async {
|
||||
// SELECT pg_advisory_xact_lock($1)
|
||||
await session.db.unsafeQuery(
|
||||
'SELECT pg_advisory_xact_lock(@key)',
|
||||
parameters: QueryParameters.named({
|
||||
'key': _lockKeyOf('second_factor:${account.uuid}'),
|
||||
'key': _lockKeyOf('second_factor:${account.uuid}:${path.name}'),
|
||||
}),
|
||||
transaction: transaction,
|
||||
);
|
||||
// SELECT * FROM "second_factor_failures" WHERE "authUserId" = $1
|
||||
// SELECT * FROM "second_factor_failures"
|
||||
// WHERE "authUserId" = $1 AND "path" = $2
|
||||
final row = await SecondFactorFailures.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(account),
|
||||
where: (t) => t.authUserId.equals(account) & t.path.equals(path),
|
||||
transaction: transaction,
|
||||
);
|
||||
final waiting = row?.waitUntil;
|
||||
|
|
@ -83,6 +88,7 @@ Future<DateTime?> takeCodeAttempt(
|
|||
final counted = SecondFactorFailures(
|
||||
id: row?.id,
|
||||
authUserId: account,
|
||||
path: path,
|
||||
failures: failures,
|
||||
waits: begins ? waits + 1 : waits,
|
||||
waitUntil: until ?? waiting,
|
||||
|
|
@ -129,7 +135,7 @@ Duration codeWait(int waits) {
|
|||
return wait > longestCodeWait ? longestCodeWait : wait;
|
||||
}
|
||||
|
||||
/// Clears [account]'s refused codes: one was accepted.
|
||||
/// Clears [account]'s refused codes, both ways': one was accepted.
|
||||
Future<void> clearRefusedCodes(Session session, UuidValue account) =>
|
||||
// DELETE FROM "second_factor_failures" WHERE "authUserId" = $1
|
||||
SecondFactorFailures.db.deleteWhere(
|
||||
|
|
|
|||
Loading…
Reference in a new issue