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? password,
|
||||||
bool? authenticator,
|
bool? authenticator,
|
||||||
bool? passwordOnly,
|
bool? passwordOnly,
|
||||||
|
bool? secondFactorWorks,
|
||||||
}) : password = password ?? false,
|
}) : password = password ?? false,
|
||||||
authenticator = authenticator ?? false,
|
authenticator = authenticator ?? false,
|
||||||
passwordOnly = passwordOnly ?? false;
|
passwordOnly = passwordOnly ?? false,
|
||||||
|
secondFactorWorks = secondFactorWorks ?? false;
|
||||||
|
|
||||||
factory SignInWays({
|
factory SignInWays({
|
||||||
required bool gitHub,
|
required bool gitHub,
|
||||||
|
|
@ -31,6 +33,7 @@ abstract class SignInWays
|
||||||
bool? password,
|
bool? password,
|
||||||
bool? authenticator,
|
bool? authenticator,
|
||||||
bool? passwordOnly,
|
bool? passwordOnly,
|
||||||
|
bool? secondFactorWorks,
|
||||||
}) = _SignInWaysImpl;
|
}) = _SignInWaysImpl;
|
||||||
|
|
||||||
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
|
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
|
@ -46,6 +49,11 @@ abstract class SignInWays
|
||||||
passwordOnly: jsonSerialization['passwordOnly'] == null
|
passwordOnly: jsonSerialization['passwordOnly'] == null
|
||||||
? null
|
? null
|
||||||
: _isc.BoolJsonExtension.fromJson(jsonSerialization['passwordOnly']),
|
: _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).
|
/// neither a code by mail nor GitHub signs into it (ADR 0017).
|
||||||
bool passwordOnly;
|
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]
|
/// Returns a shallow copy of this [SignInWays]
|
||||||
/// with some or all fields replaced by the given arguments.
|
/// with some or all fields replaced by the given arguments.
|
||||||
@_isc.useResult
|
@_isc.useResult
|
||||||
|
|
@ -73,6 +86,7 @@ abstract class SignInWays
|
||||||
bool? password,
|
bool? password,
|
||||||
bool? authenticator,
|
bool? authenticator,
|
||||||
bool? passwordOnly,
|
bool? passwordOnly,
|
||||||
|
bool? secondFactorWorks,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -83,6 +97,7 @@ abstract class SignInWays
|
||||||
'password': password,
|
'password': password,
|
||||||
'authenticator': authenticator,
|
'authenticator': authenticator,
|
||||||
'passwordOnly': passwordOnly,
|
'passwordOnly': passwordOnly,
|
||||||
|
'secondFactorWorks': secondFactorWorks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,6 +110,7 @@ abstract class SignInWays
|
||||||
'password': password,
|
'password': password,
|
||||||
'authenticator': authenticator,
|
'authenticator': authenticator,
|
||||||
'passwordOnly': passwordOnly,
|
'passwordOnly': passwordOnly,
|
||||||
|
'secondFactorWorks': secondFactorWorks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,12 +127,14 @@ class _SignInWaysImpl extends SignInWays {
|
||||||
bool? password,
|
bool? password,
|
||||||
bool? authenticator,
|
bool? authenticator,
|
||||||
bool? passwordOnly,
|
bool? passwordOnly,
|
||||||
|
bool? secondFactorWorks,
|
||||||
}) : super._(
|
}) : super._(
|
||||||
gitHub: gitHub,
|
gitHub: gitHub,
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
authenticator: authenticator,
|
authenticator: authenticator,
|
||||||
passwordOnly: passwordOnly,
|
passwordOnly: passwordOnly,
|
||||||
|
secondFactorWorks: secondFactorWorks,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Returns a shallow copy of this [SignInWays]
|
/// Returns a shallow copy of this [SignInWays]
|
||||||
|
|
@ -129,6 +147,7 @@ class _SignInWaysImpl extends SignInWays {
|
||||||
bool? password,
|
bool? password,
|
||||||
bool? authenticator,
|
bool? authenticator,
|
||||||
bool? passwordOnly,
|
bool? passwordOnly,
|
||||||
|
bool? secondFactorWorks,
|
||||||
}) {
|
}) {
|
||||||
return SignInWays(
|
return SignInWays(
|
||||||
gitHub: gitHub ?? this.gitHub,
|
gitHub: gitHub ?? this.gitHub,
|
||||||
|
|
@ -136,6 +155,7 @@ class _SignInWaysImpl extends SignInWays {
|
||||||
password: password ?? this.password,
|
password: password ?? this.password,
|
||||||
authenticator: authenticator ?? this.authenticator,
|
authenticator: authenticator ?? this.authenticator,
|
||||||
passwordOnly: passwordOnly ?? this.passwordOnly,
|
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;
|
as _ir3ecgdn;
|
||||||
import 'package:halkyon_base_client/src/protocol/password/new_password.dart'
|
import 'package:halkyon_base_client/src/protocol/password/new_password.dart'
|
||||||
as _i95l60wq;
|
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'
|
import 'package:halkyon_base_client/src/protocol/password/password_challenge.dart'
|
||||||
as _iykren7a;
|
as _iykren7a;
|
||||||
import 'package:halkyon_base_client/src/protocol/password/password_setup.dart'
|
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),
|
/// The signed-in person's authenticators and recovery codes (ADR 0017),
|
||||||
/// which the instance's register keeps (ADR 0016). Every code tried here
|
/// which the instance's register keeps (ADR 0016). Once one is confirmed,
|
||||||
/// counts for the session (`asSessionCodeTry`): the tenth wrong in a row
|
/// every change takes the owner's proof (`OwnerProof`: the password and a
|
||||||
/// ends it ([PasswordRefusal.sessionEnded]).
|
/// code; the third reading), which counts for the session
|
||||||
|
/// (`asOwnerProofTry`): the tenth wrong in a row ends it
|
||||||
|
/// ([PasswordRefusal.sessionEnded]).
|
||||||
/// {@category Endpoint}
|
/// {@category Endpoint}
|
||||||
class EndpointAuthenticator extends _isc.EndpointRef {
|
class EndpointAuthenticator extends _isc.EndpointRef {
|
||||||
EndpointAuthenticator(_isc.EndpointCaller caller) : super(caller);
|
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
|
/// Confirms [authenticator] with a [code] from it, and, where the account
|
||||||
/// has a confirmed authenticator already, an [existingCode] from that one;
|
/// has a confirmed authenticator already, the owner's [proof]; the
|
||||||
/// the account's first returns its recovery codes, shown once. Refusals as
|
/// account's first returns its recovery codes, shown once. Refusals as
|
||||||
/// `confirmAuthenticator`'s.
|
/// `confirmAuthenticator`'s.
|
||||||
_ida.Future<List<String>> confirm({
|
_ida.Future<List<String>> confirm({
|
||||||
required _isc.UuidValue authenticator,
|
required _isc.UuidValue authenticator,
|
||||||
required String code,
|
required String code,
|
||||||
String? existingCode,
|
_ikq9b2yz.OwnerProof? proof,
|
||||||
}) => caller.callServerEndpoint<List<String>>(
|
}) => caller.callServerEndpoint<List<String>>(
|
||||||
'halkyon_base.authenticator',
|
'halkyon_base.authenticator',
|
||||||
'confirm',
|
'confirm',
|
||||||
{
|
{
|
||||||
'authenticator': authenticator,
|
'authenticator': authenticator,
|
||||||
'code': code,
|
'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
|
/// Removes [authenticator]: one never confirmed as it is, a confirmed one
|
||||||
/// caller's, while another stays. Refusals as `removeAuthenticator`'s.
|
/// with the owner's [proof], while another stays. Refusals as
|
||||||
|
/// `removeAuthenticator`'s.
|
||||||
_ida.Future<void> remove({
|
_ida.Future<void> remove({
|
||||||
required _isc.UuidValue authenticator,
|
required _isc.UuidValue authenticator,
|
||||||
String? code,
|
_ikq9b2yz.OwnerProof? proof,
|
||||||
}) => caller.callServerEndpoint<void>(
|
}) => caller.callServerEndpoint<void>(
|
||||||
'halkyon_base.authenticator',
|
'halkyon_base.authenticator',
|
||||||
'remove',
|
'remove',
|
||||||
{
|
{
|
||||||
'authenticator': authenticator,
|
'authenticator': authenticator,
|
||||||
'code': code,
|
'proof': proof,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/// New recovery codes in place of the old, with a [code] from one of the
|
/// New recovery codes in place of the old, with the owner's [proof]:
|
||||||
/// caller's authenticators: shown once. Refusals as `newRecoveryCodes`'.
|
/// shown once. Refusals as `newRecoveryCodes`'.
|
||||||
_ida.Future<List<String>> newRecoveryCodes({required String code}) =>
|
_ida.Future<List<String>> newRecoveryCodes({
|
||||||
caller.callServerEndpoint<List<String>>(
|
required _ikq9b2yz.OwnerProof proof,
|
||||||
'halkyon_base.authenticator',
|
}) => caller.callServerEndpoint<List<String>>(
|
||||||
'newRecoveryCodes',
|
'halkyon_base.authenticator',
|
||||||
{'code': code},
|
'newRecoveryCodes',
|
||||||
);
|
{'proof': proof},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Giving the signed-in person a password (ADR 0017): the way an anonymous
|
/// 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:
|
/// The first step of the caller proving their password (`OwnerProof`) to
|
||||||
/// its salt and costs, and SRP's B. Refusals as `beginPasswordChange`'s.
|
/// 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() =>
|
_ida.Future<_iykren7a.PasswordChallenge> beginChange() =>
|
||||||
caller.callServerEndpoint<_iykren7a.PasswordChallenge>(
|
caller.callServerEndpoint<_iykren7a.PasswordChallenge>(
|
||||||
'halkyon_base.password',
|
'halkyon_base.password',
|
||||||
|
|
@ -458,69 +469,38 @@ class EndpointPassword extends _isc.EndpointRef {
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Changes the caller's password to [password] (the same keys, wrapped
|
/// 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
|
/// under the new password's key on the device), with the owner's [proof]
|
||||||
/// proof for [challenge] ([beginChange]) and a [totpCode] or
|
/// (the old password's proof for a challenge [beginChange] gave, and a
|
||||||
/// [recoveryCode], and the account's [address] as the device typed it
|
/// TOTP or recovery code). Every other session of theirs ends, in every
|
||||||
/// (checked before the code; the owner is mailed in [language] should
|
/// service of the instance; this one stays. The proof counts for this
|
||||||
/// codes come to wait). Every other session of theirs ends, in every
|
/// session (`asOwnerProofTry`). Refusals as `changePassword`'s.
|
||||||
/// service of the instance; this one stays. Refusals as `changePassword`'s.
|
|
||||||
_ida.Future<void> change({
|
_ida.Future<void> change({
|
||||||
required _isc.UuidValue challenge,
|
required _ikq9b2yz.OwnerProof proof,
|
||||||
required _idt.ByteData clientValue,
|
|
||||||
required _idt.ByteData clientProof,
|
|
||||||
String? totpCode,
|
|
||||||
String? recoveryCode,
|
|
||||||
required _i95l60wq.NewPassword password,
|
required _i95l60wq.NewPassword password,
|
||||||
required String host,
|
|
||||||
required String address,
|
|
||||||
required String language,
|
|
||||||
}) => caller.callServerEndpoint<void>(
|
}) => caller.callServerEndpoint<void>(
|
||||||
'halkyon_base.password',
|
'halkyon_base.password',
|
||||||
'change',
|
'change',
|
||||||
{
|
{
|
||||||
'challenge': challenge,
|
'proof': proof,
|
||||||
'clientValue': clientValue,
|
|
||||||
'clientProof': clientProof,
|
|
||||||
'totpCode': totpCode,
|
|
||||||
'recoveryCode': recoveryCode,
|
|
||||||
'password': password,
|
'password': password,
|
||||||
'host': host,
|
|
||||||
'address': address,
|
|
||||||
'language': language,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
|
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
|
||||||
/// it on the device, replace the old wrapping, and [recoveryProofKey], the
|
/// 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
|
/// public half of its proof key, the old one, with the owner's [proof], as
|
||||||
/// for [challenge] ([beginChange]) and a [totpCode] or [recoveryCode], with
|
/// [change] takes. Refusals as `setRecoveryKeys`'.
|
||||||
/// the account's [address] and [language], as [change] takes. Refusals as
|
|
||||||
/// `setRecoveryKeys`'.
|
|
||||||
_ida.Future<void> setRecoveryKeys({
|
_ida.Future<void> setRecoveryKeys({
|
||||||
required _isc.UuidValue challenge,
|
required _ikq9b2yz.OwnerProof proof,
|
||||||
required _idt.ByteData clientValue,
|
|
||||||
required _idt.ByteData clientProof,
|
|
||||||
String? totpCode,
|
|
||||||
String? recoveryCode,
|
|
||||||
required _i3so7e0p.WrappedKeys recoveryKeys,
|
required _i3so7e0p.WrappedKeys recoveryKeys,
|
||||||
required _idt.ByteData recoveryProofKey,
|
required _idt.ByteData recoveryProofKey,
|
||||||
required String host,
|
|
||||||
required String address,
|
|
||||||
required String language,
|
|
||||||
}) => caller.callServerEndpoint<void>(
|
}) => caller.callServerEndpoint<void>(
|
||||||
'halkyon_base.password',
|
'halkyon_base.password',
|
||||||
'setRecoveryKeys',
|
'setRecoveryKeys',
|
||||||
{
|
{
|
||||||
'challenge': challenge,
|
'proof': proof,
|
||||||
'clientValue': clientValue,
|
|
||||||
'clientProof': clientProof,
|
|
||||||
'totpCode': totpCode,
|
|
||||||
'recoveryCode': recoveryCode,
|
|
||||||
'recoveryKeys': recoveryKeys,
|
'recoveryKeys': recoveryKeys,
|
||||||
'recoveryProofKey': recoveryProofKey,
|
'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
|
/// What deleting an account that has a password takes (ADR 0017; the second
|
||||||
/// reading): the password's SRP proof for a challenge `password.beginChange`
|
/// 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
|
/// 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
|
/// change of password does (`OwnerProof`). A session alone deletes only an
|
||||||
/// one.
|
/// account without one.
|
||||||
abstract class DeletionProof
|
abstract class DeletionProof
|
||||||
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||||
DeletionProof._({
|
DeletionProof._({
|
||||||
|
|
@ -29,8 +29,6 @@ abstract class DeletionProof
|
||||||
this.request,
|
this.request,
|
||||||
this.code,
|
this.code,
|
||||||
required this.host,
|
required this.host,
|
||||||
required this.address,
|
|
||||||
required this.language,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory DeletionProof({
|
factory DeletionProof({
|
||||||
|
|
@ -42,8 +40,6 @@ abstract class DeletionProof
|
||||||
_isc.UuidValue? request,
|
_isc.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
required String host,
|
required String host,
|
||||||
required String address,
|
|
||||||
required String language,
|
|
||||||
}) = _DeletionProofImpl;
|
}) = _DeletionProofImpl;
|
||||||
|
|
||||||
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
|
@ -64,8 +60,6 @@ abstract class DeletionProof
|
||||||
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
|
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
|
||||||
code: jsonSerialization['code'] as String?,
|
code: jsonSerialization['code'] as String?,
|
||||||
host: jsonSerialization['host'] 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.
|
/// checks it is its own before the register binds the proof to it.
|
||||||
String host;
|
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]
|
/// Returns a shallow copy of this [DeletionProof]
|
||||||
/// with some or all fields replaced by the given arguments.
|
/// with some or all fields replaced by the given arguments.
|
||||||
@_isc.useResult
|
@_isc.useResult
|
||||||
|
|
@ -110,8 +97,6 @@ abstract class DeletionProof
|
||||||
_isc.UuidValue? request,
|
_isc.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
String? host,
|
String? host,
|
||||||
String? address,
|
|
||||||
String? language,
|
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -125,8 +110,6 @@ abstract class DeletionProof
|
||||||
if (request != null) 'request': request?.toJson(),
|
if (request != null) 'request': request?.toJson(),
|
||||||
if (code != null) 'code': code,
|
if (code != null) 'code': code,
|
||||||
'host': host,
|
'host': host,
|
||||||
'address': address,
|
|
||||||
'language': language,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,8 +125,6 @@ abstract class DeletionProof
|
||||||
if (request != null) 'request': request?.toJson(),
|
if (request != null) 'request': request?.toJson(),
|
||||||
if (code != null) 'code': code,
|
if (code != null) 'code': code,
|
||||||
'host': host,
|
'host': host,
|
||||||
'address': address,
|
|
||||||
'language': language,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,8 +146,6 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
_isc.UuidValue? request,
|
_isc.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
required String host,
|
required String host,
|
||||||
required String address,
|
|
||||||
required String language,
|
|
||||||
}) : super._(
|
}) : super._(
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
clientValue: clientValue,
|
clientValue: clientValue,
|
||||||
|
|
@ -176,8 +155,6 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
request: request,
|
request: request,
|
||||||
code: code,
|
code: code,
|
||||||
host: host,
|
host: host,
|
||||||
address: address,
|
|
||||||
language: language,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Returns a shallow copy of this [DeletionProof]
|
/// Returns a shallow copy of this [DeletionProof]
|
||||||
|
|
@ -193,8 +170,6 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
Object? request = _Undefined,
|
Object? request = _Undefined,
|
||||||
Object? code = _Undefined,
|
Object? code = _Undefined,
|
||||||
String? host,
|
String? host,
|
||||||
String? address,
|
|
||||||
String? language,
|
|
||||||
}) {
|
}) {
|
||||||
return DeletionProof(
|
return DeletionProof(
|
||||||
challenge: challenge ?? this.challenge,
|
challenge: challenge ?? this.challenge,
|
||||||
|
|
@ -205,8 +180,6 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
request: request is _isc.UuidValue? ? request : this.request,
|
request: request is _isc.UuidValue? ? request : this.request,
|
||||||
code: code is String? ? code : this.code,
|
code: code is String? ? code : this.code,
|
||||||
host: host ?? this.host,
|
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.
|
/// The account has as many authenticators as it may: remove one first.
|
||||||
tooManyAuthenticators,
|
tooManyAuthenticators,
|
||||||
|
|
||||||
/// The code from the authenticator isn't right.
|
/// The code from the authenticator being set up isn't right.
|
||||||
wrongCode,
|
wrongCode,
|
||||||
|
|
||||||
/// The reset's time hasn't come yet.
|
/// 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.
|
/// No such reset is waiting, or the address isn't the account's.
|
||||||
noReset,
|
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
|
/// The device addressed this instance by another name than its own
|
||||||
/// (`apiServer.publicHost`): what it proves is bound to the name it
|
/// (`apiServer.publicHost`): what it proves is bound to the name it
|
||||||
/// addressed, so the operator must configure the name devices use.
|
/// 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.
|
/// `takesEffectAt` (the second reading's M4). Only after a right password.
|
||||||
codesWait,
|
codesWait,
|
||||||
|
|
||||||
/// Ten codes in a row from this signed-in session were wrong: the
|
/// Ten proofs in a row from this signed-in session (the password with a
|
||||||
/// session ended (the re-review's third finding); sign in again.
|
/// code, `OwnerProof`) were wrong: the session ended (the third reading);
|
||||||
|
/// sign in again.
|
||||||
sessionEnded,
|
sessionEnded,
|
||||||
|
|
||||||
/// The password was changed (a reset carried out), but this service
|
/// The password was changed (a reset carried out), but this service
|
||||||
|
|
@ -128,8 +124,6 @@ enum PasswordRefusal implements _isc.SerializableModel {
|
||||||
return PasswordRefusal.notDue;
|
return PasswordRefusal.notDue;
|
||||||
case 'noReset':
|
case 'noReset':
|
||||||
return PasswordRefusal.noReset;
|
return PasswordRefusal.noReset;
|
||||||
case 'wrongExistingCode':
|
|
||||||
return PasswordRefusal.wrongExistingCode;
|
|
||||||
case 'otherInstance':
|
case 'otherInstance':
|
||||||
return PasswordRefusal.otherInstance;
|
return PasswordRefusal.otherInstance;
|
||||||
case 'codesWait':
|
case 'codesWait':
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import 'password/authenticator_enrolment.dart' as _iv7tra7v;
|
||||||
import 'password/deletion_proof.dart' as _ix1u7hdq;
|
import 'password/deletion_proof.dart' as _ix1u7hdq;
|
||||||
import 'password/key_proof_challenge.dart' as _iehtx95q;
|
import 'password/key_proof_challenge.dart' as _iehtx95q;
|
||||||
import 'password/new_password.dart' as _itvvd6m6;
|
import 'password/new_password.dart' as _itvvd6m6;
|
||||||
|
import 'password/owner_proof.dart' as _i5f4sdg4;
|
||||||
import 'password/password_challenge.dart' as _iuxisac7;
|
import 'password/password_challenge.dart' as _iuxisac7;
|
||||||
import 'password/password_costs.dart' as _izp492e5;
|
import 'password/password_costs.dart' as _izp492e5;
|
||||||
import 'password/password_refusal.dart' as _ibfrwdhm;
|
import 'password/password_refusal.dart' as _ibfrwdhm;
|
||||||
|
|
@ -57,6 +58,7 @@ export 'password/authenticator_enrolment.dart';
|
||||||
export 'password/deletion_proof.dart';
|
export 'password/deletion_proof.dart';
|
||||||
export 'password/key_proof_challenge.dart';
|
export 'password/key_proof_challenge.dart';
|
||||||
export 'password/new_password.dart';
|
export 'password/new_password.dart';
|
||||||
|
export 'password/owner_proof.dart';
|
||||||
export 'password/password_challenge.dart';
|
export 'password/password_challenge.dart';
|
||||||
export 'password/password_costs.dart';
|
export 'password/password_costs.dart';
|
||||||
export 'password/password_refusal.dart';
|
export 'password/password_refusal.dart';
|
||||||
|
|
@ -156,6 +158,9 @@ class Protocol extends _isc.SerializationManager {
|
||||||
if (t == _itvvd6m6.NewPassword) {
|
if (t == _itvvd6m6.NewPassword) {
|
||||||
return _itvvd6m6.NewPassword.fromJson(data) as T;
|
return _itvvd6m6.NewPassword.fromJson(data) as T;
|
||||||
}
|
}
|
||||||
|
if (t == _i5f4sdg4.OwnerProof) {
|
||||||
|
return _i5f4sdg4.OwnerProof.fromJson(data) as T;
|
||||||
|
}
|
||||||
if (t == _iuxisac7.PasswordChallenge) {
|
if (t == _iuxisac7.PasswordChallenge) {
|
||||||
return _iuxisac7.PasswordChallenge.fromJson(data) as T;
|
return _iuxisac7.PasswordChallenge.fromJson(data) as T;
|
||||||
}
|
}
|
||||||
|
|
@ -253,6 +258,9 @@ class Protocol extends _isc.SerializationManager {
|
||||||
if (t == _isc.getType<_itvvd6m6.NewPassword?>()) {
|
if (t == _isc.getType<_itvvd6m6.NewPassword?>()) {
|
||||||
return (data != null ? _itvvd6m6.NewPassword.fromJson(data) : null) as T;
|
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?>()) {
|
if (t == _isc.getType<_iuxisac7.PasswordChallenge?>()) {
|
||||||
return (data != null ? _iuxisac7.PasswordChallenge.fromJson(data) : null)
|
return (data != null ? _iuxisac7.PasswordChallenge.fromJson(data) : null)
|
||||||
as T;
|
as T;
|
||||||
|
|
@ -355,6 +363,7 @@ class Protocol extends _isc.SerializationManager {
|
||||||
_ix1u7hdq.DeletionProof => 'DeletionProof',
|
_ix1u7hdq.DeletionProof => 'DeletionProof',
|
||||||
_iehtx95q.KeyProofChallenge => 'KeyProofChallenge',
|
_iehtx95q.KeyProofChallenge => 'KeyProofChallenge',
|
||||||
_itvvd6m6.NewPassword => 'NewPassword',
|
_itvvd6m6.NewPassword => 'NewPassword',
|
||||||
|
_i5f4sdg4.OwnerProof => 'OwnerProof',
|
||||||
_iuxisac7.PasswordChallenge => 'PasswordChallenge',
|
_iuxisac7.PasswordChallenge => 'PasswordChallenge',
|
||||||
_izp492e5.PasswordCosts => 'PasswordCosts',
|
_izp492e5.PasswordCosts => 'PasswordCosts',
|
||||||
_ibfrwdhm.PasswordRefusal => 'PasswordRefusal',
|
_ibfrwdhm.PasswordRefusal => 'PasswordRefusal',
|
||||||
|
|
@ -411,6 +420,8 @@ class Protocol extends _isc.SerializationManager {
|
||||||
return 'KeyProofChallenge';
|
return 'KeyProofChallenge';
|
||||||
case _itvvd6m6.NewPassword():
|
case _itvvd6m6.NewPassword():
|
||||||
return 'NewPassword';
|
return 'NewPassword';
|
||||||
|
case _i5f4sdg4.OwnerProof():
|
||||||
|
return 'OwnerProof';
|
||||||
case _iuxisac7.PasswordChallenge():
|
case _iuxisac7.PasswordChallenge():
|
||||||
return 'PasswordChallenge';
|
return 'PasswordChallenge';
|
||||||
case _izp492e5.PasswordCosts():
|
case _izp492e5.PasswordCosts():
|
||||||
|
|
@ -486,6 +497,9 @@ class Protocol extends _isc.SerializationManager {
|
||||||
if (dataClassName == 'NewPassword') {
|
if (dataClassName == 'NewPassword') {
|
||||||
return deserialize<_itvvd6m6.NewPassword>(data['data']);
|
return deserialize<_itvvd6m6.NewPassword>(data['data']);
|
||||||
}
|
}
|
||||||
|
if (dataClassName == 'OwnerProof') {
|
||||||
|
return deserialize<_i5f4sdg4.OwnerProof>(data['data']);
|
||||||
|
}
|
||||||
if (dataClassName == 'PasswordChallenge') {
|
if (dataClassName == 'PasswordChallenge') {
|
||||||
return deserialize<_iuxisac7.PasswordChallenge>(data['data']);
|
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/auth/scopes.dart';
|
||||||
import 'package:halkyon_base_server/src/generated/protocol.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/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/password/password_change.dart';
|
||||||
import 'package:halkyon_base_server/src/register/account_register.dart';
|
import 'package:halkyon_base_server/src/register/account_register.dart';
|
||||||
import 'package:serverpod/serverpod.dart';
|
import 'package:serverpod/serverpod.dart';
|
||||||
|
|
@ -26,12 +27,17 @@ class AccountEndpoint extends Endpoint {
|
||||||
final pod = session.serverpod;
|
final pod = session.serverpod;
|
||||||
final person = session.authenticated!.authUserId;
|
final person = session.authenticated!.authUserId;
|
||||||
final bound = proof?.copyWith(host: deviceHostOn(pod, proof.host));
|
final bound = proof?.copyWith(host: deviceHostOn(pod, proof.host));
|
||||||
if (!isMember(pod)) {
|
// The proof counts for this session (`asOwnerProofTry`), as every
|
||||||
await requireOwnerToDelete(session, person, bound);
|
// change of how the account is secured does.
|
||||||
} else if (session.authenticated!.scopes.contains(identified)) {
|
await asOwnerProofTry(session, bound, () async {
|
||||||
await AccountRegister.of(pod)
|
if (!isMember(pod)) {
|
||||||
.deleteAccount(session, person, proof: bound);
|
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 deletePerson(session, person);
|
||||||
await session.messages.authenticationRevoked(
|
await session.messages.authenticationRevoked(
|
||||||
person.uuid,
|
person.uuid,
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,7 @@ fields:
|
||||||
### authenticator (a reset may have left it without one since), so
|
### authenticator (a reset may have left it without one since), so
|
||||||
### neither a code by mail nor GitHub signs into it (ADR 0017).
|
### neither a code by mail nor GitHub signs into it (ADR 0017).
|
||||||
passwordOnly: bool, default=false
|
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? password,
|
||||||
bool? authenticator,
|
bool? authenticator,
|
||||||
bool? passwordOnly,
|
bool? passwordOnly,
|
||||||
|
bool? secondFactorWorks,
|
||||||
}) : password = password ?? false,
|
}) : password = password ?? false,
|
||||||
authenticator = authenticator ?? false,
|
authenticator = authenticator ?? false,
|
||||||
passwordOnly = passwordOnly ?? false;
|
passwordOnly = passwordOnly ?? false,
|
||||||
|
secondFactorWorks = secondFactorWorks ?? false;
|
||||||
|
|
||||||
factory SignInWays({
|
factory SignInWays({
|
||||||
required bool gitHub,
|
required bool gitHub,
|
||||||
|
|
@ -31,6 +33,7 @@ abstract class SignInWays
|
||||||
bool? password,
|
bool? password,
|
||||||
bool? authenticator,
|
bool? authenticator,
|
||||||
bool? passwordOnly,
|
bool? passwordOnly,
|
||||||
|
bool? secondFactorWorks,
|
||||||
}) = _SignInWaysImpl;
|
}) = _SignInWaysImpl;
|
||||||
|
|
||||||
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
|
factory SignInWays.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
|
@ -46,6 +49,11 @@ abstract class SignInWays
|
||||||
passwordOnly: jsonSerialization['passwordOnly'] == null
|
passwordOnly: jsonSerialization['passwordOnly'] == null
|
||||||
? null
|
? null
|
||||||
: _is.BoolJsonExtension.fromJson(jsonSerialization['passwordOnly']),
|
: _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).
|
/// neither a code by mail nor GitHub signs into it (ADR 0017).
|
||||||
bool passwordOnly;
|
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]
|
/// Returns a shallow copy of this [SignInWays]
|
||||||
/// with some or all fields replaced by the given arguments.
|
/// with some or all fields replaced by the given arguments.
|
||||||
@_is.useResult
|
@_is.useResult
|
||||||
|
|
@ -73,6 +86,7 @@ abstract class SignInWays
|
||||||
bool? password,
|
bool? password,
|
||||||
bool? authenticator,
|
bool? authenticator,
|
||||||
bool? passwordOnly,
|
bool? passwordOnly,
|
||||||
|
bool? secondFactorWorks,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -83,6 +97,7 @@ abstract class SignInWays
|
||||||
'password': password,
|
'password': password,
|
||||||
'authenticator': authenticator,
|
'authenticator': authenticator,
|
||||||
'passwordOnly': passwordOnly,
|
'passwordOnly': passwordOnly,
|
||||||
|
'secondFactorWorks': secondFactorWorks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,6 +110,7 @@ abstract class SignInWays
|
||||||
'password': password,
|
'password': password,
|
||||||
'authenticator': authenticator,
|
'authenticator': authenticator,
|
||||||
'passwordOnly': passwordOnly,
|
'passwordOnly': passwordOnly,
|
||||||
|
'secondFactorWorks': secondFactorWorks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,12 +127,14 @@ class _SignInWaysImpl extends SignInWays {
|
||||||
bool? password,
|
bool? password,
|
||||||
bool? authenticator,
|
bool? authenticator,
|
||||||
bool? passwordOnly,
|
bool? passwordOnly,
|
||||||
|
bool? secondFactorWorks,
|
||||||
}) : super._(
|
}) : super._(
|
||||||
gitHub: gitHub,
|
gitHub: gitHub,
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
authenticator: authenticator,
|
authenticator: authenticator,
|
||||||
passwordOnly: passwordOnly,
|
passwordOnly: passwordOnly,
|
||||||
|
secondFactorWorks: secondFactorWorks,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Returns a shallow copy of this [SignInWays]
|
/// Returns a shallow copy of this [SignInWays]
|
||||||
|
|
@ -129,6 +147,7 @@ class _SignInWaysImpl extends SignInWays {
|
||||||
bool? password,
|
bool? password,
|
||||||
bool? authenticator,
|
bool? authenticator,
|
||||||
bool? passwordOnly,
|
bool? passwordOnly,
|
||||||
|
bool? secondFactorWorks,
|
||||||
}) {
|
}) {
|
||||||
return SignInWays(
|
return SignInWays(
|
||||||
gitHub: gitHub ?? this.gitHub,
|
gitHub: gitHub ?? this.gitHub,
|
||||||
|
|
@ -136,6 +155,7 @@ class _SignInWaysImpl extends SignInWays {
|
||||||
password: password ?? this.password,
|
password: password ?? this.password,
|
||||||
authenticator: authenticator ?? this.authenticator,
|
authenticator: authenticator ?? this.authenticator,
|
||||||
passwordOnly: passwordOnly ?? this.passwordOnly,
|
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;
|
as _i619l6vv;
|
||||||
import 'package:halkyon_base_server/src/generated/password/new_password.dart'
|
import 'package:halkyon_base_server/src/generated/password/new_password.dart'
|
||||||
as _ijye8mcv;
|
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'
|
import 'package:halkyon_base_server/src/generated/password/password_setup.dart'
|
||||||
as _ikhyee47;
|
as _ikhyee47;
|
||||||
import 'package:halkyon_base_server/src/generated/password/wrapped_keys.dart'
|
import 'package:halkyon_base_server/src/generated/password/wrapped_keys.dart'
|
||||||
|
|
@ -593,9 +595,9 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
type: _is.getType<String>(),
|
type: _is.getType<String>(),
|
||||||
nullable: false,
|
nullable: false,
|
||||||
),
|
),
|
||||||
'existingCode': _is.ParameterDescription(
|
'proof': _is.ParameterDescription(
|
||||||
name: 'existingCode',
|
name: 'proof',
|
||||||
type: _is.getType<String?>(),
|
type: _is.getType<_ifhod73s.OwnerProof?>(),
|
||||||
nullable: true,
|
nullable: true,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -610,7 +612,7 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
session,
|
session,
|
||||||
authenticator: params['authenticator'],
|
authenticator: params['authenticator'],
|
||||||
code: params['code'],
|
code: params['code'],
|
||||||
existingCode: params['existingCode'],
|
proof: params['proof'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
'list': _is.MethodConnector(
|
'list': _is.MethodConnector(
|
||||||
|
|
@ -633,9 +635,9 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
type: _is.getType<_is.UuidValue>(),
|
type: _is.getType<_is.UuidValue>(),
|
||||||
nullable: false,
|
nullable: false,
|
||||||
),
|
),
|
||||||
'code': _is.ParameterDescription(
|
'proof': _is.ParameterDescription(
|
||||||
name: 'code',
|
name: 'proof',
|
||||||
type: _is.getType<String?>(),
|
type: _is.getType<_ifhod73s.OwnerProof?>(),
|
||||||
nullable: true,
|
nullable: true,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -649,15 +651,15 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
.remove(
|
.remove(
|
||||||
session,
|
session,
|
||||||
authenticator: params['authenticator'],
|
authenticator: params['authenticator'],
|
||||||
code: params['code'],
|
proof: params['proof'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
'newRecoveryCodes': _is.MethodConnector(
|
'newRecoveryCodes': _is.MethodConnector(
|
||||||
name: 'newRecoveryCodes',
|
name: 'newRecoveryCodes',
|
||||||
params: {
|
params: {
|
||||||
'code': _is.ParameterDescription(
|
'proof': _is.ParameterDescription(
|
||||||
name: 'code',
|
name: 'proof',
|
||||||
type: _is.getType<String>(),
|
type: _is.getType<_ifhod73s.OwnerProof>(),
|
||||||
nullable: false,
|
nullable: false,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -670,7 +672,7 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
as _i3xqawxj.AuthenticatorEndpoint)
|
as _i3xqawxj.AuthenticatorEndpoint)
|
||||||
.newRecoveryCodes(
|
.newRecoveryCodes(
|
||||||
session,
|
session,
|
||||||
code: params['code'],
|
proof: params['proof'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -723,51 +725,16 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
'change': _is.MethodConnector(
|
'change': _is.MethodConnector(
|
||||||
name: 'change',
|
name: 'change',
|
||||||
params: {
|
params: {
|
||||||
'challenge': _is.ParameterDescription(
|
'proof': _is.ParameterDescription(
|
||||||
name: 'challenge',
|
name: 'proof',
|
||||||
type: _is.getType<_is.UuidValue>(),
|
type: _is.getType<_ifhod73s.OwnerProof>(),
|
||||||
nullable: false,
|
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(
|
'password': _is.ParameterDescription(
|
||||||
name: 'password',
|
name: 'password',
|
||||||
type: _is.getType<_ijye8mcv.NewPassword>(),
|
type: _is.getType<_ijye8mcv.NewPassword>(),
|
||||||
nullable: false,
|
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:
|
call:
|
||||||
(
|
(
|
||||||
|
|
@ -776,45 +743,18 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
) async =>
|
) async =>
|
||||||
(endpoints['password'] as _ig7fyg1b.PasswordEndpoint).change(
|
(endpoints['password'] as _ig7fyg1b.PasswordEndpoint).change(
|
||||||
session,
|
session,
|
||||||
challenge: params['challenge'],
|
proof: params['proof'],
|
||||||
clientValue: params['clientValue'],
|
|
||||||
clientProof: params['clientProof'],
|
|
||||||
totpCode: params['totpCode'],
|
|
||||||
recoveryCode: params['recoveryCode'],
|
|
||||||
password: params['password'],
|
password: params['password'],
|
||||||
host: params['host'],
|
|
||||||
address: params['address'],
|
|
||||||
language: params['language'],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
'setRecoveryKeys': _is.MethodConnector(
|
'setRecoveryKeys': _is.MethodConnector(
|
||||||
name: 'setRecoveryKeys',
|
name: 'setRecoveryKeys',
|
||||||
params: {
|
params: {
|
||||||
'challenge': _is.ParameterDescription(
|
'proof': _is.ParameterDescription(
|
||||||
name: 'challenge',
|
name: 'proof',
|
||||||
type: _is.getType<_is.UuidValue>(),
|
type: _is.getType<_ifhod73s.OwnerProof>(),
|
||||||
nullable: false,
|
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(
|
'recoveryKeys': _is.ParameterDescription(
|
||||||
name: 'recoveryKeys',
|
name: 'recoveryKeys',
|
||||||
type: _is.getType<_iji0wmu8.WrappedKeys>(),
|
type: _is.getType<_iji0wmu8.WrappedKeys>(),
|
||||||
|
|
@ -825,21 +765,6 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
type: _is.getType<_idt.ByteData>(),
|
type: _is.getType<_idt.ByteData>(),
|
||||||
nullable: false,
|
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:
|
call:
|
||||||
(
|
(
|
||||||
|
|
@ -848,16 +773,9 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint)
|
) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint)
|
||||||
.setRecoveryKeys(
|
.setRecoveryKeys(
|
||||||
session,
|
session,
|
||||||
challenge: params['challenge'],
|
proof: params['proof'],
|
||||||
clientValue: params['clientValue'],
|
|
||||||
clientProof: params['clientProof'],
|
|
||||||
totpCode: params['totpCode'],
|
|
||||||
recoveryCode: params['recoveryCode'],
|
|
||||||
recoveryKeys: params['recoveryKeys'],
|
recoveryKeys: params['recoveryKeys'],
|
||||||
recoveryProofKey: params['recoveryProofKey'],
|
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
|
/// What deleting an account that has a password takes (ADR 0017; the second
|
||||||
/// reading): the password's SRP proof for a challenge `password.beginChange`
|
/// 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
|
/// 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
|
/// change of password does (`OwnerProof`). A session alone deletes only an
|
||||||
/// one.
|
/// account without one.
|
||||||
abstract class DeletionProof
|
abstract class DeletionProof
|
||||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||||
DeletionProof._({
|
DeletionProof._({
|
||||||
|
|
@ -29,8 +29,6 @@ abstract class DeletionProof
|
||||||
this.request,
|
this.request,
|
||||||
this.code,
|
this.code,
|
||||||
required this.host,
|
required this.host,
|
||||||
required this.address,
|
|
||||||
required this.language,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory DeletionProof({
|
factory DeletionProof({
|
||||||
|
|
@ -42,8 +40,6 @@ abstract class DeletionProof
|
||||||
_is.UuidValue? request,
|
_is.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
required String host,
|
required String host,
|
||||||
required String address,
|
|
||||||
required String language,
|
|
||||||
}) = _DeletionProofImpl;
|
}) = _DeletionProofImpl;
|
||||||
|
|
||||||
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
|
@ -64,8 +60,6 @@ abstract class DeletionProof
|
||||||
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
|
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['request']),
|
||||||
code: jsonSerialization['code'] as String?,
|
code: jsonSerialization['code'] as String?,
|
||||||
host: jsonSerialization['host'] 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.
|
/// checks it is its own before the register binds the proof to it.
|
||||||
String host;
|
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]
|
/// Returns a shallow copy of this [DeletionProof]
|
||||||
/// with some or all fields replaced by the given arguments.
|
/// with some or all fields replaced by the given arguments.
|
||||||
@_is.useResult
|
@_is.useResult
|
||||||
|
|
@ -110,8 +97,6 @@ abstract class DeletionProof
|
||||||
_is.UuidValue? request,
|
_is.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
String? host,
|
String? host,
|
||||||
String? address,
|
|
||||||
String? language,
|
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -125,8 +110,6 @@ abstract class DeletionProof
|
||||||
if (request != null) 'request': request?.toJson(),
|
if (request != null) 'request': request?.toJson(),
|
||||||
if (code != null) 'code': code,
|
if (code != null) 'code': code,
|
||||||
'host': host,
|
'host': host,
|
||||||
'address': address,
|
|
||||||
'language': language,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,8 +125,6 @@ abstract class DeletionProof
|
||||||
if (request != null) 'request': request?.toJson(),
|
if (request != null) 'request': request?.toJson(),
|
||||||
if (code != null) 'code': code,
|
if (code != null) 'code': code,
|
||||||
'host': host,
|
'host': host,
|
||||||
'address': address,
|
|
||||||
'language': language,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,8 +146,6 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
_is.UuidValue? request,
|
_is.UuidValue? request,
|
||||||
String? code,
|
String? code,
|
||||||
required String host,
|
required String host,
|
||||||
required String address,
|
|
||||||
required String language,
|
|
||||||
}) : super._(
|
}) : super._(
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
clientValue: clientValue,
|
clientValue: clientValue,
|
||||||
|
|
@ -176,8 +155,6 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
request: request,
|
request: request,
|
||||||
code: code,
|
code: code,
|
||||||
host: host,
|
host: host,
|
||||||
address: address,
|
|
||||||
language: language,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Returns a shallow copy of this [DeletionProof]
|
/// Returns a shallow copy of this [DeletionProof]
|
||||||
|
|
@ -193,8 +170,6 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
Object? request = _Undefined,
|
Object? request = _Undefined,
|
||||||
Object? code = _Undefined,
|
Object? code = _Undefined,
|
||||||
String? host,
|
String? host,
|
||||||
String? address,
|
|
||||||
String? language,
|
|
||||||
}) {
|
}) {
|
||||||
return DeletionProof(
|
return DeletionProof(
|
||||||
challenge: challenge ?? this.challenge,
|
challenge: challenge ?? this.challenge,
|
||||||
|
|
@ -205,8 +180,6 @@ class _DeletionProofImpl extends DeletionProof {
|
||||||
request: request is _is.UuidValue? ? request : this.request,
|
request: request is _is.UuidValue? ? request : this.request,
|
||||||
code: code is String? ? code : this.code,
|
code: code is String? ? code : this.code,
|
||||||
host: host ?? this.host,
|
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.
|
/// The account has as many authenticators as it may: remove one first.
|
||||||
tooManyAuthenticators,
|
tooManyAuthenticators,
|
||||||
|
|
||||||
/// The code from the authenticator isn't right.
|
/// The code from the authenticator being set up isn't right.
|
||||||
wrongCode,
|
wrongCode,
|
||||||
|
|
||||||
/// The reset's time hasn't come yet.
|
/// 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.
|
/// No such reset is waiting, or the address isn't the account's.
|
||||||
noReset,
|
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
|
/// The device addressed this instance by another name than its own
|
||||||
/// (`apiServer.publicHost`): what it proves is bound to the name it
|
/// (`apiServer.publicHost`): what it proves is bound to the name it
|
||||||
/// addressed, so the operator must configure the name devices use.
|
/// 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.
|
/// `takesEffectAt` (the second reading's M4). Only after a right password.
|
||||||
codesWait,
|
codesWait,
|
||||||
|
|
||||||
/// Ten codes in a row from this signed-in session were wrong: the
|
/// Ten proofs in a row from this signed-in session (the password with a
|
||||||
/// session ended (the re-review's third finding); sign in again.
|
/// code, `OwnerProof`) were wrong: the session ended (the third reading);
|
||||||
|
/// sign in again.
|
||||||
sessionEnded,
|
sessionEnded,
|
||||||
|
|
||||||
/// The password was changed (a reset carried out), but this service
|
/// The password was changed (a reset carried out), but this service
|
||||||
|
|
@ -128,8 +124,6 @@ enum PasswordRefusal implements _is.SerializableModel {
|
||||||
return PasswordRefusal.notDue;
|
return PasswordRefusal.notDue;
|
||||||
case 'noReset':
|
case 'noReset':
|
||||||
return PasswordRefusal.noReset;
|
return PasswordRefusal.noReset;
|
||||||
case 'wrongExistingCode':
|
|
||||||
return PasswordRefusal.wrongExistingCode;
|
|
||||||
case 'otherInstance':
|
case 'otherInstance':
|
||||||
return PasswordRefusal.otherInstance;
|
return PasswordRefusal.otherInstance;
|
||||||
case 'codesWait':
|
case 'codesWait':
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,13 @@
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
import 'package:serverpod/serverpod.dart' as _is;
|
import 'package:serverpod/serverpod.dart' as _is;
|
||||||
|
|
||||||
/// The codes a signed-in session tried in a row without one accepted (ADR
|
/// The proofs a signed-in session sent in a row without one accepted
|
||||||
/// 0017; the re-review's third finding): removing an authenticator, new
|
/// (`OwnerProof`, the password and a code; ADR 0017, the third reading):
|
||||||
/// recovery codes, confirming an authenticator. They count per session,
|
/// every change of how an account is secured. They count per session,
|
||||||
/// never toward the account's waits (a stolen session alone would hold its
|
/// never toward the account's waits (whoever holds a session or knows the
|
||||||
/// owner out of their codes), and the tenth in a row ends the session. The
|
/// password would hold its owner out of their codes), and the tenth wrong in
|
||||||
/// row goes once a code is accepted, with the session, or with the person.
|
/// a row ends the session. The row goes once a proof is accepted, with the
|
||||||
|
/// session, or with the person.
|
||||||
abstract class SessionCodeFailures
|
abstract class SessionCodeFailures
|
||||||
implements _is.TableRow<int?>, _is.ProtocolSerialization {
|
implements _is.TableRow<int?>, _is.ProtocolSerialization {
|
||||||
SessionCodeFailures._({
|
SessionCodeFailures._({
|
||||||
|
|
@ -65,7 +66,7 @@ abstract class SessionCodeFailures
|
||||||
/// Its person (deleted with them, `deletePerson`).
|
/// Its person (deleted with them, `deletePerson`).
|
||||||
_is.UuidValue authUserId;
|
_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.
|
/// checked.
|
||||||
int failures;
|
int failures;
|
||||||
|
|
||||||
|
|
@ -227,7 +228,7 @@ class SessionCodeFailuresTable extends _is.Table<int?> {
|
||||||
/// Its person (deleted with them, `deletePerson`).
|
/// Its person (deleted with them, `deletePerson`).
|
||||||
late final _is.ColumnUuid authUserId;
|
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.
|
/// checked.
|
||||||
late final _is.ColumnInt failures;
|
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/deletion_proof.dart' as _ix1u7hdq;
|
||||||
import 'password/key_proof_challenge.dart' as _iehtx95q;
|
import 'password/key_proof_challenge.dart' as _iehtx95q;
|
||||||
import 'password/new_password.dart' as _itvvd6m6;
|
import 'password/new_password.dart' as _itvvd6m6;
|
||||||
|
import 'password/owner_proof.dart' as _i5f4sdg4;
|
||||||
import 'password/password_challenge.dart' as _iuxisac7;
|
import 'password/password_challenge.dart' as _iuxisac7;
|
||||||
import 'password/password_costs.dart' as _izp492e5;
|
import 'password/password_costs.dart' as _izp492e5;
|
||||||
import 'password/password_proof.dart' as _ionb75gc;
|
import 'password/password_proof.dart' as _ionb75gc;
|
||||||
|
|
@ -75,6 +76,7 @@ export 'password/authenticator_enrolment.dart';
|
||||||
export 'password/deletion_proof.dart';
|
export 'password/deletion_proof.dart';
|
||||||
export 'password/key_proof_challenge.dart';
|
export 'password/key_proof_challenge.dart';
|
||||||
export 'password/new_password.dart';
|
export 'password/new_password.dart';
|
||||||
|
export 'password/owner_proof.dart';
|
||||||
export 'password/password_challenge.dart';
|
export 'password/password_challenge.dart';
|
||||||
export 'password/password_costs.dart';
|
export 'password/password_costs.dart';
|
||||||
export 'password/password_proof.dart';
|
export 'password/password_proof.dart';
|
||||||
|
|
@ -1068,6 +1070,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
||||||
if (t == _itvvd6m6.NewPassword) {
|
if (t == _itvvd6m6.NewPassword) {
|
||||||
return _itvvd6m6.NewPassword.fromJson(data) as T;
|
return _itvvd6m6.NewPassword.fromJson(data) as T;
|
||||||
}
|
}
|
||||||
|
if (t == _i5f4sdg4.OwnerProof) {
|
||||||
|
return _i5f4sdg4.OwnerProof.fromJson(data) as T;
|
||||||
|
}
|
||||||
if (t == _iuxisac7.PasswordChallenge) {
|
if (t == _iuxisac7.PasswordChallenge) {
|
||||||
return _iuxisac7.PasswordChallenge.fromJson(data) as T;
|
return _iuxisac7.PasswordChallenge.fromJson(data) as T;
|
||||||
}
|
}
|
||||||
|
|
@ -1207,6 +1212,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
||||||
if (t == _is.getType<_itvvd6m6.NewPassword?>()) {
|
if (t == _is.getType<_itvvd6m6.NewPassword?>()) {
|
||||||
return (data != null ? _itvvd6m6.NewPassword.fromJson(data) : null) as T;
|
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?>()) {
|
if (t == _is.getType<_iuxisac7.PasswordChallenge?>()) {
|
||||||
return (data != null ? _iuxisac7.PasswordChallenge.fromJson(data) : null)
|
return (data != null ? _iuxisac7.PasswordChallenge.fromJson(data) : null)
|
||||||
as T;
|
as T;
|
||||||
|
|
@ -1359,6 +1367,7 @@ class Protocol extends _is.DatabaseSerializationManager {
|
||||||
_ix1u7hdq.DeletionProof => 'DeletionProof',
|
_ix1u7hdq.DeletionProof => 'DeletionProof',
|
||||||
_iehtx95q.KeyProofChallenge => 'KeyProofChallenge',
|
_iehtx95q.KeyProofChallenge => 'KeyProofChallenge',
|
||||||
_itvvd6m6.NewPassword => 'NewPassword',
|
_itvvd6m6.NewPassword => 'NewPassword',
|
||||||
|
_i5f4sdg4.OwnerProof => 'OwnerProof',
|
||||||
_iuxisac7.PasswordChallenge => 'PasswordChallenge',
|
_iuxisac7.PasswordChallenge => 'PasswordChallenge',
|
||||||
_izp492e5.PasswordCosts => 'PasswordCosts',
|
_izp492e5.PasswordCosts => 'PasswordCosts',
|
||||||
_ionb75gc.PasswordProof => 'PasswordProof',
|
_ionb75gc.PasswordProof => 'PasswordProof',
|
||||||
|
|
@ -1432,6 +1441,8 @@ class Protocol extends _is.DatabaseSerializationManager {
|
||||||
return 'KeyProofChallenge';
|
return 'KeyProofChallenge';
|
||||||
case _itvvd6m6.NewPassword():
|
case _itvvd6m6.NewPassword():
|
||||||
return 'NewPassword';
|
return 'NewPassword';
|
||||||
|
case _i5f4sdg4.OwnerProof():
|
||||||
|
return 'OwnerProof';
|
||||||
case _iuxisac7.PasswordChallenge():
|
case _iuxisac7.PasswordChallenge():
|
||||||
return 'PasswordChallenge';
|
return 'PasswordChallenge';
|
||||||
case _izp492e5.PasswordCosts():
|
case _izp492e5.PasswordCosts():
|
||||||
|
|
@ -1541,6 +1552,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
||||||
if (dataClassName == 'NewPassword') {
|
if (dataClassName == 'NewPassword') {
|
||||||
return deserialize<_itvvd6m6.NewPassword>(data['data']);
|
return deserialize<_itvvd6m6.NewPassword>(data['data']);
|
||||||
}
|
}
|
||||||
|
if (dataClassName == 'OwnerProof') {
|
||||||
|
return deserialize<_i5f4sdg4.OwnerProof>(data['data']);
|
||||||
|
}
|
||||||
if (dataClassName == 'PasswordChallenge') {
|
if (dataClassName == 'PasswordChallenge') {
|
||||||
return deserialize<_iuxisac7.PasswordChallenge>(data['data']);
|
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/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:halkyon_base_server/src/register/account_register.dart';
|
||||||
import 'package:serverpod/serverpod.dart';
|
import 'package:serverpod/serverpod.dart';
|
||||||
import 'package:serverpod_auth_idp_server/core.dart';
|
import 'package:serverpod_auth_idp_server/core.dart';
|
||||||
|
|
||||||
/// The signed-in person's authenticators and recovery codes (ADR 0017),
|
/// The signed-in person's authenticators and recovery codes (ADR 0017),
|
||||||
/// which the instance's register keeps (ADR 0016). Every code tried here
|
/// which the instance's register keeps (ADR 0016). Once one is confirmed,
|
||||||
/// counts for the session (`asSessionCodeTry`): the tenth wrong in a row
|
/// every change takes the owner's proof (`OwnerProof`: the password and a
|
||||||
/// ends it ([PasswordRefusal.sessionEnded]).
|
/// code; the third reading), which counts for the session
|
||||||
|
/// (`asOwnerProofTry`): the tenth wrong in a row ends it
|
||||||
|
/// ([PasswordRefusal.sessionEnded]).
|
||||||
class AuthenticatorEndpoint extends Endpoint {
|
class AuthenticatorEndpoint extends Endpoint {
|
||||||
@override
|
@override
|
||||||
bool get requireLogin => true;
|
bool get requireLogin => true;
|
||||||
|
|
@ -27,57 +30,75 @@ class AuthenticatorEndpoint extends Endpoint {
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Confirms [authenticator] with a [code] from it, and, where the account
|
/// Confirms [authenticator] with a [code] from it, and, where the account
|
||||||
/// has a confirmed authenticator already, an [existingCode] from that one;
|
/// has a confirmed authenticator already, the owner's [proof]; the
|
||||||
/// the account's first returns its recovery codes, shown once. Refusals as
|
/// account's first returns its recovery codes, shown once. Refusals as
|
||||||
/// `confirmAuthenticator`'s.
|
/// `confirmAuthenticator`'s.
|
||||||
Future<List<String>> confirm(
|
Future<List<String>> confirm(
|
||||||
Session session, {
|
Session session, {
|
||||||
required UuidValue authenticator,
|
required UuidValue authenticator,
|
||||||
required String code,
|
required String code,
|
||||||
String? existingCode,
|
OwnerProof? proof,
|
||||||
}) => asSessionCodeTry(
|
}) {
|
||||||
session,
|
final bound = _bound(session, proof);
|
||||||
() => AccountRegister.of(session.serverpod).confirmAuthenticator(
|
return asOwnerProofTry(
|
||||||
session,
|
session,
|
||||||
person: session.authenticated!.authUserId,
|
bound,
|
||||||
authenticator: authenticator,
|
() => AccountRegister.of(session.serverpod).confirmAuthenticator(
|
||||||
code: code,
|
session,
|
||||||
existingCode: existingCode,
|
person: session.authenticated!.authUserId,
|
||||||
),
|
authenticator: authenticator,
|
||||||
);
|
code: code,
|
||||||
|
proof: bound,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The caller's authenticators, the oldest first.
|
/// The caller's authenticators, the oldest first.
|
||||||
Future<List<Authenticator>> list(Session session) => AccountRegister.of(
|
Future<List<Authenticator>> list(Session session) => AccountRegister.of(
|
||||||
session.serverpod,
|
session.serverpod,
|
||||||
).authenticatorsOf(session, session.authenticated!.authUserId);
|
).authenticatorsOf(session, session.authenticated!.authUserId);
|
||||||
|
|
||||||
/// Removes [authenticator]: a confirmed one with a [code] from one of the
|
/// Removes [authenticator]: one never confirmed as it is, a confirmed one
|
||||||
/// caller's, while another stays. Refusals as `removeAuthenticator`'s.
|
/// with the owner's [proof], while another stays. Refusals as
|
||||||
|
/// `removeAuthenticator`'s.
|
||||||
Future<void> remove(
|
Future<void> remove(
|
||||||
Session session, {
|
Session session, {
|
||||||
required UuidValue authenticator,
|
required UuidValue authenticator,
|
||||||
String? code,
|
OwnerProof? proof,
|
||||||
}) => asSessionCodeTry(
|
}) {
|
||||||
session,
|
final bound = _bound(session, proof);
|
||||||
() => AccountRegister.of(session.serverpod).removeAuthenticator(
|
return asOwnerProofTry(
|
||||||
session,
|
session,
|
||||||
person: session.authenticated!.authUserId,
|
bound,
|
||||||
authenticator: authenticator,
|
() => AccountRegister.of(session.serverpod).removeAuthenticator(
|
||||||
code: code,
|
session,
|
||||||
),
|
person: session.authenticated!.authUserId,
|
||||||
);
|
authenticator: authenticator,
|
||||||
|
proof: bound,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// New recovery codes in place of the old, with a [code] from one of the
|
/// New recovery codes in place of the old, with the owner's [proof]:
|
||||||
/// caller's authenticators: shown once. Refusals as `newRecoveryCodes`'.
|
/// shown once. Refusals as `newRecoveryCodes`'.
|
||||||
Future<List<String>> newRecoveryCodes(
|
Future<List<String>> newRecoveryCodes(
|
||||||
Session session, {
|
Session session, {
|
||||||
required String code,
|
required OwnerProof proof,
|
||||||
}) => asSessionCodeTry(
|
}) {
|
||||||
session,
|
final bound = _bound(session, proof)!;
|
||||||
() => AccountRegister.of(session.serverpod).newRecoveryCodes(
|
return asOwnerProofTry(
|
||||||
session,
|
session,
|
||||||
person: session.authenticated!.authUserId,
|
bound,
|
||||||
code: code,
|
() => 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:clock/clock.dart';
|
||||||
import 'package:halkyon_base_server/src/generated/protocol.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/password_config.dart';
|
||||||
import 'package:halkyon_base_server/src/password/passwords.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/password/totp_secrets.dart';
|
||||||
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
|
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
|
||||||
import 'package:halkyon_base_server/src/totp/recovery_codes.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
|
// 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,
|
// it is accepted, and the first one confirmed brings ten recovery codes,
|
||||||
// shown once too. TOTP is mandatory: the last confirmed authenticator stays.
|
// 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.
|
/// How many authenticators an account may have, confirmed or not.
|
||||||
const maxAuthenticators = 10;
|
const maxAuthenticators = 10;
|
||||||
|
|
@ -106,36 +110,40 @@ String _labelOf(String label) {
|
||||||
: plain;
|
: plain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirms [person]'s [authenticator] with a [code] from it: it counts from
|
/// Confirms [person]'s [authenticator], waiting to be confirmed, with a
|
||||||
/// now on, and a reset of the authenticators, waiting or taken effect, is
|
/// [code] from it: it counts from now on, and a reset of the
|
||||||
/// over (the person, signed in, has one again). The
|
/// authenticators, waiting or taken effect, is over (the person, signed in,
|
||||||
/// account's first confirmed authenticator brings a new set of recovery
|
/// has one again). The account's first confirmed authenticator brings a new
|
||||||
/// codes, returned to be shown once; otherwise none. Confirming one
|
/// set of recovery codes, returned to be shown once; otherwise none.
|
||||||
/// confirmed before only checks the code.
|
|
||||||
///
|
///
|
||||||
/// While the account has a confirmed authenticator, another counts only
|
/// While the account has a confirmed authenticator, another counts only
|
||||||
/// with an [existingCode] from one of those too (each step's code once, as
|
/// with the owner's [proof] (the password and a code from one of those,
|
||||||
/// signing in takes it; decided 20 Oct 2026): otherwise whoever holds a
|
/// [proveOwner]; the third reading): otherwise whoever holds a session
|
||||||
/// session could add a second factor of their own, sign in with it and
|
/// could add a second factor of their own, sign in with it and remove the
|
||||||
/// remove the owner's. Without one (the first, or after a reset carried
|
/// owner's. Without one (the first, or after a reset carried out) the new
|
||||||
/// out) none is asked for.
|
/// 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
|
/// Refused ([PasswordRefusedException]) for an authenticator not the
|
||||||
/// person's ([PasswordRefusal.unknownAuthenticator]), a wrong or used code
|
/// person's, or one confirmed already, whose code a session alone must never
|
||||||
/// ([PasswordRefusal.wrongCode]), a missing, wrong or used code from one
|
/// get checked ([PasswordRefusal.unknownAuthenticator]); a wrong or used
|
||||||
/// confirmed before ([PasswordRefusal.wrongExistingCode]; checked only once
|
/// code from the new one ([PasswordRefusal.wrongCode], before any proof is
|
||||||
/// [code] is right, so a wrong one uses none). The session's tries are
|
/// checked, so it spends none); a proof where none is taken
|
||||||
/// counted where it is (`asSessionCodeTry`, the endpoint's), never toward
|
/// ([PasswordRefusal.invalidSetup]); and as [proveOwner].
|
||||||
/// the account's waits.
|
|
||||||
Future<List<String>> confirmAuthenticator(
|
Future<List<String>> confirmAuthenticator(
|
||||||
Session session, {
|
Session session, {
|
||||||
required UuidValue person,
|
required UuidValue person,
|
||||||
required UuidValue authenticator,
|
required UuidValue authenticator,
|
||||||
required String code,
|
required String code,
|
||||||
String? existingCode,
|
OwnerProof? proof,
|
||||||
}) async {
|
}) async {
|
||||||
final keys = requirePasswordKeys(session.serverpod);
|
final keys = requirePasswordKeys(session.serverpod);
|
||||||
final row = await _authenticatorOf(session, person, authenticator);
|
final row = await _authenticatorOf(session, person, authenticator);
|
||||||
|
if (row.confirmedAt != null) {
|
||||||
|
throw refusedFor(PasswordRefusal.unknownAuthenticator);
|
||||||
|
}
|
||||||
final secret = await openTotpSecret(
|
final secret = await openTotpSecret(
|
||||||
row.secret,
|
row.secret,
|
||||||
totpKey: keys.totpKey,
|
totpKey: keys.totpKey,
|
||||||
|
|
@ -151,15 +159,13 @@ Future<List<String>> confirmAuthenticator(
|
||||||
TotpAccepted(:final step) => step,
|
TotpAccepted(:final step) => step,
|
||||||
TotpRefused() => throw refusedFor(PasswordRefusal.wrongCode),
|
TotpRefused() => throw refusedFor(PasswordRefusal.wrongCode),
|
||||||
};
|
};
|
||||||
if (row.confirmedAt == null) {
|
if (await hasConfirmedAuthenticator(session, person)) {
|
||||||
final confirmedBefore = await confirmedAuthenticatorsOf(session, person);
|
if (proof == null) {
|
||||||
if (confirmedBefore.isNotEmpty) {
|
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||||
final typed = existingCode?.trim() ?? '';
|
|
||||||
if (typed.isEmpty ||
|
|
||||||
!await acceptTotpCode(session, keys, confirmedBefore, typed)) {
|
|
||||||
throw refusedFor(PasswordRefusal.wrongExistingCode);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
await proveOwner(session, keys, person: person, proof: proof);
|
||||||
|
} else if (proof != null) {
|
||||||
|
throw refusedFor(PasswordRefusal.invalidSetup);
|
||||||
}
|
}
|
||||||
return await session.db.transaction((transaction) async {
|
return await session.db.transaction((transaction) async {
|
||||||
// UPDATE "totp_authenticator" SET "lastStep" = $1, "confirmedAt" = $2
|
// UPDATE "totp_authenticator" SET "lastStep" = $1, "confirmedAt" = $2
|
||||||
|
|
@ -169,7 +175,7 @@ Future<List<String>> confirmAuthenticator(
|
||||||
session,
|
session,
|
||||||
columnValues: (t) => [
|
columnValues: (t) => [
|
||||||
t.lastStep(step),
|
t.lastStep(step),
|
||||||
t.confirmedAt(row.confirmedAt ?? clock.now().toUtc()),
|
t.confirmedAt(clock.now().toUtc()),
|
||||||
],
|
],
|
||||||
where: (t) =>
|
where: (t) =>
|
||||||
t.id.equals(authenticator) &
|
t.id.equals(authenticator) &
|
||||||
|
|
@ -179,9 +185,6 @@ Future<List<String>> confirmAuthenticator(
|
||||||
if (confirmed.isEmpty) {
|
if (confirmed.isEmpty) {
|
||||||
throw refusedFor(PasswordRefusal.wrongCode);
|
throw refusedFor(PasswordRefusal.wrongCode);
|
||||||
}
|
}
|
||||||
if (row.confirmedAt != null) {
|
|
||||||
return const <String>[];
|
|
||||||
}
|
|
||||||
// DELETE FROM "account_reset" WHERE "authUserId" = $1 AND "kind" = $2
|
// DELETE FROM "account_reset" WHERE "authUserId" = $1 AND "kind" = $2
|
||||||
await AccountReset.db.deleteWhere(
|
await AccountReset.db.deleteWhere(
|
||||||
session,
|
session,
|
||||||
|
|
@ -232,24 +235,29 @@ Future<List<Authenticator>> authenticatorsOf(
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Removes [person]'s [authenticator]. One never confirmed goes as it is; a
|
/// Removes [person]'s [authenticator]. One never confirmed goes as it is,
|
||||||
/// confirmed one only with a [code] from one of the account's confirmed
|
/// with no [proof] (one given is refused, so it is never passed unchecked);
|
||||||
/// authenticators, and only while another confirmed one stays.
|
/// 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
|
/// Refused ([PasswordRefusedException]) for an authenticator not the
|
||||||
/// person's ([PasswordRefusal.unknownAuthenticator]), the last confirmed one
|
/// person's ([PasswordRefusal.unknownAuthenticator]), the last confirmed one
|
||||||
/// ([PasswordRefusal.lastAuthenticator]), a wrong, used or missing code
|
/// ([PasswordRefusal.lastAuthenticator], before any proof is checked), a
|
||||||
/// ([PasswordRefusal.wrongCode]), and beyond 10 codes tried an hour
|
/// proof missing ([PasswordRefusal.wrongCredentials]) or given where none is
|
||||||
/// ([PasswordRefusal.tooManyAttempts]).
|
/// taken ([PasswordRefusal.invalidSetup]), and as [proveOwner].
|
||||||
Future<void> removeAuthenticator(
|
Future<void> removeAuthenticator(
|
||||||
Session session, {
|
Session session, {
|
||||||
required UuidValue person,
|
required UuidValue person,
|
||||||
required UuidValue authenticator,
|
required UuidValue authenticator,
|
||||||
String? code,
|
OwnerProof? proof,
|
||||||
}) async {
|
}) async {
|
||||||
final keys = requirePasswordKeys(session.serverpod);
|
final keys = requirePasswordKeys(session.serverpod);
|
||||||
final row = await _authenticatorOf(session, person, authenticator);
|
final row = await _authenticatorOf(session, person, authenticator);
|
||||||
if (row.confirmedAt == null) {
|
if (row.confirmedAt == null) {
|
||||||
|
if (proof != null) {
|
||||||
|
throw refusedFor(PasswordRefusal.invalidSetup);
|
||||||
|
}
|
||||||
// DELETE FROM "totp_authenticator" WHERE "id" = $1 AND "authUserId" = $2
|
// DELETE FROM "totp_authenticator" WHERE "id" = $1 AND "authUserId" = $2
|
||||||
await TotpAuthenticator.db.deleteWhere(
|
await TotpAuthenticator.db.deleteWhere(
|
||||||
session,
|
session,
|
||||||
|
|
@ -261,7 +269,10 @@ Future<void> removeAuthenticator(
|
||||||
if (confirmed.length < 2) {
|
if (confirmed.length < 2) {
|
||||||
throw refusedFor(PasswordRefusal.lastAuthenticator);
|
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.
|
// Serializable: two removals at once must not take both of the last two.
|
||||||
await session.db.transaction(
|
await session.db.transaction(
|
||||||
(transaction) async {
|
(transaction) async {
|
||||||
|
|
@ -292,22 +303,16 @@ Future<void> removeAuthenticator(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A new set of recovery codes for [person], replacing the old, with a
|
/// A new set of recovery codes for [person], replacing the old, with the
|
||||||
/// [code] from one of their confirmed authenticators: returned to be shown
|
/// owner's [proof] (the password and a code, [proveOwner]; the third
|
||||||
/// once. Refused as [removeAuthenticator] refuses a code
|
/// reading): returned to be shown once. Refused as [proveOwner].
|
||||||
/// ([PasswordRefusal.wrongCode], [PasswordRefusal.tooManyAttempts]), and
|
|
||||||
/// without a confirmed authenticator ([PasswordRefusal.setupIncomplete]).
|
|
||||||
Future<List<String>> newRecoveryCodes(
|
Future<List<String>> newRecoveryCodes(
|
||||||
Session session, {
|
Session session, {
|
||||||
required UuidValue person,
|
required UuidValue person,
|
||||||
required String code,
|
required OwnerProof proof,
|
||||||
}) async {
|
}) async {
|
||||||
final keys = requirePasswordKeys(session.serverpod);
|
final keys = requirePasswordKeys(session.serverpod);
|
||||||
final confirmed = await confirmedAuthenticatorsOf(session, person);
|
await proveOwner(session, keys, person: person, proof: proof);
|
||||||
if (confirmed.isEmpty) {
|
|
||||||
throw refusedFor(PasswordRefusal.setupIncomplete);
|
|
||||||
}
|
|
||||||
await _requireAuthenticatorCode(session, keys, person, confirmed, code);
|
|
||||||
return await session.db.transaction(
|
return await session.db.transaction(
|
||||||
(transaction) =>
|
(transaction) =>
|
||||||
_replaceRecoveryCodes(session, keys, person, transaction: transaction),
|
_replaceRecoveryCodes(session, keys, person, transaction: transaction),
|
||||||
|
|
@ -316,10 +321,9 @@ Future<List<String>> newRecoveryCodes(
|
||||||
|
|
||||||
/// Deletes the authenticators nobody confirmed within
|
/// Deletes the authenticators nobody confirmed within
|
||||||
/// [unconfirmedAuthenticatorLifetime] at [now], the sign-in challenges past
|
/// [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,
|
/// (`forgetEndedSessionsCodeTries`), and returns how many rows went: hourly,
|
||||||
/// with the
|
/// with the pruning (`PruningFutureCall`).
|
||||||
/// pruning (`PruningFutureCall`).
|
|
||||||
Future<int> forgetUnconfirmedAuthenticators(
|
Future<int> forgetUnconfirmedAuthenticators(
|
||||||
Session session, {
|
Session session, {
|
||||||
required DateTime now,
|
required DateTime now,
|
||||||
|
|
@ -341,24 +345,6 @@ Future<int> forgetUnconfirmedAuthenticators(
|
||||||
return authenticators.length + challenges.length + tries;
|
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
|
/// [person]'s authenticator [id]; refused
|
||||||
/// ([PasswordRefusal.unknownAuthenticator]) for anyone else's, or none.
|
/// ([PasswordRefusal.unknownAuthenticator]) for anyone else's, or none.
|
||||||
Future<TotpAuthenticator> _authenticatorOf(
|
Future<TotpAuthenticator> _authenticatorOf(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
### What deleting an account that has a password takes (ADR 0017; the second
|
### What deleting an account that has a password takes (ADR 0017; the second
|
||||||
### reading): the password's SRP proof for a challenge `password.beginChange`
|
### 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
|
### 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
|
### change of password does (`OwnerProof`). A session alone deletes only an
|
||||||
### one.
|
### account without one.
|
||||||
class: DeletionProof
|
class: DeletionProof
|
||||||
fields:
|
fields:
|
||||||
challenge: UuidValue
|
challenge: UuidValue
|
||||||
|
|
@ -19,8 +19,3 @@ fields:
|
||||||
### The host the device addressed the instance by: the server it reached
|
### 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.
|
### checks it is its own before the register binds the proof to it.
|
||||||
host: String
|
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