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
10 changed files with 873 additions and 32 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue