fix(auth): codes wait after ten wrong in a row, and the owner is told

Whoever knew the password could guess codes at 10 an hour forever,
unnoticed, each guess checked against every authenticator (the second
reading's M4). Every code an account's second factor refuses after a
right password (sign-in, a phrase's reset, a change) or from a signed-in
device (removing an authenticator, new recovery codes, the code from one
confirmed before) now counts, per account, from every network; after
every tenth in a row no code is taken (codesWait, saying until when), an
hour, then twice as long each time, a day at the most (longestCodeWait,
second_factor_waits.dart): at a day, ten guesses a day still have about a
1 % chance a year against one authenticator, so the first wait mails the
owner that someone knows their password. The instance keeps no address,
so sign-in and the phrase's reset now send the address the device typed
(and its language), used only for that mail after its hash is checked
against the account's. A code accepted clears it all.

Migration (module, Learn's and Todo's servers, Learn's device schema):
CREATE TABLE "second_factor_failures" ("id" bigserial PRIMARY KEY,
"authUserId" uuid NOT NULL, "failures" bigint NOT NULL, "waits"
bigint NOT NULL, "waitUntil" timestamp, "updatedAt" timestamp NOT
NULL) with a unique index on "authUserId": a new table, nothing
touched.

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 21:47:38 +02:00
commit 4a32f150db
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 42635 additions and 28 deletions

View file

@ -110,6 +110,12 @@ Future<void> deletePerson(Session session, UuidValue authUserId) async {
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// DELETE FROM "second_factor_failures" WHERE "authUserId" = $1
await SecondFactorFailures.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// Their GitHub id and anonymous account go with them (ON DELETE CASCADE).
// Gone already on a member the register told first (ADR 0016).
// DELETE FROM "serverpod_auth_core_user" WHERE "id" = $1

View file

@ -29,6 +29,16 @@ abstract interface class CodeMailer {
required DateTime takesEffectAt,
required String language,
});
/// Tells [to], worded in [language], that so many codes were refused
/// after the account's password proved right that none is taken until
/// [until] ([codesWaitMail]): whoever tried knows the password.
Future<void> sendCodesWaitNotice(
Session session, {
required String to,
required DateTime until,
required String language,
});
}
/// Writes codes into the server's log, for development, where nobody has a
@ -55,6 +65,14 @@ final class LogCodeMailer implements CodeMailer {
}) async => session.log(
'Reset notice for $to: ${kind.name}, ${_utc(takesEffectAt)}',
);
@override
Future<void> sendCodesWaitNotice(
Session session, {
required String to,
required DateTime until,
required String language,
}) async => session.log('Codes wait for $to until ${_utc(until)}');
}
/// A code's mail, worded in [language] (`en`, else German for `de`): its
@ -165,6 +183,42 @@ final class LogCodeMailer implements CodeMailer {
};
}
/// The notice that codes wait (ADR 0017; the second reading's M4), worded
/// in [language] (`en`, else German for `de`): its subject and its body.
/// Codes were refused one after another after the account's password
/// proved right, so whoever tried knows it; no code is taken until [until]
/// (shown in UTC). [app] and [instance] as in [codeMail].
({String subject, String body}) codesWaitMail({
required DateTime until,
required String language,
required String app,
required String instance,
}) {
final when = _utc(until);
return switch (language) {
'de' => (
subject: 'Jemand kennt das Passwort deines $app-Kontos',
body:
'Bei der Anmeldung in dein Konto auf $instance wurde dein Passwort '
'richtig eingegeben, danach aber ein falscher Code nach dem '
'anderen. Bis $when nimmt die Instanz keinen Code an.\n'
'\n'
'Warst du es nicht, kennt jemand dein Passwort: Melde dich danach an '
'und ändere es.\n',
),
_ => (
subject: 'Someone knows the password of your $app account',
body:
'Signing in to your account on $instance, your password was typed '
'right, but then one wrong code after another. Until $when the '
'instance takes no code.\n'
'\n'
"If it wasn't you, someone knows your password: sign in after "
'that, and change it.\n',
),
};
}
/// [time] as a reset's notice names it: to the minute, in UTC.
String _utc(DateTime time) {
final utc = time.toUtc();
@ -251,6 +305,22 @@ final class SmtpCodeMailer implements CodeMailer {
),
);
@override
Future<void> sendCodesWaitNotice(
Session session, {
required String to,
required DateTime until,
required String language,
}) => _deliver(
to,
codesWaitMail(
until: until,
language: language,
app: _app,
instance: _instance,
),
);
/// Mails [code] to [to], worded in [language]; throws what the service
/// answered when it took no mail.
Future<void> mail({

View file

@ -420,6 +420,16 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<String>(),
nullable: false,
),
'address': _is.ParameterDescription(
name: 'address',
type: _is.getType<String?>(),
nullable: true,
),
'language': _is.ParameterDescription(
name: 'language',
type: _is.getType<String?>(),
nullable: true,
),
},
call:
(
@ -436,6 +446,8 @@ class Endpoints extends _is.EndpointDispatch {
totpCode: params['totpCode'],
recoveryCode: params['recoveryCode'],
host: params['host'],
address: params['address'],
language: params['language'],
),
),
'startAccountReset': _is.MethodConnector(
@ -875,6 +887,16 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<String>(),
nullable: false,
),
'address': _is.ParameterDescription(
name: 'address',
type: _is.getType<String?>(),
nullable: true,
),
'language': _is.ParameterDescription(
name: 'language',
type: _is.getType<String?>(),
nullable: true,
),
},
call:
(
@ -893,6 +915,8 @@ class Endpoints extends _is.EndpointDispatch {
request: params['request'],
code: params['code'],
host: params['host'],
address: params['address'],
language: params['language'],
),
),
'signUp': _is.MethodConnector(

View file

@ -78,7 +78,11 @@ enum PasswordRefusal implements _is.SerializableModel {
/// The device addressed this instance by another name than its own
/// (`apiServer.publicHost`): what it proves is bound to the name it
/// addressed, so the operator must configure the name devices use.
otherInstance;
otherInstance,
/// 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;
static PasswordRefusal fromJson(String name) {
switch (name) {
@ -118,6 +122,8 @@ enum PasswordRefusal implements _is.SerializableModel {
return PasswordRefusal.wrongExistingCode;
case 'otherInstance':
return PasswordRefusal.otherInstance;
case 'codesWait':
return PasswordRefusal.codesWait;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "PasswordRefusal"',

View file

@ -48,7 +48,8 @@ abstract class PasswordRefusedException
_i647hpdf.PasswordRefusal reason;
/// When the reset waiting takes effect, for [PasswordRefusal.notDue];
/// null otherwise.
/// when codes are taken again, for [PasswordRefusal.codesWait]; null
/// otherwise.
DateTime? takesEffectAt;
/// Returns a shallow copy of this [PasswordRefusedException]

View file

@ -0,0 +1,694 @@
/* 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 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.
abstract class SecondFactorFailures
implements _is.TableRow<int?>, _is.ProtocolSerialization {
SecondFactorFailures._({
this.id,
required this.authUserId,
required this.failures,
required this.waits,
this.waitUntil,
required this.updatedAt,
});
factory SecondFactorFailures({
int? id,
required _is.UuidValue authUserId,
required int failures,
required int waits,
DateTime? waitUntil,
required DateTime updatedAt,
}) = _SecondFactorFailuresImpl;
factory SecondFactorFailures.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return SecondFactorFailures(
id: jsonSerialization['id'] as int?,
authUserId: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['authUserId'],
),
failures: jsonSerialization['failures'] as int,
waits: jsonSerialization['waits'] as int,
waitUntil: jsonSerialization['waitUntil'] == null
? null
: _is.DateTimeJsonExtension.fromJson(jsonSerialization['waitUntil']),
updatedAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['updatedAt'],
),
);
}
static final t = SecondFactorFailuresTable();
static const db = SecondFactorFailuresRepository._();
@override
int? id;
/// The account: its person's ID (deleted with them, `deletePerson`).
_is.UuidValue authUserId;
/// Codes refused in a row since the last accepted.
int failures;
/// How many waits began since the last accepted code: the next lasts an
/// hour times two to their number, `longestCodeWait` at the most.
int waits;
/// Until when no code is taken; null before the first wait.
DateTime? waitUntil;
DateTime updatedAt;
@override
_is.Table<int?> get table => t;
/// Returns a shallow copy of this [SecondFactorFailures]
/// with some or all fields replaced by the given arguments.
@_is.useResult
SecondFactorFailures copyWith({
int? id,
_is.UuidValue? authUserId,
int? failures,
int? waits,
DateTime? waitUntil,
DateTime? updatedAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.SecondFactorFailures',
if (id != null) 'id': id,
'authUserId': authUserId.toJson(),
'failures': failures,
'waits': waits,
if (waitUntil != null) 'waitUntil': waitUntil?.toJson(),
'updatedAt': updatedAt.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {};
}
static SecondFactorFailuresInclude include() {
return SecondFactorFailuresInclude._();
}
static SecondFactorFailuresIncludeList includeList({
_is.WhereExpressionBuilder<SecondFactorFailuresTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<SecondFactorFailuresTable>? orderBy,
_is.OrderByListBuilder<SecondFactorFailuresTable>? orderByList,
SecondFactorFailuresInclude? include,
}) {
return SecondFactorFailuresIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(SecondFactorFailures.t),
orderByList: orderByList?.call(SecondFactorFailures.t),
include: include,
);
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _SecondFactorFailuresImpl extends SecondFactorFailures {
_SecondFactorFailuresImpl({
int? id,
required _is.UuidValue authUserId,
required int failures,
required int waits,
DateTime? waitUntil,
required DateTime updatedAt,
}) : super._(
id: id,
authUserId: authUserId,
failures: failures,
waits: waits,
waitUntil: waitUntil,
updatedAt: updatedAt,
);
/// Returns a shallow copy of this [SecondFactorFailures]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
SecondFactorFailures copyWith({
Object? id = _Undefined,
_is.UuidValue? authUserId,
int? failures,
int? waits,
Object? waitUntil = _Undefined,
DateTime? updatedAt,
}) {
return SecondFactorFailures(
id: id is int? ? id : this.id,
authUserId: authUserId ?? this.authUserId,
failures: failures ?? this.failures,
waits: waits ?? this.waits,
waitUntil: waitUntil is DateTime? ? waitUntil : this.waitUntil,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
class SecondFactorFailuresUpdateTable
extends _is.UpdateTable<SecondFactorFailuresTable> {
SecondFactorFailuresUpdateTable(super.table);
_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<int, int> waits(int value) => _is.ColumnValue(
table.waits,
value,
);
_is.ColumnValue<DateTime, DateTime> waitUntil(DateTime? value) =>
_is.ColumnValue(
table.waitUntil,
value,
);
_is.ColumnValue<DateTime, DateTime> updatedAt(DateTime value) =>
_is.ColumnValue(
table.updatedAt,
value,
);
}
class SecondFactorFailuresTable extends _is.Table<int?> {
SecondFactorFailuresTable({super.tableRelation})
: super(tableName: 'second_factor_failures') {
updateTable = SecondFactorFailuresUpdateTable(this);
authUserId = _is.ColumnUuid(
'authUserId',
this,
);
failures = _is.ColumnInt(
'failures',
this,
);
waits = _is.ColumnInt(
'waits',
this,
);
waitUntil = _is.ColumnDateTime(
'waitUntil',
this,
);
updatedAt = _is.ColumnDateTime(
'updatedAt',
this,
);
}
late final SecondFactorFailuresUpdateTable updateTable;
/// The account: its person's ID (deleted with them, `deletePerson`).
late final _is.ColumnUuid authUserId;
/// Codes refused in a row since the last accepted.
late final _is.ColumnInt failures;
/// How many waits began since the last accepted code: the next lasts an
/// hour times two to their number, `longestCodeWait` at the most.
late final _is.ColumnInt waits;
/// Until when no code is taken; null before the first wait.
late final _is.ColumnDateTime waitUntil;
late final _is.ColumnDateTime updatedAt;
@override
List<_is.Column> get columns => [
id,
authUserId,
failures,
waits,
waitUntil,
updatedAt,
];
}
class SecondFactorFailuresInclude extends _is.IncludeObject {
SecondFactorFailuresInclude._();
@override
Map<String, _is.Include?> get includes => {};
@override
_is.Table<int?> get table => SecondFactorFailures.t;
}
class SecondFactorFailuresIncludeList extends _is.IncludeList {
SecondFactorFailuresIncludeList._({
_is.WhereExpressionBuilder<SecondFactorFailuresTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(SecondFactorFailures.t);
}
@override
Map<String, _is.Include?> get includes => include?.includes ?? {};
@override
_is.Table<int?> get table => SecondFactorFailures.t;
}
class SecondFactorFailuresRepository {
const SecondFactorFailuresRepository._();
/// Returns a list of [SecondFactorFailures]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<SecondFactorFailures>> find(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<SecondFactorFailuresTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<SecondFactorFailuresTable>? orderBy,
_is.OrderByListBuilder<SecondFactorFailuresTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.find<SecondFactorFailures>(
where: where?.call(SecondFactorFailures.t),
orderBy: orderBy?.call(SecondFactorFailures.t),
orderByList: orderByList?.call(SecondFactorFailures.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [SecondFactorFailures] 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<SecondFactorFailures?> findFirstRow(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<SecondFactorFailuresTable>? where,
int? offset,
_is.OrderByBuilder<SecondFactorFailuresTable>? orderBy,
_is.OrderByListBuilder<SecondFactorFailuresTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<SecondFactorFailures>(
where: where?.call(SecondFactorFailures.t),
orderBy: orderBy?.call(SecondFactorFailures.t),
orderByList: orderByList?.call(SecondFactorFailures.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [SecondFactorFailures] by its [id] or null if no such row exists.
Future<SecondFactorFailures?> findById(
_is.DatabaseSession session,
int id, {
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findById<SecondFactorFailures>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [SecondFactorFailures]s in the list and returns the inserted rows.
///
/// The returned [SecondFactorFailures]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<SecondFactorFailures>> insert(
_is.DatabaseSession session,
List<SecondFactorFailures> rows, {
_is.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<SecondFactorFailures>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [SecondFactorFailures] and returns the inserted row.
///
/// The returned [SecondFactorFailures] will have its `id` field set.
Future<SecondFactorFailures> insertRow(
_is.DatabaseSession session,
SecondFactorFailures row, {
_is.Transaction? transaction,
}) async {
return session.db.insertRow<SecondFactorFailures>(
row,
transaction: transaction,
);
}
/// Upserts all [SecondFactorFailures]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 [SecondFactorFailures]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<SecondFactorFailures>> upsert(
_is.DatabaseSession session,
List<SecondFactorFailures> rows, {
required _is.ColumnSelections<SecondFactorFailuresTable> conflictColumns,
_is.ColumnSelections<SecondFactorFailuresTable>? updateColumns,
_is.WhereExpressionBuilder<SecondFactorFailuresTable>? updateWhere,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<SecondFactorFailures>(
rows,
conflictColumns: conflictColumns(SecondFactorFailures.t),
updateColumns: updateColumns?.call(SecondFactorFailures.t),
updateWhere: updateWhere?.call(SecondFactorFailures.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [SecondFactorFailures] 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 [SecondFactorFailures] will have its `id` field set.
Future<SecondFactorFailures?> upsertRow(
_is.DatabaseSession session,
SecondFactorFailures row, {
required _is.ColumnSelections<SecondFactorFailuresTable> conflictColumns,
_is.ColumnSelections<SecondFactorFailuresTable>? updateColumns,
_is.WhereExpressionBuilder<SecondFactorFailuresTable>? updateWhere,
_is.Transaction? transaction,
}) async {
return session.db.upsertRow<SecondFactorFailures>(
row,
conflictColumns: conflictColumns(SecondFactorFailures.t),
updateColumns: updateColumns?.call(SecondFactorFailures.t),
updateWhere: updateWhere?.call(SecondFactorFailures.t),
transaction: transaction,
);
}
/// Updates all [SecondFactorFailures]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<SecondFactorFailures>> update(
_is.DatabaseSession session,
List<SecondFactorFailures> rows, {
_is.ColumnSelections<SecondFactorFailuresTable>? columns,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<SecondFactorFailures>(
rows,
columns: columns?.call(SecondFactorFailures.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [SecondFactorFailures]. 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<SecondFactorFailures> updateRow(
_is.DatabaseSession session,
SecondFactorFailures row, {
_is.ColumnSelections<SecondFactorFailuresTable>? columns,
_is.Transaction? transaction,
}) async {
return session.db.updateRow<SecondFactorFailures>(
row,
columns: columns?.call(SecondFactorFailures.t),
transaction: transaction,
);
}
/// Updates a single [SecondFactorFailures] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<SecondFactorFailures?> updateById(
_is.DatabaseSession session,
int id, {
required _is.ColumnValueListBuilder<SecondFactorFailuresUpdateTable>
columnValues,
_is.Transaction? transaction,
}) async {
return session.db.updateById<SecondFactorFailures>(
id,
columnValues: columnValues(SecondFactorFailures.t.updateTable),
transaction: transaction,
);
}
/// Updates all [SecondFactorFailures]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<SecondFactorFailures>> updateWhere(
_is.DatabaseSession session, {
required _is.ColumnValueListBuilder<SecondFactorFailuresUpdateTable>
columnValues,
required _is.WhereExpressionBuilder<SecondFactorFailuresTable> where,
int? limit,
int? offset,
_is.OrderByBuilder<SecondFactorFailuresTable>? orderBy,
_is.OrderByListBuilder<SecondFactorFailuresTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<SecondFactorFailures>(
columnValues: columnValues(SecondFactorFailures.t.updateTable),
where: where(SecondFactorFailures.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(SecondFactorFailures.t),
orderByList: orderByList?.call(SecondFactorFailures.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [SecondFactorFailures]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<SecondFactorFailures>> delete(
_is.DatabaseSession session,
List<SecondFactorFailures> rows, {
_is.OrderByBuilder<SecondFactorFailuresTable>? orderBy,
_is.OrderByListBuilder<SecondFactorFailuresTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<SecondFactorFailures>(
rows,
orderBy: orderBy?.call(SecondFactorFailures.t),
orderByList: orderByList?.call(SecondFactorFailures.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [SecondFactorFailures].
Future<SecondFactorFailures> deleteRow(
_is.DatabaseSession session,
SecondFactorFailures row, {
_is.Transaction? transaction,
}) async {
return session.db.deleteRow<SecondFactorFailures>(
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<SecondFactorFailures>> deleteWhere(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<SecondFactorFailuresTable> where,
_is.OrderByBuilder<SecondFactorFailuresTable>? orderBy,
_is.OrderByListBuilder<SecondFactorFailuresTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<SecondFactorFailures>(
where: where(SecondFactorFailures.t),
orderBy: orderBy?.call(SecondFactorFailures.t),
orderByList: orderByList?.call(SecondFactorFailures.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<SecondFactorFailuresTable>? where,
int? limit,
_is.Transaction? transaction,
}) async {
return session.db.count<SecondFactorFailures>(
where: where?.call(SecondFactorFailures.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [SecondFactorFailures] rows matching the [where] expression.
Future<void> lockRows(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<SecondFactorFailuresTable> where,
required _is.LockMode lockMode,
required _is.Transaction transaction,
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
}) async {
return session.db.lockRows<SecondFactorFailures>(
where: where(SecondFactorFailures.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

View file

@ -43,6 +43,7 @@ import 'password/password_setup.dart' as _igpggy1y;
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/sign_in_challenge.dart' as _i9rg59hu;
import 'password/sign_in_challenge_purpose.dart' as _izijxzng;
import 'password/totp_authenticator.dart' as _i4d44ram;
@ -78,6 +79,7 @@ export 'password/password_setup.dart';
export 'password/password_signed_in.dart';
export 'password/recovery_code.dart';
export 'password/reset_pending.dart';
export 'password/second_factor_failures.dart';
export 'password/sign_in_challenge.dart';
export 'password/sign_in_challenge_purpose.dart';
export 'password/totp_authenticator.dart';
@ -637,6 +639,68 @@ class Protocol extends _is.DatabaseSerializationManager {
],
managed: true,
),
_isp.TableDefinition(
name: 'second_factor_failures',
dartName: 'SecondFactorFailures',
schema: 'public',
module: 'halkyon_base',
columns: [
_isp.ColumnDefinition(
name: 'id',
columnType: _isp.ColumnType.bigint,
isNullable: false,
dartType: 'int?',
columnDefault: 'serial',
),
_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: 'waits',
columnType: _isp.ColumnType.bigint,
isNullable: false,
dartType: 'int',
),
_isp.ColumnDefinition(
name: 'waitUntil',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: true,
dartType: 'DateTime?',
),
_isp.ColumnDefinition(
name: 'updatedAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: false,
dartType: 'DateTime',
),
],
foreignKeys: [],
indexes: [
_isp.IndexDefinition(
indexName: 'second_factor_failures_person_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'authUserId',
),
],
type: 'btree',
isUnique: true,
isPrimary: false,
),
],
managed: true,
),
_isp.TableDefinition(
name: 'sessions_ended_notice',
dartName: 'SessionsEndedNotice',
@ -957,6 +1021,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _i3y6qzi3.ResetPending) {
return _i3y6qzi3.ResetPending.fromJson(data) as T;
}
if (t == _i9cg650q.SecondFactorFailures) {
return _i9cg650q.SecondFactorFailures.fromJson(data) as T;
}
if (t == _i9rg59hu.SignInChallenge) {
return _i9rg59hu.SignInChallenge.fromJson(data) as T;
}
@ -1091,6 +1158,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _is.getType<_i3y6qzi3.ResetPending?>()) {
return (data != null ? _i3y6qzi3.ResetPending.fromJson(data) : null) as T;
}
if (t == _is.getType<_i9cg650q.SecondFactorFailures?>()) {
return (data != null
? _i9cg650q.SecondFactorFailures.fromJson(data)
: null)
as T;
}
if (t == _is.getType<_i9rg59hu.SignInChallenge?>()) {
return (data != null ? _i9rg59hu.SignInChallenge.fromJson(data) : null)
as T;
@ -1202,6 +1275,7 @@ class Protocol extends _is.DatabaseSerializationManager {
_ipk7gvzy.PasswordSignedIn => 'PasswordSignedIn',
_iart5x0c.RecoveryCode => 'RecoveryCode',
_i3y6qzi3.ResetPending => 'ResetPending',
_i9cg650q.SecondFactorFailures => 'SecondFactorFailures',
_i9rg59hu.SignInChallenge => 'SignInChallenge',
_izijxzng.SignInChallengePurpose => 'SignInChallengePurpose',
_i4d44ram.TotpAuthenticator => 'TotpAuthenticator',
@ -1278,6 +1352,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'RecoveryCode';
case _i3y6qzi3.ResetPending():
return 'ResetPending';
case _i9cg650q.SecondFactorFailures():
return 'SecondFactorFailures';
case _i9rg59hu.SignInChallenge():
return 'SignInChallenge';
case _izijxzng.SignInChallengePurpose():
@ -1386,6 +1462,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'ResetPending') {
return deserialize<_i3y6qzi3.ResetPending>(data['data']);
}
if (dataClassName == 'SecondFactorFailures') {
return deserialize<_i9cg650q.SecondFactorFailures>(data['data']);
}
if (dataClassName == 'SignInChallenge') {
return deserialize<_i9rg59hu.SignInChallenge>(data['data']);
}
@ -1516,6 +1595,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return _i3fad0p6.AccountReset.t;
case _iart5x0c.RecoveryCode:
return _iart5x0c.RecoveryCode.t;
case _i9cg650q.SecondFactorFailures:
return _i9cg650q.SecondFactorFailures.t;
case _i9rg59hu.SignInChallenge:
return _i9rg59hu.SignInChallenge.t;
case _i4d44ram.TotpAuthenticator:

View file

@ -123,6 +123,8 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
String? totpCode,
String? recoveryCode,
required String host,
String? address,
String? language,
}) async {
final keys = requirePasswordKeys(session.serverpod);
if (!isNewPassword(password)) {
@ -144,6 +146,9 @@ Future<AccountSessionsEnded> resetPasswordWithPhrase(
account,
totpCode: totpCode,
recoveryCode: recoveryCode,
notice: address == null
? null
: await _noticeOf(session, account, address, language),
);
final endedBefore = await session.db.transaction((transaction) async {
await writeNewPassword(session, row, password, transaction: transaction);
@ -377,3 +382,26 @@ Future<ResetPending> _askForReset(
return ResetPending(takesEffectAt: reset.takesEffectAt);
});
}
/// The notice of codes waiting for [account] to [typed], the address a
/// device sent, if it is the account's; null otherwise.
Future<CodesWaitNotice?> _noticeOf(
Session session,
UuidValue account,
String typed,
String? language,
) async {
final to = normalizedAddress(typed);
if (to == null) {
return null;
}
// SELECT * FROM "email_identity"
// WHERE "addressHash" = $1 AND "authUserId" = $2
final identity = await EmailIdentity.db.findFirstRow(
session,
where: (t) =>
t.addressHash.equals(addressHash(session.serverpod, to)) &
t.authUserId.equals(account),
);
return identity == null ? null : (to: to, language: language ?? 'en');
}

View file

@ -59,7 +59,10 @@ class AccountRecoveryEndpoint extends Endpoint {
/// Sets a new [password] with the recovery phrase's [signature] for
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Every
/// session of the account ends, in every service of the instance, and
/// this one signs in. Refusals as `resetPasswordWithPhrase`'s.
/// this one signs in. [host] is the host the device addressed this
/// instance by, which the phrase's proof signs; [address] and [language],
/// as the device typed them, serve only to tell the account's owner
/// should codes come to wait. Refusals as `resetPasswordWithPhrase`'s.
Future<AuthSuccess> resetPassword(
Session session, {
required UuidValue challenge,
@ -68,6 +71,8 @@ class AccountRecoveryEndpoint extends Endpoint {
String? totpCode,
String? recoveryCode,
required String host,
String? address,
String? language,
}) async {
final ended = await AccountRegister.of(session.serverpod)
.resetPasswordWithPhrase(
@ -78,6 +83,8 @@ class AccountRecoveryEndpoint extends Endpoint {
totpCode: totpCode,
recoveryCode: recoveryCode,
host: deviceHostOn(session.serverpod, host),
address: address,
language: language,
);
return await issueSessionAfter(
session,

View file

@ -2,6 +2,7 @@ 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/totp_secrets.dart';
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
import 'package:halkyon_base_server/src/totp/recovery_codes.dart';
@ -165,11 +166,18 @@ Future<List<String>> confirmAuthenticator(
};
if (row.confirmedAt == null) {
final confirmedBefore = await confirmedAuthenticatorsOf(session, person);
final typed = existingCode?.trim() ?? '';
if (confirmedBefore.isNotEmpty &&
(typed.isEmpty ||
!await acceptTotpCode(session, keys, confirmedBefore, typed))) {
throw refusedFor(PasswordRefusal.wrongExistingCode);
if (confirmedBefore.isNotEmpty) {
// Tried here once already (_countTry above): not counted again.
await refuseWhileCodesWait(session, person);
final typed = existingCode?.trim() ?? '';
if (typed.isEmpty ||
!await acceptTotpCode(session, keys, confirmedBefore, typed)) {
if (typed.isNotEmpty) {
await codeRefused(session, person);
}
throw refusedFor(PasswordRefusal.wrongExistingCode);
}
await clearRefusedCodes(session, person);
}
}
return await session.db.transaction((transaction) async {
@ -359,19 +367,29 @@ Future<void> _countTry(Session session, UuidValue person) async {
/// 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]).
/// ([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]).
Future<void> _requireAuthenticatorCode(
Session session,
PasswordKeys keys,
UuidValue person,
List<TotpAuthenticator> confirmed,
String? code,
) async {
String? code, {
PasswordRefusal refusal = PasswordRefusal.wrongCode,
}) async {
await _countTry(session, person);
await refuseWhileCodesWait(session, person);
final typed = code?.trim() ?? '';
if (typed.isEmpty || !await acceptTotpCode(session, keys, confirmed, typed)) {
throw refusedFor(PasswordRefusal.wrongCode);
if (typed.isNotEmpty &&
await acceptTotpCode(session, keys, confirmed, typed)) {
await clearRefusedCodes(session, person);
return;
}
if (typed.isNotEmpty) {
await codeRefused(session, person);
}
throw refusedFor(refusal);
}
/// [person]'s authenticator [id]; refused

View file

@ -50,3 +50,6 @@ values:
### (`apiServer.publicHost`): what it proves is bound to the name it
### addressed, so the operator must configure the name devices use.
- otherInstance
### 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

View file

@ -4,5 +4,6 @@ exception: PasswordRefusedException
fields:
reason: PasswordRefusal
### When the reset waiting takes effect, for [PasswordRefusal.notDue];
### null otherwise.
### when codes are taken again, for [PasswordRefusal.codesWait]; null
### otherwise.
takesEffectAt: DateTime?

View file

@ -32,7 +32,11 @@ class PasswordSignInEndpoint extends Endpoint {
/// (or, for a setup never finished, a [code] mailed for [request] to the
/// address the sign-in began with). Signed in, SRP's M2 (which the device checks before trusting anything
/// else), the account's keys as the register keeps them, and the session.
/// Refusals as `completePasswordSignIn`'s.
/// [host] is the host the device addressed this instance by, which its
/// proof is bound to; [address] and [language], the address the sign-in
/// began with as the device typed it, serve only to tell its owner should
/// codes come to wait (the instance keeps no address). Refusals as
/// `completePasswordSignIn`'s.
Future<PasswordSignedIn> complete(
Session session, {
required UuidValue challenge,
@ -43,6 +47,8 @@ class PasswordSignInEndpoint extends Endpoint {
UuidValue? request,
String? code,
required String host,
String? address,
String? language,
}) async {
final proof = await AccountRegister.of(session.serverpod)
.completePasswordSignIn(
@ -55,6 +61,8 @@ class PasswordSignInEndpoint extends Endpoint {
request: request,
code: code,
host: deviceHostOn(session.serverpod, host),
address: address,
language: language,
);
final signedIn = await issueSession(
session,

View file

@ -10,6 +10,7 @@ import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/password/second_factor_waits.dart';
import 'package:halkyon_base_server/src/password/totp_secrets.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:halkyon_base_server/src/srp/srp_group.dart';
@ -644,6 +645,8 @@ Future<PasswordProof> completePasswordSignIn(
UuidValue? request,
String? code,
required String host,
String? address,
String? language,
}) async {
final keys = requirePasswordKeys(session.serverpod);
final proved = await provePassword(
@ -676,6 +679,12 @@ Future<PasswordProof> completePasswordSignIn(
account,
totpCode: totpCode,
recoveryCode: recoveryCode,
notice: noticeTo(
session.serverpod,
proved.addressHash,
typed: address,
language: language,
),
);
}
final password = proved.password;
@ -851,6 +860,7 @@ Future<bool> secondFactor(
UuidValue account, {
String? totpCode,
String? recoveryCode,
CodesWaitNotice? notice,
}) async {
final now = clock.now().toUtc();
final confirmed = await confirmedAuthenticatorsOf(session, account);
@ -873,16 +883,16 @@ Future<bool> secondFactor(
}
throw refusedFor(PasswordRefusal.setupIncomplete);
}
if (totp.isNotEmpty) {
if (await acceptTotpCode(session, keys, confirmed, totp)) {
await _callOffResets(session, account);
return false;
}
} else if (recovery.isNotEmpty) {
if (await _useRecoveryCode(session, keys, account, recovery)) {
if (totp.isNotEmpty || recovery.isNotEmpty) {
await refuseWhileCodesWait(session, account);
if (totp.isNotEmpty
? await acceptTotpCode(session, keys, confirmed, totp)
: await _useRecoveryCode(session, keys, account, recovery)) {
await clearRefusedCodes(session, account);
await _callOffResets(session, account);
return false;
}
await codeRefused(session, account, notice: notice);
} else if (resetTookEffect) {
await _carryOutAuthenticatorReset(session, account);
await _callOffAccountReset(session, account);
@ -896,6 +906,56 @@ Future<bool> secondFactor(
throw refusedFor(PasswordRefusal.wrongCredentials);
}
/// Where to tell an account's owner that codes wait: the address the
/// device typed (checked against the account's by the caller, as the
/// instance keeps none) and the language to word it in.
typedef CodesWaitNotice = ({String to, String language});
/// Counts a code refused for [account] ([countRefusedCode]), and mails
/// [notice]'s address when the first wait since the last accepted code
/// begins: whoever guesses knows the password. A mail that can't be sent
/// is logged by its type and refuses nothing.
Future<void> codeRefused(
Session session,
UuidValue account, {
CodesWaitNotice? notice,
}) async {
final began = await countRefusedCode(session, account);
final mailer = EmailCodes.of(session.serverpod).mailer;
if (began == null || !began.first || notice == null || mailer == null) {
return;
}
try {
await mailer.sendCodesWaitNotice(
session,
to: notice.to,
until: began.until,
language: notice.language == 'de' ? 'de' : 'en',
);
} on Exception catch (error) {
// Its type only: what the mail service answered may name the address.
session.log(
'Mailing a notice of codes waiting failed: ${error.runtimeType}',
level: LogLevel.warning,
);
}
}
/// The notice of codes waiting for the account with the address whose
/// hash is [hash], to [typed] (as a device sent it), if [typed] is that
/// address; null otherwise, and then nobody is mailed.
CodesWaitNotice? noticeTo(
Serverpod pod,
String hash, {
String? typed,
String? language,
}) {
final to = typed == null ? null : normalizedAddress(typed);
return to == null || addressHash(pod, to) != hash
? null
: (to: to, language: language ?? 'en');
}
/// [account]'s confirmed authenticators.
Future<List<TotpAuthenticator>> confirmedAuthenticatorsOf(
Session session,

View file

@ -0,0 +1,23 @@
### 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.
class: SecondFactorFailures
serverOnly: true
table: second_factor_failures
fields:
### The account: its person's ID (deleted with them, `deletePerson`).
authUserId: UuidValue
### Codes refused in a row since the last accepted.
failures: int
### How many waits began since the last accepted code: the next lasts an
### hour times two to their number, `longestCodeWait` at the most.
waits: int
### Until when no code is taken; null before the first wait.
waitUntil: DateTime?
updatedAt: DateTime
indexes:
### One row per account.
second_factor_failures_person_idx:
fields: authUserId
unique: true

View file

@ -0,0 +1,100 @@
import 'dart:math';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
// Codes guessed with the password known (ADR 0017; the second reading's M4):
// every code checked against an account's authenticators, from a sign-in,
// a reset with the recovery phrase or a signed-in change, and every
// recovery code, counts while refused, per account, from every network.
// After every [codeFailuresBeforeWait] refused 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 when the first wait
// begins where their address is at hand: whoever guesses knows the
// password. A code accepted clears all of it. Only ever after a right
// password (or a session), so a wait tells nobody else anything.
/// How many codes are refused in a row before codes wait.
const codeFailuresBeforeWait = 10;
/// How long codes wait the first time.
const firstCodeWait = Duration(hours: 1);
/// The longest codes wait: a day. Longer would hold their owner out of
/// their own account for days on the say of whoever knows the password; at
/// a day, ten guesses a day still hit one authenticator's code with a
/// chance of about 1 % a year, which is why the owner is told by mail at
/// the first wait, to change the password.
const longestCodeWait = Duration(days: 1);
/// Refused ([PasswordRefusal.codesWait], with when codes are taken again)
/// while [account]'s codes wait.
Future<void> refuseWhileCodesWait(Session session, UuidValue account) async {
// SELECT * FROM "second_factor_failures" WHERE "authUserId" = $1
final row = await SecondFactorFailures.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(account),
);
final until = row?.waitUntil;
if (until != null && until.isAfter(clock.now())) {
throw PasswordRefusedException(
reason: PasswordRefusal.codesWait,
takesEffectAt: until,
);
}
}
/// Counts a code refused for [account], and returns when codes are taken
/// again where this one began a wait (null otherwise), and whether it is
/// the first wait since the last accepted code (the owner is mailed then).
/// The count is one statement, so two refused at once count two.
Future<({DateTime until, bool first})?> countRefusedCode(
Session session,
UuidValue account,
) async {
final now = clock.now().toUtc();
final counted = await session.db.unsafeQuery(
'INSERT INTO "second_factor_failures" '
'("authUserId", "failures", "waits", "updatedAt") '
'VALUES (@account, 1, 0, @now) '
'ON CONFLICT ("authUserId") DO UPDATE SET '
'"failures" = "second_factor_failures"."failures" + 1, '
'"updatedAt" = @now '
'RETURNING "failures", "waits"',
parameters: QueryParameters.named({'account': account.uuid, 'now': now}),
);
final failures = counted.first[0]! as int;
final waits = counted.first[1]! as int;
if (failures % codeFailuresBeforeWait != 0) {
return null;
}
final until = now.add(codeWait(waits));
// UPDATE "second_factor_failures" SET "waits" = "waits" + 1,
// "waitUntil" = $1 WHERE "authUserId" = $2
await session.db.unsafeExecute(
'UPDATE "second_factor_failures" SET "waits" = "waits" + 1, '
'"waitUntil" = @until WHERE "authUserId" = @account',
parameters: QueryParameters.named({
'account': account.uuid,
'until': until,
}),
);
return (until: until, first: waits == 0);
}
/// How long codes wait after [waits] waits before: an hour times two to
/// their number, [longestCodeWait] at the most.
Duration codeWait(int waits) {
final hours = pow(2, min(waits, 16)).toInt();
final wait = firstCodeWait * hours;
return wait > longestCodeWait ? longestCodeWait : wait;
}
/// Clears [account]'s refused codes: one was accepted.
Future<void> clearRefusedCodes(Session session, UuidValue account) =>
// DELETE FROM "second_factor_failures" WHERE "authUserId" = $1
SecondFactorFailures.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(account),
);

View file

@ -171,6 +171,8 @@ abstract interface class AccountRegister {
UuidValue? request,
String? code,
required String host,
String? address,
String? language,
});
/// Signs up with the address [code] proves for [request] and the password
@ -259,6 +261,8 @@ abstract interface class AccountRegister {
String? totpCode,
String? recoveryCode,
required String host,
String? address,
String? language,
});
/// Asks for the reset of a whole account. Refusals as

View file

@ -237,6 +237,8 @@ Future<Map<String, Object?>?> _answer(
request: arguments.optionalId('request'),
code: arguments.optionalString('code'),
host: arguments.string('host'),
address: arguments.optionalString('address'),
language: arguments.optionalString('language'),
);
return {'proof': proof.toJson()};
case RegisterOperation.signUpWithCode:
@ -320,6 +322,8 @@ Future<Map<String, Object?>?> _answer(
totpCode: arguments.optionalString('totpCode'),
recoveryCode: arguments.optionalString('recoveryCode'),
host: arguments.string('host'),
address: arguments.optionalString('address'),
language: arguments.optionalString('language'),
);
return _sessionsEnded(ended);
case RegisterOperation.startAccountReset:

View file

@ -431,6 +431,8 @@ final class LocalRegister implements AccountRegister {
UuidValue? request,
String? code,
required String host,
String? address,
String? language,
}) => passwords.completePasswordSignIn(
session,
challenge: challenge,
@ -441,6 +443,8 @@ final class LocalRegister implements AccountRegister {
request: request,
code: code,
host: host,
address: address,
language: language,
);
@override
@ -553,6 +557,8 @@ final class LocalRegister implements AccountRegister {
String? totpCode,
String? recoveryCode,
required String host,
String? address,
String? language,
}) => recovery.resetPasswordWithPhrase(
session,
challenge: challenge,
@ -561,6 +567,8 @@ final class LocalRegister implements AccountRegister {
totpCode: totpCode,
recoveryCode: recoveryCode,
host: host,
address: address,
language: language,
);
@override

View file

@ -322,10 +322,14 @@ final class RemoteRegister implements AccountRegister {
UuidValue? request,
String? code,
required String host,
String? address,
String? language,
}) async => _model<PasswordProof>(
RegisterOperation.completePasswordSignIn,
await _ask(RegisterOperation.completePasswordSignIn, {
'host': host,
'address': address,
'language': language,
'challenge': challenge.uuid,
'clientValue': _bytes(clientValue),
'clientProof': _bytes(clientProof),
@ -487,10 +491,14 @@ final class RemoteRegister implements AccountRegister {
String? totpCode,
String? recoveryCode,
required String host,
String? address,
String? language,
}) async => _sessionsEnded(
RegisterOperation.resetPasswordWithPhrase,
await _ask(RegisterOperation.resetPasswordWithPhrase, {
'host': host,
'address': address,
'language': language,
'challenge': challenge.uuid,
'signature': _bytes(signature),
'password': password.toJson(),