fix(auth)!: a session alone never gets a code checked
The third reading found a stolen session guessing TOTP codes without end: nine wrong codes for new recovery codes, then removing an unconfirmed authenticator of its own, a success that cleared the session's count. As Proton has it, every signed-in change of how an account is secured now takes the owner's proof (OwnerProof): the password's SRP proof for a change's challenge and a TOTP or recovery code. Adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password and deleting take it; only the first authenticator is confirmed with its own code alone, and removing an unconfirmed one takes nothing. A proof given where none is taken is refused, and a confirmed authenticator's code is never checked for a session alone, so no success passes a proof unchecked. A session's proofs count for it alone, before they are checked, cleared only by one accepted; the tenth wrong in a row ends the session. They never count toward the account's waits nor wait for them, so whoever knows the password no longer holds a signed-in owner out of changing it (the second finding), and a new password forgets those waits. Beginning a change counts nothing against the account (the third finding): 30 an hour per session, holding back that session alone. Deleting takes a TOTP code wherever an authenticator is confirmed, a code by mail where no second factor works (the fourth finding), which SignInWays now tells (secondFactorWorks). Signed-in calls no longer take the address: nothing there mails. wrongExistingCode goes, a wrong existing code being a wrong proof. The app follows in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
70b434bb84
commit
a9459bb875
36 changed files with 1461 additions and 1026 deletions
|
|
@ -21,9 +21,11 @@ abstract class SignInWays
|
|||
bool? password,
|
||||
bool? authenticator,
|
||||
bool? passwordOnly,
|
||||
bool? secondFactorWorks,
|
||||
}) : password = password ?? false,
|
||||
authenticator = authenticator ?? false,
|
||||
passwordOnly = passwordOnly ?? false;
|
||||
passwordOnly = passwordOnly ?? false,
|
||||
secondFactorWorks = secondFactorWorks ?? false;
|
||||
|
||||
factory SignInWays({
|
||||
required bool gitHub,
|
||||
|
|
@ -31,6 +33,7 @@ abstract class SignInWays
|
|||
bool? password,
|
||||
bool? authenticator,
|
||||
bool? passwordOnly,
|
||||
bool? secondFactorWorks,
|
||||
}) = _SignInWaysImpl;
|
||||
|
||||
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
|
|
@ -46,6 +49,11 @@ abstract class SignInWays
|
|||
passwordOnly: jsonSerialization['passwordOnly'] == null
|
||||
? null
|
||||
: _isc.BoolJsonExtension.fromJson(jsonSerialization['passwordOnly']),
|
||||
secondFactorWorks: jsonSerialization['secondFactorWorks'] == null
|
||||
? null
|
||||
: _isc.BoolJsonExtension.fromJson(
|
||||
jsonSerialization['secondFactorWorks'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +72,11 @@ abstract class SignInWays
|
|||
/// neither a code by mail nor GitHub signs into it (ADR 0017).
|
||||
bool passwordOnly;
|
||||
|
||||
/// A second factor works: a confirmed authenticator, and no reset of the
|
||||
/// authenticators that took effect. Where none does, deleting the account
|
||||
/// takes a code by mail in place of a TOTP or recovery code.
|
||||
bool secondFactorWorks;
|
||||
|
||||
/// Returns a shallow copy of this [SignInWays]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
|
|
@ -73,6 +86,7 @@ abstract class SignInWays
|
|||
bool? password,
|
||||
bool? authenticator,
|
||||
bool? passwordOnly,
|
||||
bool? secondFactorWorks,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
|
|
@ -83,6 +97,7 @@ abstract class SignInWays
|
|||
'password': password,
|
||||
'authenticator': authenticator,
|
||||
'passwordOnly': passwordOnly,
|
||||
'secondFactorWorks': secondFactorWorks,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +110,7 @@ abstract class SignInWays
|
|||
'password': password,
|
||||
'authenticator': authenticator,
|
||||
'passwordOnly': passwordOnly,
|
||||
'secondFactorWorks': secondFactorWorks,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -111,12 +127,14 @@ class _SignInWaysImpl extends SignInWays {
|
|||
bool? password,
|
||||
bool? authenticator,
|
||||
bool? passwordOnly,
|
||||
bool? secondFactorWorks,
|
||||
}) : super._(
|
||||
gitHub: gitHub,
|
||||
email: email,
|
||||
password: password,
|
||||
authenticator: authenticator,
|
||||
passwordOnly: passwordOnly,
|
||||
secondFactorWorks: secondFactorWorks,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [SignInWays]
|
||||
|
|
@ -129,6 +147,7 @@ class _SignInWaysImpl extends SignInWays {
|
|||
bool? password,
|
||||
bool? authenticator,
|
||||
bool? passwordOnly,
|
||||
bool? secondFactorWorks,
|
||||
}) {
|
||||
return SignInWays(
|
||||
gitHub: gitHub ?? this.gitHub,
|
||||
|
|
@ -136,6 +155,7 @@ class _SignInWaysImpl extends SignInWays {
|
|||
password: password ?? this.password,
|
||||
authenticator: authenticator ?? this.authenticator,
|
||||
passwordOnly: passwordOnly ?? this.passwordOnly,
|
||||
secondFactorWorks: secondFactorWorks ?? this.secondFactorWorks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import 'package:halkyon_base_client/src/protocol/password/key_proof_challenge.da
|
|||
as _ir3ecgdn;
|
||||
import 'package:halkyon_base_client/src/protocol/password/new_password.dart'
|
||||
as _i95l60wq;
|
||||
import 'package:halkyon_base_client/src/protocol/password/owner_proof.dart'
|
||||
as _ikq9b2yz;
|
||||
import 'package:halkyon_base_client/src/protocol/password/password_challenge.dart'
|
||||
as _iykren7a;
|
||||
import 'package:halkyon_base_client/src/protocol/password/password_setup.dart'
|
||||
|
|
@ -343,9 +345,11 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
|
|||
}
|
||||
|
||||
/// The signed-in person's authenticators and recovery codes (ADR 0017),
|
||||
/// which the instance's register keeps (ADR 0016). Every code tried here
|
||||
/// counts for the session (`asSessionCodeTry`): the tenth wrong in a row
|
||||
/// ends it ([PasswordRefusal.sessionEnded]).
|
||||
/// which the instance's register keeps (ADR 0016). Once one is confirmed,
|
||||
/// every change takes the owner's proof (`OwnerProof`: the password and a
|
||||
/// code; the third reading), which counts for the session
|
||||
/// (`asOwnerProofTry`): the tenth wrong in a row ends it
|
||||
/// ([PasswordRefusal.sessionEnded]).
|
||||
/// {@category Endpoint}
|
||||
class EndpointAuthenticator extends _isc.EndpointRef {
|
||||
EndpointAuthenticator(_isc.EndpointCaller caller) : super(caller);
|
||||
|
|
@ -369,20 +373,20 @@ class EndpointAuthenticator extends _isc.EndpointRef {
|
|||
);
|
||||
|
||||
/// Confirms [authenticator] with a [code] from it, and, where the account
|
||||
/// has a confirmed authenticator already, an [existingCode] from that one;
|
||||
/// the account's first returns its recovery codes, shown once. Refusals as
|
||||
/// has a confirmed authenticator already, the owner's [proof]; the
|
||||
/// account's first returns its recovery codes, shown once. Refusals as
|
||||
/// `confirmAuthenticator`'s.
|
||||
_ida.Future<List<String>> confirm({
|
||||
required _isc.UuidValue authenticator,
|
||||
required String code,
|
||||
String? existingCode,
|
||||
_ikq9b2yz.OwnerProof? proof,
|
||||
}) => caller.callServerEndpoint<List<String>>(
|
||||
'halkyon_base.authenticator',
|
||||
'confirm',
|
||||
{
|
||||
'authenticator': authenticator,
|
||||
'code': code,
|
||||
'existingCode': existingCode,
|
||||
'proof': proof,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -394,28 +398,30 @@ class EndpointAuthenticator extends _isc.EndpointRef {
|
|||
{},
|
||||
);
|
||||
|
||||
/// Removes [authenticator]: a confirmed one with a [code] from one of the
|
||||
/// caller's, while another stays. Refusals as `removeAuthenticator`'s.
|
||||
/// Removes [authenticator]: one never confirmed as it is, a confirmed one
|
||||
/// with the owner's [proof], while another stays. Refusals as
|
||||
/// `removeAuthenticator`'s.
|
||||
_ida.Future<void> remove({
|
||||
required _isc.UuidValue authenticator,
|
||||
String? code,
|
||||
_ikq9b2yz.OwnerProof? proof,
|
||||
}) => caller.callServerEndpoint<void>(
|
||||
'halkyon_base.authenticator',
|
||||
'remove',
|
||||
{
|
||||
'authenticator': authenticator,
|
||||
'code': code,
|
||||
'proof': proof,
|
||||
},
|
||||
);
|
||||
|
||||
/// New recovery codes in place of the old, with a [code] from one of the
|
||||
/// caller's authenticators: shown once. Refusals as `newRecoveryCodes`'.
|
||||
_ida.Future<List<String>> newRecoveryCodes({required String code}) =>
|
||||
caller.callServerEndpoint<List<String>>(
|
||||
'halkyon_base.authenticator',
|
||||
'newRecoveryCodes',
|
||||
{'code': code},
|
||||
);
|
||||
/// New recovery codes in place of the old, with the owner's [proof]:
|
||||
/// shown once. Refusals as `newRecoveryCodes`'.
|
||||
_ida.Future<List<String>> newRecoveryCodes({
|
||||
required _ikq9b2yz.OwnerProof proof,
|
||||
}) => caller.callServerEndpoint<List<String>>(
|
||||
'halkyon_base.authenticator',
|
||||
'newRecoveryCodes',
|
||||
{'proof': proof},
|
||||
);
|
||||
}
|
||||
|
||||
/// Giving the signed-in person a password (ADR 0017): the way an anonymous
|
||||
|
|
@ -448,8 +454,13 @@ class EndpointPassword extends _isc.EndpointRef {
|
|||
},
|
||||
);
|
||||
|
||||
/// The first step of the caller proving their password to change it:
|
||||
/// its salt and costs, and SRP's B. Refusals as `beginPasswordChange`'s.
|
||||
/// The first step of the caller proving their password (`OwnerProof`) to
|
||||
/// change how their account is secured: its salt and costs, and SRP's B.
|
||||
/// At most [changesBegunPerSession] an hour per session, which holds back
|
||||
/// no other session, the owner's among them (the third reading's third
|
||||
/// finding); the proofs that fail count against the session too
|
||||
/// (`asOwnerProofTry`). Refusals as `beginPasswordChange`'s, and
|
||||
/// [PasswordRefusal.tooManyAttempts].
|
||||
_ida.Future<_iykren7a.PasswordChallenge> beginChange() =>
|
||||
caller.callServerEndpoint<_iykren7a.PasswordChallenge>(
|
||||
'halkyon_base.password',
|
||||
|
|
@ -458,69 +469,38 @@ class EndpointPassword extends _isc.EndpointRef {
|
|||
);
|
||||
|
||||
/// Changes the caller's password to [password] (the same keys, wrapped
|
||||
/// under the new password's key on the device), with the old password's
|
||||
/// proof for [challenge] ([beginChange]) and a [totpCode] or
|
||||
/// [recoveryCode], and the account's [address] as the device typed it
|
||||
/// (checked before the code; the owner is mailed in [language] should
|
||||
/// codes come to wait). Every other session of theirs ends, in every
|
||||
/// service of the instance; this one stays. Refusals as `changePassword`'s.
|
||||
/// under the new password's key on the device), with the owner's [proof]
|
||||
/// (the old password's proof for a challenge [beginChange] gave, and a
|
||||
/// TOTP or recovery code). Every other session of theirs ends, in every
|
||||
/// service of the instance; this one stays. The proof counts for this
|
||||
/// session (`asOwnerProofTry`). Refusals as `changePassword`'s.
|
||||
_ida.Future<void> change({
|
||||
required _isc.UuidValue challenge,
|
||||
required _idt.ByteData clientValue,
|
||||
required _idt.ByteData clientProof,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required _ikq9b2yz.OwnerProof proof,
|
||||
required _i95l60wq.NewPassword password,
|
||||
required String host,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => caller.callServerEndpoint<void>(
|
||||
'halkyon_base.password',
|
||||
'change',
|
||||
{
|
||||
'challenge': challenge,
|
||||
'clientValue': clientValue,
|
||||
'clientProof': clientProof,
|
||||
'totpCode': totpCode,
|
||||
'recoveryCode': recoveryCode,
|
||||
'proof': proof,
|
||||
'password': password,
|
||||
'host': host,
|
||||
'address': address,
|
||||
'language': language,
|
||||
},
|
||||
);
|
||||
|
||||
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
|
||||
/// it on the device, replace the old wrapping, and [recoveryProofKey], the
|
||||
/// public half of its proof key, the old one, with the password's proof
|
||||
/// for [challenge] ([beginChange]) and a [totpCode] or [recoveryCode], with
|
||||
/// the account's [address] and [language], as [change] takes. Refusals as
|
||||
/// `setRecoveryKeys`'.
|
||||
/// public half of its proof key, the old one, with the owner's [proof], as
|
||||
/// [change] takes. Refusals as `setRecoveryKeys`'.
|
||||
_ida.Future<void> setRecoveryKeys({
|
||||
required _isc.UuidValue challenge,
|
||||
required _idt.ByteData clientValue,
|
||||
required _idt.ByteData clientProof,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required _ikq9b2yz.OwnerProof proof,
|
||||
required _i3so7e0p.WrappedKeys recoveryKeys,
|
||||
required _idt.ByteData recoveryProofKey,
|
||||
required String host,
|
||||
required String address,
|
||||
required String language,
|
||||
}) => caller.callServerEndpoint<void>(
|
||||
'halkyon_base.password',
|
||||
'setRecoveryKeys',
|
||||
{
|
||||
'challenge': challenge,
|
||||
'clientValue': clientValue,
|
||||
'clientProof': clientProof,
|
||||
'totpCode': totpCode,
|
||||
'recoveryCode': recoveryCode,
|
||||
'proof': proof,
|
||||
'recoveryKeys': recoveryKeys,
|
||||
'recoveryProofKey': recoveryProofKey,
|
||||
'host': host,
|
||||
'address': address,
|
||||
'language': language,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
|||
/// What deleting an account that has a password takes (ADR 0017; the second
|
||||
/// reading): the password's SRP proof for a challenge `password.beginChange`
|
||||
/// gave, a TOTP or recovery code, and the host the device addressed, as a
|
||||
/// change of password does. A session alone deletes only an account without
|
||||
/// one.
|
||||
/// change of password does (`OwnerProof`). A session alone deletes only an
|
||||
/// account without one.
|
||||
abstract class DeletionProof
|
||||
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||
DeletionProof._({
|
||||
|
|
@ -29,8 +29,6 @@ abstract class DeletionProof
|
|||
this.request,
|
||||
this.code,
|
||||
required this.host,
|
||||
required this.address,
|
||||
required this.language,
|
||||
});
|
||||
|
||||
factory DeletionProof({
|
||||
|
|
@ -42,8 +40,6 @@ abstract class DeletionProof
|
|||
_isc.UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
required String address,
|
||||
required String language,
|
||||
}) = _DeletionProofImpl;
|
||||
|
||||
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
|
|
@ -64,8 +60,6 @@ abstract class DeletionProof
|
|||
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
|
||||
code: jsonSerialization['code'] as String?,
|
||||
host: jsonSerialization['host'] as String,
|
||||
address: jsonSerialization['address'] as String,
|
||||
language: jsonSerialization['language'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -91,13 +85,6 @@ abstract class DeletionProof
|
|||
/// checks it is its own before the register binds the proof to it.
|
||||
String host;
|
||||
|
||||
/// The account's address as the device typed it, and the language its
|
||||
/// mails are worded in: checked before the code, so the owner is mailed
|
||||
/// should codes come to wait (the re-review's fourth finding).
|
||||
String address;
|
||||
|
||||
String language;
|
||||
|
||||
/// Returns a shallow copy of this [DeletionProof]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
|
|
@ -110,8 +97,6 @@ abstract class DeletionProof
|
|||
_isc.UuidValue? request,
|
||||
String? code,
|
||||
String? host,
|
||||
String? address,
|
||||
String? language,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
|
|
@ -125,8 +110,6 @@ abstract class DeletionProof
|
|||
if (request != null) 'request': request?.toJson(),
|
||||
if (code != null) 'code': code,
|
||||
'host': host,
|
||||
'address': address,
|
||||
'language': language,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -142,8 +125,6 @@ abstract class DeletionProof
|
|||
if (request != null) 'request': request?.toJson(),
|
||||
if (code != null) 'code': code,
|
||||
'host': host,
|
||||
'address': address,
|
||||
'language': language,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -165,8 +146,6 @@ class _DeletionProofImpl extends DeletionProof {
|
|||
_isc.UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
required String address,
|
||||
required String language,
|
||||
}) : super._(
|
||||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
|
|
@ -176,8 +155,6 @@ class _DeletionProofImpl extends DeletionProof {
|
|||
request: request,
|
||||
code: code,
|
||||
host: host,
|
||||
address: address,
|
||||
language: language,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [DeletionProof]
|
||||
|
|
@ -193,8 +170,6 @@ class _DeletionProofImpl extends DeletionProof {
|
|||
Object? request = _Undefined,
|
||||
Object? code = _Undefined,
|
||||
String? host,
|
||||
String? address,
|
||||
String? language,
|
||||
}) {
|
||||
return DeletionProof(
|
||||
challenge: challenge ?? this.challenge,
|
||||
|
|
@ -205,8 +180,6 @@ class _DeletionProofImpl extends DeletionProof {
|
|||
request: request is _isc.UuidValue? ? request : this.request,
|
||||
code: code is String? ? code : this.code,
|
||||
host: host ?? this.host,
|
||||
address: address ?? this.address,
|
||||
language: language ?? this.language,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
/* 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 'dart:typed_data' as _idt;
|
||||
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||
|
||||
/// What a signed-in owner proves to change how their account is secured
|
||||
/// (ADR 0017; the third reading, as Proton has it): the password's SRP
|
||||
/// proof for a challenge `password.beginChange` gave, bound to the host the
|
||||
/// device addressed, and a TOTP code from one of the account's confirmed
|
||||
/// authenticators or one of its recovery codes. Adding an authenticator once
|
||||
/// one is confirmed, removing a confirmed one, new recovery codes, a new
|
||||
/// recovery phrase and a new password take it: a session alone never gets a
|
||||
/// code checked.
|
||||
abstract class OwnerProof
|
||||
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||
OwnerProof._({
|
||||
required this.challenge,
|
||||
required this.clientValue,
|
||||
required this.clientProof,
|
||||
required this.host,
|
||||
this.totpCode,
|
||||
this.recoveryCode,
|
||||
});
|
||||
|
||||
factory OwnerProof({
|
||||
required _isc.UuidValue challenge,
|
||||
required _idt.ByteData clientValue,
|
||||
required _idt.ByteData clientProof,
|
||||
required String host,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
}) = _OwnerProofImpl;
|
||||
|
||||
factory OwnerProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return OwnerProof(
|
||||
challenge: _isc.UuidValueJsonExtension.fromJson(
|
||||
jsonSerialization['challenge'],
|
||||
),
|
||||
clientValue: _isc.ByteDataJsonExtension.fromJson(
|
||||
jsonSerialization['clientValue'],
|
||||
),
|
||||
clientProof: _isc.ByteDataJsonExtension.fromJson(
|
||||
jsonSerialization['clientProof'],
|
||||
),
|
||||
host: jsonSerialization['host'] as String,
|
||||
totpCode: jsonSerialization['totpCode'] as String?,
|
||||
recoveryCode: jsonSerialization['recoveryCode'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
_isc.UuidValue challenge;
|
||||
|
||||
_idt.ByteData clientValue;
|
||||
|
||||
_idt.ByteData clientProof;
|
||||
|
||||
/// The host the device addressed the instance by: the server it reached
|
||||
/// checks it is its own before the register binds the proof to it.
|
||||
String host;
|
||||
|
||||
String? totpCode;
|
||||
|
||||
String? recoveryCode;
|
||||
|
||||
/// Returns a shallow copy of this [OwnerProof]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
OwnerProof copyWith({
|
||||
_isc.UuidValue? challenge,
|
||||
_idt.ByteData? clientValue,
|
||||
_idt.ByteData? clientProof,
|
||||
String? host,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'halkyon_base.OwnerProof',
|
||||
'challenge': challenge.toJson(),
|
||||
'clientValue': clientValue.toJson(),
|
||||
'clientProof': clientProof.toJson(),
|
||||
'host': host,
|
||||
if (totpCode != null) 'totpCode': totpCode,
|
||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'halkyon_base.OwnerProof',
|
||||
'challenge': challenge.toJson(),
|
||||
'clientValue': clientValue.toJson(),
|
||||
'clientProof': clientProof.toJson(),
|
||||
'host': host,
|
||||
if (totpCode != null) 'totpCode': totpCode,
|
||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _isc.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _OwnerProofImpl extends OwnerProof {
|
||||
_OwnerProofImpl({
|
||||
required _isc.UuidValue challenge,
|
||||
required _idt.ByteData clientValue,
|
||||
required _idt.ByteData clientProof,
|
||||
required String host,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
}) : super._(
|
||||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
clientProof: clientProof,
|
||||
host: host,
|
||||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [OwnerProof]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
@override
|
||||
OwnerProof copyWith({
|
||||
_isc.UuidValue? challenge,
|
||||
_idt.ByteData? clientValue,
|
||||
_idt.ByteData? clientProof,
|
||||
String? host,
|
||||
Object? totpCode = _Undefined,
|
||||
Object? recoveryCode = _Undefined,
|
||||
}) {
|
||||
return OwnerProof(
|
||||
challenge: challenge ?? this.challenge,
|
||||
clientValue: clientValue ?? this.clientValue.clone(),
|
||||
clientProof: clientProof ?? this.clientProof.clone(),
|
||||
host: host ?? this.host,
|
||||
totpCode: totpCode is String? ? totpCode : this.totpCode,
|
||||
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ enum PasswordRefusal implements _isc.SerializableModel {
|
|||
/// The account has as many authenticators as it may: remove one first.
|
||||
tooManyAuthenticators,
|
||||
|
||||
/// The code from the authenticator isn't right.
|
||||
/// The code from the authenticator being set up isn't right.
|
||||
wrongCode,
|
||||
|
||||
/// The reset's time hasn't come yet.
|
||||
|
|
@ -70,11 +70,6 @@ enum PasswordRefusal implements _isc.SerializableModel {
|
|||
/// No such reset is waiting, or the address isn't the account's.
|
||||
noReset,
|
||||
|
||||
/// Adding an authenticator to an account that has one confirmed takes a
|
||||
/// code from one of those, which is missing or isn't right: a session
|
||||
/// alone adds no second factor.
|
||||
wrongExistingCode,
|
||||
|
||||
/// 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.
|
||||
|
|
@ -84,8 +79,9 @@ enum PasswordRefusal implements _isc.SerializableModel {
|
|||
/// `takesEffectAt` (the second reading's M4). Only after a right password.
|
||||
codesWait,
|
||||
|
||||
/// Ten codes in a row from this signed-in session were wrong: the
|
||||
/// session ended (the re-review's third finding); sign in again.
|
||||
/// Ten proofs in a row from this signed-in session (the password with a
|
||||
/// code, `OwnerProof`) were wrong: the session ended (the third reading);
|
||||
/// sign in again.
|
||||
sessionEnded,
|
||||
|
||||
/// The password was changed (a reset carried out), but this service
|
||||
|
|
@ -128,8 +124,6 @@ enum PasswordRefusal implements _isc.SerializableModel {
|
|||
return PasswordRefusal.notDue;
|
||||
case 'noReset':
|
||||
return PasswordRefusal.noReset;
|
||||
case 'wrongExistingCode':
|
||||
return PasswordRefusal.wrongExistingCode;
|
||||
case 'otherInstance':
|
||||
return PasswordRefusal.otherInstance;
|
||||
case 'codesWait':
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import 'password/authenticator_enrolment.dart' as _iv7tra7v;
|
|||
import 'password/deletion_proof.dart' as _ix1u7hdq;
|
||||
import 'password/key_proof_challenge.dart' as _iehtx95q;
|
||||
import 'password/new_password.dart' as _itvvd6m6;
|
||||
import 'password/owner_proof.dart' as _i5f4sdg4;
|
||||
import 'password/password_challenge.dart' as _iuxisac7;
|
||||
import 'password/password_costs.dart' as _izp492e5;
|
||||
import 'password/password_refusal.dart' as _ibfrwdhm;
|
||||
|
|
@ -57,6 +58,7 @@ export 'password/authenticator_enrolment.dart';
|
|||
export 'password/deletion_proof.dart';
|
||||
export 'password/key_proof_challenge.dart';
|
||||
export 'password/new_password.dart';
|
||||
export 'password/owner_proof.dart';
|
||||
export 'password/password_challenge.dart';
|
||||
export 'password/password_costs.dart';
|
||||
export 'password/password_refusal.dart';
|
||||
|
|
@ -156,6 +158,9 @@ class Protocol extends _isc.SerializationManager {
|
|||
if (t == _itvvd6m6.NewPassword) {
|
||||
return _itvvd6m6.NewPassword.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i5f4sdg4.OwnerProof) {
|
||||
return _i5f4sdg4.OwnerProof.fromJson(data) as T;
|
||||
}
|
||||
if (t == _iuxisac7.PasswordChallenge) {
|
||||
return _iuxisac7.PasswordChallenge.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -253,6 +258,9 @@ class Protocol extends _isc.SerializationManager {
|
|||
if (t == _isc.getType<_itvvd6m6.NewPassword?>()) {
|
||||
return (data != null ? _itvvd6m6.NewPassword.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _isc.getType<_i5f4sdg4.OwnerProof?>()) {
|
||||
return (data != null ? _i5f4sdg4.OwnerProof.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _isc.getType<_iuxisac7.PasswordChallenge?>()) {
|
||||
return (data != null ? _iuxisac7.PasswordChallenge.fromJson(data) : null)
|
||||
as T;
|
||||
|
|
@ -355,6 +363,7 @@ class Protocol extends _isc.SerializationManager {
|
|||
_ix1u7hdq.DeletionProof => 'DeletionProof',
|
||||
_iehtx95q.KeyProofChallenge => 'KeyProofChallenge',
|
||||
_itvvd6m6.NewPassword => 'NewPassword',
|
||||
_i5f4sdg4.OwnerProof => 'OwnerProof',
|
||||
_iuxisac7.PasswordChallenge => 'PasswordChallenge',
|
||||
_izp492e5.PasswordCosts => 'PasswordCosts',
|
||||
_ibfrwdhm.PasswordRefusal => 'PasswordRefusal',
|
||||
|
|
@ -411,6 +420,8 @@ class Protocol extends _isc.SerializationManager {
|
|||
return 'KeyProofChallenge';
|
||||
case _itvvd6m6.NewPassword():
|
||||
return 'NewPassword';
|
||||
case _i5f4sdg4.OwnerProof():
|
||||
return 'OwnerProof';
|
||||
case _iuxisac7.PasswordChallenge():
|
||||
return 'PasswordChallenge';
|
||||
case _izp492e5.PasswordCosts():
|
||||
|
|
@ -486,6 +497,9 @@ class Protocol extends _isc.SerializationManager {
|
|||
if (dataClassName == 'NewPassword') {
|
||||
return deserialize<_itvvd6m6.NewPassword>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'OwnerProof') {
|
||||
return deserialize<_i5f4sdg4.OwnerProof>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'PasswordChallenge') {
|
||||
return deserialize<_iuxisac7.PasswordChallenge>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:halkyon_base_server/src/auth/membership.dart';
|
|||
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/owner_proof_tries.dart';
|
||||
import 'package:halkyon_base_server/src/password/password_change.dart';
|
||||
import 'package:halkyon_base_server/src/register/account_register.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
|
@ -26,12 +27,17 @@ class AccountEndpoint extends Endpoint {
|
|||
final pod = session.serverpod;
|
||||
final person = session.authenticated!.authUserId;
|
||||
final bound = proof?.copyWith(host: deviceHostOn(pod, proof.host));
|
||||
if (!isMember(pod)) {
|
||||
await requireOwnerToDelete(session, person, bound);
|
||||
} else if (session.authenticated!.scopes.contains(identified)) {
|
||||
await AccountRegister.of(pod)
|
||||
.deleteAccount(session, person, proof: bound);
|
||||
}
|
||||
// The proof counts for this session (`asOwnerProofTry`), as every
|
||||
// change of how the account is secured does.
|
||||
await asOwnerProofTry(session, bound, () async {
|
||||
if (!isMember(pod)) {
|
||||
await requireOwnerToDelete(session, person, bound);
|
||||
} else if (session.authenticated!.scopes.contains(identified)) {
|
||||
await AccountRegister.of(
|
||||
pod,
|
||||
).deleteAccount(session, person, proof: bound);
|
||||
}
|
||||
});
|
||||
await deletePerson(session, person);
|
||||
await session.messages.authenticationRevoked(
|
||||
person.uuid,
|
||||
|
|
|
|||
|
|
@ -11,3 +11,7 @@ fields:
|
|||
### authenticator (a reset may have left it without one since), so
|
||||
### neither a code by mail nor GitHub signs into it (ADR 0017).
|
||||
passwordOnly: bool, default=false
|
||||
### A second factor works: a confirmed authenticator, and no reset of the
|
||||
### 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
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ abstract class SignInWays
|
|||
bool? password,
|
||||
bool? authenticator,
|
||||
bool? passwordOnly,
|
||||
bool? secondFactorWorks,
|
||||
}) : password = password ?? false,
|
||||
authenticator = authenticator ?? false,
|
||||
passwordOnly = passwordOnly ?? false;
|
||||
passwordOnly = passwordOnly ?? false,
|
||||
secondFactorWorks = secondFactorWorks ?? false;
|
||||
|
||||
factory SignInWays({
|
||||
required bool gitHub,
|
||||
|
|
@ -31,6 +33,7 @@ abstract class SignInWays
|
|||
bool? password,
|
||||
bool? authenticator,
|
||||
bool? passwordOnly,
|
||||
bool? secondFactorWorks,
|
||||
}) = _SignInWaysImpl;
|
||||
|
||||
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
|
|
@ -46,6 +49,11 @@ abstract class SignInWays
|
|||
passwordOnly: jsonSerialization['passwordOnly'] == null
|
||||
? null
|
||||
: _is.BoolJsonExtension.fromJson(jsonSerialization['passwordOnly']),
|
||||
secondFactorWorks: jsonSerialization['secondFactorWorks'] == null
|
||||
? null
|
||||
: _is.BoolJsonExtension.fromJson(
|
||||
jsonSerialization['secondFactorWorks'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +72,11 @@ abstract class SignInWays
|
|||
/// neither a code by mail nor GitHub signs into it (ADR 0017).
|
||||
bool passwordOnly;
|
||||
|
||||
/// A second factor works: a confirmed authenticator, and no reset of the
|
||||
/// authenticators that took effect. Where none does, deleting the account
|
||||
/// takes a code by mail in place of a TOTP or recovery code.
|
||||
bool secondFactorWorks;
|
||||
|
||||
/// Returns a shallow copy of this [SignInWays]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
|
|
@ -73,6 +86,7 @@ abstract class SignInWays
|
|||
bool? password,
|
||||
bool? authenticator,
|
||||
bool? passwordOnly,
|
||||
bool? secondFactorWorks,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
|
|
@ -83,6 +97,7 @@ abstract class SignInWays
|
|||
'password': password,
|
||||
'authenticator': authenticator,
|
||||
'passwordOnly': passwordOnly,
|
||||
'secondFactorWorks': secondFactorWorks,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +110,7 @@ abstract class SignInWays
|
|||
'password': password,
|
||||
'authenticator': authenticator,
|
||||
'passwordOnly': passwordOnly,
|
||||
'secondFactorWorks': secondFactorWorks,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -111,12 +127,14 @@ class _SignInWaysImpl extends SignInWays {
|
|||
bool? password,
|
||||
bool? authenticator,
|
||||
bool? passwordOnly,
|
||||
bool? secondFactorWorks,
|
||||
}) : super._(
|
||||
gitHub: gitHub,
|
||||
email: email,
|
||||
password: password,
|
||||
authenticator: authenticator,
|
||||
passwordOnly: passwordOnly,
|
||||
secondFactorWorks: secondFactorWorks,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [SignInWays]
|
||||
|
|
@ -129,6 +147,7 @@ class _SignInWaysImpl extends SignInWays {
|
|||
bool? password,
|
||||
bool? authenticator,
|
||||
bool? passwordOnly,
|
||||
bool? secondFactorWorks,
|
||||
}) {
|
||||
return SignInWays(
|
||||
gitHub: gitHub ?? this.gitHub,
|
||||
|
|
@ -136,6 +155,7 @@ class _SignInWaysImpl extends SignInWays {
|
|||
password: password ?? this.password,
|
||||
authenticator: authenticator ?? this.authenticator,
|
||||
passwordOnly: passwordOnly ?? this.passwordOnly,
|
||||
secondFactorWorks: secondFactorWorks ?? this.secondFactorWorks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import 'package:halkyon_base_server/src/generated/password/deletion_proof.dart'
|
|||
as _i619l6vv;
|
||||
import 'package:halkyon_base_server/src/generated/password/new_password.dart'
|
||||
as _ijye8mcv;
|
||||
import 'package:halkyon_base_server/src/generated/password/owner_proof.dart'
|
||||
as _ifhod73s;
|
||||
import 'package:halkyon_base_server/src/generated/password/password_setup.dart'
|
||||
as _ikhyee47;
|
||||
import 'package:halkyon_base_server/src/generated/password/wrapped_keys.dart'
|
||||
|
|
@ -593,9 +595,9 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
'existingCode': _is.ParameterDescription(
|
||||
name: 'existingCode',
|
||||
type: _is.getType<String?>(),
|
||||
'proof': _is.ParameterDescription(
|
||||
name: 'proof',
|
||||
type: _is.getType<_ifhod73s.OwnerProof?>(),
|
||||
nullable: true,
|
||||
),
|
||||
},
|
||||
|
|
@ -610,7 +612,7 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
session,
|
||||
authenticator: params['authenticator'],
|
||||
code: params['code'],
|
||||
existingCode: params['existingCode'],
|
||||
proof: params['proof'],
|
||||
),
|
||||
),
|
||||
'list': _is.MethodConnector(
|
||||
|
|
@ -633,9 +635,9 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
type: _is.getType<_is.UuidValue>(),
|
||||
nullable: false,
|
||||
),
|
||||
'code': _is.ParameterDescription(
|
||||
name: 'code',
|
||||
type: _is.getType<String?>(),
|
||||
'proof': _is.ParameterDescription(
|
||||
name: 'proof',
|
||||
type: _is.getType<_ifhod73s.OwnerProof?>(),
|
||||
nullable: true,
|
||||
),
|
||||
},
|
||||
|
|
@ -649,15 +651,15 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
.remove(
|
||||
session,
|
||||
authenticator: params['authenticator'],
|
||||
code: params['code'],
|
||||
proof: params['proof'],
|
||||
),
|
||||
),
|
||||
'newRecoveryCodes': _is.MethodConnector(
|
||||
name: 'newRecoveryCodes',
|
||||
params: {
|
||||
'code': _is.ParameterDescription(
|
||||
name: 'code',
|
||||
type: _is.getType<String>(),
|
||||
'proof': _is.ParameterDescription(
|
||||
name: 'proof',
|
||||
type: _is.getType<_ifhod73s.OwnerProof>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
|
|
@ -670,7 +672,7 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
as _i3xqawxj.AuthenticatorEndpoint)
|
||||
.newRecoveryCodes(
|
||||
session,
|
||||
code: params['code'],
|
||||
proof: params['proof'],
|
||||
),
|
||||
),
|
||||
},
|
||||
|
|
@ -723,51 +725,16 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
'change': _is.MethodConnector(
|
||||
name: 'change',
|
||||
params: {
|
||||
'challenge': _is.ParameterDescription(
|
||||
name: 'challenge',
|
||||
type: _is.getType<_is.UuidValue>(),
|
||||
'proof': _is.ParameterDescription(
|
||||
name: 'proof',
|
||||
type: _is.getType<_ifhod73s.OwnerProof>(),
|
||||
nullable: false,
|
||||
),
|
||||
'clientValue': _is.ParameterDescription(
|
||||
name: 'clientValue',
|
||||
type: _is.getType<_idt.ByteData>(),
|
||||
nullable: false,
|
||||
),
|
||||
'clientProof': _is.ParameterDescription(
|
||||
name: 'clientProof',
|
||||
type: _is.getType<_idt.ByteData>(),
|
||||
nullable: false,
|
||||
),
|
||||
'totpCode': _is.ParameterDescription(
|
||||
name: 'totpCode',
|
||||
type: _is.getType<String?>(),
|
||||
nullable: true,
|
||||
),
|
||||
'recoveryCode': _is.ParameterDescription(
|
||||
name: 'recoveryCode',
|
||||
type: _is.getType<String?>(),
|
||||
nullable: true,
|
||||
),
|
||||
'password': _is.ParameterDescription(
|
||||
name: 'password',
|
||||
type: _is.getType<_ijye8mcv.NewPassword>(),
|
||||
nullable: false,
|
||||
),
|
||||
'host': _is.ParameterDescription(
|
||||
name: 'host',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
'address': _is.ParameterDescription(
|
||||
name: 'address',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
'language': _is.ParameterDescription(
|
||||
name: 'language',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
|
|
@ -776,45 +743,18 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
) async =>
|
||||
(endpoints['password'] as _ig7fyg1b.PasswordEndpoint).change(
|
||||
session,
|
||||
challenge: params['challenge'],
|
||||
clientValue: params['clientValue'],
|
||||
clientProof: params['clientProof'],
|
||||
totpCode: params['totpCode'],
|
||||
recoveryCode: params['recoveryCode'],
|
||||
proof: params['proof'],
|
||||
password: params['password'],
|
||||
host: params['host'],
|
||||
address: params['address'],
|
||||
language: params['language'],
|
||||
),
|
||||
),
|
||||
'setRecoveryKeys': _is.MethodConnector(
|
||||
name: 'setRecoveryKeys',
|
||||
params: {
|
||||
'challenge': _is.ParameterDescription(
|
||||
name: 'challenge',
|
||||
type: _is.getType<_is.UuidValue>(),
|
||||
'proof': _is.ParameterDescription(
|
||||
name: 'proof',
|
||||
type: _is.getType<_ifhod73s.OwnerProof>(),
|
||||
nullable: false,
|
||||
),
|
||||
'clientValue': _is.ParameterDescription(
|
||||
name: 'clientValue',
|
||||
type: _is.getType<_idt.ByteData>(),
|
||||
nullable: false,
|
||||
),
|
||||
'clientProof': _is.ParameterDescription(
|
||||
name: 'clientProof',
|
||||
type: _is.getType<_idt.ByteData>(),
|
||||
nullable: false,
|
||||
),
|
||||
'totpCode': _is.ParameterDescription(
|
||||
name: 'totpCode',
|
||||
type: _is.getType<String?>(),
|
||||
nullable: true,
|
||||
),
|
||||
'recoveryCode': _is.ParameterDescription(
|
||||
name: 'recoveryCode',
|
||||
type: _is.getType<String?>(),
|
||||
nullable: true,
|
||||
),
|
||||
'recoveryKeys': _is.ParameterDescription(
|
||||
name: 'recoveryKeys',
|
||||
type: _is.getType<_iji0wmu8.WrappedKeys>(),
|
||||
|
|
@ -825,21 +765,6 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
type: _is.getType<_idt.ByteData>(),
|
||||
nullable: false,
|
||||
),
|
||||
'host': _is.ParameterDescription(
|
||||
name: 'host',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
'address': _is.ParameterDescription(
|
||||
name: 'address',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
'language': _is.ParameterDescription(
|
||||
name: 'language',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
|
|
@ -848,16 +773,9 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint)
|
||||
.setRecoveryKeys(
|
||||
session,
|
||||
challenge: params['challenge'],
|
||||
clientValue: params['clientValue'],
|
||||
clientProof: params['clientProof'],
|
||||
totpCode: params['totpCode'],
|
||||
recoveryCode: params['recoveryCode'],
|
||||
proof: params['proof'],
|
||||
recoveryKeys: params['recoveryKeys'],
|
||||
recoveryProofKey: params['recoveryProofKey'],
|
||||
host: params['host'],
|
||||
address: params['address'],
|
||||
language: params['language'],
|
||||
),
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import 'package:serverpod/serverpod.dart' as _is;
|
|||
/// What deleting an account that has a password takes (ADR 0017; the second
|
||||
/// reading): the password's SRP proof for a challenge `password.beginChange`
|
||||
/// gave, a TOTP or recovery code, and the host the device addressed, as a
|
||||
/// change of password does. A session alone deletes only an account without
|
||||
/// one.
|
||||
/// change of password does (`OwnerProof`). A session alone deletes only an
|
||||
/// account without one.
|
||||
abstract class DeletionProof
|
||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||
DeletionProof._({
|
||||
|
|
@ -29,8 +29,6 @@ abstract class DeletionProof
|
|||
this.request,
|
||||
this.code,
|
||||
required this.host,
|
||||
required this.address,
|
||||
required this.language,
|
||||
});
|
||||
|
||||
factory DeletionProof({
|
||||
|
|
@ -42,8 +40,6 @@ abstract class DeletionProof
|
|||
_is.UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
required String address,
|
||||
required String language,
|
||||
}) = _DeletionProofImpl;
|
||||
|
||||
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
|
|
@ -64,8 +60,6 @@ abstract class DeletionProof
|
|||
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
|
||||
code: jsonSerialization['code'] as String?,
|
||||
host: jsonSerialization['host'] as String,
|
||||
address: jsonSerialization['address'] as String,
|
||||
language: jsonSerialization['language'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -91,13 +85,6 @@ abstract class DeletionProof
|
|||
/// checks it is its own before the register binds the proof to it.
|
||||
String host;
|
||||
|
||||
/// The account's address as the device typed it, and the language its
|
||||
/// mails are worded in: checked before the code, so the owner is mailed
|
||||
/// should codes come to wait (the re-review's fourth finding).
|
||||
String address;
|
||||
|
||||
String language;
|
||||
|
||||
/// Returns a shallow copy of this [DeletionProof]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
|
|
@ -110,8 +97,6 @@ abstract class DeletionProof
|
|||
_is.UuidValue? request,
|
||||
String? code,
|
||||
String? host,
|
||||
String? address,
|
||||
String? language,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
|
|
@ -125,8 +110,6 @@ abstract class DeletionProof
|
|||
if (request != null) 'request': request?.toJson(),
|
||||
if (code != null) 'code': code,
|
||||
'host': host,
|
||||
'address': address,
|
||||
'language': language,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -142,8 +125,6 @@ abstract class DeletionProof
|
|||
if (request != null) 'request': request?.toJson(),
|
||||
if (code != null) 'code': code,
|
||||
'host': host,
|
||||
'address': address,
|
||||
'language': language,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -165,8 +146,6 @@ class _DeletionProofImpl extends DeletionProof {
|
|||
_is.UuidValue? request,
|
||||
String? code,
|
||||
required String host,
|
||||
required String address,
|
||||
required String language,
|
||||
}) : super._(
|
||||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
|
|
@ -176,8 +155,6 @@ class _DeletionProofImpl extends DeletionProof {
|
|||
request: request,
|
||||
code: code,
|
||||
host: host,
|
||||
address: address,
|
||||
language: language,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [DeletionProof]
|
||||
|
|
@ -193,8 +170,6 @@ class _DeletionProofImpl extends DeletionProof {
|
|||
Object? request = _Undefined,
|
||||
Object? code = _Undefined,
|
||||
String? host,
|
||||
String? address,
|
||||
String? language,
|
||||
}) {
|
||||
return DeletionProof(
|
||||
challenge: challenge ?? this.challenge,
|
||||
|
|
@ -205,8 +180,6 @@ class _DeletionProofImpl extends DeletionProof {
|
|||
request: request is _is.UuidValue? ? request : this.request,
|
||||
code: code is String? ? code : this.code,
|
||||
host: host ?? this.host,
|
||||
address: address ?? this.address,
|
||||
language: language ?? this.language,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
/* 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 'dart:typed_data' as _idt;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
|
||||
/// What a signed-in owner proves to change how their account is secured
|
||||
/// (ADR 0017; the third reading, as Proton has it): the password's SRP
|
||||
/// proof for a challenge `password.beginChange` gave, bound to the host the
|
||||
/// device addressed, and a TOTP code from one of the account's confirmed
|
||||
/// authenticators or one of its recovery codes. Adding an authenticator once
|
||||
/// one is confirmed, removing a confirmed one, new recovery codes, a new
|
||||
/// recovery phrase and a new password take it: a session alone never gets a
|
||||
/// code checked.
|
||||
abstract class OwnerProof
|
||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||
OwnerProof._({
|
||||
required this.challenge,
|
||||
required this.clientValue,
|
||||
required this.clientProof,
|
||||
required this.host,
|
||||
this.totpCode,
|
||||
this.recoveryCode,
|
||||
});
|
||||
|
||||
factory OwnerProof({
|
||||
required _is.UuidValue challenge,
|
||||
required _idt.ByteData clientValue,
|
||||
required _idt.ByteData clientProof,
|
||||
required String host,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
}) = _OwnerProofImpl;
|
||||
|
||||
factory OwnerProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return OwnerProof(
|
||||
challenge: _is.UuidValueJsonExtension.fromJson(
|
||||
jsonSerialization['challenge'],
|
||||
),
|
||||
clientValue: _is.ByteDataJsonExtension.fromJson(
|
||||
jsonSerialization['clientValue'],
|
||||
),
|
||||
clientProof: _is.ByteDataJsonExtension.fromJson(
|
||||
jsonSerialization['clientProof'],
|
||||
),
|
||||
host: jsonSerialization['host'] as String,
|
||||
totpCode: jsonSerialization['totpCode'] as String?,
|
||||
recoveryCode: jsonSerialization['recoveryCode'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
_is.UuidValue challenge;
|
||||
|
||||
_idt.ByteData clientValue;
|
||||
|
||||
_idt.ByteData clientProof;
|
||||
|
||||
/// The host the device addressed the instance by: the server it reached
|
||||
/// checks it is its own before the register binds the proof to it.
|
||||
String host;
|
||||
|
||||
String? totpCode;
|
||||
|
||||
String? recoveryCode;
|
||||
|
||||
/// Returns a shallow copy of this [OwnerProof]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
OwnerProof copyWith({
|
||||
_is.UuidValue? challenge,
|
||||
_idt.ByteData? clientValue,
|
||||
_idt.ByteData? clientProof,
|
||||
String? host,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'halkyon_base.OwnerProof',
|
||||
'challenge': challenge.toJson(),
|
||||
'clientValue': clientValue.toJson(),
|
||||
'clientProof': clientProof.toJson(),
|
||||
'host': host,
|
||||
if (totpCode != null) 'totpCode': totpCode,
|
||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'halkyon_base.OwnerProof',
|
||||
'challenge': challenge.toJson(),
|
||||
'clientValue': clientValue.toJson(),
|
||||
'clientProof': clientProof.toJson(),
|
||||
'host': host,
|
||||
if (totpCode != null) 'totpCode': totpCode,
|
||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _OwnerProofImpl extends OwnerProof {
|
||||
_OwnerProofImpl({
|
||||
required _is.UuidValue challenge,
|
||||
required _idt.ByteData clientValue,
|
||||
required _idt.ByteData clientProof,
|
||||
required String host,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
}) : super._(
|
||||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
clientProof: clientProof,
|
||||
host: host,
|
||||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [OwnerProof]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
OwnerProof copyWith({
|
||||
_is.UuidValue? challenge,
|
||||
_idt.ByteData? clientValue,
|
||||
_idt.ByteData? clientProof,
|
||||
String? host,
|
||||
Object? totpCode = _Undefined,
|
||||
Object? recoveryCode = _Undefined,
|
||||
}) {
|
||||
return OwnerProof(
|
||||
challenge: challenge ?? this.challenge,
|
||||
clientValue: clientValue ?? this.clientValue.clone(),
|
||||
clientProof: clientProof ?? this.clientProof.clone(),
|
||||
host: host ?? this.host,
|
||||
totpCode: totpCode is String? ? totpCode : this.totpCode,
|
||||
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ enum PasswordRefusal implements _is.SerializableModel {
|
|||
/// The account has as many authenticators as it may: remove one first.
|
||||
tooManyAuthenticators,
|
||||
|
||||
/// The code from the authenticator isn't right.
|
||||
/// The code from the authenticator being set up isn't right.
|
||||
wrongCode,
|
||||
|
||||
/// The reset's time hasn't come yet.
|
||||
|
|
@ -70,11 +70,6 @@ enum PasswordRefusal implements _is.SerializableModel {
|
|||
/// No such reset is waiting, or the address isn't the account's.
|
||||
noReset,
|
||||
|
||||
/// Adding an authenticator to an account that has one confirmed takes a
|
||||
/// code from one of those, which is missing or isn't right: a session
|
||||
/// alone adds no second factor.
|
||||
wrongExistingCode,
|
||||
|
||||
/// 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.
|
||||
|
|
@ -84,8 +79,9 @@ enum PasswordRefusal implements _is.SerializableModel {
|
|||
/// `takesEffectAt` (the second reading's M4). Only after a right password.
|
||||
codesWait,
|
||||
|
||||
/// Ten codes in a row from this signed-in session were wrong: the
|
||||
/// session ended (the re-review's third finding); sign in again.
|
||||
/// Ten proofs in a row from this signed-in session (the password with a
|
||||
/// code, `OwnerProof`) were wrong: the session ended (the third reading);
|
||||
/// sign in again.
|
||||
sessionEnded,
|
||||
|
||||
/// The password was changed (a reset carried out), but this service
|
||||
|
|
@ -128,8 +124,6 @@ enum PasswordRefusal implements _is.SerializableModel {
|
|||
return PasswordRefusal.notDue;
|
||||
case 'noReset':
|
||||
return PasswordRefusal.noReset;
|
||||
case 'wrongExistingCode':
|
||||
return PasswordRefusal.wrongExistingCode;
|
||||
case 'otherInstance':
|
||||
return PasswordRefusal.otherInstance;
|
||||
case 'codesWait':
|
||||
|
|
|
|||
|
|
@ -12,12 +12,13 @@
|
|||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
|
||||
/// The codes a signed-in session tried in a row without one accepted (ADR
|
||||
/// 0017; the re-review's third finding): removing an authenticator, new
|
||||
/// recovery codes, confirming an authenticator. They count per session,
|
||||
/// never toward the account's waits (a stolen session alone would hold its
|
||||
/// owner out of their codes), and the tenth in a row ends the session. The
|
||||
/// row goes once a code is accepted, with the session, or with the person.
|
||||
/// The proofs a signed-in session sent in a row without one accepted
|
||||
/// (`OwnerProof`, the password and a code; ADR 0017, the third reading):
|
||||
/// every change of how an account is secured. They count per session,
|
||||
/// never toward the account's waits (whoever holds a session or knows the
|
||||
/// password would hold its owner out of their codes), and the tenth wrong in
|
||||
/// a row ends the session. The row goes once a proof is accepted, with the
|
||||
/// session, or with the person.
|
||||
abstract class SessionCodeFailures
|
||||
implements _is.TableRow<int?>, _is.ProtocolSerialization {
|
||||
SessionCodeFailures._({
|
||||
|
|
@ -65,7 +66,7 @@ abstract class SessionCodeFailures
|
|||
/// Its person (deleted with them, `deletePerson`).
|
||||
_is.UuidValue authUserId;
|
||||
|
||||
/// Codes tried in a row since the last accepted, counted before each is
|
||||
/// Proofs sent in a row since the last accepted, counted before each is
|
||||
/// checked.
|
||||
int failures;
|
||||
|
||||
|
|
@ -227,7 +228,7 @@ class SessionCodeFailuresTable extends _is.Table<int?> {
|
|||
/// Its person (deleted with them, `deletePerson`).
|
||||
late final _is.ColumnUuid authUserId;
|
||||
|
||||
/// Codes tried in a row since the last accepted, counted before each is
|
||||
/// Proofs sent in a row since the last accepted, counted before each is
|
||||
/// checked.
|
||||
late final _is.ColumnInt failures;
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import 'password/authenticator_enrolment.dart' as _iv7tra7v;
|
|||
import 'password/deletion_proof.dart' as _ix1u7hdq;
|
||||
import 'password/key_proof_challenge.dart' as _iehtx95q;
|
||||
import 'password/new_password.dart' as _itvvd6m6;
|
||||
import 'password/owner_proof.dart' as _i5f4sdg4;
|
||||
import 'password/password_challenge.dart' as _iuxisac7;
|
||||
import 'password/password_costs.dart' as _izp492e5;
|
||||
import 'password/password_proof.dart' as _ionb75gc;
|
||||
|
|
@ -75,6 +76,7 @@ export 'password/authenticator_enrolment.dart';
|
|||
export 'password/deletion_proof.dart';
|
||||
export 'password/key_proof_challenge.dart';
|
||||
export 'password/new_password.dart';
|
||||
export 'password/owner_proof.dart';
|
||||
export 'password/password_challenge.dart';
|
||||
export 'password/password_costs.dart';
|
||||
export 'password/password_proof.dart';
|
||||
|
|
@ -1068,6 +1070,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _itvvd6m6.NewPassword) {
|
||||
return _itvvd6m6.NewPassword.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i5f4sdg4.OwnerProof) {
|
||||
return _i5f4sdg4.OwnerProof.fromJson(data) as T;
|
||||
}
|
||||
if (t == _iuxisac7.PasswordChallenge) {
|
||||
return _iuxisac7.PasswordChallenge.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -1207,6 +1212,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _is.getType<_itvvd6m6.NewPassword?>()) {
|
||||
return (data != null ? _itvvd6m6.NewPassword.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_i5f4sdg4.OwnerProof?>()) {
|
||||
return (data != null ? _i5f4sdg4.OwnerProof.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_iuxisac7.PasswordChallenge?>()) {
|
||||
return (data != null ? _iuxisac7.PasswordChallenge.fromJson(data) : null)
|
||||
as T;
|
||||
|
|
@ -1359,6 +1367,7 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
_ix1u7hdq.DeletionProof => 'DeletionProof',
|
||||
_iehtx95q.KeyProofChallenge => 'KeyProofChallenge',
|
||||
_itvvd6m6.NewPassword => 'NewPassword',
|
||||
_i5f4sdg4.OwnerProof => 'OwnerProof',
|
||||
_iuxisac7.PasswordChallenge => 'PasswordChallenge',
|
||||
_izp492e5.PasswordCosts => 'PasswordCosts',
|
||||
_ionb75gc.PasswordProof => 'PasswordProof',
|
||||
|
|
@ -1432,6 +1441,8 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
return 'KeyProofChallenge';
|
||||
case _itvvd6m6.NewPassword():
|
||||
return 'NewPassword';
|
||||
case _i5f4sdg4.OwnerProof():
|
||||
return 'OwnerProof';
|
||||
case _iuxisac7.PasswordChallenge():
|
||||
return 'PasswordChallenge';
|
||||
case _izp492e5.PasswordCosts():
|
||||
|
|
@ -1541,6 +1552,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (dataClassName == 'NewPassword') {
|
||||
return deserialize<_itvvd6m6.NewPassword>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'OwnerProof') {
|
||||
return deserialize<_i5f4sdg4.OwnerProof>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'PasswordChallenge') {
|
||||
return deserialize<_iuxisac7.PasswordChallenge>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/password/session_code_tries.dart';
|
||||
import 'package:halkyon_base_server/src/password/key_proof.dart';
|
||||
import 'package:halkyon_base_server/src/password/owner_proof_tries.dart';
|
||||
import 'package:halkyon_base_server/src/register/account_register.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// The signed-in person's authenticators and recovery codes (ADR 0017),
|
||||
/// which the instance's register keeps (ADR 0016). Every code tried here
|
||||
/// counts for the session (`asSessionCodeTry`): the tenth wrong in a row
|
||||
/// ends it ([PasswordRefusal.sessionEnded]).
|
||||
/// which the instance's register keeps (ADR 0016). Once one is confirmed,
|
||||
/// every change takes the owner's proof (`OwnerProof`: the password and a
|
||||
/// code; the third reading), which counts for the session
|
||||
/// (`asOwnerProofTry`): the tenth wrong in a row ends it
|
||||
/// ([PasswordRefusal.sessionEnded]).
|
||||
class AuthenticatorEndpoint extends Endpoint {
|
||||
@override
|
||||
bool get requireLogin => true;
|
||||
|
|
@ -27,57 +30,75 @@ class AuthenticatorEndpoint extends Endpoint {
|
|||
);
|
||||
|
||||
/// Confirms [authenticator] with a [code] from it, and, where the account
|
||||
/// has a confirmed authenticator already, an [existingCode] from that one;
|
||||
/// the account's first returns its recovery codes, shown once. Refusals as
|
||||
/// has a confirmed authenticator already, the owner's [proof]; the
|
||||
/// account's first returns its recovery codes, shown once. Refusals as
|
||||
/// `confirmAuthenticator`'s.
|
||||
Future<List<String>> confirm(
|
||||
Session session, {
|
||||
required UuidValue authenticator,
|
||||
required String code,
|
||||
String? existingCode,
|
||||
}) => asSessionCodeTry(
|
||||
session,
|
||||
() => AccountRegister.of(session.serverpod).confirmAuthenticator(
|
||||
OwnerProof? proof,
|
||||
}) {
|
||||
final bound = _bound(session, proof);
|
||||
return asOwnerProofTry(
|
||||
session,
|
||||
person: session.authenticated!.authUserId,
|
||||
authenticator: authenticator,
|
||||
code: code,
|
||||
existingCode: existingCode,
|
||||
),
|
||||
);
|
||||
bound,
|
||||
() => AccountRegister.of(session.serverpod).confirmAuthenticator(
|
||||
session,
|
||||
person: session.authenticated!.authUserId,
|
||||
authenticator: authenticator,
|
||||
code: code,
|
||||
proof: bound,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The caller's authenticators, the oldest first.
|
||||
Future<List<Authenticator>> list(Session session) => AccountRegister.of(
|
||||
session.serverpod,
|
||||
).authenticatorsOf(session, session.authenticated!.authUserId);
|
||||
|
||||
/// Removes [authenticator]: a confirmed one with a [code] from one of the
|
||||
/// caller's, while another stays. Refusals as `removeAuthenticator`'s.
|
||||
/// Removes [authenticator]: one never confirmed as it is, a confirmed one
|
||||
/// with the owner's [proof], while another stays. Refusals as
|
||||
/// `removeAuthenticator`'s.
|
||||
Future<void> remove(
|
||||
Session session, {
|
||||
required UuidValue authenticator,
|
||||
String? code,
|
||||
}) => asSessionCodeTry(
|
||||
session,
|
||||
() => AccountRegister.of(session.serverpod).removeAuthenticator(
|
||||
OwnerProof? proof,
|
||||
}) {
|
||||
final bound = _bound(session, proof);
|
||||
return asOwnerProofTry(
|
||||
session,
|
||||
person: session.authenticated!.authUserId,
|
||||
authenticator: authenticator,
|
||||
code: code,
|
||||
),
|
||||
);
|
||||
bound,
|
||||
() => AccountRegister.of(session.serverpod).removeAuthenticator(
|
||||
session,
|
||||
person: session.authenticated!.authUserId,
|
||||
authenticator: authenticator,
|
||||
proof: bound,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// New recovery codes in place of the old, with a [code] from one of the
|
||||
/// caller's authenticators: shown once. Refusals as `newRecoveryCodes`'.
|
||||
/// New recovery codes in place of the old, with the owner's [proof]:
|
||||
/// shown once. Refusals as `newRecoveryCodes`'.
|
||||
Future<List<String>> newRecoveryCodes(
|
||||
Session session, {
|
||||
required String code,
|
||||
}) => asSessionCodeTry(
|
||||
session,
|
||||
() => AccountRegister.of(session.serverpod).newRecoveryCodes(
|
||||
required OwnerProof proof,
|
||||
}) {
|
||||
final bound = _bound(session, proof)!;
|
||||
return asOwnerProofTry(
|
||||
session,
|
||||
person: session.authenticated!.authUserId,
|
||||
code: code,
|
||||
),
|
||||
);
|
||||
bound,
|
||||
() => AccountRegister.of(session.serverpod).newRecoveryCodes(
|
||||
session,
|
||||
person: session.authenticated!.authUserId,
|
||||
proof: bound,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// [proof] bound to the host the device addressed, checked to be this
|
||||
/// server's own before anything counts (`deviceHostOn`).
|
||||
OwnerProof? _bound(Session session, OwnerProof? proof) =>
|
||||
proof?.copyWith(host: deviceHostOn(session.serverpod, proof.host));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/password/owner_proof_tries.dart';
|
||||
import 'package:halkyon_base_server/src/password/password_change.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/session_code_tries.dart';
|
||||
import 'package:halkyon_base_server/src/password/totp_secrets.dart';
|
||||
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
|
||||
import 'package:halkyon_base_server/src/totp/recovery_codes.dart';
|
||||
|
|
@ -14,6 +15,9 @@ import 'package:serverpod/serverpod.dart';
|
|||
// is made here and shown once (an `otpauth://` URI), counts once a code from
|
||||
// it is accepted, and the first one confirmed brings ten recovery codes,
|
||||
// shown once too. TOTP is mandatory: the last confirmed authenticator stays.
|
||||
// Once one is confirmed, every change takes the owner's proof (`OwnerProof`:
|
||||
// the password and a code, `proveOwner`, the third reading): a session alone
|
||||
// never gets a code of the account's checked.
|
||||
|
||||
/// How many authenticators an account may have, confirmed or not.
|
||||
const maxAuthenticators = 10;
|
||||
|
|
@ -106,36 +110,40 @@ String _labelOf(String label) {
|
|||
: plain;
|
||||
}
|
||||
|
||||
/// Confirms [person]'s [authenticator] with a [code] from it: it counts from
|
||||
/// now on, and a reset of the authenticators, waiting or taken effect, is
|
||||
/// over (the person, signed in, has one again). The
|
||||
/// account's first confirmed authenticator brings a new set of recovery
|
||||
/// codes, returned to be shown once; otherwise none. Confirming one
|
||||
/// confirmed before only checks the code.
|
||||
/// Confirms [person]'s [authenticator], waiting to be confirmed, with a
|
||||
/// [code] from it: it counts from now on, and a reset of the
|
||||
/// authenticators, waiting or taken effect, is over (the person, signed in,
|
||||
/// has one again). The account's first confirmed authenticator brings a new
|
||||
/// set of recovery codes, returned to be shown once; otherwise none.
|
||||
///
|
||||
/// While the account has a confirmed authenticator, another counts only
|
||||
/// with an [existingCode] from one of those too (each step's code once, as
|
||||
/// signing in takes it; decided 20 Oct 2026): otherwise whoever holds a
|
||||
/// session could add a second factor of their own, sign in with it and
|
||||
/// remove the owner's. Without one (the first, or after a reset carried
|
||||
/// out) none is asked for.
|
||||
/// with the owner's [proof] (the password and a code from one of those,
|
||||
/// [proveOwner]; the third reading): otherwise whoever holds a session
|
||||
/// could add a second factor of their own, sign in with it and remove the
|
||||
/// owner's. Without one (the first, or after a reset carried out) the new
|
||||
/// one's own code alone confirms it, and a [proof] is refused: one given is
|
||||
/// always checked or refused, so no success ever passes a proof unchecked
|
||||
/// (the session's count of wrong proofs clears only on an accepted one,
|
||||
/// `asOwnerProofTry`).
|
||||
///
|
||||
/// Refused ([PasswordRefusedException]) for an authenticator not the
|
||||
/// person's ([PasswordRefusal.unknownAuthenticator]), a wrong or used code
|
||||
/// ([PasswordRefusal.wrongCode]), a missing, wrong or used code from one
|
||||
/// confirmed before ([PasswordRefusal.wrongExistingCode]; checked only once
|
||||
/// [code] is right, so a wrong one uses none). The session's tries are
|
||||
/// counted where it is (`asSessionCodeTry`, the endpoint's), never toward
|
||||
/// the account's waits.
|
||||
/// person's, or one confirmed already, whose code a session alone must never
|
||||
/// get checked ([PasswordRefusal.unknownAuthenticator]); a wrong or used
|
||||
/// code from the new one ([PasswordRefusal.wrongCode], before any proof is
|
||||
/// checked, so it spends none); a proof where none is taken
|
||||
/// ([PasswordRefusal.invalidSetup]); and as [proveOwner].
|
||||
Future<List<String>> confirmAuthenticator(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required UuidValue authenticator,
|
||||
required String code,
|
||||
String? existingCode,
|
||||
OwnerProof? proof,
|
||||
}) async {
|
||||
final keys = requirePasswordKeys(session.serverpod);
|
||||
final row = await _authenticatorOf(session, person, authenticator);
|
||||
if (row.confirmedAt != null) {
|
||||
throw refusedFor(PasswordRefusal.unknownAuthenticator);
|
||||
}
|
||||
final secret = await openTotpSecret(
|
||||
row.secret,
|
||||
totpKey: keys.totpKey,
|
||||
|
|
@ -151,15 +159,13 @@ Future<List<String>> confirmAuthenticator(
|
|||
TotpAccepted(:final step) => step,
|
||||
TotpRefused() => throw refusedFor(PasswordRefusal.wrongCode),
|
||||
};
|
||||
if (row.confirmedAt == null) {
|
||||
final confirmedBefore = await confirmedAuthenticatorsOf(session, person);
|
||||
if (confirmedBefore.isNotEmpty) {
|
||||
final typed = existingCode?.trim() ?? '';
|
||||
if (typed.isEmpty ||
|
||||
!await acceptTotpCode(session, keys, confirmedBefore, typed)) {
|
||||
throw refusedFor(PasswordRefusal.wrongExistingCode);
|
||||
}
|
||||
if (await hasConfirmedAuthenticator(session, person)) {
|
||||
if (proof == null) {
|
||||
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||
}
|
||||
await proveOwner(session, keys, person: person, proof: proof);
|
||||
} else if (proof != null) {
|
||||
throw refusedFor(PasswordRefusal.invalidSetup);
|
||||
}
|
||||
return await session.db.transaction((transaction) async {
|
||||
// UPDATE "totp_authenticator" SET "lastStep" = $1, "confirmedAt" = $2
|
||||
|
|
@ -169,7 +175,7 @@ Future<List<String>> confirmAuthenticator(
|
|||
session,
|
||||
columnValues: (t) => [
|
||||
t.lastStep(step),
|
||||
t.confirmedAt(row.confirmedAt ?? clock.now().toUtc()),
|
||||
t.confirmedAt(clock.now().toUtc()),
|
||||
],
|
||||
where: (t) =>
|
||||
t.id.equals(authenticator) &
|
||||
|
|
@ -179,9 +185,6 @@ Future<List<String>> confirmAuthenticator(
|
|||
if (confirmed.isEmpty) {
|
||||
throw refusedFor(PasswordRefusal.wrongCode);
|
||||
}
|
||||
if (row.confirmedAt != null) {
|
||||
return const <String>[];
|
||||
}
|
||||
// DELETE FROM "account_reset" WHERE "authUserId" = $1 AND "kind" = $2
|
||||
await AccountReset.db.deleteWhere(
|
||||
session,
|
||||
|
|
@ -232,24 +235,29 @@ Future<List<Authenticator>> authenticatorsOf(
|
|||
),
|
||||
];
|
||||
|
||||
/// Removes [person]'s [authenticator]. One never confirmed goes as it is; a
|
||||
/// confirmed one only with a [code] from one of the account's confirmed
|
||||
/// authenticators, and only while another confirmed one stays.
|
||||
/// Removes [person]'s [authenticator]. One never confirmed goes as it is,
|
||||
/// with no [proof] (one given is refused, so it is never passed unchecked);
|
||||
/// a confirmed one only with the owner's [proof] (the password and a code,
|
||||
/// [proveOwner]; the third reading), and only while another confirmed one
|
||||
/// stays.
|
||||
///
|
||||
/// Refused ([PasswordRefusedException]) for an authenticator not the
|
||||
/// person's ([PasswordRefusal.unknownAuthenticator]), the last confirmed one
|
||||
/// ([PasswordRefusal.lastAuthenticator]), a wrong, used or missing code
|
||||
/// ([PasswordRefusal.wrongCode]), and beyond 10 codes tried an hour
|
||||
/// ([PasswordRefusal.tooManyAttempts]).
|
||||
/// ([PasswordRefusal.lastAuthenticator], before any proof is checked), a
|
||||
/// proof missing ([PasswordRefusal.wrongCredentials]) or given where none is
|
||||
/// taken ([PasswordRefusal.invalidSetup]), and as [proveOwner].
|
||||
Future<void> removeAuthenticator(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required UuidValue authenticator,
|
||||
String? code,
|
||||
OwnerProof? proof,
|
||||
}) async {
|
||||
final keys = requirePasswordKeys(session.serverpod);
|
||||
final row = await _authenticatorOf(session, person, authenticator);
|
||||
if (row.confirmedAt == null) {
|
||||
if (proof != null) {
|
||||
throw refusedFor(PasswordRefusal.invalidSetup);
|
||||
}
|
||||
// DELETE FROM "totp_authenticator" WHERE "id" = $1 AND "authUserId" = $2
|
||||
await TotpAuthenticator.db.deleteWhere(
|
||||
session,
|
||||
|
|
@ -261,7 +269,10 @@ Future<void> removeAuthenticator(
|
|||
if (confirmed.length < 2) {
|
||||
throw refusedFor(PasswordRefusal.lastAuthenticator);
|
||||
}
|
||||
await _requireAuthenticatorCode(session, keys, person, confirmed, code);
|
||||
if (proof == null) {
|
||||
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||
}
|
||||
await proveOwner(session, keys, person: person, proof: proof);
|
||||
// Serializable: two removals at once must not take both of the last two.
|
||||
await session.db.transaction(
|
||||
(transaction) async {
|
||||
|
|
@ -292,22 +303,16 @@ Future<void> removeAuthenticator(
|
|||
);
|
||||
}
|
||||
|
||||
/// A new set of recovery codes for [person], replacing the old, with a
|
||||
/// [code] from one of their confirmed authenticators: returned to be shown
|
||||
/// once. Refused as [removeAuthenticator] refuses a code
|
||||
/// ([PasswordRefusal.wrongCode], [PasswordRefusal.tooManyAttempts]), and
|
||||
/// without a confirmed authenticator ([PasswordRefusal.setupIncomplete]).
|
||||
/// A new set of recovery codes for [person], replacing the old, with the
|
||||
/// owner's [proof] (the password and a code, [proveOwner]; the third
|
||||
/// reading): returned to be shown once. Refused as [proveOwner].
|
||||
Future<List<String>> newRecoveryCodes(
|
||||
Session session, {
|
||||
required UuidValue person,
|
||||
required String code,
|
||||
required OwnerProof proof,
|
||||
}) async {
|
||||
final keys = requirePasswordKeys(session.serverpod);
|
||||
final confirmed = await confirmedAuthenticatorsOf(session, person);
|
||||
if (confirmed.isEmpty) {
|
||||
throw refusedFor(PasswordRefusal.setupIncomplete);
|
||||
}
|
||||
await _requireAuthenticatorCode(session, keys, person, confirmed, code);
|
||||
await proveOwner(session, keys, person: person, proof: proof);
|
||||
return await session.db.transaction(
|
||||
(transaction) =>
|
||||
_replaceRecoveryCodes(session, keys, person, transaction: transaction),
|
||||
|
|
@ -316,10 +321,9 @@ Future<List<String>> newRecoveryCodes(
|
|||
|
||||
/// Deletes the authenticators nobody confirmed within
|
||||
/// [unconfirmedAuthenticatorLifetime] at [now], the sign-in challenges past
|
||||
/// their time and the code tries of sessions that ended
|
||||
/// their time and the counts of wrong proofs of sessions that ended
|
||||
/// (`forgetEndedSessionsCodeTries`), and returns how many rows went: hourly,
|
||||
/// with the
|
||||
/// pruning (`PruningFutureCall`).
|
||||
/// with the pruning (`PruningFutureCall`).
|
||||
Future<int> forgetUnconfirmedAuthenticators(
|
||||
Session session, {
|
||||
required DateTime now,
|
||||
|
|
@ -341,24 +345,6 @@ Future<int> forgetUnconfirmedAuthenticators(
|
|||
return authenticators.length + challenges.length + tries;
|
||||
}
|
||||
|
||||
/// Requires [code] to be a fresh code of one of [person]'s [confirmed]
|
||||
/// authenticators; refused otherwise ([PasswordRefusal.wrongCode]). A
|
||||
/// signed-in session's try: counted where the session is
|
||||
/// (`asSessionCodeTry`), never toward the account's waits.
|
||||
Future<void> _requireAuthenticatorCode(
|
||||
Session session,
|
||||
PasswordKeys keys,
|
||||
UuidValue person,
|
||||
List<TotpAuthenticator> confirmed,
|
||||
String? code, {
|
||||
PasswordRefusal refusal = PasswordRefusal.wrongCode,
|
||||
}) async {
|
||||
final typed = code?.trim() ?? '';
|
||||
if (typed.isEmpty || !await acceptTotpCode(session, keys, confirmed, typed)) {
|
||||
throw refusedFor(refusal);
|
||||
}
|
||||
}
|
||||
|
||||
/// [person]'s authenticator [id]; refused
|
||||
/// ([PasswordRefusal.unknownAuthenticator]) for anyone else's, or none.
|
||||
Future<TotpAuthenticator> _authenticatorOf(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
### What deleting an account that has a password takes (ADR 0017; the second
|
||||
### reading): the password's SRP proof for a challenge `password.beginChange`
|
||||
### gave, a TOTP or recovery code, and the host the device addressed, as a
|
||||
### change of password does. A session alone deletes only an account without
|
||||
### one.
|
||||
### change of password does (`OwnerProof`). A session alone deletes only an
|
||||
### account without one.
|
||||
class: DeletionProof
|
||||
fields:
|
||||
challenge: UuidValue
|
||||
|
|
@ -19,8 +19,3 @@ fields:
|
|||
### The host the device addressed the instance by: the server it reached
|
||||
### checks it is its own before the register binds the proof to it.
|
||||
host: String
|
||||
### The account's address as the device typed it, and the language its
|
||||
### mails are worded in: checked before the code, so the owner is mailed
|
||||
### should codes come to wait (the re-review's fourth finding).
|
||||
address: String
|
||||
language: String
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
### What a signed-in owner proves to change how their account is secured
|
||||
### (ADR 0017; the third reading, as Proton has it): the password's SRP
|
||||
### proof for a challenge `password.beginChange` gave, bound to the host the
|
||||
### device addressed, and a TOTP code from one of the account's confirmed
|
||||
### authenticators or one of its recovery codes. Adding an authenticator once
|
||||
### one is confirmed, removing a confirmed one, new recovery codes, a new
|
||||
### recovery phrase and a new password take it: a session alone never gets a
|
||||
### code checked.
|
||||
class: OwnerProof
|
||||
fields:
|
||||
challenge: UuidValue
|
||||
clientValue: ByteData
|
||||
clientProof: ByteData
|
||||
### The host the device addressed the instance by: the server it reached
|
||||
### checks it is its own before the register binds the proof to it.
|
||||
host: String
|
||||
totpCode: String?
|
||||
recoveryCode: String?
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
// A signed-in session's proofs (`OwnerProof`: the password and a code, ADR
|
||||
// 0017; the third reading, as Proton has it): adding an authenticator once
|
||||
// one is confirmed, removing a confirmed one, new recovery codes, a new
|
||||
// recovery phrase, a new password, deleting the account. A session alone
|
||||
// never gets a code checked: every code a signed-in call carries comes after
|
||||
// the password proved right. Each proof sent counts against its session,
|
||||
// where the session is (the register's own, or a member's, which forwards
|
||||
// the proof), never toward the account's waits: those hold sign-ins back,
|
||||
// and a session (or whoever knows the password) would otherwise hold its
|
||||
// owner out of changing the password, the way to end that session. A proof
|
||||
// is counted before it is checked (one statement, so proofs sent at once
|
||||
// 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.
|
||||
|
||||
/// How many proofs a session sends wrong in a row before it ends.
|
||||
const ownerProofTriesBeforeSignOut = 10;
|
||||
|
||||
/// The refusal that says a proof (the password, or the code with it) was
|
||||
/// wrong: it counts. Any other outcome (a refusal of something else, an
|
||||
/// error) checked no proof or found it right, and is taken back.
|
||||
const _wrongProofs = {PasswordRefusal.wrongCredentials};
|
||||
|
||||
/// [check]'s outcome, with [proof] sent from [session]'s signed-in session
|
||||
/// (none: [check] alone, counted nowhere, as it checks no code of the
|
||||
/// account's): counted before [check] runs, refused
|
||||
/// ([PasswordRefusal.sessionEnded]) and the session ended once proofs in a
|
||||
/// row go past [ownerProofTriesBeforeSignOut] or the last of them is wrong.
|
||||
/// A proof accepted clears the count.
|
||||
Future<T> asOwnerProofTry<T>(
|
||||
Session session,
|
||||
Object? proof,
|
||||
Future<T> Function() check,
|
||||
) async {
|
||||
if (proof == null) {
|
||||
return await check();
|
||||
}
|
||||
final authenticated = session.authenticated!;
|
||||
final sessionId = UuidValue.withValidation(authenticated.authId);
|
||||
final person = authenticated.authUserId;
|
||||
final now = clock.now().toUtc();
|
||||
final counted = await session.db.unsafeQuery(
|
||||
'INSERT INTO "session_code_failures" '
|
||||
'("sessionId", "authUserId", "failures", "updatedAt") '
|
||||
'VALUES (@session, @person, 1, @now) '
|
||||
'ON CONFLICT ("sessionId") DO UPDATE SET '
|
||||
'"failures" = "session_code_failures"."failures" + 1, '
|
||||
'"updatedAt" = @now '
|
||||
'RETURNING "failures"',
|
||||
parameters: QueryParameters.named({
|
||||
'session': sessionId.uuid,
|
||||
'person': person.uuid,
|
||||
'now': now,
|
||||
}),
|
||||
);
|
||||
final tries = counted.first[0]! as int;
|
||||
if (tries > ownerProofTriesBeforeSignOut) {
|
||||
await _endSession(session, sessionId, person);
|
||||
throw PasswordRefusedException(reason: PasswordRefusal.sessionEnded);
|
||||
}
|
||||
try {
|
||||
final result = await check();
|
||||
await _clear(session, sessionId);
|
||||
return result;
|
||||
} on PasswordRefusedException catch (refusal) {
|
||||
if (!_wrongProofs.contains(refusal.reason)) {
|
||||
await _takeBack(session, sessionId);
|
||||
rethrow;
|
||||
}
|
||||
if (tries >= ownerProofTriesBeforeSignOut) {
|
||||
await _endSession(session, sessionId, person);
|
||||
throw PasswordRefusedException(reason: PasswordRefusal.sessionEnded);
|
||||
}
|
||||
rethrow;
|
||||
} on Object {
|
||||
await _takeBack(session, sessionId);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the counts of sessions that ended: hourly
|
||||
/// (`PruningFutureCall`); returns how many.
|
||||
Future<int> forgetEndedSessionsCodeTries(Session session) async {
|
||||
final forgotten = await session.db.unsafeExecute(
|
||||
'DELETE FROM "session_code_failures" WHERE "sessionId" NOT IN '
|
||||
'(SELECT "id" FROM "serverpod_auth_core_session")',
|
||||
);
|
||||
return forgotten;
|
||||
}
|
||||
|
||||
/// Clears [sessionId]'s count: a proof was accepted.
|
||||
Future<void> _clear(Session session, UuidValue sessionId) =>
|
||||
// DELETE FROM "session_code_failures" WHERE "sessionId" = $1
|
||||
SessionCodeFailures.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.sessionId.equals(sessionId),
|
||||
);
|
||||
|
||||
/// Takes back [sessionId]'s last try: it checked no proof, or found it
|
||||
/// right.
|
||||
Future<void> _takeBack(Session session, UuidValue sessionId) =>
|
||||
session.db.unsafeExecute(
|
||||
'UPDATE "session_code_failures" '
|
||||
'SET "failures" = GREATEST("failures" - 1, 0) '
|
||||
'WHERE "sessionId" = @session',
|
||||
parameters: QueryParameters.named({'session': sessionId.uuid}),
|
||||
);
|
||||
|
||||
/// Ends [sessionId], [person]'s; their device learns at once. Its count
|
||||
/// stays, so tries already under way when it ended are refused too, until
|
||||
/// the hourly pruning forgets it ([forgetEndedSessionsCodeTries]).
|
||||
Future<void> _endSession(
|
||||
Session session,
|
||||
UuidValue sessionId,
|
||||
UuidValue person,
|
||||
) async {
|
||||
// DELETE FROM "serverpod_auth_core_session"
|
||||
// WHERE "id" = $1 AND "authUserId" = $2
|
||||
await ServerSideSession.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.equals(sessionId) & t.authUserId.equals(person),
|
||||
);
|
||||
await session.messages.authenticationRevoked(
|
||||
person.uuid,
|
||||
RevokedAuthenticationAuthId(authId: sessionId.uuid),
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue