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
11 changed files with 873 additions and 32 deletions
|
|
@ -209,5 +209,6 @@
|
|||
"passwordFailed": "Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.",
|
||||
"passwordMinLength": "Mindestens {count} Zeichen",
|
||||
"passwordTooShort": "Ein neues Passwort braucht mindestens {count} Zeichen: Sollte die Datenbank der Instanz je nach außen gelangen, wird ein kurzes zuerst erraten.",
|
||||
"passwordCodesWait": "Zu viele falsche Codes hintereinander: Bis {date} wird kein Code angenommen. Waren es nicht deine, kennt jemand dein Passwort: Ändere es dann."
|
||||
"passwordCodesWait": "Zu viele falsche Codes hintereinander: Bis {date} wird kein Code angenommen. Waren es nicht deine, kennt jemand dein Passwort: Ändere es dann.",
|
||||
"passwordDeleteIntro": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Gib zur Bestätigung dein Passwort und einen Code deiner Authenticator-App ein."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -456,5 +456,6 @@
|
|||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"passwordDeleteIntro": "Your account goes with everything in it, in every service of this instance. To confirm, type your password and a code from your authenticator app."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1362,6 +1362,12 @@ abstract class HalkyonAppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Too many wrong codes in a row: no code is taken until {date}. If they weren\'t yours, someone knows your password: change it then.'**
|
||||
String passwordCodesWait(String date);
|
||||
|
||||
/// No description provided for @passwordDeleteIntro.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your account goes with everything in it, in every service of this instance. To confirm, type your password and a code from your authenticator app.'**
|
||||
String get passwordDeleteIntro;
|
||||
}
|
||||
|
||||
class _HalkyonAppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -788,4 +788,8 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
|||
String passwordCodesWait(String date) {
|
||||
return 'Zu viele falsche Codes hintereinander: Bis $date wird kein Code angenommen. Waren es nicht deine, kennt jemand dein Passwort: Ändere es dann.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get passwordDeleteIntro =>
|
||||
'Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Gib zur Bestätigung dein Passwort und einen Code deiner Authenticator-App ein.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -771,6 +771,10 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
|||
String passwordCodesWait(String date) {
|
||||
return 'Too many wrong codes in a row: no code is taken until $date. If they weren\'t yours, someone knows your password: change it then.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get passwordDeleteIntro =>
|
||||
'Your account goes with everything in it, in every service of this instance. To confirm, type your password and a code from your authenticator app.';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import 'package:halkyon_app/src/app/app_scope.dart';
|
|||
import 'package:halkyon_app/src/auth/bloc/session_bloc.dart';
|
||||
import 'package:halkyon_app/src/instance/instance_scope.dart';
|
||||
import 'package:halkyon_app/src/instance/widget/account_switcher_sheet.dart';
|
||||
import 'package:halkyon_app/src/password/model/password_failed.dart';
|
||||
import 'package:halkyon_app/src/password/widget/delete_account_sheet.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -104,17 +106,37 @@ class const AccountTiles({
|
|||
session.add(const SessionEventSignOutRequested());
|
||||
}
|
||||
|
||||
/// Deletes the account with everything in it, after asking.
|
||||
/// Deletes the account with everything in it, after asking; an account
|
||||
/// with a password only with it and a code (ADR 0017), which a sheet asks
|
||||
/// for.
|
||||
Future<void> _delete(BuildContext context) async {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
final session = context.read<SessionBloc>();
|
||||
if (await confirmDestructiveAction(
|
||||
final passwords = AppScope.of(context).passwords;
|
||||
final identified = switch (session.state) {
|
||||
SessionStateActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
};
|
||||
if (!await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.accountDeleteTitle,
|
||||
message: deleteMessage,
|
||||
confirmLabel: l10n.accountDelete,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
final bool hasPassword;
|
||||
try {
|
||||
hasPassword = identified && (await passwords.security()).hasPassword;
|
||||
} on PasswordFailed {
|
||||
// Unreachable: deleting would fail too, as the session bloc says.
|
||||
session.add(const SessionEventDeleteRequested());
|
||||
return;
|
||||
}
|
||||
if (!hasPassword) {
|
||||
session.add(const SessionEventDeleteRequested());
|
||||
} else if (context.mounted) {
|
||||
await showDeleteAccountWithPassword(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,16 @@ abstract interface class PasswordGateway() {
|
|||
/// A change of password's first step.
|
||||
Future<protocol.PasswordChallenge> beginChange();
|
||||
|
||||
/// Deletes the signed-in person's account, which has a password: [answer]
|
||||
/// to [challenge] ([beginChange]) proves it, with a [totpCode] or
|
||||
/// [recoveryCode]; this device then forgets its session.
|
||||
Future<void> deleteAccount(
|
||||
protocol.PasswordChallenge challenge,
|
||||
SrpAnswer answer, {
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
});
|
||||
|
||||
/// Changes the password: [answer] to [challenge] proves the old one.
|
||||
Future<void> change(
|
||||
protocol.PasswordChallenge challenge,
|
||||
|
|
|
|||
|
|
@ -344,6 +344,29 @@ class PasswordRepository(
|
|||
_changed();
|
||||
}
|
||||
|
||||
/// Deletes the signed-in person's account, which has a password, with
|
||||
/// [password] and a [totpCode] or [recoveryCode], as a change of password
|
||||
/// takes (a session alone deletes only an account without one): on the
|
||||
/// instance and every service of it, then signed out here.
|
||||
Future<void> deleteAccount({
|
||||
required String password,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
}) async {
|
||||
final challenge = await _gateway.beginChange();
|
||||
final unlock = await _crypto.unlock(
|
||||
password,
|
||||
salt: Uint8List.sublistView(challenge.salt),
|
||||
costs: challenge.costs,
|
||||
);
|
||||
await _gateway.deleteAccount(
|
||||
challenge,
|
||||
await _crypto.answer(unlock, challenge, host: _gateway.host),
|
||||
totpCode: totpCode?.replaceAll(' ', ''),
|
||||
recoveryCode: recoveryCode,
|
||||
);
|
||||
}
|
||||
|
||||
/// A new recovery phrase for the signed-in person's keys, to show; the
|
||||
/// instance takes it with [keepPhrase].
|
||||
Future<NewPhrase> newPhrase() async {
|
||||
|
|
|
|||
|
|
@ -179,6 +179,28 @@ class ServerpodPasswordGateway(
|
|||
Future<protocol.PasswordChallenge> beginChange() =>
|
||||
_failing(_base.password.beginChange);
|
||||
|
||||
@override
|
||||
Future<void> deleteAccount(
|
||||
protocol.PasswordChallenge challenge,
|
||||
SrpAnswer answer, {
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
}) => _failing(() async {
|
||||
await _base.account.delete(
|
||||
proof: protocol.DeletionProof(
|
||||
challenge: challenge.challenge,
|
||||
clientValue: answer.clientValue,
|
||||
clientProof: answer.clientProof,
|
||||
totpCode: totpCode,
|
||||
recoveryCode: recoveryCode,
|
||||
host: host,
|
||||
),
|
||||
);
|
||||
// The server ended every session of the person; forget this device's
|
||||
// copy, which signs out here too.
|
||||
await _client.auth.updateSignedInUser(null);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> change(
|
||||
protocol.PasswordChallenge challenge,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
||||
import 'package:halkyon_app/src/app/app_scope.dart';
|
||||
import 'package:halkyon_app/src/password/bloc/account_security_cubit.dart';
|
||||
import 'package:halkyon_app/src/password/widget/password_texts.dart';
|
||||
import 'package:halkyon_app/src/password/widget/password_widgets.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Deletes the signed-in person's account, which has a password: the
|
||||
/// password and a code from an authenticator, as changing the password
|
||||
/// takes (ADR 0017; the second reading). Once deleted, the person is signed
|
||||
/// out here, and the app asks how to start.
|
||||
Future<void> showDeleteAccountWithPassword(BuildContext context) =>
|
||||
showAdaptiveSheet<void>(
|
||||
context,
|
||||
swipeable: false,
|
||||
builder: (context) => const _DeleteAccount(),
|
||||
);
|
||||
|
||||
class const _DeleteAccount() extends StatefulWidget {
|
||||
@override
|
||||
State<_DeleteAccount> createState() => _DeleteAccountState();
|
||||
}
|
||||
|
||||
class _DeleteAccountState() extends State<_DeleteAccount> {
|
||||
final _password = TextEditingController();
|
||||
final _code = TextEditingController();
|
||||
late final _step = SecurityStepCubit<bool>();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_password.dispose();
|
||||
_code.dispose();
|
||||
_step.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final passwords = AppScope.of(context).passwords;
|
||||
await _step.run(() async {
|
||||
await passwords.deleteAccount(
|
||||
password: _password.text,
|
||||
totpCode: _code.text,
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return BlocBuilder<SecurityStepCubit<bool>, SecurityStepState<bool>>(
|
||||
bloc: _step,
|
||||
builder: (context, state) => SheetScaffold(
|
||||
title: l10n.accountDeleteTitle,
|
||||
busy: state.busy,
|
||||
body: AutofillGroup(
|
||||
child: state.busy
|
||||
? const SheetWorking()
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SheetIntro(l10n.passwordDeleteIntro),
|
||||
PasswordField(
|
||||
controller: _password,
|
||||
label: l10n.passwordCurrent,
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CodeField(
|
||||
controller: _code,
|
||||
kind: CodeKind.totp,
|
||||
errorText: switch (state.problem) {
|
||||
final failed? => passwordProblemText(context, failed),
|
||||
null => null,
|
||||
},
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
footer: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
),
|
||||
onPressed: state.busy ? null : _submit,
|
||||
child: Text(l10n.accountDelete),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -512,6 +512,21 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
return _challengeFor(account.address);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> deleteAccount(
|
||||
protocol.PasswordChallenge challenge,
|
||||
SrpAnswer answer, {
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
}) async {
|
||||
final account = await _call(
|
||||
'deleteAccount',
|
||||
() => _ownerProved(challenge, answer, totpCode, recoveryCode),
|
||||
);
|
||||
accounts.remove(account.address);
|
||||
await _auth.deleteAccount();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> change(
|
||||
protocol.PasswordChallenge challenge,
|
||||
|
|
|
|||
Loading…
Reference in a new issue