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:
parent
858fffdce3
commit
5e7d7f4e36
30 changed files with 41919 additions and 34 deletions
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ password:
|
|||
- beginChange:
|
||||
- change:
|
||||
- setRecoveryKeys:
|
||||
- dismissSessionEndedWarning:
|
||||
passwordSignIn:
|
||||
- begin:
|
||||
- complete:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue