fix(auth): a reset waits while its owner is held out, and devices show it
The full reading's H1: an owner could be kept from calling a reset off, and the reset then went through. Whoever knew the password and had the mailbox asked an authenticators' reset and kept the codes waiting, so the owner's sign-ins were refused until it took effect; whoever had the mailbox filled the address's proofs ceiling from three networks and completed an account reset. Now every hold-out of the owner after a reset was asked (a wait that begins, an attempt a wait refuses, a sign-in or proof refused by the address's ceiling) moves the account's resets not carried out to take effect no sooner than 72 hours after it ends (pushOutResets). A reset carried out (the password alone signing in until an authenticator is confirmed) is never pushed, nor a lapsed one brought back. Signed-in devices are told too: SignInWays carries when a waiting reset of each kind takes effect, the account section shows them with Call it off, which takes the owner's proof (password and code; password. callOffResets, forwarded by members) and calls every reset off, as no wait holds a signed-in owner's proof back. Texts in English and German. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4d1cd2045c
commit
9d0d037c86
11 changed files with 1022 additions and 18 deletions
|
|
@ -225,5 +225,11 @@
|
|||
"passwordWarningTitle": "Jemand hat dein Passwort ausprobiert",
|
||||
"passwordWarningBody": "Am {date} hat ein bei deinem Konto angemeldetes Gerät zehnmal hintereinander ein falsches Passwort oder einen falschen Code gesendet und wurde abgemeldet. Warst du das nicht, hat vielleicht jemand anderes dieses Gerät oder seine Sitzung: Ändere dein Passwort.",
|
||||
"passwordWarningDismiss": "Ausblenden",
|
||||
"passwordWarningLater": "Später erinnern"
|
||||
"passwordWarningLater": "Später erinnern",
|
||||
"passwordResetsTitle": "Ein Zurücksetzen steht an",
|
||||
"passwordResetsAuthenticator": "Am {date} werden deine Authenticator-Apps entfernt: Jemand hat es mit deinem Passwort angefordert. Warst du das nicht, sag es ab und ändere dein Passwort.",
|
||||
"passwordResetsAccount": "Am {date} bekommt dein Konto ein neues Passwort und neue Schlüssel, und was mit den alten verschlüsselt ist, geht verloren: Jemand hat es mit einem Code per Mail angefordert. Warst du das nicht, sag es ab.",
|
||||
"passwordResetsCallOff": "Absagen",
|
||||
"passwordCallOffTitle": "Zurücksetzen absagen",
|
||||
"passwordCallOffIntro": "Dein Passwort und ein Code sagen jedes anstehende Zurücksetzen ab."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -479,5 +479,25 @@
|
|||
}
|
||||
},
|
||||
"passwordWarningDismiss": "Dismiss",
|
||||
"passwordWarningLater": "Remind me later"
|
||||
"passwordWarningLater": "Remind me later",
|
||||
"passwordResetsTitle": "A reset is waiting",
|
||||
"passwordResetsAuthenticator": "Your authenticators will be removed on {date}: someone asked with your password. If that wasn't you, call it off and change your password.",
|
||||
"@passwordResetsAuthenticator": {
|
||||
"placeholders": {
|
||||
"date": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passwordResetsAccount": "Your account will get a new password and new keys on {date}, and what is encrypted under the old ones will be lost: someone asked with a code by mail. If that wasn't you, call it off.",
|
||||
"@passwordResetsAccount": {
|
||||
"placeholders": {
|
||||
"date": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passwordResetsCallOff": "Call it off",
|
||||
"passwordCallOffTitle": "Call off the reset",
|
||||
"passwordCallOffIntro": "Your password and a code call off every reset waiting."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1458,6 +1458,42 @@ abstract class HalkyonAppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Remind me later'**
|
||||
String get passwordWarningLater;
|
||||
|
||||
/// No description provided for @passwordResetsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A reset is waiting'**
|
||||
String get passwordResetsTitle;
|
||||
|
||||
/// No description provided for @passwordResetsAuthenticator.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your authenticators will be removed on {date}: someone asked with your password. If that wasn\'t you, call it off and change your password.'**
|
||||
String passwordResetsAuthenticator(String date);
|
||||
|
||||
/// No description provided for @passwordResetsAccount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your account will get a new password and new keys on {date}, and what is encrypted under the old ones will be lost: someone asked with a code by mail. If that wasn\'t you, call it off.'**
|
||||
String passwordResetsAccount(String date);
|
||||
|
||||
/// No description provided for @passwordResetsCallOff.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Call it off'**
|
||||
String get passwordResetsCallOff;
|
||||
|
||||
/// No description provided for @passwordCallOffTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Call off the reset'**
|
||||
String get passwordCallOffTitle;
|
||||
|
||||
/// No description provided for @passwordCallOffIntro.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your password and a code call off every reset waiting.'**
|
||||
String get passwordCallOffIntro;
|
||||
}
|
||||
|
||||
class _HalkyonAppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -846,4 +846,27 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
|||
|
||||
@override
|
||||
String get passwordWarningLater => 'Später erinnern';
|
||||
|
||||
@override
|
||||
String get passwordResetsTitle => 'Ein Zurücksetzen steht an';
|
||||
|
||||
@override
|
||||
String passwordResetsAuthenticator(String date) {
|
||||
return 'Am $date werden deine Authenticator-Apps entfernt: Jemand hat es mit deinem Passwort angefordert. Warst du das nicht, sag es ab und ändere dein Passwort.';
|
||||
}
|
||||
|
||||
@override
|
||||
String passwordResetsAccount(String date) {
|
||||
return 'Am $date bekommt dein Konto ein neues Passwort und neue Schlüssel, und was mit den alten verschlüsselt ist, geht verloren: Jemand hat es mit einem Code per Mail angefordert. Warst du das nicht, sag es ab.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get passwordResetsCallOff => 'Absagen';
|
||||
|
||||
@override
|
||||
String get passwordCallOffTitle => 'Zurücksetzen absagen';
|
||||
|
||||
@override
|
||||
String get passwordCallOffIntro =>
|
||||
'Dein Passwort und ein Code sagen jedes anstehende Zurücksetzen ab.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -829,6 +829,29 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
|||
|
||||
@override
|
||||
String get passwordWarningLater => 'Remind me later';
|
||||
|
||||
@override
|
||||
String get passwordResetsTitle => 'A reset is waiting';
|
||||
|
||||
@override
|
||||
String passwordResetsAuthenticator(String date) {
|
||||
return 'Your authenticators will be removed on $date: someone asked with your password. If that wasn\'t you, call it off and change your password.';
|
||||
}
|
||||
|
||||
@override
|
||||
String passwordResetsAccount(String date) {
|
||||
return 'Your account will get a new password and new keys on $date, and what is encrypted under the old ones will be lost: someone asked with a code by mail. If that wasn\'t you, call it off.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get passwordResetsCallOff => 'Call it off';
|
||||
|
||||
@override
|
||||
String get passwordCallOffTitle => 'Call off the reset';
|
||||
|
||||
@override
|
||||
String get passwordCallOffIntro =>
|
||||
'Your password and a code call off every reset waiting.';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ typedef PasswordWays = ({
|
|||
/// When a session of the account was last ended for ten wrong proofs in
|
||||
/// a row, until the warning is dismissed; null for none.
|
||||
DateTime? sessionEndedForProofsAt,
|
||||
|
||||
/// When a reset of the authenticators, and of the account, waiting takes
|
||||
/// effect; null for none (ADR 0017, the full reading's H1).
|
||||
DateTime? authenticatorResetAt,
|
||||
DateTime? accountResetAt,
|
||||
});
|
||||
|
||||
/// What a signed-in owner proves to change how their account is secured
|
||||
|
|
@ -121,6 +126,10 @@ abstract interface class PasswordGateway() {
|
|||
/// New recovery codes for the [owner]'s proof.
|
||||
Future<List<String>> newRecoveryCodes(OwnerAnswer owner);
|
||||
|
||||
/// Calls off every reset of the signed-in person's account waiting, for
|
||||
/// the [owner]'s proof.
|
||||
Future<void> callOffResets(OwnerAnswer owner);
|
||||
|
||||
/// How the signed-in person signs in.
|
||||
Future<PasswordWays> ways();
|
||||
|
||||
|
|
|
|||
|
|
@ -330,6 +330,17 @@ class PasswordRepository(
|
|||
await _ownerAnswer(password, totpCode: code),
|
||||
);
|
||||
|
||||
/// Calls off every reset of the signed-in person's account waiting, for
|
||||
/// their [password] and a [code] (the full reading's H1: the section shows
|
||||
/// them, as no wait holds back a signed-in owner's proof).
|
||||
Future<void> callOffResets({
|
||||
required String password,
|
||||
required String code,
|
||||
}) async {
|
||||
await _gateway.callOffResets(await _ownerAnswer(password, totpCode: code));
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// The owner's proof (`OwnerProof`): [password] (or [unlocked], unlocked
|
||||
/// at a sign-in already) answering a change's challenge, of the signed-in
|
||||
/// session or [as], with a [totpCode] or [recoveryCode] where given.
|
||||
|
|
@ -377,6 +388,8 @@ class PasswordRepository(
|
|||
hasPassword: ways.password,
|
||||
secondFactorWorks: ways.secondFactorWorks,
|
||||
sessionEndedAt: ways.sessionEndedForProofsAt,
|
||||
authenticatorResetAt: ways.authenticatorResetAt,
|
||||
accountResetAt: ways.accountResetAt,
|
||||
authenticators: ways.password
|
||||
? await _gateway.authenticators()
|
||||
: const <AccountAuthenticator>[],
|
||||
|
|
|
|||
|
|
@ -182,6 +182,11 @@ class ServerpodPasswordGateway(
|
|||
() => _base.authenticator.newRecoveryCodes(proof: _proofOf(owner)!),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> callOffResets(OwnerAnswer owner) => _failing(
|
||||
() => _base.password.callOffResets(proof: _proofOf(owner)!),
|
||||
);
|
||||
|
||||
/// [owner] as the instance takes it, bound to the [host] addressed.
|
||||
protocol.OwnerProof? _proofOf(OwnerAnswer? owner) => switch (owner) {
|
||||
null => null,
|
||||
|
|
@ -206,6 +211,8 @@ class ServerpodPasswordGateway(
|
|||
authenticator: ways.authenticator,
|
||||
secondFactorWorks: ways.secondFactorWorks,
|
||||
sessionEndedForProofsAt: ways.sessionEndedForProofsAt,
|
||||
authenticatorResetAt: ways.authenticatorResetAt,
|
||||
accountResetAt: ways.accountResetAt,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ final class const AccountSecurity({
|
|||
/// owner's warning until dismissed or a new password; null for none.
|
||||
final DateTime? sessionEndedAt,
|
||||
|
||||
/// When a reset of the authenticators, and of the account, waiting takes
|
||||
/// effect (the full reading's H1): the section shows them, with a way to
|
||||
/// call them off; null for none.
|
||||
final DateTime? authenticatorResetAt,
|
||||
final DateTime? accountResetAt,
|
||||
|
||||
/// Their authenticators, the oldest first; empty without a password.
|
||||
final List<AccountAuthenticator> authenticators = const [],
|
||||
|
||||
|
|
@ -48,6 +54,8 @@ final class const AccountSecurity({
|
|||
hasPassword,
|
||||
secondFactorWorks,
|
||||
sessionEndedAt,
|
||||
authenticatorResetAt,
|
||||
accountResetAt,
|
||||
authenticators,
|
||||
keysHere,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -64,6 +64,14 @@ class const _AccountSecurity() extends StatelessWidget {
|
|||
Text(l10n.passwordSectionTitle, style: theme.textTheme.titleSmall),
|
||||
if (security.sessionEndedAt case final ended?)
|
||||
_SessionEndedWarning(ended),
|
||||
if (security.authenticatorResetAt != null ||
|
||||
security.accountResetAt != null)
|
||||
_ResetsWaiting(
|
||||
authenticatorAt: security.authenticatorResetAt,
|
||||
accountAt: security.accountResetAt,
|
||||
onCallOff: () =>
|
||||
_show(context, (context) => const _CallOffResets()),
|
||||
),
|
||||
if (security.gitHub && security.gitHubEnds != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
|
|
@ -605,6 +613,151 @@ class _AddAuthenticatorState() extends State<_AddAuthenticator> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The resets of the account waiting, each with when it takes effect, and
|
||||
/// a way to call them off (the full reading's H1: an owner held out of
|
||||
/// signing in by codes made to wait still calls them off here).
|
||||
class const _ResetsWaiting({
|
||||
required final DateTime? authenticatorAt,
|
||||
required final DateTime? accountAt,
|
||||
required final VoidCallback onCallOff,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final body = theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onErrorContainer,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(LucideIcons.shieldAlert, color: colors.onErrorContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.passwordResetsTitle,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: colors.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (authenticatorAt case final at?)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, right: 8),
|
||||
child: Text(
|
||||
l10n.passwordResetsAuthenticator(
|
||||
passwordMoment(context, at),
|
||||
),
|
||||
style: body,
|
||||
),
|
||||
),
|
||||
if (accountAt case final at?)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, right: 8),
|
||||
child: Text(
|
||||
l10n.passwordResetsAccount(passwordMoment(context, at)),
|
||||
style: body,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
onPressed: onCallOff,
|
||||
child: Text(l10n.passwordResetsCallOff),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls off every reset waiting, for the password and a code.
|
||||
class const _CallOffResets() extends StatefulWidget {
|
||||
@override
|
||||
State<_CallOffResets> createState() => _CallOffResetsState();
|
||||
}
|
||||
|
||||
class _CallOffResetsState() extends State<_CallOffResets> {
|
||||
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 = _passwordsOf(context);
|
||||
final navigator = Navigator.of(context);
|
||||
if (await _step.run(() async {
|
||||
await passwords.callOffResets(
|
||||
password: _password.text,
|
||||
code: _code.text,
|
||||
);
|
||||
return true;
|
||||
})) {
|
||||
navigator.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
return BlocBuilder<SecurityStepCubit<bool>, SecurityStepState<bool>>(
|
||||
bloc: _step,
|
||||
builder: (context, state) => SheetScaffold(
|
||||
title: l10n.passwordCallOffTitle,
|
||||
busy: state.busy,
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SheetIntro(l10n.passwordCallOffIntro),
|
||||
PasswordField(
|
||||
controller: _password,
|
||||
label: l10n.passwordCurrent,
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CodeField(
|
||||
controller: _code,
|
||||
kind: CodeKind.totp,
|
||||
errorText: _problemOf(context, state),
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
],
|
||||
),
|
||||
footer: FilledButton(
|
||||
onPressed: state.busy ? null : _submit,
|
||||
child: state.busy
|
||||
? const ButtonLoadingIndicator()
|
||||
: Text(l10n.passwordResetsCallOff),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// New recovery codes for the password and a code: shown once.
|
||||
class const _NewRecoveryCodes() extends StatefulWidget {
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -559,6 +559,10 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
},
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> callOffResets(OwnerAnswer owner) =>
|
||||
_call('callOffResets', () => _ownerProved(owner));
|
||||
|
||||
@override
|
||||
Future<PasswordWays> ways() => _call('ways', () {
|
||||
final person = _signedIn();
|
||||
|
|
@ -572,6 +576,11 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
|
|||
(account?.finished ?? false) &&
|
||||
(account?.authenticatorReset?.isAfter(clock.now()) ?? true),
|
||||
sessionEndedForProofsAt: account?.sessionEndedForProofsAt,
|
||||
// Not carried out: one of its authenticators still confirmed.
|
||||
authenticatorResetAt: (account?.finished ?? false)
|
||||
? account?.authenticatorReset
|
||||
: null,
|
||||
accountResetAt: account?.accountReset,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue