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
50 changed files with 41919 additions and 34 deletions

View file

@ -0,0 +1,33 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "account_password",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "sessionEndedForProofsAt",
"fieldName": "sessionEndedForProofsAt",
"columnType": 4,
"isNullable": true,
"dartType": "DateTime?"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -24,6 +24,7 @@ part '20261020185750538-recovery-proof-key/migration.dart';
part '20261020194228286-second-factor-waits/migration.dart'; part '20261020194228286-second-factor-waits/migration.dart';
part '20261020204619104-session-code-tries/migration.dart'; part '20261020204619104-session-code-tries/migration.dart';
part '20261020211224692-email-code-purpose/migration.dart'; part '20261020211224692-email-code-purpose/migration.dart';
part '20261021100422048-session-ended-warning/migration.dart';
/// Migration registry for the client-side database. /// Migration registry for the client-side database.
class MigrationRegistry { class MigrationRegistry {
@ -50,6 +51,7 @@ class MigrationRegistry {
_Migration20261020194228286_second_factor_waits(), _Migration20261020194228286_second_factor_waits(),
_Migration20261020204619104_session_code_tries(), _Migration20261020204619104_session_code_tries(),
_Migration20261020211224692_email_code_purpose(), _Migration20261020211224692_email_code_purpose(),
_Migration20261021100422048_session_ended_warning(),
]; ];
/// List of all client-side database migration versions. /// List of all client-side database migration versions.

View file

@ -0,0 +1,33 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "account_password",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "sessionEndedForProofsAt",
"fieldName": "sessionEndedForProofsAt",
"columnType": 4,
"isNullable": true,
"dartType": "DateTime?"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,57 @@
BEGIN;
--
-- ACTION ALTER TABLE
--
ALTER TABLE "account_password" ADD COLUMN "sessionEndedForProofsAt" timestamp without time zone;
--
-- MIGRATION VERSION FOR halkyon_learn
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_learn', '20261021100422048-session-ended-warning', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261021100422048-session-ended-warning', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_base
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_base', '20261021100319502-session-ended-warning', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261021100319502-session-ended-warning', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_offline_sync
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_offline_sync', '20260914143806119', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260914143806119', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -41,3 +41,4 @@
20261020194228286-second-factor-waits 20261020194228286-second-factor-waits
20261020204619104-session-code-tries 20261020204619104-session-code-tries
20261020211224692-email-code-purpose 20261020211224692-email-code-purpose
20261021100422048-session-ended-warning

View file

@ -0,0 +1,29 @@
{
"__className__": "serverpod.DatabaseDefinition",
"schemaVersion": 2,
"moduleName": "halkyon_todo",
"tables": [],
"installedModules": [
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "halkyon_base",
"version": "20261021100319502-session-ended-warning"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod",
"version": "20260824182259319"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_auth_idp",
"version": "20260924105404509"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_auth_core",
"version": "20260924105232991"
}
],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,33 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "account_password",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "sessionEndedForProofsAt",
"fieldName": "sessionEndedForProofsAt",
"columnType": 4,
"isNullable": true,
"dartType": "DateTime?"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,49 @@
BEGIN;
--
-- ACTION ALTER TABLE
--
ALTER TABLE "account_password" ADD COLUMN "sessionEndedForProofsAt" timestamp without time zone;
--
-- MIGRATION VERSION FOR halkyon_todo
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_todo', '20261021100422779-session-ended-warning', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261021100422779-session-ended-warning', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_base
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_base', '20261021100319502-session-ended-warning', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261021100319502-session-ended-warning', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -13,3 +13,4 @@
20261020194229631-second-factor-waits 20261020194229631-second-factor-waits
20261020204619863-session-code-tries 20261020204619863-session-code-tries
20261020211225101-email-code-purpose 20261020211225101-email-code-purpose
20261021100422779-session-ended-warning

View file

@ -22,6 +22,7 @@ abstract class SignInWays
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
this.sessionEndedForProofsAt,
}) : password = password ?? false, }) : password = password ?? false,
authenticator = authenticator ?? false, authenticator = authenticator ?? false,
passwordOnly = passwordOnly ?? false, passwordOnly = passwordOnly ?? false,
@ -34,6 +35,7 @@ abstract class SignInWays
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt,
}) = _SignInWaysImpl; }) = _SignInWaysImpl;
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) { factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
@ -54,6 +56,12 @@ abstract class SignInWays
: _isc.BoolJsonExtension.fromJson( : _isc.BoolJsonExtension.fromJson(
jsonSerialization['secondFactorWorks'], jsonSerialization['secondFactorWorks'],
), ),
sessionEndedForProofsAt:
jsonSerialization['sessionEndedForProofsAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionEndedForProofsAt'],
),
); );
} }
@ -77,6 +85,11 @@ abstract class SignInWays
/// takes a code by mail in place of a TOTP or recovery code. /// takes a code by mail in place of a TOTP or recovery code.
bool secondFactorWorks; 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] /// Returns a shallow copy of this [SignInWays]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_isc.useResult @_isc.useResult
@ -87,6 +100,7 @@ abstract class SignInWays
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -98,6 +112,8 @@ abstract class SignInWays
'authenticator': authenticator, 'authenticator': authenticator,
'passwordOnly': passwordOnly, 'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks, 'secondFactorWorks': secondFactorWorks,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
}; };
} }
@ -111,6 +127,8 @@ abstract class SignInWays
'authenticator': authenticator, 'authenticator': authenticator,
'passwordOnly': passwordOnly, 'passwordOnly': passwordOnly,
'secondFactorWorks': secondFactorWorks, 'secondFactorWorks': secondFactorWorks,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
}; };
} }
@ -120,6 +138,8 @@ abstract class SignInWays
} }
} }
class _Undefined {}
class _SignInWaysImpl extends SignInWays { class _SignInWaysImpl extends SignInWays {
_SignInWaysImpl({ _SignInWaysImpl({
required bool gitHub, required bool gitHub,
@ -128,6 +148,7 @@ class _SignInWaysImpl extends SignInWays {
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
DateTime? sessionEndedForProofsAt,
}) : super._( }) : super._(
gitHub: gitHub, gitHub: gitHub,
email: email, email: email,
@ -135,6 +156,7 @@ class _SignInWaysImpl extends SignInWays {
authenticator: authenticator, authenticator: authenticator,
passwordOnly: passwordOnly, passwordOnly: passwordOnly,
secondFactorWorks: secondFactorWorks, secondFactorWorks: secondFactorWorks,
sessionEndedForProofsAt: sessionEndedForProofsAt,
); );
/// Returns a shallow copy of this [SignInWays] /// Returns a shallow copy of this [SignInWays]
@ -148,6 +170,7 @@ class _SignInWaysImpl extends SignInWays {
bool? authenticator, bool? authenticator,
bool? passwordOnly, bool? passwordOnly,
bool? secondFactorWorks, bool? secondFactorWorks,
Object? sessionEndedForProofsAt = _Undefined,
}) { }) {
return SignInWays( return SignInWays(
gitHub: gitHub ?? this.gitHub, gitHub: gitHub ?? this.gitHub,
@ -156,6 +179,9 @@ class _SignInWaysImpl extends SignInWays {
authenticator: authenticator ?? this.authenticator, authenticator: authenticator ?? this.authenticator,
passwordOnly: passwordOnly ?? this.passwordOnly, passwordOnly: passwordOnly ?? this.passwordOnly,
secondFactorWorks: secondFactorWorks ?? this.secondFactorWorks, secondFactorWorks: secondFactorWorks ?? this.secondFactorWorks,
sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime?
? sessionEndedForProofsAt
: this.sessionEndedForProofsAt,
); );
} }
} }

View file

@ -506,6 +506,17 @@ class EndpointPassword extends _isc.EndpointRef {
'recoveryProofKey': recoveryProofKey, 'recoveryProofKey': recoveryProofKey,
}, },
); );
/// 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.
_ida.Future<void> dismissSessionEndedWarning() =>
caller.callServerEndpoint<void>(
'halkyon_base.password',
'dismissSessionEndedWarning',
{},
);
} }
/// Signing in with an address, a password and a code from an authenticator /// Signing in with an address, a password and a code from an authenticator

View file

@ -29,6 +29,7 @@ abstract class PasswordSignedIn
required this.signingPublicKey, required this.signingPublicKey,
required this.passwordKeys, required this.passwordKeys,
required this.needsAuthenticator, required this.needsAuthenticator,
this.sessionEndedForProofsAt,
required this.authStrategy, required this.authStrategy,
required this.token, required this.token,
this.tokenExpiresAt, this.tokenExpiresAt,
@ -43,6 +44,7 @@ abstract class PasswordSignedIn
required _idt.ByteData signingPublicKey, required _idt.ByteData signingPublicKey,
required _i2rxumte.WrappedKeys passwordKeys, required _i2rxumte.WrappedKeys passwordKeys,
required bool needsAuthenticator, required bool needsAuthenticator,
DateTime? sessionEndedForProofsAt,
required String authStrategy, required String authStrategy,
required String token, required String token,
DateTime? tokenExpiresAt, DateTime? tokenExpiresAt,
@ -68,6 +70,12 @@ abstract class PasswordSignedIn
needsAuthenticator: _isc.BoolJsonExtension.fromJson( needsAuthenticator: _isc.BoolJsonExtension.fromJson(
jsonSerialization['needsAuthenticator'], jsonSerialization['needsAuthenticator'],
), ),
sessionEndedForProofsAt:
jsonSerialization['sessionEndedForProofsAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['sessionEndedForProofsAt'],
),
authStrategy: jsonSerialization['authStrategy'] as String, authStrategy: jsonSerialization['authStrategy'] as String,
token: jsonSerialization['token'] as String, token: jsonSerialization['token'] as String,
tokenExpiresAt: jsonSerialization['tokenExpiresAt'] == null tokenExpiresAt: jsonSerialization['tokenExpiresAt'] == null
@ -95,6 +103,11 @@ abstract class PasswordSignedIn
bool needsAuthenticator; 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 /// The session, as Serverpod's `AuthSuccess` has it (the device makes one
/// of these fields for its session manager). /// of these fields for its session manager).
String authStrategy; String authStrategy;
@ -118,6 +131,7 @@ abstract class PasswordSignedIn
_idt.ByteData? signingPublicKey, _idt.ByteData? signingPublicKey,
_i2rxumte.WrappedKeys? passwordKeys, _i2rxumte.WrappedKeys? passwordKeys,
bool? needsAuthenticator, bool? needsAuthenticator,
DateTime? sessionEndedForProofsAt,
String? authStrategy, String? authStrategy,
String? token, String? token,
DateTime? tokenExpiresAt, DateTime? tokenExpiresAt,
@ -134,6 +148,8 @@ abstract class PasswordSignedIn
'signingPublicKey': signingPublicKey.toJson(), 'signingPublicKey': signingPublicKey.toJson(),
'passwordKeys': passwordKeys.toJson(), 'passwordKeys': passwordKeys.toJson(),
'needsAuthenticator': needsAuthenticator, 'needsAuthenticator': needsAuthenticator,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
'authStrategy': authStrategy, 'authStrategy': authStrategy,
'token': token, 'token': token,
if (tokenExpiresAt != null) 'tokenExpiresAt': tokenExpiresAt?.toJson(), if (tokenExpiresAt != null) 'tokenExpiresAt': tokenExpiresAt?.toJson(),
@ -152,6 +168,8 @@ abstract class PasswordSignedIn
'signingPublicKey': signingPublicKey.toJson(), 'signingPublicKey': signingPublicKey.toJson(),
'passwordKeys': passwordKeys.toJsonForProtocol(), 'passwordKeys': passwordKeys.toJsonForProtocol(),
'needsAuthenticator': needsAuthenticator, 'needsAuthenticator': needsAuthenticator,
if (sessionEndedForProofsAt != null)
'sessionEndedForProofsAt': sessionEndedForProofsAt?.toJson(),
'authStrategy': authStrategy, 'authStrategy': authStrategy,
'token': token, 'token': token,
if (tokenExpiresAt != null) 'tokenExpiresAt': tokenExpiresAt?.toJson(), if (tokenExpiresAt != null) 'tokenExpiresAt': tokenExpiresAt?.toJson(),
@ -176,6 +194,7 @@ class _PasswordSignedInImpl extends PasswordSignedIn {
required _idt.ByteData signingPublicKey, required _idt.ByteData signingPublicKey,
required _i2rxumte.WrappedKeys passwordKeys, required _i2rxumte.WrappedKeys passwordKeys,
required bool needsAuthenticator, required bool needsAuthenticator,
DateTime? sessionEndedForProofsAt,
required String authStrategy, required String authStrategy,
required String token, required String token,
DateTime? tokenExpiresAt, DateTime? tokenExpiresAt,
@ -188,6 +207,7 @@ class _PasswordSignedInImpl extends PasswordSignedIn {
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
passwordKeys: passwordKeys, passwordKeys: passwordKeys,
needsAuthenticator: needsAuthenticator, needsAuthenticator: needsAuthenticator,
sessionEndedForProofsAt: sessionEndedForProofsAt,
authStrategy: authStrategy, authStrategy: authStrategy,
token: token, token: token,
tokenExpiresAt: tokenExpiresAt, tokenExpiresAt: tokenExpiresAt,
@ -206,6 +226,7 @@ class _PasswordSignedInImpl extends PasswordSignedIn {
_idt.ByteData? signingPublicKey, _idt.ByteData? signingPublicKey,
_i2rxumte.WrappedKeys? passwordKeys, _i2rxumte.WrappedKeys? passwordKeys,
bool? needsAuthenticator, bool? needsAuthenticator,
Object? sessionEndedForProofsAt = _Undefined,
String? authStrategy, String? authStrategy,
String? token, String? token,
Object? tokenExpiresAt = _Undefined, Object? tokenExpiresAt = _Undefined,
@ -220,6 +241,9 @@ class _PasswordSignedInImpl extends PasswordSignedIn {
signingPublicKey: signingPublicKey ?? this.signingPublicKey.clone(), signingPublicKey: signingPublicKey ?? this.signingPublicKey.clone(),
passwordKeys: passwordKeys ?? this.passwordKeys.copyWith(), passwordKeys: passwordKeys ?? this.passwordKeys.copyWith(),
needsAuthenticator: needsAuthenticator ?? this.needsAuthenticator, needsAuthenticator: needsAuthenticator ?? this.needsAuthenticator,
sessionEndedForProofsAt: sessionEndedForProofsAt is DateTime?
? sessionEndedForProofsAt
: this.sessionEndedForProofsAt,
authStrategy: authStrategy ?? this.authStrategy, authStrategy: authStrategy ?? this.authStrategy,
token: token ?? this.token, token: token ?? this.token,
tokenExpiresAt: tokenExpiresAt is DateTime? tokenExpiresAt: tokenExpiresAt is DateTime?

Some files were not shown because too many files have changed in this diff Show more