feat(auth): change the password and the recovery phrase while signed in
ADR 0017, step 3. `password.beginChange` and `password.change` take the old password's SRP proof and a TOTP or recovery code, and the same keys wrapped under the new password; a session alone changes nothing, and an account without a confirmed authenticator is told to set one up first. `password.setRecoveryKeys` replaces the keys' wrapping under the recovery phrase for a TOTP code, so a device holding the keys can make a new phrase. A change of password ends every other session of the account in every service of the instance; resets of the password and of the account end them all. The register ends its own and keeps a notice per member (`sessions_ended_notice`, a migration that only creates it), told at once and hourly until it confirms, as deletions are told: it names a moment, so sessions made since stay, and the session that asked for a change. A member answering one ends its sessions made before that moment; a member signing someone in after a reset ends its own first and makes the new session only once its clock is past the moment. A reset not due says when it takes effect (`takesEffectAt` on `PasswordRefusedException`), across members too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f8076dcacf
commit
d00da1f24a
1 changed files with 43506 additions and 126 deletions
|
|
@ -9,6 +9,7 @@ import 'package:halkyon_base_server/src/register/accounts_mode.dart';
|
|||
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
|
||||
import 'package:halkyon_base_server/src/register/local_register.dart';
|
||||
import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
||||
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
|
|
@ -323,7 +324,7 @@ Future<Map<String, Object?>?> _answer(
|
|||
);
|
||||
return {'challenge': challenge.toJson()};
|
||||
case RegisterOperation.resetPasswordWithPhrase:
|
||||
final account = await register.resetPasswordWithPhrase(
|
||||
final ended = await register.resetPasswordWithPhrase(
|
||||
session,
|
||||
challenge: arguments.id('challenge'),
|
||||
signature: arguments.bytes('signature'),
|
||||
|
|
@ -331,7 +332,7 @@ Future<Map<String, Object?>?> _answer(
|
|||
totpCode: arguments.optionalString('totpCode'),
|
||||
recoveryCode: arguments.optionalString('recoveryCode'),
|
||||
);
|
||||
return {'account': account.uuid};
|
||||
return _sessionsEnded(ended);
|
||||
case RegisterOperation.startAccountReset:
|
||||
final pending = await register.startAccountReset(
|
||||
session,
|
||||
|
|
@ -342,18 +343,51 @@ Future<Map<String, Object?>?> _answer(
|
|||
);
|
||||
return {'pending': pending.toJson()};
|
||||
case RegisterOperation.completeAccountReset:
|
||||
final account = await register.completeAccountReset(
|
||||
final ended = await register.completeAccountReset(
|
||||
session,
|
||||
address: arguments.string('address'),
|
||||
request: arguments.id('request'),
|
||||
code: arguments.string('code'),
|
||||
setup: arguments.model('setup', PasswordSetup.fromJson),
|
||||
);
|
||||
return {'account': account.uuid};
|
||||
return _sessionsEnded(ended);
|
||||
case RegisterOperation.beginPasswordChange:
|
||||
final challenge = await register.beginPasswordChange(
|
||||
session,
|
||||
person: arguments.id('person'),
|
||||
);
|
||||
return {'challenge': challenge.toJson()};
|
||||
case RegisterOperation.changePassword:
|
||||
await register.changePassword(
|
||||
session,
|
||||
person: arguments.id('person'),
|
||||
challenge: arguments.id('challenge'),
|
||||
clientValue: arguments.bytes('clientValue'),
|
||||
clientProof: arguments.bytes('clientProof'),
|
||||
totpCode: arguments.optionalString('totpCode'),
|
||||
recoveryCode: arguments.optionalString('recoveryCode'),
|
||||
password: arguments.model('password', NewPassword.fromJson),
|
||||
keepSession: arguments.optionalId('keepSession'),
|
||||
);
|
||||
return const {};
|
||||
case RegisterOperation.setRecoveryKeys:
|
||||
await register.setRecoveryKeys(
|
||||
session,
|
||||
person: arguments.id('person'),
|
||||
code: arguments.string('code'),
|
||||
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
|
||||
);
|
||||
return const {};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// [ended] as the register answers it.
|
||||
Map<String, Object?> _sessionsEnded(AccountSessionsEnded ended) => {
|
||||
'account': ended.account.uuid,
|
||||
'endedBefore': ended.endedBefore.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
/// A member answering the register that [body]'s account is gone
|
||||
/// ([accountGonePath]): that person's data here is deleted, here alone (the
|
||||
/// register deleted the account), and the register told so. On the register
|
||||
|
|
@ -391,6 +425,46 @@ Future<InstanceAnswer> answerRegister(
|
|||
return (status: 200, body: const <String, Object?>{});
|
||||
}
|
||||
|
||||
/// A member answering the register that [body]'s account's sessions ended
|
||||
/// ([sessionsEndedPath], ADR 0017): those here created before the moment it
|
||||
/// names end, but the one it keeps, and the register is told so. On the
|
||||
/// register itself, nothing is answered.
|
||||
Future<InstanceAnswer> answerSessionsEnded(
|
||||
Session session,
|
||||
String body, {
|
||||
required String? authorization,
|
||||
}) async {
|
||||
final pod = session.serverpod;
|
||||
if (!_carriesSecret(pod, authorization)) {
|
||||
return _unauthorized;
|
||||
}
|
||||
if (HalkyonBase.maybeOf(pod)?.accounts is! MemberOfRegister) {
|
||||
return _notHere;
|
||||
}
|
||||
final arguments = _arguments(body);
|
||||
if (arguments == null) {
|
||||
return _unreadable;
|
||||
}
|
||||
final UuidValue account;
|
||||
final DateTime endedBefore;
|
||||
final UuidValue? keepSession;
|
||||
try {
|
||||
final read = _Arguments(arguments);
|
||||
account = read.id('account');
|
||||
endedBefore = DateTime.parse(read.string('endedBefore')).toUtc();
|
||||
keepSession = read.optionalId('keepSession');
|
||||
} on FormatException {
|
||||
return _unreadable;
|
||||
}
|
||||
await endSessionsHere(
|
||||
session,
|
||||
account,
|
||||
endedBefore: endedBefore,
|
||||
keepSession: keepSession,
|
||||
);
|
||||
return (status: 200, body: const <String, Object?>{});
|
||||
}
|
||||
|
||||
/// A request's arguments, each read as what it must be; a [FormatException]
|
||||
/// for anything else.
|
||||
extension type const _Arguments(Map<String, Object?> _map) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue