fix(auth): an account with a password deletes itself only with it and a code
A session alone deleted any account, with everything encrypted under its keys (the second reading's lows; decided by the developer: the password and a code, as a change of password takes). account.delete now takes a DeletionProof (the password's SRP proof for a beginChange challenge, a TOTP or recovery code, the host the device addressed); the register checks it (requireOwnerToDelete), a member passing it on bound to its own host. Accounts without a password, anonymous ones among them, delete with their session as before. In the app, deleting an account with a password asks for it and a code in a sheet of its own; a wrong one deletes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
c4f863ff8b
commit
d53844bc93
14 changed files with 873 additions and 32 deletions
|
|
@ -245,6 +245,11 @@ Built on the three parts above, on the register (members forward every step thro
|
|||
password; `password.setRecoveryKeys` replaces the phrase's wrapping with the same
|
||||
proof and code (a phrase of one's own would otherwise set a new password through the
|
||||
phrase's reset). A session alone changes neither, nor a session and a code.
|
||||
- **Deleting an account with a password** (`account.delete(proof:)`, a `DeletionProof`)
|
||||
takes the same: the password's proof for a `beginChange` challenge and a code, bound
|
||||
to the host the device addressed (`requireOwnerToDelete`; a member passes the proof
|
||||
on and the register checks it). An account without a password deletes with its
|
||||
session, as before.
|
||||
- **Sessions end everywhere:** a change of password ends every other session of the
|
||||
account; a reset of the password or the account ends all. The register ends its own
|
||||
and keeps a `sessions_ended_notice` per member (told at once and hourly until it
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import 'package:halkyon_base_server/src/auth/account.dart';
|
|||
import 'package:halkyon_base_server/src/auth/membership.dart';
|
||||
import 'package:halkyon_base_server/src/auth/scopes.dart';
|
||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_base_server/src/password/key_proof.dart';
|
||||
import 'package:halkyon_base_server/src/password/password_change.dart';
|
||||
import 'package:halkyon_base_server/src/register/account_register.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
|
@ -16,13 +18,19 @@ class AccountEndpoint extends Endpoint {
|
|||
/// 2026-09-30). Their other devices learn at once that their sessions
|
||||
/// ended. An account deletes everywhere (ADR 0016): on a member, the
|
||||
/// register deletes it and tells every member, this one deleting its own
|
||||
/// data at once; an anonymous person is this service's alone.
|
||||
Future<void> delete(Session session) async {
|
||||
/// data at once; an anonymous person is this service's alone. An account
|
||||
/// with a password deletes itself only with [proof] (the password's proof
|
||||
/// and a code, `requireOwnerToDelete`), which the register checks, its
|
||||
/// host the one the device addressed here.
|
||||
Future<void> delete(Session session, {DeletionProof? proof}) async {
|
||||
final pod = session.serverpod;
|
||||
final person = session.authenticated!.authUserId;
|
||||
if (isMember(session.serverpod) &&
|
||||
session.authenticated!.scopes.contains(identified)) {
|
||||
await AccountRegister.of(session.serverpod)
|
||||
.deleteAccount(session, person);
|
||||
final bound = proof?.copyWith(host: deviceHostOn(pod, proof.host));
|
||||
if (!isMember(pod)) {
|
||||
await requireOwnerToDelete(session, person, bound);
|
||||
} else if (session.authenticated!.scopes.contains(identified)) {
|
||||
await AccountRegister.of(pod)
|
||||
.deleteAccount(session, person, proof: bound);
|
||||
}
|
||||
await deletePerson(session, person);
|
||||
await session.messages.authenticationRevoked(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:typed_data' as _idt;
|
||||
import 'package:halkyon_base_server/src/generated/password/deletion_proof.dart'
|
||||
as _i619l6vv;
|
||||
import 'package:halkyon_base_server/src/generated/password/new_password.dart'
|
||||
as _ijye8mcv;
|
||||
import 'package:halkyon_base_server/src/generated/password/password_setup.dart'
|
||||
|
|
@ -117,13 +119,22 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
methodConnectors: {
|
||||
'delete': _is.MethodConnector(
|
||||
name: 'delete',
|
||||
params: {},
|
||||
params: {
|
||||
'proof': _is.ParameterDescription(
|
||||
name: 'proof',
|
||||
type: _is.getType<_i619l6vv.DeletionProof?>(),
|
||||
nullable: true,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async => (endpoints['account'] as _ii6ycf6v.AccountEndpoint)
|
||||
.delete(session),
|
||||
) async =>
|
||||
(endpoints['account'] as _ii6ycf6v.AccountEndpoint).delete(
|
||||
session,
|
||||
proof: params['proof'],
|
||||
),
|
||||
),
|
||||
'signInWays': _is.MethodConnector(
|
||||
name: 'signInWays',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
/* 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 deleting an account that has a password takes (ADR 0017; the second
|
||||
/// reading): the password's SRP proof for a challenge `password.beginChange`
|
||||
/// gave, a TOTP or recovery code, and the host the device addressed, as a
|
||||
/// change of password does. A session alone deletes only an account without
|
||||
/// one.
|
||||
abstract class DeletionProof
|
||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||
DeletionProof._({
|
||||
required this.challenge,
|
||||
required this.clientValue,
|
||||
required this.clientProof,
|
||||
this.totpCode,
|
||||
this.recoveryCode,
|
||||
required this.host,
|
||||
});
|
||||
|
||||
factory DeletionProof({
|
||||
required _is.UuidValue challenge,
|
||||
required _idt.ByteData clientValue,
|
||||
required _idt.ByteData clientProof,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
}) = _DeletionProofImpl;
|
||||
|
||||
factory DeletionProof.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return DeletionProof(
|
||||
challenge: _is.UuidValueJsonExtension.fromJson(
|
||||
jsonSerialization['challenge'],
|
||||
),
|
||||
clientValue: _is.ByteDataJsonExtension.fromJson(
|
||||
jsonSerialization['clientValue'],
|
||||
),
|
||||
clientProof: _is.ByteDataJsonExtension.fromJson(
|
||||
jsonSerialization['clientProof'],
|
||||
),
|
||||
totpCode: jsonSerialization['totpCode'] as String?,
|
||||
recoveryCode: jsonSerialization['recoveryCode'] as String?,
|
||||
host: jsonSerialization['host'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
_is.UuidValue challenge;
|
||||
|
||||
_idt.ByteData clientValue;
|
||||
|
||||
_idt.ByteData clientProof;
|
||||
|
||||
String? totpCode;
|
||||
|
||||
String? recoveryCode;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Returns a shallow copy of this [DeletionProof]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
DeletionProof copyWith({
|
||||
_is.UuidValue? challenge,
|
||||
_idt.ByteData? clientValue,
|
||||
_idt.ByteData? clientProof,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
String? host,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'halkyon_base.DeletionProof',
|
||||
'challenge': challenge.toJson(),
|
||||
'clientValue': clientValue.toJson(),
|
||||
'clientProof': clientProof.toJson(),
|
||||
if (totpCode != null) 'totpCode': totpCode,
|
||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||
'host': host,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'halkyon_base.DeletionProof',
|
||||
'challenge': challenge.toJson(),
|
||||
'clientValue': clientValue.toJson(),
|
||||
'clientProof': clientProof.toJson(),
|
||||
if (totpCode != null) 'totpCode': totpCode,
|
||||
if (recoveryCode != null) 'recoveryCode': recoveryCode,
|
||||
'host': host,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _DeletionProofImpl extends DeletionProof {
|
||||
_DeletionProofImpl({
|
||||
required _is.UuidValue challenge,
|
||||
required _idt.ByteData clientValue,
|
||||
required _idt.ByteData clientProof,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required String host,
|
||||
}) : super._(
|
||||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
clientProof: clientProof,
|
||||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
host: host,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [DeletionProof]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
DeletionProof copyWith({
|
||||
_is.UuidValue? challenge,
|
||||
_idt.ByteData? clientValue,
|
||||
_idt.ByteData? clientProof,
|
||||
Object? totpCode = _Undefined,
|
||||
Object? recoveryCode = _Undefined,
|
||||
String? host,
|
||||
}) {
|
||||
return DeletionProof(
|
||||
challenge: challenge ?? this.challenge,
|
||||
clientValue: clientValue ?? this.clientValue.clone(),
|
||||
clientProof: clientProof ?? this.clientProof.clone(),
|
||||
totpCode: totpCode is String? ? totpCode : this.totpCode,
|
||||
recoveryCode: recoveryCode is String? ? recoveryCode : this.recoveryCode,
|
||||
host: host ?? this.host,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import 'password/account_reset.dart' as _i3fad0p6;
|
|||
import 'password/account_reset_kind.dart' as _i91ka1it;
|
||||
import 'password/authenticator.dart' as _icpzuvvi;
|
||||
import 'password/authenticator_enrolment.dart' as _iv7tra7v;
|
||||
import 'password/deletion_proof.dart' as _ix1u7hdq;
|
||||
import 'password/key_proof_challenge.dart' as _iehtx95q;
|
||||
import 'password/new_password.dart' as _itvvd6m6;
|
||||
import 'password/password_challenge.dart' as _iuxisac7;
|
||||
|
|
@ -68,6 +69,7 @@ export 'password/account_reset.dart';
|
|||
export 'password/account_reset_kind.dart';
|
||||
export 'password/authenticator.dart';
|
||||
export 'password/authenticator_enrolment.dart';
|
||||
export 'password/deletion_proof.dart';
|
||||
export 'password/key_proof_challenge.dart';
|
||||
export 'password/new_password.dart';
|
||||
export 'password/password_challenge.dart';
|
||||
|
|
@ -988,6 +990,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _iv7tra7v.AuthenticatorEnrolment) {
|
||||
return _iv7tra7v.AuthenticatorEnrolment.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ix1u7hdq.DeletionProof) {
|
||||
return _ix1u7hdq.DeletionProof.fromJson(data) as T;
|
||||
}
|
||||
if (t == _iehtx95q.KeyProofChallenge) {
|
||||
return _iehtx95q.KeyProofChallenge.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -1115,6 +1120,10 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
: null)
|
||||
as T;
|
||||
}
|
||||
if (t == _is.getType<_ix1u7hdq.DeletionProof?>()) {
|
||||
return (data != null ? _ix1u7hdq.DeletionProof.fromJson(data) : null)
|
||||
as T;
|
||||
}
|
||||
if (t == _is.getType<_iehtx95q.KeyProofChallenge?>()) {
|
||||
return (data != null ? _iehtx95q.KeyProofChallenge.fromJson(data) : null)
|
||||
as T;
|
||||
|
|
@ -1264,6 +1273,7 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
_i91ka1it.AccountResetKind => 'AccountResetKind',
|
||||
_icpzuvvi.Authenticator => 'Authenticator',
|
||||
_iv7tra7v.AuthenticatorEnrolment => 'AuthenticatorEnrolment',
|
||||
_ix1u7hdq.DeletionProof => 'DeletionProof',
|
||||
_iehtx95q.KeyProofChallenge => 'KeyProofChallenge',
|
||||
_itvvd6m6.NewPassword => 'NewPassword',
|
||||
_iuxisac7.PasswordChallenge => 'PasswordChallenge',
|
||||
|
|
@ -1330,6 +1340,8 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
return 'Authenticator';
|
||||
case _iv7tra7v.AuthenticatorEnrolment():
|
||||
return 'AuthenticatorEnrolment';
|
||||
case _ix1u7hdq.DeletionProof():
|
||||
return 'DeletionProof';
|
||||
case _iehtx95q.KeyProofChallenge():
|
||||
return 'KeyProofChallenge';
|
||||
case _itvvd6m6.NewPassword():
|
||||
|
|
@ -1429,6 +1441,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (dataClassName == 'AuthenticatorEnrolment') {
|
||||
return deserialize<_iv7tra7v.AuthenticatorEnrolment>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'DeletionProof') {
|
||||
return deserialize<_ix1u7hdq.DeletionProof>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'KeyProofChallenge') {
|
||||
return deserialize<_iehtx95q.KeyProofChallenge>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
### What deleting an account that has a password takes (ADR 0017; the second
|
||||
### reading): the password's SRP proof for a challenge `password.beginChange`
|
||||
### gave, a TOTP or recovery code, and the host the device addressed, as a
|
||||
### change of password does. A session alone deletes only an account without
|
||||
### one.
|
||||
class: DeletionProof
|
||||
fields:
|
||||
challenge: UuidValue
|
||||
clientValue: ByteData
|
||||
clientProof: ByteData
|
||||
totpCode: String?
|
||||
recoveryCode: String?
|
||||
### 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
|
||||
|
|
@ -126,6 +126,43 @@ Future<void> setRecoveryKeys(
|
|||
);
|
||||
}
|
||||
|
||||
/// Proves [person] may delete their account: one with a password only
|
||||
/// with [proof] (the password's proof and a code, bound to the host the
|
||||
/// device addressed, as a change of password takes: a session alone, or a
|
||||
/// phone with its authenticator, would otherwise delete the account and
|
||||
/// everything encrypted under its keys); one without, by its session alone,
|
||||
/// as before. Refused as [changePassword] refuses, and without [proof]
|
||||
/// where one is needed ([PasswordRefusal.wrongCredentials]).
|
||||
Future<void> requireOwnerToDelete(
|
||||
Session session,
|
||||
UuidValue person,
|
||||
DeletionProof? proof,
|
||||
) async {
|
||||
// SELECT count(*) FROM "account_password" WHERE "authUserId" = $1
|
||||
if (await AccountPassword.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
) ==
|
||||
0) {
|
||||
return;
|
||||
}
|
||||
final keys = requirePasswordKeys(session.serverpod);
|
||||
if (proof == null) {
|
||||
throw refusedFor(PasswordRefusal.wrongCredentials);
|
||||
}
|
||||
await _proveOwner(
|
||||
session,
|
||||
keys,
|
||||
person: person,
|
||||
challenge: proof.challenge,
|
||||
clientValue: proof.clientValue,
|
||||
clientProof: proof.clientProof,
|
||||
totpCode: proof.totpCode,
|
||||
recoveryCode: proof.recoveryCode,
|
||||
host: proof.host,
|
||||
);
|
||||
}
|
||||
|
||||
/// [person]'s password row, once they proved owning the account for a
|
||||
/// change: the old password's proof for [challenge], [person]'s own, and a
|
||||
/// [totpCode] or [recoveryCode] ([secondFactor], which also calls off a reset
|
||||
|
|
|
|||
|
|
@ -325,8 +325,13 @@ abstract interface class AccountRegister {
|
|||
});
|
||||
|
||||
/// Deletes the account [person] with everything the register's server has
|
||||
/// of them, and has every member of the instance delete theirs.
|
||||
Future<void> deleteAccount(Session session, UuidValue person);
|
||||
/// of them, and has every member of the instance delete theirs: one with a
|
||||
/// password only with [proof] (`requireOwnerToDelete`).
|
||||
Future<void> deleteAccount(
|
||||
Session session,
|
||||
UuidValue person, {
|
||||
DeletionProof? proof,
|
||||
});
|
||||
|
||||
/// Those of [people] that are accounts of the register.
|
||||
Future<Set<UuidValue>> knownAccounts(
|
||||
|
|
|
|||
|
|
@ -198,7 +198,13 @@ Future<Map<String, Object?>?> _answer(
|
|||
);
|
||||
return {'ways': ways.toJson()};
|
||||
case RegisterOperation.deleteAccount:
|
||||
await register.deleteAccount(session, arguments.id('person'));
|
||||
await register.deleteAccount(
|
||||
session,
|
||||
arguments.id('person'),
|
||||
proof: arguments.has('proof')
|
||||
? arguments.model('proof', DeletionProof.fromJson)
|
||||
: null,
|
||||
);
|
||||
return const {};
|
||||
case RegisterOperation.knownAccounts:
|
||||
final known = await register.knownAccounts(
|
||||
|
|
@ -499,6 +505,9 @@ extension type const _Arguments(Map<String, Object?> _map) {
|
|||
ByteData? optionalBytes(String name) =>
|
||||
optionalString(name) == null ? null : bytes(name);
|
||||
|
||||
/// Whether the argument [name] holds anything (not null).
|
||||
bool has(String name) => _map[name] != null;
|
||||
|
||||
/// The model [read] makes of the object [name]; a [FormatException] for
|
||||
/// what isn't one.
|
||||
T model<T>(String name, T Function(Map<String, dynamic> json) read) {
|
||||
|
|
|
|||
|
|
@ -658,7 +658,12 @@ final class LocalRegister implements AccountRegister {
|
|||
);
|
||||
|
||||
@override
|
||||
Future<void> deleteAccount(Session session, UuidValue person) async {
|
||||
Future<void> deleteAccount(
|
||||
Session session,
|
||||
UuidValue person, {
|
||||
DeletionProof? proof,
|
||||
}) async {
|
||||
await change.requireOwnerToDelete(session, person, proof);
|
||||
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = $1
|
||||
if (await AuthUser.db.findById(session, person) != null) {
|
||||
await deletePerson(session, person);
|
||||
|
|
|
|||
|
|
@ -621,8 +621,14 @@ final class RemoteRegister implements AccountRegister {
|
|||
});
|
||||
|
||||
@override
|
||||
Future<void> deleteAccount(Session session, UuidValue person) =>
|
||||
_ask(RegisterOperation.deleteAccount, {'person': person.uuid});
|
||||
Future<void> deleteAccount(
|
||||
Session session,
|
||||
UuidValue person, {
|
||||
DeletionProof? proof,
|
||||
}) => _ask(RegisterOperation.deleteAccount, {
|
||||
'person': person.uuid,
|
||||
'proof': proof?.toJson(),
|
||||
});
|
||||
|
||||
@override
|
||||
Future<Set<UuidValue>> knownAccounts(
|
||||
|
|
|
|||
|
|
@ -504,6 +504,97 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('deleting the account', () {
|
||||
/// Deletes Anna's account from her phone, with the password's proof
|
||||
/// (hers by default, by [privateValue] otherwise, none [withoutProof])
|
||||
/// and [totpCode] (her authenticator's now by default), bound to
|
||||
/// [host].
|
||||
Future<void> delete({
|
||||
BigInt? privateValue,
|
||||
String? totpCode,
|
||||
bool withoutCode = false,
|
||||
bool withoutProof = false,
|
||||
String host = testHost,
|
||||
}) => atNow(() async {
|
||||
if (withoutProof) {
|
||||
return await endpoints.account.delete(onPhone());
|
||||
}
|
||||
final challenge = await endpoints.password.beginChange(onPhone());
|
||||
final answer = device.answer(
|
||||
challenge,
|
||||
privateValue: privateValue,
|
||||
host: host,
|
||||
);
|
||||
await endpoints.account.delete(
|
||||
onPhone(),
|
||||
proof: DeletionProof(
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: withoutCode
|
||||
? null
|
||||
: totpCode ?? codeOf(enrolment, at: now),
|
||||
host: testHost,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'takes the password and a code, as a change does: a session '
|
||||
"alone, or with a phone's authenticator, deletes nothing",
|
||||
() async {
|
||||
await expectLater(
|
||||
delete(withoutProof: true),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
delete(withoutCode: true),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
delete(privateValue: BigInt.from(4242)),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
delete(totpCode: '000000'),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
await expectLater(
|
||||
delete(host: 'learn.evil.example'),
|
||||
refused(PasswordRefusal.wrongCredentials),
|
||||
);
|
||||
expect(await AuthUser.db.findById(session, anna), isNotNull);
|
||||
expect(await AccountPassword.db.count(session), 1);
|
||||
|
||||
nextStep();
|
||||
await delete();
|
||||
|
||||
expect(await AuthUser.db.findById(session, anna), isNull);
|
||||
expect(await AccountPassword.db.count(session), 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'of an anonymous person takes its session alone, as before',
|
||||
() async {
|
||||
final ben = await newAnonymousPerson(session);
|
||||
final bensLaptop = await signInNewDevice(session, ben);
|
||||
|
||||
await endpoints.account.delete(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
ben.uuid,
|
||||
{},
|
||||
authId: bensLaptop.uuid,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(await AuthUser.db.findById(session, ben), isNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('a reset', () {
|
||||
test('of the password with the phrase ends the sessions at the '
|
||||
'members too, the new session made after', () async {
|
||||
|
|
|
|||
|
|
@ -433,11 +433,18 @@ final class _Register implements AccountRegister {
|
|||
}) => _answer('setRecoveryKeys', () {});
|
||||
|
||||
@override
|
||||
Future<void> deleteAccount(Session session, UuidValue person) =>
|
||||
_answer('deleteAccount', () {
|
||||
deleted.add(person);
|
||||
accounts.remove(person);
|
||||
});
|
||||
Future<void> deleteAccount(
|
||||
Session session,
|
||||
UuidValue person, {
|
||||
DeletionProof? proof,
|
||||
}) => _answer('deleteAccount', () {
|
||||
deleted.add(person);
|
||||
deletedWith.add(proof);
|
||||
accounts.remove(person);
|
||||
});
|
||||
|
||||
/// The proofs deletions came with, as the member bound them.
|
||||
final deletedWith = <DeletionProof?>[];
|
||||
|
||||
@override
|
||||
Future<Set<UuidValue>> knownAccounts(
|
||||
|
|
@ -831,6 +838,24 @@ void main() {
|
|||
expect(await AuthUser.db.findById(session, anna), isNotNull);
|
||||
});
|
||||
|
||||
test("passes the password's proof on to the register, bound to "
|
||||
'the host the device addressed here', () async {
|
||||
await endpoints.account.delete(
|
||||
asBen(),
|
||||
proof: DeletionProof(
|
||||
challenge: const Uuid().v7obj(),
|
||||
clientValue: ByteData(384),
|
||||
clientProof: ByteData(32),
|
||||
totpCode: '123456',
|
||||
host: 'LOCALHOST',
|
||||
),
|
||||
);
|
||||
|
||||
expect(register.deleted, [ben]);
|
||||
expect(register.deletedWith.single?.host, testHost);
|
||||
expect(register.deletedWith.single?.totpCode, '123456');
|
||||
});
|
||||
|
||||
test('of an anonymous person deletes it here alone', () async {
|
||||
await endpoints.account.delete(asAnna());
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import 'package:halkyon_base_server/src/generated/password/authenticator.dart'
|
|||
as _ip34xal6;
|
||||
import 'package:halkyon_base_server/src/generated/password/authenticator_enrolment.dart'
|
||||
as _id229ey1;
|
||||
import 'package:halkyon_base_server/src/generated/password/deletion_proof.dart'
|
||||
as _i619l6vv;
|
||||
import 'package:halkyon_base_server/src/generated/password/key_proof_challenge.dart'
|
||||
as _iaysae70;
|
||||
import 'package:halkyon_base_server/src/generated/password/new_password.dart'
|
||||
|
|
@ -270,7 +272,10 @@ class _AccountEndpoint {
|
|||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<void> delete(_ist.TestSessionBuilder sessionBuilder) async {
|
||||
_ida.Future<void> delete(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
_i619l6vv.DeletionProof? proof,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
|
|
@ -282,7 +287,7 @@ class _AccountEndpoint {
|
|||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'account',
|
||||
methodName: 'delete',
|
||||
parameters: _ist.testObjectToJson({}),
|
||||
parameters: _ist.testObjectToJson({'proof': proof}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
|
|
|
|||
Loading…
Reference in a new issue