fix(auth): a session's wrong codes end the session, not the account's codes

Wrong codes for removing an authenticator, new recovery codes or confirming
one counted toward the account's waits (the re-review's third finding): a
stolen session alone, without the password, could keep the owner's codes
waiting for up to a day at a time, renewed without end, holding them out
of signing in and of changing the password, the way to end that session,
and nobody was mailed. Those codes now count per session, where the session
is (the register's own, or a member's, which forwards the code): each try
counted before its code is checked (one statement, so tries sent at once
count each), a code accepted clearing the count, and the tenth wrong in a
row ending the session (sessionEnded; the device says it was signed out).
A try that checked no code (a refusal of something else) is taken back.
The account's waits now count only codes after a right password, and the
per-account ten tries an hour, which a session could fill as well, go.

Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "session_code_failures" ("id" bigserial PRIMARY KEY,
"sessionId" uuid NOT NULL, "authUserId" uuid NOT NULL, "failures"
bigint NOT NULL, "updatedAt" timestamp NOT NULL) with a unique index on
"sessionId": a new table, nothing touched. Its rows go with a code
accepted, with the person, and hourly once their session is gone.

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-20 22:53:20 +02:00
commit e8c4a3d472
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 42634 additions and 88 deletions

View file

@ -104,6 +104,12 @@ Future<void> deletePerson(Session session, UuidValue authUserId) async {
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// DELETE FROM "session_code_failures" WHERE "authUserId" = $1
await SessionCodeFailures.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// DELETE FROM "sign_in_challenge" WHERE "authUserId" = $1
await SignInChallenge.db.deleteWhere(
session,

View file

@ -82,7 +82,11 @@ enum PasswordRefusal implements _is.SerializableModel {
/// Too many codes were refused in a row: no code is taken until
/// `takesEffectAt` (the second reading's M4). Only after a right password.
codesWait;
codesWait,
/// Ten codes in a row from this signed-in session were wrong: the
/// session ended (the re-review's third finding); sign in again.
sessionEnded;
static PasswordRefusal fromJson(String name) {
switch (name) {
@ -124,6 +128,8 @@ enum PasswordRefusal implements _is.SerializableModel {
return PasswordRefusal.otherInstance;
case 'codesWait':
return PasswordRefusal.codesWait;
case 'sessionEnded':
return PasswordRefusal.sessionEnded;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "PasswordRefusal"',

View file

@ -0,0 +1,670 @@
/* 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;
/// The codes a signed-in session tried in a row without one accepted (ADR
/// 0017; the re-review's third finding): removing an authenticator, new
/// recovery codes, confirming an authenticator. They count per session,
/// never toward the account's waits (a stolen session alone would hold its
/// owner out of their codes), and the tenth in a row ends the session. The
/// row goes once a code is accepted, with the session, or with the person.
abstract class SessionCodeFailures
implements _is.TableRow<int?>, _is.ProtocolSerialization {
SessionCodeFailures._({
this.id,
required this.sessionId,
required this.authUserId,
required this.failures,
required this.updatedAt,
});
factory SessionCodeFailures({
int? id,
required _is.UuidValue sessionId,
required _is.UuidValue authUserId,
required int failures,
required DateTime updatedAt,
}) = _SessionCodeFailuresImpl;
factory SessionCodeFailures.fromJson(Map<String, dynamic> jsonSerialization) {
return SessionCodeFailures(
id: jsonSerialization['id'] as int?,
sessionId: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['sessionId'],
),
authUserId: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['authUserId'],
),
failures: jsonSerialization['failures'] as int,
updatedAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['updatedAt'],
),
);
}
static final t = SessionCodeFailuresTable();
static const db = SessionCodeFailuresRepository._();
@override
int? id;
/// The server-side session's ID (its `authId`).
_is.UuidValue sessionId;
/// Its person (deleted with them, `deletePerson`).
_is.UuidValue authUserId;
/// Codes tried in a row since the last accepted, counted before each is
/// checked.
int failures;
DateTime updatedAt;
@override
_is.Table<int?> get table => t;
/// Returns a shallow copy of this [SessionCodeFailures]
/// with some or all fields replaced by the given arguments.
@_is.useResult
SessionCodeFailures copyWith({
int? id,
_is.UuidValue? sessionId,
_is.UuidValue? authUserId,
int? failures,
DateTime? updatedAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.SessionCodeFailures',
if (id != null) 'id': id,
'sessionId': sessionId.toJson(),
'authUserId': authUserId.toJson(),
'failures': failures,
'updatedAt': updatedAt.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {};
}
static SessionCodeFailuresInclude include() {
return SessionCodeFailuresInclude._();
}
static SessionCodeFailuresIncludeList includeList({
_is.WhereExpressionBuilder<SessionCodeFailuresTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<SessionCodeFailuresTable>? orderBy,
_is.OrderByListBuilder<SessionCodeFailuresTable>? orderByList,
SessionCodeFailuresInclude? include,
}) {
return SessionCodeFailuresIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(SessionCodeFailures.t),
orderByList: orderByList?.call(SessionCodeFailures.t),
include: include,
);
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _SessionCodeFailuresImpl extends SessionCodeFailures {
_SessionCodeFailuresImpl({
int? id,
required _is.UuidValue sessionId,
required _is.UuidValue authUserId,
required int failures,
required DateTime updatedAt,
}) : super._(
id: id,
sessionId: sessionId,
authUserId: authUserId,
failures: failures,
updatedAt: updatedAt,
);
/// Returns a shallow copy of this [SessionCodeFailures]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
SessionCodeFailures copyWith({
Object? id = _Undefined,
_is.UuidValue? sessionId,
_is.UuidValue? authUserId,
int? failures,
DateTime? updatedAt,
}) {
return SessionCodeFailures(
id: id is int? ? id : this.id,
sessionId: sessionId ?? this.sessionId,
authUserId: authUserId ?? this.authUserId,
failures: failures ?? this.failures,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
class SessionCodeFailuresUpdateTable
extends _is.UpdateTable<SessionCodeFailuresTable> {
SessionCodeFailuresUpdateTable(super.table);
_is.ColumnValue<_is.UuidValue, _is.UuidValue> sessionId(
_is.UuidValue value,
) => _is.ColumnValue(
table.sessionId,
value,
);
_is.ColumnValue<_is.UuidValue, _is.UuidValue> authUserId(
_is.UuidValue value,
) => _is.ColumnValue(
table.authUserId,
value,
);
_is.ColumnValue<int, int> failures(int value) => _is.ColumnValue(
table.failures,
value,
);
_is.ColumnValue<DateTime, DateTime> updatedAt(DateTime value) =>
_is.ColumnValue(
table.updatedAt,
value,
);
}
class SessionCodeFailuresTable extends _is.Table<int?> {
SessionCodeFailuresTable({super.tableRelation})
: super(tableName: 'session_code_failures') {
updateTable = SessionCodeFailuresUpdateTable(this);
sessionId = _is.ColumnUuid(
'sessionId',
this,
);
authUserId = _is.ColumnUuid(
'authUserId',
this,
);
failures = _is.ColumnInt(
'failures',
this,
);
updatedAt = _is.ColumnDateTime(
'updatedAt',
this,
);
}
late final SessionCodeFailuresUpdateTable updateTable;
/// The server-side session's ID (its `authId`).
late final _is.ColumnUuid sessionId;
/// Its person (deleted with them, `deletePerson`).
late final _is.ColumnUuid authUserId;
/// Codes tried in a row since the last accepted, counted before each is
/// checked.
late final _is.ColumnInt failures;
late final _is.ColumnDateTime updatedAt;
@override
List<_is.Column> get columns => [
id,
sessionId,
authUserId,
failures,
updatedAt,
];
}
class SessionCodeFailuresInclude extends _is.IncludeObject {
SessionCodeFailuresInclude._();
@override
Map<String, _is.Include?> get includes => {};
@override
_is.Table<int?> get table => SessionCodeFailures.t;
}
class SessionCodeFailuresIncludeList extends _is.IncludeList {
SessionCodeFailuresIncludeList._({
_is.WhereExpressionBuilder<SessionCodeFailuresTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(SessionCodeFailures.t);
}
@override
Map<String, _is.Include?> get includes => include?.includes ?? {};
@override
_is.Table<int?> get table => SessionCodeFailures.t;
}
class SessionCodeFailuresRepository {
const SessionCodeFailuresRepository._();
/// Returns a list of [SessionCodeFailures]s matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order of the items use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// The maximum number of items can be set by [limit]. If no limit is set,
/// all items matching the query will be returned.
///
/// [offset] defines how many items to skip, after which [limit] (or all)
/// items are read from the database.
///
/// ```dart
/// var persons = await Persons.db.find(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.firstName,
/// limit: 100,
/// );
/// ```
Future<List<SessionCodeFailures>> find(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<SessionCodeFailuresTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<SessionCodeFailuresTable>? orderBy,
_is.OrderByListBuilder<SessionCodeFailuresTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.find<SessionCodeFailures>(
where: where?.call(SessionCodeFailures.t),
orderBy: orderBy?.call(SessionCodeFailures.t),
orderByList: orderByList?.call(SessionCodeFailures.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [SessionCodeFailures] matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// [offset] defines how many items to skip, after which the next one will be picked.
///
/// ```dart
/// var youngestPerson = await Persons.db.findFirstRow(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.age,
/// );
/// ```
Future<SessionCodeFailures?> findFirstRow(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<SessionCodeFailuresTable>? where,
int? offset,
_is.OrderByBuilder<SessionCodeFailuresTable>? orderBy,
_is.OrderByListBuilder<SessionCodeFailuresTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<SessionCodeFailures>(
where: where?.call(SessionCodeFailures.t),
orderBy: orderBy?.call(SessionCodeFailures.t),
orderByList: orderByList?.call(SessionCodeFailures.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [SessionCodeFailures] by its [id] or null if no such row exists.
Future<SessionCodeFailures?> findById(
_is.DatabaseSession session,
int id, {
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findById<SessionCodeFailures>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [SessionCodeFailures]s in the list and returns the inserted rows.
///
/// The returned [SessionCodeFailures]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails to
/// insert, none of the rows will be inserted.
///
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
/// rows are silently skipped, and only the successfully inserted rows are
/// returned.
///
/// If [noReturn] is set to `true`, the inserted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<SessionCodeFailures>> insert(
_is.DatabaseSession session,
List<SessionCodeFailures> rows, {
_is.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<SessionCodeFailures>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [SessionCodeFailures] and returns the inserted row.
///
/// The returned [SessionCodeFailures] will have its `id` field set.
Future<SessionCodeFailures> insertRow(
_is.DatabaseSession session,
SessionCodeFailures row, {
_is.Transaction? transaction,
}) async {
return session.db.insertRow<SessionCodeFailures>(
row,
transaction: transaction,
);
}
/// Upserts all [SessionCodeFailures]s in the list and returns the resulting rows.
///
/// If a row conflicts on the given [conflictColumns], the existing row is
/// updated with the new values. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies to rows matching the
/// given expression. Conflicting rows that don't match are skipped and not
/// returned, so the resulting list may be shorter than [rows].
///
/// The returned [SessionCodeFailures]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails,
/// none of the rows will be affected.
///
/// If [noReturn] is set to `true`, the resulting rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<SessionCodeFailures>> upsert(
_is.DatabaseSession session,
List<SessionCodeFailures> rows, {
required _is.ColumnSelections<SessionCodeFailuresTable> conflictColumns,
_is.ColumnSelections<SessionCodeFailuresTable>? updateColumns,
_is.WhereExpressionBuilder<SessionCodeFailuresTable>? updateWhere,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<SessionCodeFailures>(
rows,
conflictColumns: conflictColumns(SessionCodeFailures.t),
updateColumns: updateColumns?.call(SessionCodeFailures.t),
updateWhere: updateWhere?.call(SessionCodeFailures.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [SessionCodeFailures] and returns the resulting row.
///
/// If the row conflicts on the given [conflictColumns], the existing row is
/// updated. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies when the existing
/// row matches the expression. Returns `null` if no row was affected — for
/// example when [updateWhere] does not match the conflicting row.
///
/// The returned [SessionCodeFailures] will have its `id` field set.
Future<SessionCodeFailures?> upsertRow(
_is.DatabaseSession session,
SessionCodeFailures row, {
required _is.ColumnSelections<SessionCodeFailuresTable> conflictColumns,
_is.ColumnSelections<SessionCodeFailuresTable>? updateColumns,
_is.WhereExpressionBuilder<SessionCodeFailuresTable>? updateWhere,
_is.Transaction? transaction,
}) async {
return session.db.upsertRow<SessionCodeFailures>(
row,
conflictColumns: conflictColumns(SessionCodeFailures.t),
updateColumns: updateColumns?.call(SessionCodeFailures.t),
updateWhere: updateWhere?.call(SessionCodeFailures.t),
transaction: transaction,
);
}
/// Updates all [SessionCodeFailures]s in the list and returns the updated rows. If
/// [columns] is provided, only those columns will be updated. Defaults to
/// all columns.
/// This is an atomic operation, meaning that if one of the rows fails to
/// update, none of the rows will be updated.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<SessionCodeFailures>> update(
_is.DatabaseSession session,
List<SessionCodeFailures> rows, {
_is.ColumnSelections<SessionCodeFailuresTable>? columns,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<SessionCodeFailures>(
rows,
columns: columns?.call(SessionCodeFailures.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [SessionCodeFailures]. The row needs to have its id set.
/// Optionally, a list of [columns] can be provided to only update those
/// columns. Defaults to all columns.
Future<SessionCodeFailures> updateRow(
_is.DatabaseSession session,
SessionCodeFailures row, {
_is.ColumnSelections<SessionCodeFailuresTable>? columns,
_is.Transaction? transaction,
}) async {
return session.db.updateRow<SessionCodeFailures>(
row,
columns: columns?.call(SessionCodeFailures.t),
transaction: transaction,
);
}
/// Updates a single [SessionCodeFailures] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<SessionCodeFailures?> updateById(
_is.DatabaseSession session,
int id, {
required _is.ColumnValueListBuilder<SessionCodeFailuresUpdateTable>
columnValues,
_is.Transaction? transaction,
}) async {
return session.db.updateById<SessionCodeFailures>(
id,
columnValues: columnValues(SessionCodeFailures.t.updateTable),
transaction: transaction,
);
}
/// Updates all [SessionCodeFailures]s matching the [where] expression with the specified [columnValues].
/// Returns the list of updated rows.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<SessionCodeFailures>> updateWhere(
_is.DatabaseSession session, {
required _is.ColumnValueListBuilder<SessionCodeFailuresUpdateTable>
columnValues,
required _is.WhereExpressionBuilder<SessionCodeFailuresTable> where,
int? limit,
int? offset,
_is.OrderByBuilder<SessionCodeFailuresTable>? orderBy,
_is.OrderByListBuilder<SessionCodeFailuresTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<SessionCodeFailures>(
columnValues: columnValues(SessionCodeFailures.t.updateTable),
where: where(SessionCodeFailures.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(SessionCodeFailures.t),
orderByList: orderByList?.call(SessionCodeFailures.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [SessionCodeFailures]s in the list and returns the deleted rows.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// This is an atomic operation, meaning that if one of the rows fail to
/// be deleted, none of the rows will be deleted.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<SessionCodeFailures>> delete(
_is.DatabaseSession session,
List<SessionCodeFailures> rows, {
_is.OrderByBuilder<SessionCodeFailuresTable>? orderBy,
_is.OrderByListBuilder<SessionCodeFailuresTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<SessionCodeFailures>(
rows,
orderBy: orderBy?.call(SessionCodeFailures.t),
orderByList: orderByList?.call(SessionCodeFailures.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [SessionCodeFailures].
Future<SessionCodeFailures> deleteRow(
_is.DatabaseSession session,
SessionCodeFailures row, {
_is.Transaction? transaction,
}) async {
return session.db.deleteRow<SessionCodeFailures>(
row,
transaction: transaction,
);
}
/// Deletes all rows matching the [where] expression.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<SessionCodeFailures>> deleteWhere(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<SessionCodeFailuresTable> where,
_is.OrderByBuilder<SessionCodeFailuresTable>? orderBy,
_is.OrderByListBuilder<SessionCodeFailuresTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<SessionCodeFailures>(
where: where(SessionCodeFailures.t),
orderBy: orderBy?.call(SessionCodeFailures.t),
orderByList: orderByList?.call(SessionCodeFailures.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Counts the number of rows matching the [where] expression. If omitted,
/// will return the count of all rows in the table.
Future<int> count(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<SessionCodeFailuresTable>? where,
int? limit,
_is.Transaction? transaction,
}) async {
return session.db.count<SessionCodeFailures>(
where: where?.call(SessionCodeFailures.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [SessionCodeFailures] rows matching the [where] expression.
Future<void> lockRows(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<SessionCodeFailuresTable> where,
required _is.LockMode lockMode,
required _is.Transaction transaction,
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
}) async {
return session.db.lockRows<SessionCodeFailures>(
where: where(SessionCodeFailures.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

View file

@ -45,6 +45,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/session_code_failures.dart' as _iqr11ieo;
import 'password/sign_in_challenge.dart' as _i9rg59hu;
import 'password/sign_in_challenge_purpose.dart' as _izijxzng;
import 'password/totp_authenticator.dart' as _i4d44ram;
@ -82,6 +83,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/session_code_failures.dart';
export 'password/sign_in_challenge.dart';
export 'password/sign_in_challenge_purpose.dart';
export 'password/totp_authenticator.dart';
@ -703,6 +705,62 @@ class Protocol extends _is.DatabaseSerializationManager {
],
managed: true,
),
_isp.TableDefinition(
name: 'session_code_failures',
dartName: 'SessionCodeFailures',
schema: 'public',
module: 'halkyon_base',
columns: [
_isp.ColumnDefinition(
name: 'id',
columnType: _isp.ColumnType.bigint,
isNullable: false,
dartType: 'int?',
columnDefault: 'serial',
),
_isp.ColumnDefinition(
name: 'sessionId',
columnType: _isp.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue',
),
_isp.ColumnDefinition(
name: 'authUserId',
columnType: _isp.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue',
),
_isp.ColumnDefinition(
name: 'failures',
columnType: _isp.ColumnType.bigint,
isNullable: false,
dartType: 'int',
),
_isp.ColumnDefinition(
name: 'updatedAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: false,
dartType: 'DateTime',
),
],
foreignKeys: [],
indexes: [
_isp.IndexDefinition(
indexName: 'session_code_failures_session_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'sessionId',
),
],
type: 'btree',
isUnique: true,
isPrimary: false,
),
],
managed: true,
),
_isp.TableDefinition(
name: 'sessions_ended_notice',
dartName: 'SessionsEndedNotice',
@ -1029,6 +1087,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _i9cg650q.SecondFactorFailures) {
return _i9cg650q.SecondFactorFailures.fromJson(data) as T;
}
if (t == _iqr11ieo.SessionCodeFailures) {
return _iqr11ieo.SessionCodeFailures.fromJson(data) as T;
}
if (t == _i9rg59hu.SignInChallenge) {
return _i9rg59hu.SignInChallenge.fromJson(data) as T;
}
@ -1173,6 +1234,12 @@ class Protocol extends _is.DatabaseSerializationManager {
: null)
as T;
}
if (t == _is.getType<_iqr11ieo.SessionCodeFailures?>()) {
return (data != null
? _iqr11ieo.SessionCodeFailures.fromJson(data)
: null)
as T;
}
if (t == _is.getType<_i9rg59hu.SignInChallenge?>()) {
return (data != null ? _i9rg59hu.SignInChallenge.fromJson(data) : null)
as T;
@ -1286,6 +1353,7 @@ class Protocol extends _is.DatabaseSerializationManager {
_iart5x0c.RecoveryCode => 'RecoveryCode',
_i3y6qzi3.ResetPending => 'ResetPending',
_i9cg650q.SecondFactorFailures => 'SecondFactorFailures',
_iqr11ieo.SessionCodeFailures => 'SessionCodeFailures',
_i9rg59hu.SignInChallenge => 'SignInChallenge',
_izijxzng.SignInChallengePurpose => 'SignInChallengePurpose',
_i4d44ram.TotpAuthenticator => 'TotpAuthenticator',
@ -1366,6 +1434,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'ResetPending';
case _i9cg650q.SecondFactorFailures():
return 'SecondFactorFailures';
case _iqr11ieo.SessionCodeFailures():
return 'SessionCodeFailures';
case _i9rg59hu.SignInChallenge():
return 'SignInChallenge';
case _izijxzng.SignInChallengePurpose():
@ -1480,6 +1550,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'SecondFactorFailures') {
return deserialize<_i9cg650q.SecondFactorFailures>(data['data']);
}
if (dataClassName == 'SessionCodeFailures') {
return deserialize<_iqr11ieo.SessionCodeFailures>(data['data']);
}
if (dataClassName == 'SignInChallenge') {
return deserialize<_i9rg59hu.SignInChallenge>(data['data']);
}
@ -1612,6 +1685,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return _iart5x0c.RecoveryCode.t;
case _i9cg650q.SecondFactorFailures:
return _i9cg650q.SecondFactorFailures.t;
case _iqr11ieo.SessionCodeFailures:
return _iqr11ieo.SessionCodeFailures.t;
case _i9rg59hu.SignInChallenge:
return _i9rg59hu.SignInChallenge.t;
case _i4d44ram.TotpAuthenticator:

View file

@ -1,10 +1,13 @@
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/session_code_tries.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// The signed-in person's authenticators and recovery codes (ADR 0017),
/// which the instance's register keeps (ADR 0016).
/// which the instance's register keeps (ADR 0016). Every code tried here
/// counts for the session (`asSessionCodeTry`): the tenth wrong in a row
/// ends it ([PasswordRefusal.sessionEnded]).
class AuthenticatorEndpoint extends Endpoint {
@override
bool get requireLogin => true;
@ -32,12 +35,15 @@ class AuthenticatorEndpoint extends Endpoint {
required UuidValue authenticator,
required String code,
String? existingCode,
}) => AccountRegister.of(session.serverpod).confirmAuthenticator(
}) => asSessionCodeTry(
session,
person: session.authenticated!.authUserId,
authenticator: authenticator,
code: code,
existingCode: existingCode,
() => AccountRegister.of(session.serverpod).confirmAuthenticator(
session,
person: session.authenticated!.authUserId,
authenticator: authenticator,
code: code,
existingCode: existingCode,
),
);
/// The caller's authenticators, the oldest first.
@ -51,11 +57,14 @@ class AuthenticatorEndpoint extends Endpoint {
Session session, {
required UuidValue authenticator,
String? code,
}) => AccountRegister.of(session.serverpod).removeAuthenticator(
}) => asSessionCodeTry(
session,
person: session.authenticated!.authUserId,
authenticator: authenticator,
code: code,
() => AccountRegister.of(session.serverpod).removeAuthenticator(
session,
person: session.authenticated!.authUserId,
authenticator: authenticator,
code: code,
),
);
/// New recovery codes in place of the old, with a [code] from one of the
@ -63,9 +72,12 @@ class AuthenticatorEndpoint extends Endpoint {
Future<List<String>> newRecoveryCodes(
Session session, {
required String code,
}) => AccountRegister.of(session.serverpod).newRecoveryCodes(
}) => asSessionCodeTry(
session,
person: session.authenticated!.authUserId,
code: code,
() => AccountRegister.of(session.serverpod).newRecoveryCodes(
session,
person: session.authenticated!.authUserId,
code: code,
),
);
}

View file

@ -2,13 +2,12 @@ import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
import 'package:halkyon_base_server/src/password/second_factor_waits.dart';
import 'package:halkyon_base_server/src/password/session_code_tries.dart';
import 'package:halkyon_base_server/src/password/totp_secrets.dart';
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
import 'package:halkyon_base_server/src/totp/recovery_codes.dart';
import 'package:halkyon_base_server/src/totp/totp.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
// An account's authenticators and recovery codes (ADR 0017), as their
// signed-in owner manages them on the register: a new authenticator's secret
@ -29,18 +28,6 @@ const maxAuthenticatorNameLength = 40;
/// The longest account name an authenticator app shows.
const _maxLabelLength = 254;
/// How many codes one account's owner tries in an hour (confirming,
/// removing, new recovery codes): a session alone mustn't guess its way past
/// the second factor.
final _codeTries = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'authenticators',
source: 'code_tries',
maxAttempts: 10,
timeframe: const Duration(hours: 1),
),
);
/// A new authenticator of [person], called [name], for an app to read: its
/// secret, made here and kept sealed ([sealTotpSecret]), in an `otpauth://`
/// URI naming the instance's account domain and [label] (what the person
@ -137,8 +124,9 @@ String _labelOf(String label) {
/// person's ([PasswordRefusal.unknownAuthenticator]), a wrong or used code
/// ([PasswordRefusal.wrongCode]), a missing, wrong or used code from one
/// confirmed before ([PasswordRefusal.wrongExistingCode]; checked only once
/// [code] is right, so a wrong one uses none), and beyond 10 tries an hour
/// ([PasswordRefusal.tooManyAttempts]).
/// [code] is right, so a wrong one uses none). The session's tries are
/// counted where it is (`asSessionCodeTry`, the endpoint's), never toward
/// the account's waits.
Future<List<String>> confirmAuthenticator(
Session session, {
required UuidValue person,
@ -147,7 +135,6 @@ Future<List<String>> confirmAuthenticator(
String? existingCode,
}) async {
final keys = requirePasswordKeys(session.serverpod);
await _countTry(session, person);
final row = await _authenticatorOf(session, person, authenticator);
final secret = await openTotpSecret(
row.secret,
@ -167,17 +154,11 @@ Future<List<String>> confirmAuthenticator(
if (row.confirmedAt == null) {
final confirmedBefore = await confirmedAuthenticatorsOf(session, person);
if (confirmedBefore.isNotEmpty) {
// Tried here once already (_countTry above): not counted again.
final typed = existingCode?.trim() ?? '';
if (typed.isEmpty) {
if (typed.isEmpty ||
!await acceptTotpCode(session, keys, confirmedBefore, typed)) {
throw refusedFor(PasswordRefusal.wrongExistingCode);
}
final waitBegan = await takeCodeAttempt(session, person);
if (!await acceptTotpCode(session, keys, confirmedBefore, typed)) {
await noticeCodesWait(session, waitBegan);
throw refusedFor(PasswordRefusal.wrongExistingCode);
}
await clearRefusedCodes(session, person);
}
}
return await session.db.transaction((transaction) async {
@ -334,8 +315,10 @@ Future<List<String>> newRecoveryCodes(
}
/// Deletes the authenticators nobody confirmed within
/// [unconfirmedAuthenticatorLifetime] at [now], and the sign-in challenges
/// past their time, and returns how many rows went: hourly, with the
/// [unconfirmedAuthenticatorLifetime] at [now], the sign-in challenges past
/// their time and the code tries of sessions that ended
/// (`forgetEndedSessionsCodeTries`), and returns how many rows went: hourly,
/// with the
/// pruning (`PruningFutureCall`).
Future<int> forgetUnconfirmedAuthenticators(
Session session, {
@ -354,22 +337,14 @@ Future<int> forgetUnconfirmedAuthenticators(
session,
where: (t) => t.createdAt < now.toUtc().subtract(signInChallengeLifetime),
);
return authenticators.length + challenges.length;
}
/// Counts a code tried by [person]'s owner; refused beyond 10 an hour.
Future<void> _countTry(Session session, UuidValue person) async {
if (!await _codeTries.tryRecordAttempt(session, key: person.uuid)) {
throw refusedFor(PasswordRefusal.tooManyAttempts);
}
final tries = await forgetEndedSessionsCodeTries(session);
return authenticators.length + challenges.length + tries;
}
/// Requires [code] to be a fresh code of one of [person]'s [confirmed]
/// authenticators, each try counted (10 an hour,
/// [PasswordRefusal.tooManyAttempts]); refused otherwise
/// ([PasswordRefusal.wrongCode]). Codes refused here count toward the
/// account's waits as a sign-in's do (`second_factor_waits.dart`), and
/// none is taken while one runs ([PasswordRefusal.codesWait]).
/// authenticators; refused otherwise ([PasswordRefusal.wrongCode]). A
/// signed-in session's try: counted where the session is
/// (`asSessionCodeTry`), never toward the account's waits.
Future<void> _requireAuthenticatorCode(
Session session,
PasswordKeys keys,
@ -378,18 +353,10 @@ Future<void> _requireAuthenticatorCode(
String? code, {
PasswordRefusal refusal = PasswordRefusal.wrongCode,
}) async {
await _countTry(session, person);
final typed = code?.trim() ?? '';
if (typed.isEmpty) {
if (typed.isEmpty || !await acceptTotpCode(session, keys, confirmed, typed)) {
throw refusedFor(refusal);
}
final waitBegan = await takeCodeAttempt(session, person);
if (await acceptTotpCode(session, keys, confirmed, typed)) {
await clearRefusedCodes(session, person);
return;
}
await noticeCodesWait(session, waitBegan);
throw refusedFor(refusal);
}
/// [person]'s authenticator [id]; refused

View file

@ -53,3 +53,6 @@ values:
### Too many codes were refused in a row: no code is taken until
### `takesEffectAt` (the second reading's M4). Only after a right password.
- codesWait
### Ten codes in a row from this signed-in session were wrong: the
### session ended (the re-review's third finding); sign in again.
- sessionEnded

View file

@ -0,0 +1,23 @@
### The codes a signed-in session tried in a row without one accepted (ADR
### 0017; the re-review's third finding): removing an authenticator, new
### recovery codes, confirming an authenticator. They count per session,
### never toward the account's waits (a stolen session alone would hold its
### owner out of their codes), and the tenth in a row ends the session. The
### row goes once a code is accepted, with the session, or with the person.
class: SessionCodeFailures
serverOnly: true
table: session_code_failures
fields:
### The server-side session's ID (its `authId`).
sessionId: UuidValue
### Its person (deleted with them, `deletePerson`).
authUserId: UuidValue
### Codes tried in a row since the last accepted, counted before each is
### checked.
failures: int
updatedAt: DateTime
indexes:
### One row per session.
session_code_failures_session_idx:
fields: sessionId
unique: true

View file

@ -0,0 +1,126 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
// Codes a signed-in session tries without the password (ADR 0017; the
// re-review's third finding): removing an authenticator, new recovery codes,
// confirming an authenticator. Counted per session, where the session is
// (the register's own, or a member's, which forwards the code), never
// toward the account's waits: those hold everyone out of the account's
// codes, and a stolen session alone could then keep its owner out of
// signing in and of changing the password, the way to end that session.
// Each try counts before its code is checked (one statement, so tries sent
// at once count each), a code accepted clears the count, and the
// [sessionCodeTriesBeforeSignOut]th wrong in a row ends the session.
/// How many codes a session tries wrong in a row before it ends.
const sessionCodeTriesBeforeSignOut = 10;
/// The refusals that say a code (or what came with it) was wrong: they
/// count. Any other outcome (a refusal of something else, an error)
/// checked no code, and is taken back.
const _wrongCodes = {
PasswordRefusal.wrongCode,
PasswordRefusal.wrongExistingCode,
PasswordRefusal.wrongCredentials,
};
/// [check]'s outcome, as a try at a code from [session]'s signed-in
/// session: counted before [check] runs, refused
/// ([PasswordRefusal.sessionEnded]) and the session ended once tries in a
/// row go past [sessionCodeTriesBeforeSignOut] or the last of them is
/// wrong. A code accepted clears the count.
Future<T> asSessionCodeTry<T>(
Session session,
Future<T> Function() check,
) async {
final authenticated = session.authenticated!;
final sessionId = UuidValue.withValidation(authenticated.authId);
final person = authenticated.authUserId;
final now = clock.now().toUtc();
final counted = await session.db.unsafeQuery(
'INSERT INTO "session_code_failures" '
'("sessionId", "authUserId", "failures", "updatedAt") '
'VALUES (@session, @person, 1, @now) '
'ON CONFLICT ("sessionId") DO UPDATE SET '
'"failures" = "session_code_failures"."failures" + 1, '
'"updatedAt" = @now '
'RETURNING "failures"',
parameters: QueryParameters.named({
'session': sessionId.uuid,
'person': person.uuid,
'now': now,
}),
);
final tries = counted.first[0]! as int;
if (tries > sessionCodeTriesBeforeSignOut) {
await _endSession(session, sessionId, person);
throw PasswordRefusedException(reason: PasswordRefusal.sessionEnded);
}
try {
final result = await check();
await _clear(session, sessionId);
return result;
} on PasswordRefusedException catch (refusal) {
if (!_wrongCodes.contains(refusal.reason)) {
await _takeBack(session, sessionId);
rethrow;
}
if (tries >= sessionCodeTriesBeforeSignOut) {
await _endSession(session, sessionId, person);
throw PasswordRefusedException(reason: PasswordRefusal.sessionEnded);
}
rethrow;
} on Object {
await _takeBack(session, sessionId);
rethrow;
}
}
/// Deletes the counts of sessions that ended: hourly
/// (`PruningFutureCall`); returns how many.
Future<int> forgetEndedSessionsCodeTries(Session session) async {
final forgotten = await session.db.unsafeExecute(
'DELETE FROM "session_code_failures" WHERE "sessionId" NOT IN '
'(SELECT "id" FROM "serverpod_auth_core_session")',
);
return forgotten;
}
/// Clears [sessionId]'s count: a code was accepted.
Future<void> _clear(Session session, UuidValue sessionId) =>
// DELETE FROM "session_code_failures" WHERE "sessionId" = $1
SessionCodeFailures.db.deleteWhere(
session,
where: (t) => t.sessionId.equals(sessionId),
);
/// Takes back [sessionId]'s last try: it checked no code.
Future<void> _takeBack(Session session, UuidValue sessionId) =>
session.db.unsafeExecute(
'UPDATE "session_code_failures" '
'SET "failures" = GREATEST("failures" - 1, 0) '
'WHERE "sessionId" = @session',
parameters: QueryParameters.named({'session': sessionId.uuid}),
);
/// Ends [sessionId], [person]'s; their device learns at once. Its count
/// stays, so tries already under way when it ended are refused too, until
/// the hourly pruning forgets it ([forgetEndedSessionsCodeTries]).
Future<void> _endSession(
Session session,
UuidValue sessionId,
UuidValue person,
) async {
// DELETE FROM "serverpod_auth_core_session"
// WHERE "id" = $1 AND "authUserId" = $2
await ServerSideSession.db.deleteWhere(
session,
where: (t) => t.id.equals(sessionId) & t.authUserId.equals(person),
);
await session.messages.authenticationRevoked(
person.uuid,
RevokedAuthenticationAuthId(authId: sessionId.uuid),
);
}