feat(auth): a session ended for its wrong proofs warns its owner

The tenth wrong owner proof in a row ends a session (the third
reading); someone holding that session tried the password or its
codes. The register now keeps the moment with the account's password
(`account_password.sessionEndedForProofsAt`): the account's sign-in
ways and its next password sign-in carry it, until the owner dismisses
it (`password.dismissSessionEndedWarning`, their session alone: it
hides a warning and secures nothing) or sets a new password. A member
ending its own session tells the register
(`noteSessionEndedForProofs`); a register out of reach leaves the
owner unwarned, the session ended all the same.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "sessionEndedForProofsAt"
timestamp without time zone; nullable, so every row keeps its values
and has no warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 12:05:18 +02:00
commit 5e7d7f4e36
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 41919 additions and 34 deletions

View file

@ -15,3 +15,7 @@ fields:
### authenticators that took effect. Where none does, deleting the account
### takes a code by mail in place of a TOTP or recovery code.
secondFactorWorks: bool, default=false
### When a session of the account was last ended for ten wrong proofs in
### a row, the owner's warning until they dismiss it or set a new
### password (ADR 0017, the fourth reading); null for none.
sessionEndedForProofsAt: DateTime?

View file

@ -22,6 +22,7 @@ abstract class SignInWays
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
this.sessionEndedForProofsAt,
}) : password = password ?? false,
authenticator = authenticator ?? false,
passwordOnly = passwordOnly ?? false,
@ -34,6 +35,7 @@ abstract class SignInWays
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt,
}) = _SignInWaysImpl;
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
@ -54,6 +56,12 @@ abstract class SignInWays
: _is.BoolJsonExtension.fromJson(
jsonSerialization['secondFactorWorks'],
),
sessionEndedForProofsAt:
jsonSerialization['sessionEndedForProofsAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionEndedForProofsAt'],
),
);
}
@ -77,6 +85,11 @@ abstract class SignInWays
/// takes a code by mail in place of a TOTP or recovery code.
bool secondFactorWorks;
/// When a session of the account was last ended for ten wrong proofs in
/// a row, the owner's warning until they dismiss it or set a new
/// password (ADR 0017, the fourth reading); null for none.
DateTime? sessionEndedForProofsAt;
/// Returns a shallow copy of this [SignInWays]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@ -87,6 +100,7 @@ abstract class SignInWays
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt,
});
@override
Map<String, dynamic> toJson() {
@ -98,6 +112,8 @@ abstract class SignInWays
'authenticator': authenticator,
'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
};
}
@ -111,6 +127,8 @@ abstract class SignInWays
'authenticator': authenticator,
'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
};
}
@ -120,6 +138,8 @@ abstract class SignInWays
}
}
class _Undefined {}
class _SignInWaysImpl extends SignInWays {
_SignInWaysImpl({
required bool gitHub,
@ -128,6 +148,7 @@ class _SignInWaysImpl extends SignInWays {
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt,
}) : super._(
gitHub: gitHub,
email: email,
@ -135,6 +156,7 @@ class _SignInWaysImpl extends SignInWays {
authenticator: authenticator,
passwordOnly: passwordOnly,
secondFactorWorks: secondFactorWorks,
sessionEndedForProofsAt: sessionEndedForProofsAt,
);
/// Returns a shallow copy of this [SignInWays]
@ -148,6 +170,7 @@ class _SignInWaysImpl extends SignInWays {
bool? authenticator,
bool? passwordOnly,
bool? secondFactorWorks,
Object? sessionEndedForProofsAt = _Undefined,
}) {
return SignInWays(
gitHub: gitHub ?? this.gitHub,
@ -156,6 +179,9 @@ class _SignInWaysImpl extends SignInWays {
authenticator: authenticator ?? this.authenticator,
passwordOnly: passwordOnly ?? this.passwordOnly,
secondFactorWorks: secondFactorWorks ?? this.secondFactorWorks,
sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime?
? sessionEndedForProofsAt
: this.sessionEndedForProofsAt,
);
}
}

View file

@ -778,6 +778,16 @@ class Endpoints extends _is.EndpointDispatch {
recoveryProofKey: params['recoveryProofKey'],
),
),
'dismissSessionEndedWarning': _is.MethodConnector(
name: 'dismissSessionEndedWarning',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint)
.dismissSessionEndedWarning(session),
),
},
);
connectors['passwordSignIn'] = _is.EndpointConnector(

View file

@ -36,6 +36,7 @@ abstract class AccountPassword
required this.recoveryKeysNonce,
required this.recoveryKeysCipherText,
this.recoveryProofKey,
this.sessionEndedForProofsAt,
required this.createdAt,
required this.updatedAt,
});
@ -55,6 +56,7 @@ abstract class AccountPassword
required _idt.ByteData recoveryKeysNonce,
required _idt.ByteData recoveryKeysCipherText,
_idt.ByteData? recoveryProofKey,
DateTime? sessionEndedForProofsAt,
required DateTime createdAt,
required DateTime updatedAt,
}) = _AccountPasswordImpl;
@ -95,6 +97,12 @@ abstract class AccountPassword
: _is.ByteDataJsonExtension.fromJson(
jsonSerialization['recoveryProofKey'],
),
sessionEndedForProofsAt:
jsonSerialization['sessionEndedForProofsAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionEndedForProofsAt'],
),
createdAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
@ -154,6 +162,13 @@ abstract class AccountPassword
/// sets a new one.
_idt.ByteData? recoveryProofKey;
/// When ten wrong proofs in a row from a signed-in session of the
/// account last ended that session (`asOwnerProofTry`): someone with a
/// session tried the password or its codes. The owner's warning, which
/// their sign-in ways and their next sign-in carry, until they dismiss it
/// or set a new password; null for none.
DateTime? sessionEndedForProofsAt;
DateTime createdAt;
DateTime updatedAt;
@ -179,6 +194,7 @@ abstract class AccountPassword
_idt.ByteData? recoveryKeysNonce,
_idt.ByteData? recoveryKeysCipherText,
_idt.ByteData? recoveryProofKey,
DateTime? sessionEndedForProofsAt,
DateTime? createdAt,
DateTime? updatedAt,
});
@ -201,6 +217,8 @@ abstract class AccountPassword
'recoveryKeysCipherText': recoveryKeysCipherText.toJson(),
if (recoveryProofKey != null)
'recoveryProofKey': recoveryProofKey?.toJson(),
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
'createdAt': createdAt.toJson(),
'updatedAt': updatedAt.toJson(),
};
@ -257,6 +275,7 @@ class _AccountPasswordImpl extends AccountPassword {
required _idt.ByteData recoveryKeysNonce,
required _idt.ByteData recoveryKeysCipherText,
_idt.ByteData? recoveryProofKey,
DateTime? sessionEndedForProofsAt,
required DateTime createdAt,
required DateTime updatedAt,
}) : super._(
@ -274,6 +293,7 @@ class _AccountPasswordImpl extends AccountPassword {
recoveryKeysNonce: recoveryKeysNonce,
recoveryKeysCipherText: recoveryKeysCipherText,
recoveryProofKey: recoveryProofKey,
sessionEndedForProofsAt: sessionEndedForProofsAt,
createdAt: createdAt,
updatedAt: updatedAt,
);
@ -297,6 +317,7 @@ class _AccountPasswordImpl extends AccountPassword {
_idt.ByteData? recoveryKeysNonce,
_idt.ByteData? recoveryKeysCipherText,
Object? recoveryProofKey = _Undefined,
Object? sessionEndedForProofsAt = _Undefined,
DateTime? createdAt,
DateTime? updatedAt,
}) {
@ -320,6 +341,9 @@ class _AccountPasswordImpl extends AccountPassword {
recoveryProofKey: recoveryProofKey is _idt.ByteData?
? recoveryProofKey
: this.recoveryProofKey?.clone(),
sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime?
? sessionEndedForProofsAt
: this.sessionEndedForProofsAt,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
@ -412,6 +436,13 @@ class AccountPasswordUpdateTable extends _is.UpdateTable<AccountPasswordTable> {
value,
);
_is.ColumnValue<DateTime, DateTime> sessionEndedForProofsAt(
DateTime? value,
) => _is.ColumnValue(
table.sessionEndedForProofsAt,
value,
);
_is.ColumnValue<DateTime, DateTime> createdAt(DateTime value) =>
_is.ColumnValue(
table.createdAt,
@ -481,6 +512,10 @@ class AccountPasswordTable extends _is.Table<int?> {
'recoveryProofKey',
this,
);
sessionEndedForProofsAt = _is.ColumnDateTime(
'sessionEndedForProofsAt',
this,
);
createdAt = _is.ColumnDateTime(
'createdAt',
this,
@ -536,6 +571,13 @@ class AccountPasswordTable extends _is.Table<int?> {
/// sets a new one.
late final _is.ColumnByteData recoveryProofKey;
/// When ten wrong proofs in a row from a signed-in session of the
/// account last ended that session (`asOwnerProofTry`): someone with a
/// session tried the password or its codes. The owner's warning, which
/// their sign-in ways and their next sign-in carry, until they dismiss it
/// or set a new password; null for none.
late final _is.ColumnDateTime sessionEndedForProofsAt;
late final _is.ColumnDateTime createdAt;
late final _is.ColumnDateTime updatedAt;
@ -556,6 +598,7 @@ class AccountPasswordTable extends _is.Table<int?> {
recoveryKeysNonce,
recoveryKeysCipherText,
recoveryProofKey,
sessionEndedForProofsAt,
createdAt,
updatedAt,
];

View file

@ -30,6 +30,7 @@ abstract class PasswordProof
required this.passwordKeys,
required this.needsAuthenticator,
this.sessionsEndedBefore,
this.sessionEndedForProofsAt,
});
factory PasswordProof({
@ -40,6 +41,7 @@ abstract class PasswordProof
required _i2rxumte.WrappedKeys passwordKeys,
required bool needsAuthenticator,
DateTime? sessionsEndedBefore,
DateTime? sessionEndedForProofsAt,
}) = _PasswordProofImpl;
factory PasswordProof.fromJson(Map<String, dynamic> jsonSerialization) {
@ -67,6 +69,12 @@ abstract class PasswordProof
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionsEndedBefore'],
),
sessionEndedForProofsAt:
jsonSerialization['sessionEndedForProofsAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionEndedForProofsAt'],
),
);
}
@ -88,6 +96,10 @@ abstract class PasswordProof
/// (`issueSessionAfter`).
DateTime? sessionsEndedBefore;
/// The owner's warning that a session of the account was ended for ten
/// wrong proofs in a row, as the account's sign-in ways carry it.
DateTime? sessionEndedForProofsAt;
/// Returns a shallow copy of this [PasswordProof]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@ -99,6 +111,7 @@ abstract class PasswordProof
_i2rxumte.WrappedKeys? passwordKeys,
bool? needsAuthenticator,
DateTime? sessionsEndedBefore,
DateTime? sessionEndedForProofsAt,
});
@override
Map<String, dynamic> toJson() {
@ -112,6 +125,8 @@ abstract class PasswordProof
'needsAuthenticator': needsAuthenticator,
if (sessionsEndedBefore != null)
'sessionsEndedBefore': sessionsEndedBefore?.toJson(),
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
};
}
@ -137,6 +152,7 @@ class _PasswordProofImpl extends PasswordProof {
required _i2rxumte.WrappedKeys passwordKeys,
required bool needsAuthenticator,
DateTime? sessionsEndedBefore,
DateTime? sessionEndedForProofsAt,
}) : super._(
account: account,
serverProof: serverProof,
@ -145,6 +161,7 @@ class _PasswordProofImpl extends PasswordProof {
passwordKeys: passwordKeys,
needsAuthenticator: needsAuthenticator,
sessionsEndedBefore: sessionsEndedBefore,
sessionEndedForProofsAt: sessionEndedForProofsAt,
);
/// Returns a shallow copy of this [PasswordProof]
@ -159,6 +176,7 @@ class _PasswordProofImpl extends PasswordProof {
_i2rxumte.WrappedKeys? passwordKeys,
bool? needsAuthenticator,
Object? sessionsEndedBefore = _Undefined,
Object? sessionEndedForProofsAt = _Undefined,
}) {
return PasswordProof(
account: account ?? this.account,
@ -171,6 +189,9 @@ class _PasswordProofImpl extends PasswordProof {
sessionsEndedBefore: sessionsEndedBefore is DateTime?
? sessionsEndedBefore
: this.sessionsEndedBefore,
sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime?
? sessionEndedForProofsAt
: this.sessionEndedForProofsAt,
);
}
}

View file

@ -29,6 +29,7 @@ abstract class PasswordSignedIn
required this.signingPublicKey,
required this.passwordKeys,
required this.needsAuthenticator,
this.sessionEndedForProofsAt,
required this.authStrategy,
required this.token,
this.tokenExpiresAt,
@ -43,6 +44,7 @@ abstract class PasswordSignedIn
required _idt.ByteData signingPublicKey,
required _i2rxumte.WrappedKeys passwordKeys,
required bool needsAuthenticator,
DateTime? sessionEndedForProofsAt,
required String authStrategy,
required String token,
DateTime? tokenExpiresAt,
@ -68,6 +70,12 @@ abstract class PasswordSignedIn
needsAuthenticator: _is.BoolJsonExtension.fromJson(
jsonSerialization['needsAuthenticator'],
),
sessionEndedForProofsAt:
jsonSerialization['sessionEndedForProofsAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionEndedForProofsAt'],
),
authStrategy: jsonSerialization['authStrategy'] as String,
token: jsonSerialization['token'] as String,
tokenExpiresAt: jsonSerialization['tokenExpiresAt'] == null
@ -95,6 +103,11 @@ abstract class PasswordSignedIn
bool needsAuthenticator;
/// When a session of the account was last ended for ten wrong proofs in
/// a row (someone with a session tried the password or its codes), until
/// the owner dismisses the warning or sets a new password; null for none.
DateTime? sessionEndedForProofsAt;
/// The session, as Serverpod's `AuthSuccess` has it (the device makes one
/// of these fields for its session manager).
String authStrategy;
@ -118,6 +131,7 @@ abstract class PasswordSignedIn
_idt.ByteData? signingPublicKey,
_i2rxumte.WrappedKeys? passwordKeys,
bool? needsAuthenticator,
DateTime? sessionEndedForProofsAt,
String? authStrategy,
String? token,
DateTime? tokenExpiresAt,
@ -134,6 +148,8 @@ abstract class PasswordSignedIn
'signingPublicKey': signingPublicKey.toJson(),
'passwordKeys': passwordKeys.toJson(),
'needsAuthenticator': needsAuthenticator,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
'authStrategy': authStrategy,
'token': token,
if (tokenExpiresAt != null) 'tokenExpiresAt': tokenExpiresAt?.toJson(),
@ -152,6 +168,8 @@ abstract class PasswordSignedIn
'signingPublicKey': signingPublicKey.toJson(),
'passwordKeys': passwordKeys.toJsonForProtocol(),
'needsAuthenticator': needsAuthenticator,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
'authStrategy': authStrategy,
'token': token,
if (tokenExpiresAt != null) 'tokenExpiresAt': tokenExpiresAt?.toJson(),
@ -176,6 +194,7 @@ class _PasswordSignedInImpl extends PasswordSignedIn {
required _idt.ByteData signingPublicKey,
required _i2rxumte.WrappedKeys passwordKeys,
required bool needsAuthenticator,
DateTime? sessionEndedForProofsAt,
required String authStrategy,
required String token,
DateTime? tokenExpiresAt,
@ -188,6 +207,7 @@ class _PasswordSignedInImpl extends PasswordSignedIn {
signingPublicKey: signingPublicKey,
passwordKeys: passwordKeys,
needsAuthenticator: needsAuthenticator,
sessionEndedForProofsAt: sessionEndedForProofsAt,
authStrategy: authStrategy,
token: token,
tokenExpiresAt: tokenExpiresAt,
@ -206,6 +226,7 @@ class _PasswordSignedInImpl extends PasswordSignedIn {
_idt.ByteData? signingPublicKey,
_i2rxumte.WrappedKeys? passwordKeys,
bool? needsAuthenticator,
Object? sessionEndedForProofsAt = _Undefined,
String? authStrategy,
String? token,
Object? tokenExpiresAt = _Undefined,
@ -220,6 +241,9 @@ class _PasswordSignedInImpl extends PasswordSignedIn {
signingPublicKey: signingPublicKey ?? this.signingPublicKey.clone(),
passwordKeys: passwordKeys ?? this.passwordKeys.copyWith(),
needsAuthenticator: needsAuthenticator ?? this.needsAuthenticator,
sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime?
? sessionEndedForProofsAt
: this.sessionEndedForProofsAt,
authStrategy: authStrategy ?? this.authStrategy,
token: token ?? this.token,
tokenExpiresAt: tokenExpiresAt is DateTime?

View file

@ -267,6 +267,12 @@ class Protocol extends _is.DatabaseSerializationManager {
isNullable: true,
dartType: 'dart:typed_data:ByteData?',
),
_isp.ColumnDefinition(
name: 'sessionEndedForProofsAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: true,
dartType: 'DateTime?',
),
_isp.ColumnDefinition(
name: 'createdAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,

View file

@ -28,6 +28,7 @@ password:
- beginChange:
- change:
- setRecoveryKeys:
- dismissSessionEndedWarning:
passwordSignIn:
- begin:
- complete:

View file

@ -38,6 +38,12 @@ fields:
### set up before it was kept: such an account proves no phrase until it
### sets a new one.
recoveryProofKey: ByteData?
### When ten wrong proofs in a row from a signed-in session of the
### account last ended that session (`asOwnerProofTry`): someone with a
### session tried the password or its codes. The owner's warning, which
### their sign-in ways and their next sign-in carry, until they dismiss it
### or set a new password; null for none.
sessionEndedForProofsAt: DateTime?
createdAt: DateTime
updatedAt: DateTime
indexes:

View file

@ -1,5 +1,6 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/register_protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -18,7 +19,11 @@ import 'package:serverpod_auth_idp_server/core.dart';
// count each), only a proof accepted clears the count (an operation given a
// proof always checks it or refuses it: `confirmAuthenticator`,
// `removeAuthenticator`), and the [ownerProofTriesBeforeSignOut]th wrong in a
// row ends the session.
// row ends the session. Its owner is warned (the fourth reading): someone
// with a session tried their password or codes. The register keeps the
// warning with the account's password (`sessionEndedForProofsAt`), which
// their sign-in ways and their next sign-in carry until they dismiss it or
// set a new password.
/// How many proofs a session sends wrong in a row before it ends.
const ownerProofTriesBeforeSignOut = 10;
@ -136,4 +141,56 @@ Future<void> _endSession(
person.uuid,
RevokedAuthenticationAuthId(authId: sessionId.uuid),
);
await _warnOwner(session, person);
}
/// Tells the register to warn [person] that a session of theirs was ended
/// for its wrong proofs. The session is ended whatever becomes of this: a
/// register out of reach leaves the owner unwarned, logged by the error's
/// type alone.
Future<void> _warnOwner(Session session, UuidValue person) async {
try {
await AccountRegister.of(
session.serverpod,
).noteSessionEndedForProofs(session, person);
} on Exception catch (error) {
session.log(
'Warning the owner of a session ended for its proofs failed: '
'${error.runtimeType}',
level: LogLevel.warning,
);
}
}
/// Keeps [person]'s warning that a session of theirs was ended for its
/// wrong proofs, at this moment: the register's side of
/// `AccountRegister.noteSessionEndedForProofs`. Nothing for an account
/// without a password, for which no proof is ever checked.
Future<void> noteSessionEndedForProofs(
Session session,
UuidValue person,
) async {
// UPDATE "account_password" SET "sessionEndedForProofsAt" = $1
// WHERE "authUserId" = $2
await AccountPassword.db.updateWhere(
session,
columnValues: (t) => [t.sessionEndedForProofsAt(clock.now().toUtc())],
where: (t) => t.authUserId.equals(person),
);
}
/// Dismisses [person]'s warning: it only hides a warning, so their session
/// alone does, without a proof. The register's side of
/// `AccountRegister.dismissSessionEndedWarning`.
Future<void> dismissSessionEndedWarning(
Session session,
UuidValue person,
) async {
// UPDATE "account_password" SET "sessionEndedForProofsAt" = NULL
// WHERE "authUserId" = $1
await AccountPassword.db.updateWhere(
session,
columnValues: (t) => [t.sessionEndedForProofsAt(null)],
where: (t) => t.authUserId.equals(person),
);
}

View file

@ -125,6 +125,16 @@ class PasswordEndpoint extends Endpoint {
),
);
}
/// Dismisses the caller's warning that a session of theirs was ended for
/// ten wrong proofs in a row (`account.signInWays`'
/// `sessionEndedForProofsAt`). Their session alone does: it hides a
/// warning and changes nothing about how the account is secured.
Future<void> dismissSessionEndedWarning(Session session) =>
AccountRegister.of(session.serverpod).dismissSessionEndedWarning(
session,
session.authenticated!.authUserId,
);
}
/// Whether [session]'s own session (this server's) was made by a GitHub

View file

@ -17,3 +17,6 @@ fields:
### reading), which the session issued for it comes after
### (`issueSessionAfter`).
sessionsEndedBefore: DateTime?
### The owner's warning that a session of the account was ended for ten
### wrong proofs in a row, as the account's sign-in ways carry it.
sessionEndedForProofsAt: DateTime?

View file

@ -85,6 +85,7 @@ class PasswordSignInEndpoint extends Endpoint {
signingPublicKey: proof.signingPublicKey,
passwordKeys: proof.passwordKeys,
needsAuthenticator: proof.needsAuthenticator,
sessionEndedForProofsAt: proof.sessionEndedForProofsAt,
authStrategy: signedIn.authStrategy,
token: signedIn.token,
tokenExpiresAt: signedIn.tokenExpiresAt,

View file

@ -11,6 +11,10 @@ fields:
signingPublicKey: ByteData
passwordKeys: WrappedKeys
needsAuthenticator: bool
### When a session of the account was last ended for ten wrong proofs in
### a row (someone with a session tried the password or its codes), until
### the owner dismisses the warning or sets a new password; null for none.
sessionEndedForProofsAt: DateTime?
### The session, as Serverpod's `AuthSuccess` has it (the device makes one
### of these fields for its session manager).
authStrategy: String

View file

@ -605,7 +605,9 @@ Future<void> writeNewPassword(
// UPDATE "account_password" SET "salt" = $1, "kdfAlgorithm" = $2,
// "kdfOpsLimit" = $3, "kdfMemLimit" = $4, "verifier" = $5,
// "passwordKeysNonce" = $6, "passwordKeysCipherText" = $7,
// "updatedAt" = $8 WHERE "id" = $9
// "sessionEndedForProofsAt" = NULL, "updatedAt" = $8 WHERE "id" = $9
// (a new password answers the warning of a session ended for its
// proofs: whoever tried the old one no longer knows it).
AccountPassword.db.updateRow(
session,
row.copyWith(
@ -616,6 +618,10 @@ Future<void> writeNewPassword(
verifier: password.verifier,
passwordKeysNonce: password.passwordKeys.nonce,
passwordKeysCipherText: password.passwordKeys.cipherText,
// A generated copyWith tells an explicit null (clear it) from no
// argument (keep it), which the lint can't see.
// ignore: avoid_redundant_argument_values
sessionEndedForProofsAt: null,
updatedAt: clock.now().toUtc(),
),
columns: (t) => [
@ -626,6 +632,7 @@ Future<void> writeNewPassword(
t.verifier,
t.passwordKeysNonce,
t.passwordKeysCipherText,
t.sessionEndedForProofsAt,
t.updatedAt,
],
transaction: transaction,
@ -775,6 +782,7 @@ Future<PasswordProof> completePasswordSignIn(
),
needsAuthenticator: passed.needsAuthenticator,
sessionsEndedBefore: passed.sessionsEndedBefore,
sessionEndedForProofsAt: password.sessionEndedForProofsAt,
);
}

View file

@ -133,9 +133,18 @@ abstract interface class AccountRegister {
Future<UuidValue?> accountWithGitHub(Session session, String gitHubUserId);
/// How [person] signs in: with GitHub, an email address, a password and
/// whether an authenticator is confirmed.
/// whether an authenticator is confirmed; and their warning that a session
/// was ended for its wrong proofs, if any.
Future<SignInWays> signInWaysOf(Session session, UuidValue person);
/// Warns [person] that a session of theirs was ended for ten wrong proofs
/// in a row (ADR 0017, the fourth reading), as of now: a member tells it
/// of its own sessions.
Future<void> noteSessionEndedForProofs(Session session, UuidValue person);
/// Dismisses [person]'s warning that a session was ended for its proofs.
Future<void> dismissSessionEndedWarning(Session session, UuidValue person);
/// Whether people sign in with a password and TOTP (ADR 0017): the
/// register holds the keys it needs.
Future<bool> passwordSignInOffered(Session session);

View file

@ -198,6 +198,15 @@ Future<Map<String, Object?>?> _answer(
arguments.id('person'),
);
return {'ways': ways.toJson()};
case RegisterOperation.noteSessionEndedForProofs:
await register.noteSessionEndedForProofs(session, arguments.id('person'));
return const {};
case RegisterOperation.dismissSessionEndedWarning:
await register.dismissSessionEndedWarning(
session,
arguments.id('person'),
);
return const {};
case RegisterOperation.deleteAccount:
await register.deleteAccount(
session,

View file

@ -11,6 +11,8 @@ import 'package:halkyon_base_server/src/password/account_recovery.dart'
as recovery;
import 'package:halkyon_base_server/src/password/authenticators.dart'
as authenticators;
import 'package:halkyon_base_server/src/password/owner_proof_tries.dart'
as owner_proofs;
import 'package:halkyon_base_server/src/password/password_change.dart'
as change;
import 'package:halkyon_base_server/src/password/password_config.dart';
@ -361,37 +363,48 @@ final class LocalRegister implements AccountRegister {
github.accountWithGitHub(session, gitHubUserId: gitHubUserId);
@override
Future<SignInWays> signInWaysOf(Session session, UuidValue person) async =>
SignInWays(
// SELECT count(*) FROM "serverpod_auth_idp_github_account"
// WHERE "authUserId" = $1
gitHub:
await GitHubAccount.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0,
// SELECT count(*) FROM "email_identity" WHERE "authUserId" = $1
email:
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0,
// SELECT count(*) FROM "account_password" WHERE "authUserId" = $1
password:
await AccountPassword.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0,
authenticator: await passwords.hasConfirmedAuthenticator(
session,
person,
),
passwordOnly: await passwords.signsInWithPassword(session, person),
secondFactorWorks: await passwords.secondFactorWorks(session, person),
);
Future<SignInWays> signInWaysOf(Session session, UuidValue person) async {
// SELECT * FROM "account_password" WHERE "authUserId" = $1
final password = await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
);
return SignInWays(
// SELECT count(*) FROM "serverpod_auth_idp_github_account"
// WHERE "authUserId" = $1
gitHub:
await GitHubAccount.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0,
// SELECT count(*) FROM "email_identity" WHERE "authUserId" = $1
email:
await EmailIdentity.db.count(
session,
where: (t) => t.authUserId.equals(person),
) >
0,
password: password != null,
authenticator: await passwords.hasConfirmedAuthenticator(
session,
person,
),
passwordOnly: await passwords.signsInWithPassword(session, person),
secondFactorWorks: await passwords.secondFactorWorks(session, person),
sessionEndedForProofsAt: password?.sessionEndedForProofsAt,
);
}
@override
Future<void> noteSessionEndedForProofs(Session session, UuidValue person) =>
owner_proofs.noteSessionEndedForProofs(session, person);
@override
Future<void> dismissSessionEndedWarning(
Session session,
UuidValue person,
) => owner_proofs.dismissSessionEndedWarning(session, person);
@override
Future<bool> passwordSignInOffered(Session session) async =>

View file

@ -29,6 +29,8 @@ abstract final class RegisterOperation {
static const linkGitHub = 'link-github';
static const accountWithGitHub = 'account-with-github';
static const signInWaysOf = 'sign-in-ways-of';
static const noteSessionEndedForProofs = 'note-session-ended-for-proofs';
static const dismissSessionEndedWarning = 'dismiss-session-ended-warning';
static const deleteAccount = 'delete-account';
static const knownAccounts = 'known-accounts';
static const passwordSignInOffered = 'password-sign-in-offered';

View file

@ -276,6 +276,20 @@ final class RemoteRegister implements AccountRegister {
),
};
@override
Future<void> noteSessionEndedForProofs(Session session, UuidValue person) =>
_ask(RegisterOperation.noteSessionEndedForProofs, {
'person': person.uuid,
});
@override
Future<void> dismissSessionEndedWarning(
Session session,
UuidValue person,
) => _ask(RegisterOperation.dismissSessionEndedWarning, {
'person': person.uuid,
});
@override
Future<bool> passwordSignInOffered(Session session) async =>
(await _ask(