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:
Tizian 「ティツィアーン」 2026-10-20 14:53:58 +02:00
commit d00da1f24a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
34 changed files with 43506 additions and 126 deletions

View file

@ -56,6 +56,8 @@ export 'src/register/deletion_notices.dart'
export 'src/register/instance_routes.dart';
export 'src/register/member_pruning.dart';
export 'src/register/people.dart' show issueSession;
export 'src/register/sessions_ended.dart'
show AccountSessionsEnded, deliverSessionNotices;
export 'src/srp/srp_group.dart';
export 'src/srp/srp_server.dart'
show

View file

@ -15,6 +15,8 @@ import 'package:halkyon_base_server/src/generated/password/new_password.dart'
as _ijye8mcv;
import 'package:halkyon_base_server/src/generated/password/password_setup.dart'
as _ikhyee47;
import 'package:halkyon_base_server/src/generated/password/wrapped_keys.dart'
as _iji0wmu8;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
as _iacs;
@ -750,6 +752,90 @@ class Endpoints extends _is.EndpointDispatch {
code: params['code'],
),
),
'beginChange': _is.MethodConnector(
name: 'beginChange',
params: {},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint)
.beginChange(session),
),
'change': _is.MethodConnector(
name: 'change',
params: {
'challenge': _is.ParameterDescription(
name: 'challenge',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
'clientValue': _is.ParameterDescription(
name: 'clientValue',
type: _is.getType<_idt.ByteData>(),
nullable: false,
),
'clientProof': _is.ParameterDescription(
name: 'clientProof',
type: _is.getType<_idt.ByteData>(),
nullable: false,
),
'totpCode': _is.ParameterDescription(
name: 'totpCode',
type: _is.getType<String?>(),
nullable: true,
),
'recoveryCode': _is.ParameterDescription(
name: 'recoveryCode',
type: _is.getType<String?>(),
nullable: true,
),
'password': _is.ParameterDescription(
name: 'password',
type: _is.getType<_ijye8mcv.NewPassword>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['password'] as _ig7fyg1b.PasswordEndpoint).change(
session,
challenge: params['challenge'],
clientValue: params['clientValue'],
clientProof: params['clientProof'],
totpCode: params['totpCode'],
recoveryCode: params['recoveryCode'],
password: params['password'],
),
),
'setRecoveryKeys': _is.MethodConnector(
name: 'setRecoveryKeys',
params: {
'code': _is.ParameterDescription(
name: 'code',
type: _is.getType<String>(),
nullable: false,
),
'recoveryKeys': _is.ParameterDescription(
name: 'recoveryKeys',
type: _is.getType<_iji0wmu8.WrappedKeys>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['password'] as _ig7fyg1b.PasswordEndpoint)
.setRecoveryKeys(
session,
code: params['code'],
recoveryKeys: params['recoveryKeys'],
),
),
},
);
connectors['passwordSignIn'] = _is.EndpointConnector(

View file

@ -20,10 +20,14 @@ abstract class PasswordRefusedException
_is.SerializableException,
_is.SerializableModel,
_is.ProtocolSerialization {
PasswordRefusedException._({required this.reason});
PasswordRefusedException._({
required this.reason,
this.takesEffectAt,
});
factory PasswordRefusedException({
required _i647hpdf.PasswordRefusal reason,
DateTime? takesEffectAt,
}) = _PasswordRefusedExceptionImpl;
factory PasswordRefusedException.fromJson(
@ -33,20 +37,33 @@ abstract class PasswordRefusedException
reason: _i647hpdf.PasswordRefusal.fromJson(
(jsonSerialization['reason'] as String),
),
takesEffectAt: jsonSerialization['takesEffectAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['takesEffectAt'],
),
);
}
_i647hpdf.PasswordRefusal reason;
/// When the reset waiting takes effect, for [PasswordRefusal.notDue];
/// null otherwise.
DateTime? takesEffectAt;
/// Returns a shallow copy of this [PasswordRefusedException]
/// with some or all fields replaced by the given arguments.
@_is.useResult
PasswordRefusedException copyWith({_i647hpdf.PasswordRefusal? reason});
PasswordRefusedException copyWith({
_i647hpdf.PasswordRefusal? reason,
DateTime? takesEffectAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.PasswordRefusedException',
'reason': reason.toJson(),
if (takesEffectAt != null) 'takesEffectAt': takesEffectAt?.toJson(),
};
}
@ -55,24 +72,40 @@ abstract class PasswordRefusedException
return {
'__className__': 'halkyon_base.PasswordRefusedException',
'reason': reason.toJson(),
if (takesEffectAt != null) 'takesEffectAt': takesEffectAt?.toJson(),
};
}
@override
String toString() {
return 'PasswordRefusedException(reason: $reason)';
return 'PasswordRefusedException(reason: $reason, takesEffectAt: $takesEffectAt)';
}
}
class _Undefined {}
class _PasswordRefusedExceptionImpl extends PasswordRefusedException {
_PasswordRefusedExceptionImpl({required _i647hpdf.PasswordRefusal reason})
: super._(reason: reason);
_PasswordRefusedExceptionImpl({
required _i647hpdf.PasswordRefusal reason,
DateTime? takesEffectAt,
}) : super._(
reason: reason,
takesEffectAt: takesEffectAt,
);
/// Returns a shallow copy of this [PasswordRefusedException]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
PasswordRefusedException copyWith({_i647hpdf.PasswordRefusal? reason}) {
return PasswordRefusedException(reason: reason ?? this.reason);
PasswordRefusedException copyWith({
_i647hpdf.PasswordRefusal? reason,
Object? takesEffectAt = _Undefined,
}) {
return PasswordRefusedException(
reason: reason ?? this.reason,
takesEffectAt: takesEffectAt is DateTime?
? takesEffectAt
: this.takesEffectAt,
);
}
}

View file

@ -54,6 +54,7 @@ import 'profile/profile_refusal.dart' as _i6j6efd1;
import 'profile/profile_refused_exception.dart' as _idvc5e7p;
import 'profile/public_profile.dart' as _ioppe2ep;
import 'register/account_deletion_notice.dart' as _i2lyzpm9;
import 'register/sessions_ended_notice.dart' as _io5c3tod;
export 'auth/email_code_accepted.dart';
export 'auth/email_code_request.dart';
export 'auth/email_identity.dart';
@ -89,6 +90,7 @@ export 'profile/profile_refusal.dart';
export 'profile/profile_refused_exception.dart';
export 'profile/public_profile.dart';
export 'register/account_deletion_notice.dart';
export 'register/sessions_ended_notice.dart';
class Protocol extends _is.DatabaseSerializationManager {
Protocol._();
@ -631,6 +633,78 @@ class Protocol extends _is.DatabaseSerializationManager {
],
managed: true,
),
_isp.TableDefinition(
name: 'sessions_ended_notice',
dartName: 'SessionsEndedNotice',
schema: 'public',
module: 'halkyon_base',
columns: [
_isp.ColumnDefinition(
name: 'id',
columnType: _isp.ColumnType.bigint,
isNullable: false,
dartType: 'int?',
columnDefault: 'serial',
),
_isp.ColumnDefinition(
name: 'account',
columnType: _isp.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue',
),
_isp.ColumnDefinition(
name: 'member',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'endedBefore',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: false,
dartType: 'DateTime',
),
_isp.ColumnDefinition(
name: 'keepSession',
columnType: _isp.ColumnType.uuid,
isNullable: true,
dartType: 'UuidValue?',
),
_isp.ColumnDefinition(
name: 'attempts',
columnType: _isp.ColumnType.bigint,
isNullable: false,
dartType: 'int',
),
_isp.ColumnDefinition(
name: 'lastAttemptAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: true,
dartType: 'DateTime?',
),
],
foreignKeys: [],
indexes: [
_isp.IndexDefinition(
indexName: 'sessions_ended_notice_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'account',
),
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'member',
),
],
type: 'btree',
isUnique: true,
isPrimary: false,
),
],
managed: true,
),
_isp.TableDefinition(
name: 'sign_in_challenge',
dartName: 'SignInChallenge',
@ -912,6 +986,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _i2lyzpm9.AccountDeletionNotice) {
return _i2lyzpm9.AccountDeletionNotice.fromJson(data) as T;
}
if (t == _io5c3tod.SessionsEndedNotice) {
return _io5c3tod.SessionsEndedNotice.fromJson(data) as T;
}
if (t == _is.getType<_iys46ui1.EmailCodeAccepted?>()) {
return (data != null ? _iys46ui1.EmailCodeAccepted.fromJson(data) : null)
as T;
@ -1063,6 +1140,12 @@ class Protocol extends _is.DatabaseSerializationManager {
: null)
as T;
}
if (t == _is.getType<_io5c3tod.SessionsEndedNotice?>()) {
return (data != null
? _io5c3tod.SessionsEndedNotice.fromJson(data)
: null)
as T;
}
if (t == Set<String>) {
return (data as List).map((e) => deserialize<String>(e)).toSet() as T;
}
@ -1133,6 +1216,7 @@ class Protocol extends _is.DatabaseSerializationManager {
_idvc5e7p.ProfileRefusedException => 'ProfileRefusedException',
_ioppe2ep.PublicProfile => 'PublicProfile',
_i2lyzpm9.AccountDeletionNotice => 'AccountDeletionNotice',
_io5c3tod.SessionsEndedNotice => 'SessionsEndedNotice',
_ => null,
};
}
@ -1220,6 +1304,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'PublicProfile';
case _i2lyzpm9.AccountDeletionNotice():
return 'AccountDeletionNotice';
case _io5c3tod.SessionsEndedNotice():
return 'SessionsEndedNotice';
}
className = _isp.Protocol().getClassNameForObject(data);
if (className != null) {
@ -1339,6 +1425,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'AccountDeletionNotice') {
return deserialize<_i2lyzpm9.AccountDeletionNotice>(data['data']);
}
if (dataClassName == 'SessionsEndedNotice') {
return deserialize<_io5c3tod.SessionsEndedNotice>(data['data']);
}
if (dataClassName.startsWith('serverpod.')) {
data['className'] = dataClassName.substring(10);
return _isp.Protocol().deserializeByClassName(data);
@ -1444,6 +1533,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return _ioppe2ep.PublicProfile.t;
case _i2lyzpm9.AccountDeletionNotice:
return _i2lyzpm9.AccountDeletionNotice.t;
case _io5c3tod.SessionsEndedNotice:
return _io5c3tod.SessionsEndedNotice.t;
}
return null;
}

View file

@ -29,6 +29,9 @@ authenticator:
- newRecoveryCodes:
password:
- set:
- beginChange:
- change:
- setRecoveryKeys:
passwordSignIn:
- begin:
- complete:

View file

@ -0,0 +1,734 @@
/* 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 'package:serverpod/serverpod.dart' as _is;
/// That an account's sessions ended (ADR 0017: its password changed, or was
/// reset), still to be told to a member of the instance (ADR 0016), so the
/// member ends that account's sessions there too: kept by the register until
/// the member confirms, and tried again hourly.
abstract class SessionsEndedNotice
implements _is.TableRow<int?>, _is.ProtocolSerialization {
SessionsEndedNotice._({
this.id,
required this.account,
required this.member,
required this.endedBefore,
this.keepSession,
required this.attempts,
this.lastAttemptAt,
});
factory SessionsEndedNotice({
int? id,
required _is.UuidValue account,
required String member,
required DateTime endedBefore,
_is.UuidValue? keepSession,
required int attempts,
DateTime? lastAttemptAt,
}) = _SessionsEndedNoticeImpl;
factory SessionsEndedNotice.fromJson(Map<String, dynamic> jsonSerialization) {
return SessionsEndedNotice(
id: jsonSerialization['id'] as int?,
account: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['account'],
),
member: jsonSerialization['member'] as String,
endedBefore: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['endedBefore'],
),
keepSession: jsonSerialization['keepSession'] == null
? null
: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['keepSession'],
),
attempts: jsonSerialization['attempts'] as int,
lastAttemptAt: jsonSerialization['lastAttemptAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['lastAttemptAt'],
),
);
}
static final t = SessionsEndedNoticeTable();
static const db = SessionsEndedNoticeRepository._();
@override
int? id;
/// The account: the person's ID in every service.
_is.UuidValue account;
/// The member's web server, as the register's configuration names it.
String member;
/// The sessions created before this moment end, in UTC; those signed in
/// since stay.
DateTime endedBefore;
/// The session that stays all the same (the one that changed the
/// password); null for none. A session's ID is unique across the
/// instance's services, so it names one on one member at most.
_is.UuidValue? keepSession;
/// How often telling the member has failed so far.
int attempts;
/// When it was last tried, in UTC; null before the first try.
DateTime? lastAttemptAt;
@override
_is.Table<int?> get table => t;
/// Returns a shallow copy of this [SessionsEndedNotice]
/// with some or all fields replaced by the given arguments.
@_is.useResult
SessionsEndedNotice copyWith({
int? id,
_is.UuidValue? account,
String? member,
DateTime? endedBefore,
_is.UuidValue? keepSession,
int? attempts,
DateTime? lastAttemptAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.SessionsEndedNotice',
if (id != null) 'id': id,
'account': account.toJson(),
'member': member,
'endedBefore': endedBefore.toJson(),
if (keepSession != null) 'keepSession': keepSession?.toJson(),
'attempts': attempts,
if (lastAttemptAt != null) 'lastAttemptAt': lastAttemptAt?.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {};
}
static SessionsEndedNoticeInclude include() {
return SessionsEndedNoticeInclude._();
}
static SessionsEndedNoticeIncludeList includeList({
_is.WhereExpressionBuilder<SessionsEndedNoticeTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<SessionsEndedNoticeTable>? orderBy,
_is.OrderByListBuilder<SessionsEndedNoticeTable>? orderByList,
SessionsEndedNoticeInclude? include,
}) {
return SessionsEndedNoticeIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(SessionsEndedNotice.t),
orderByList: orderByList?.call(SessionsEndedNotice.t),
include: include,
);
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _SessionsEndedNoticeImpl extends SessionsEndedNotice {
_SessionsEndedNoticeImpl({
int? id,
required _is.UuidValue account,
required String member,
required DateTime endedBefore,
_is.UuidValue? keepSession,
required int attempts,
DateTime? lastAttemptAt,
}) : super._(
id: id,
account: account,
member: member,
endedBefore: endedBefore,
keepSession: keepSession,
attempts: attempts,
lastAttemptAt: lastAttemptAt,
);
/// Returns a shallow copy of this [SessionsEndedNotice]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
SessionsEndedNotice copyWith({
Object? id = _Undefined,
_is.UuidValue? account,
String? member,
DateTime? endedBefore,
Object? keepSession = _Undefined,
int? attempts,
Object? lastAttemptAt = _Undefined,
}) {
return SessionsEndedNotice(
id: id is int? ? id : this.id,
account: account ?? this.account,
member: member ?? this.member,
endedBefore: endedBefore ?? this.endedBefore,
keepSession: keepSession is _is.UuidValue?
? keepSession
: this.keepSession,
attempts: attempts ?? this.attempts,
lastAttemptAt: lastAttemptAt is DateTime?
? lastAttemptAt
: this.lastAttemptAt,
);
}
}
class SessionsEndedNoticeUpdateTable
extends _is.UpdateTable<SessionsEndedNoticeTable> {
SessionsEndedNoticeUpdateTable(super.table);
_is.ColumnValue<_is.UuidValue, _is.UuidValue> account(_is.UuidValue value) =>
_is.ColumnValue(
table.account,
value,
);
_is.ColumnValue<String, String> member(String value) => _is.ColumnValue(
table.member,
value,
);
_is.ColumnValue<DateTime, DateTime> endedBefore(DateTime value) =>
_is.ColumnValue(
table.endedBefore,
value,
);
_is.ColumnValue<_is.UuidValue, _is.UuidValue> keepSession(
_is.UuidValue? value,
) => _is.ColumnValue(
table.keepSession,
value,
);
_is.ColumnValue<int, int> attempts(int value) => _is.ColumnValue(
table.attempts,
value,
);
_is.ColumnValue<DateTime, DateTime> lastAttemptAt(DateTime? value) =>
_is.ColumnValue(
table.lastAttemptAt,
value,
);
}
class SessionsEndedNoticeTable extends _is.Table<int?> {
SessionsEndedNoticeTable({super.tableRelation})
: super(tableName: 'sessions_ended_notice') {
updateTable = SessionsEndedNoticeUpdateTable(this);
account = _is.ColumnUuid(
'account',
this,
);
member = _is.ColumnString(
'member',
this,
);
endedBefore = _is.ColumnDateTime(
'endedBefore',
this,
);
keepSession = _is.ColumnUuid(
'keepSession',
this,
);
attempts = _is.ColumnInt(
'attempts',
this,
);
lastAttemptAt = _is.ColumnDateTime(
'lastAttemptAt',
this,
);
}
late final SessionsEndedNoticeUpdateTable updateTable;
/// The account: the person's ID in every service.
late final _is.ColumnUuid account;
/// The member's web server, as the register's configuration names it.
late final _is.ColumnString member;
/// The sessions created before this moment end, in UTC; those signed in
/// since stay.
late final _is.ColumnDateTime endedBefore;
/// The session that stays all the same (the one that changed the
/// password); null for none. A session's ID is unique across the
/// instance's services, so it names one on one member at most.
late final _is.ColumnUuid keepSession;
/// How often telling the member has failed so far.
late final _is.ColumnInt attempts;
/// When it was last tried, in UTC; null before the first try.
late final _is.ColumnDateTime lastAttemptAt;
@override
List<_is.Column> get columns => [
id,
account,
member,
endedBefore,
keepSession,
attempts,
lastAttemptAt,
];
}
class SessionsEndedNoticeInclude extends _is.IncludeObject {
SessionsEndedNoticeInclude._();
@override
Map<String, _is.Include?> get includes => {};
@override
_is.Table<int?> get table => SessionsEndedNotice.t;
}
class SessionsEndedNoticeIncludeList extends _is.IncludeList {
SessionsEndedNoticeIncludeList._({
_is.WhereExpressionBuilder<SessionsEndedNoticeTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(SessionsEndedNotice.t);
}
@override
Map<String, _is.Include?> get includes => include?.includes ?? {};
@override
_is.Table<int?> get table => SessionsEndedNotice.t;
}
class SessionsEndedNoticeRepository {
const SessionsEndedNoticeRepository._();
/// Returns a list of [SessionsEndedNotice]s matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order of the items use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// The maximum number of items can be set by [limit]. If no limit is set,
/// all items matching the query will be returned.
///
/// [offset] defines how many items to skip, after which [limit] (or all)
/// items are read from the database.
///
/// ```dart
/// var persons = await Persons.db.find(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.firstName,
/// limit: 100,
/// );
/// ```
Future<List<SessionsEndedNotice>> find(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<SessionsEndedNoticeTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<SessionsEndedNoticeTable>? orderBy,
_is.OrderByListBuilder<SessionsEndedNoticeTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.find<SessionsEndedNotice>(
where: where?.call(SessionsEndedNotice.t),
orderBy: orderBy?.call(SessionsEndedNotice.t),
orderByList: orderByList?.call(SessionsEndedNotice.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [SessionsEndedNotice] matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// [offset] defines how many items to skip, after which the next one will be picked.
///
/// ```dart
/// var youngestPerson = await Persons.db.findFirstRow(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.age,
/// );
/// ```
Future<SessionsEndedNotice?> findFirstRow(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<SessionsEndedNoticeTable>? where,
int? offset,
_is.OrderByBuilder<SessionsEndedNoticeTable>? orderBy,
_is.OrderByListBuilder<SessionsEndedNoticeTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<SessionsEndedNotice>(
where: where?.call(SessionsEndedNotice.t),
orderBy: orderBy?.call(SessionsEndedNotice.t),
orderByList: orderByList?.call(SessionsEndedNotice.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [SessionsEndedNotice] by its [id] or null if no such row exists.
Future<SessionsEndedNotice?> findById(
_is.DatabaseSession session,
int id, {
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findById<SessionsEndedNotice>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [SessionsEndedNotice]s in the list and returns the inserted rows.
///
/// The returned [SessionsEndedNotice]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails to
/// insert, none of the rows will be inserted.
///
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
/// rows are silently skipped, and only the successfully inserted rows are
/// returned.
///
/// If [noReturn] is set to `true`, the inserted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<SessionsEndedNotice>> insert(
_is.DatabaseSession session,
List<SessionsEndedNotice> rows, {
_is.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<SessionsEndedNotice>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [SessionsEndedNotice] and returns the inserted row.
///
/// The returned [SessionsEndedNotice] will have its `id` field set.
Future<SessionsEndedNotice> insertRow(
_is.DatabaseSession session,
SessionsEndedNotice row, {
_is.Transaction? transaction,
}) async {
return session.db.insertRow<SessionsEndedNotice>(
row,
transaction: transaction,
);
}
/// Upserts all [SessionsEndedNotice]s in the list and returns the resulting rows.
///
/// If a row conflicts on the given [conflictColumns], the existing row is
/// updated with the new values. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies to rows matching the
/// given expression. Conflicting rows that don't match are skipped and not
/// returned, so the resulting list may be shorter than [rows].
///
/// The returned [SessionsEndedNotice]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails,
/// none of the rows will be affected.
///
/// If [noReturn] is set to `true`, the resulting rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<SessionsEndedNotice>> upsert(
_is.DatabaseSession session,
List<SessionsEndedNotice> rows, {
required _is.ColumnSelections<SessionsEndedNoticeTable> conflictColumns,
_is.ColumnSelections<SessionsEndedNoticeTable>? updateColumns,
_is.WhereExpressionBuilder<SessionsEndedNoticeTable>? updateWhere,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<SessionsEndedNotice>(
rows,
conflictColumns: conflictColumns(SessionsEndedNotice.t),
updateColumns: updateColumns?.call(SessionsEndedNotice.t),
updateWhere: updateWhere?.call(SessionsEndedNotice.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [SessionsEndedNotice] and returns the resulting row.
///
/// If the row conflicts on the given [conflictColumns], the existing row is
/// updated. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies when the existing
/// row matches the expression. Returns `null` if no row was affected — for
/// example when [updateWhere] does not match the conflicting row.
///
/// The returned [SessionsEndedNotice] will have its `id` field set.
Future<SessionsEndedNotice?> upsertRow(
_is.DatabaseSession session,
SessionsEndedNotice row, {
required _is.ColumnSelections<SessionsEndedNoticeTable> conflictColumns,
_is.ColumnSelections<SessionsEndedNoticeTable>? updateColumns,
_is.WhereExpressionBuilder<SessionsEndedNoticeTable>? updateWhere,
_is.Transaction? transaction,
}) async {
return session.db.upsertRow<SessionsEndedNotice>(
row,
conflictColumns: conflictColumns(SessionsEndedNotice.t),
updateColumns: updateColumns?.call(SessionsEndedNotice.t),
updateWhere: updateWhere?.call(SessionsEndedNotice.t),
transaction: transaction,
);
}
/// Updates all [SessionsEndedNotice]s in the list and returns the updated rows. If
/// [columns] is provided, only those columns will be updated. Defaults to
/// all columns.
/// This is an atomic operation, meaning that if one of the rows fails to
/// update, none of the rows will be updated.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<SessionsEndedNotice>> update(
_is.DatabaseSession session,
List<SessionsEndedNotice> rows, {
_is.ColumnSelections<SessionsEndedNoticeTable>? columns,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<SessionsEndedNotice>(
rows,
columns: columns?.call(SessionsEndedNotice.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [SessionsEndedNotice]. The row needs to have its id set.
/// Optionally, a list of [columns] can be provided to only update those
/// columns. Defaults to all columns.
Future<SessionsEndedNotice> updateRow(
_is.DatabaseSession session,
SessionsEndedNotice row, {
_is.ColumnSelections<SessionsEndedNoticeTable>? columns,
_is.Transaction? transaction,
}) async {
return session.db.updateRow<SessionsEndedNotice>(
row,
columns: columns?.call(SessionsEndedNotice.t),
transaction: transaction,
);
}
/// Updates a single [SessionsEndedNotice] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<SessionsEndedNotice?> updateById(
_is.DatabaseSession session,
int id, {
required _is.ColumnValueListBuilder<SessionsEndedNoticeUpdateTable>
columnValues,
_is.Transaction? transaction,
}) async {
return session.db.updateById<SessionsEndedNotice>(
id,
columnValues: columnValues(SessionsEndedNotice.t.updateTable),
transaction: transaction,
);
}
/// Updates all [SessionsEndedNotice]s matching the [where] expression with the specified [columnValues].
/// Returns the list of updated rows.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<SessionsEndedNotice>> updateWhere(
_is.DatabaseSession session, {
required _is.ColumnValueListBuilder<SessionsEndedNoticeUpdateTable>
columnValues,
required _is.WhereExpressionBuilder<SessionsEndedNoticeTable> where,
int? limit,
int? offset,
_is.OrderByBuilder<SessionsEndedNoticeTable>? orderBy,
_is.OrderByListBuilder<SessionsEndedNoticeTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<SessionsEndedNotice>(
columnValues: columnValues(SessionsEndedNotice.t.updateTable),
where: where(SessionsEndedNotice.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(SessionsEndedNotice.t),
orderByList: orderByList?.call(SessionsEndedNotice.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [SessionsEndedNotice]s in the list and returns the deleted rows.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// This is an atomic operation, meaning that if one of the rows fail to
/// be deleted, none of the rows will be deleted.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<SessionsEndedNotice>> delete(
_is.DatabaseSession session,
List<SessionsEndedNotice> rows, {
_is.OrderByBuilder<SessionsEndedNoticeTable>? orderBy,
_is.OrderByListBuilder<SessionsEndedNoticeTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<SessionsEndedNotice>(
rows,
orderBy: orderBy?.call(SessionsEndedNotice.t),
orderByList: orderByList?.call(SessionsEndedNotice.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [SessionsEndedNotice].
Future<SessionsEndedNotice> deleteRow(
_is.DatabaseSession session,
SessionsEndedNotice row, {
_is.Transaction? transaction,
}) async {
return session.db.deleteRow<SessionsEndedNotice>(
row,
transaction: transaction,
);
}
/// Deletes all rows matching the [where] expression.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<SessionsEndedNotice>> deleteWhere(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<SessionsEndedNoticeTable> where,
_is.OrderByBuilder<SessionsEndedNoticeTable>? orderBy,
_is.OrderByListBuilder<SessionsEndedNoticeTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<SessionsEndedNotice>(
where: where(SessionsEndedNotice.t),
orderBy: orderBy?.call(SessionsEndedNotice.t),
orderByList: orderByList?.call(SessionsEndedNotice.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Counts the number of rows matching the [where] expression. If omitted,
/// will return the count of all rows in the table.
Future<int> count(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<SessionsEndedNoticeTable>? where,
int? limit,
_is.Transaction? transaction,
}) async {
return session.db.count<SessionsEndedNotice>(
where: where?.call(SessionsEndedNotice.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [SessionsEndedNotice] rows matching the [where] expression.
Future<void> lockRows(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<SessionsEndedNoticeTable> where,
required _is.LockMode lockMode,
required _is.Transaction transaction,
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
}) async {
return session.db.lockRows<SessionsEndedNotice>(
where: where(SessionsEndedNotice.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

View file

@ -4,8 +4,8 @@ import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/email.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
// Getting back into an account (ADR 0017), on the register:
//
@ -110,7 +110,7 @@ Future<ResetPending> resetAuthenticator(
/// Refused ([PasswordRefusedException]) for a new password out of range
/// ([PasswordRefusal.invalidSetup]), and as [proveRecoveryPhrase] and
/// [secondFactor] refuse.
Future<UuidValue> resetPasswordWithPhrase(
Future<AccountSessionsEnded> resetPasswordWithPhrase(
Session session, {
required UuidValue challenge,
required ByteData signature,
@ -136,7 +136,7 @@ Future<UuidValue> resetPasswordWithPhrase(
totpCode: totpCode,
recoveryCode: recoveryCode,
);
await session.db.transaction((transaction) async {
final endedBefore = await session.db.transaction((transaction) async {
// UPDATE "account_password" SET "salt" = $1, "kdfAlgorithm" = $2,
// "kdfOpsLimit" = $3, "kdfMemLimit" = $4, "verifier" = $5,
// "passwordKeysNonce" = $6, "passwordKeysCipherText" = $7,
@ -155,14 +155,15 @@ Future<UuidValue> resetPasswordWithPhrase(
),
transaction: transaction,
);
await _endSessions(session, account, transaction: transaction);
return await endSessionsEverywhere(
session,
account,
transaction: transaction,
);
});
// Their devices learn at once that their sessions ended.
await session.messages.authenticationRevoked(
account.uuid,
RevokedAuthenticationUser(),
);
return account;
// Their devices learn at once that their sessions ended, everywhere.
await announceSessionsEnded(session, account);
return (account: account, endedBefore: endedBefore);
}
/// Asks for the reset of the whole account whose address is [address], as
@ -212,7 +213,7 @@ Future<ResetPending> startAccountReset(
/// ([PasswordRefusal.invalidSetup]), as [startAccountReset] refuses, without
/// such a reset ([PasswordRefusal.noReset]) and before its time
/// ([PasswordRefusal.notDue]).
Future<UuidValue> completeAccountReset(
Future<AccountSessionsEnded> completeAccountReset(
Session session, {
required String address,
required UuidValue request,
@ -231,7 +232,7 @@ Future<UuidValue> completeAccountReset(
}
final account = await _accountWithPassword(session, proven);
final now = clock.now().toUtc();
await session.db.transaction((transaction) async {
final endedBefore = await session.db.transaction((transaction) async {
// DELETE FROM "account_reset" WHERE "authUserId" = $1 AND "kind" = $2
// RETURNING *
final reset = (await AccountReset.db.deleteWhere(
@ -245,7 +246,10 @@ Future<UuidValue> completeAccountReset(
throw refusedFor(PasswordRefusal.noReset);
}
if (reset.takesEffectAt.isAfter(now)) {
throw refusedFor(PasswordRefusal.notDue);
throw PasswordRefusedException(
reason: PasswordRefusal.notDue,
takesEffectAt: reset.takesEffectAt,
);
}
// SELECT * FROM "account_password" WHERE "authUserId" = $1
final row = await AccountPassword.db.findFirstRow(
@ -291,14 +295,15 @@ Future<UuidValue> completeAccountReset(
),
transaction: transaction,
);
await _endSessions(session, account, transaction: transaction);
return await endSessionsEverywhere(
session,
account,
transaction: transaction,
);
});
// Their devices learn at once that their sessions ended.
await session.messages.authenticationRevoked(
account.uuid,
RevokedAuthenticationUser(),
);
return account;
// Their devices learn at once that their sessions ended, everywhere.
await announceSessionsEnded(session, account);
return (account: account, endedBefore: endedBefore);
}
/// The account whose address's hash is [hash], with a password; refused
@ -380,17 +385,3 @@ Future<ResetPending> _askForReset(
return ResetPending(takesEffectAt: reset.takesEffectAt);
});
}
/// Ends every session of [account] on this server: its password or keys
/// changed, and whoever held a session before needn't be the owner.
Future<void> _endSessions(
Session session,
UuidValue account, {
required Transaction transaction,
}) =>
// DELETE FROM "serverpod_auth_core_session" WHERE "authUserId" = $1
ServerSideSession.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(account),
transaction: transaction,
);

View file

@ -68,8 +68,9 @@ class AccountRecoveryEndpoint extends Endpoint {
).beginKeyProof(session, address: address, network: networkOf(session));
/// Sets a new [password] with the recovery phrase's [signature] for
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Signs
/// in. Refusals as `resetPasswordWithPhrase`'s.
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Every
/// session of the account ends, in every service of the instance, and
/// this one signs in. Refusals as `resetPasswordWithPhrase`'s.
Future<AuthSuccess> resetPassword(
Session session, {
required UuidValue challenge,
@ -78,7 +79,7 @@ class AccountRecoveryEndpoint extends Endpoint {
String? totpCode,
String? recoveryCode,
}) async {
final account = await AccountRegister.of(session.serverpod)
final ended = await AccountRegister.of(session.serverpod)
.resetPasswordWithPhrase(
session,
challenge: challenge,
@ -87,7 +88,12 @@ class AccountRecoveryEndpoint extends Endpoint {
totpCode: totpCode,
recoveryCode: recoveryCode,
);
return await issueSession(session, account, method: passwordMethod);
return await issueSessionAfter(
session,
ended.account,
method: passwordMethod,
endedBefore: ended.endedBefore,
);
}
/// Asks for the reset of the whole account at [address], as a [code]
@ -110,8 +116,9 @@ class AccountRecoveryEndpoint extends Endpoint {
/// Carries out the reset of the whole account at [address] once due, as
/// a new [code] mailed for [request] proves: [setup] replaces its
/// password and keys, and an authenticator must be set up again. Signs
/// in. Refusals as `completeAccountReset`'s.
/// password and keys, and an authenticator must be set up again. Every
/// session of the account ends, in every service of the instance, and
/// this one signs in. Refusals as `completeAccountReset`'s.
Future<AuthSuccess> completeAccountReset(
Session session, {
required String address,
@ -119,7 +126,7 @@ class AccountRecoveryEndpoint extends Endpoint {
required String code,
required PasswordSetup setup,
}) async {
final account = await AccountRegister.of(session.serverpod)
final ended = await AccountRegister.of(session.serverpod)
.completeAccountReset(
session,
address: address,
@ -127,6 +134,11 @@ class AccountRecoveryEndpoint extends Endpoint {
code: code,
setup: setup,
);
return await issueSession(session, account, method: passwordMethod);
return await issueSessionAfter(
session,
ended.account,
method: passwordMethod,
endedBefore: ended.endedBefore,
);
}
}

View file

@ -272,7 +272,7 @@ Future<void> removeAuthenticator(
if (confirmed.length < 2) {
throw refusedFor(PasswordRefusal.lastAuthenticator);
}
await _requireCode(session, keys, person, confirmed, code);
await requireAuthenticatorCode(session, keys, person, confirmed, code);
// Serializable: two removals at once must not take both of the last two.
await session.db.transaction(
(transaction) async {
@ -318,7 +318,7 @@ Future<List<String>> newRecoveryCodes(
if (confirmed.isEmpty) {
throw refusedFor(PasswordRefusal.setupIncomplete);
}
await _requireCode(session, keys, person, confirmed, code);
await requireAuthenticatorCode(session, keys, person, confirmed, code);
return await session.db.transaction(
(transaction) =>
_replaceRecoveryCodes(session, keys, person, transaction: transaction),
@ -356,9 +356,11 @@ Future<void> _countTry(Session session, UuidValue person) async {
}
}
/// Requires [code] to be a fresh code of one of [confirmed], counted
/// ([_countTry]).
Future<void> _requireCode(
/// Requires [code] to be a fresh code of one of [person]'s [confirmed]
/// authenticators, each try counted (10 an hour,
/// [PasswordRefusal.tooManyAttempts]); refused otherwise
/// ([PasswordRefusal.wrongCode]).
Future<void> requireAuthenticatorCode(
Session session,
PasswordKeys keys,
UuidValue person,

View file

@ -0,0 +1,143 @@
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/authenticators.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:serverpod/serverpod.dart';
// Changing what keeps an account while signed in (ADR 0017), on the
// register: the password, with the old password's proof and a code from an
// authenticator, and the recovery phrase's wrapping of the keys, with a code.
// A session alone changes neither: whoever took one over would otherwise
// lock the owner out of their keys.
/// Changes [person]'s password to [password] (the same keys, wrapped under
/// the new password's key on the device): the old password's proof for
/// [challenge] (`beginPasswordChange`) and a [totpCode] or [recoveryCode]
/// from the account. Every other session of the account ends, in every
/// service of the instance; [keepSession], the one asking, stays.
///
/// Refused ([PasswordRefusedException]) for a new password out of range
/// ([PasswordRefusal.invalidSetup]); for a wrong old password, a challenge
/// that isn't [person]'s, or a wrong, used or missing code, alike
/// ([PasswordRefusal.wrongCredentials]); a challenge unknown, used or too
/// old ([PasswordRefusal.challengeExpired]); and, once the old password
/// proved right, for an account without a confirmed authenticator
/// ([PasswordRefusal.setupIncomplete]). Nothing changes then.
Future<void> changePassword(
Session session, {
required UuidValue person,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required NewPassword password,
UuidValue? keepSession,
}) async {
final keys = requirePasswordKeys(session.serverpod);
if (!isNewPassword(password)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
final proved = await provePassword(
session,
keys,
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
);
final row = proved.password;
if (row.authUserId != person) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
if ((totpCode?.trim() ?? '').isEmpty &&
(recoveryCode?.trim() ?? '').isEmpty) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
// With a code: the code is checked, or, where the authenticators' reset
// took effect and none is confirmed, it says so (true).
if (await secondFactor(
session,
keys,
person,
totpCode: totpCode,
recoveryCode: recoveryCode,
)) {
throw refusedFor(PasswordRefusal.setupIncomplete);
}
await session.db.transaction((transaction) async {
// UPDATE "account_password" SET "salt" = $1, "kdfAlgorithm" = $2,
// "kdfOpsLimit" = $3, "kdfMemLimit" = $4, "verifier" = $5,
// "passwordKeysNonce" = $6, "passwordKeysCipherText" = $7,
// "updatedAt" = $8 WHERE "id" = $9
await AccountPassword.db.updateRow(
session,
row.copyWith(
salt: password.salt,
kdfAlgorithm: password.costs.algorithm,
kdfOpsLimit: password.costs.opsLimit,
kdfMemLimit: password.costs.memLimit,
verifier: password.verifier,
passwordKeysNonce: password.passwordKeys.nonce,
passwordKeysCipherText: password.passwordKeys.cipherText,
updatedAt: clock.now().toUtc(),
),
transaction: transaction,
);
await endSessionsEverywhere(
session,
person,
keepSession: keepSession,
transaction: transaction,
);
});
await announceSessionsEnded(session, person);
}
/// Replaces the wrapping of [person]'s keys under their recovery phrase
/// with [recoveryKeys] (a new phrase, made on the device, which holds the
/// keys), for a [code] from one of their authenticators: the old phrase opens
/// nothing any more.
///
/// Refused ([PasswordRefusedException]) for wrapped keys out of range
/// ([PasswordRefusal.invalidSetup]), without a password
/// ([PasswordRefusal.noPassword]) or a confirmed authenticator
/// ([PasswordRefusal.setupIncomplete]), and for a wrong code
/// ([PasswordRefusal.wrongCode]; 10 tries an hour,
/// [PasswordRefusal.tooManyAttempts]). Nothing changes then.
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
required String code,
required WrappedKeys recoveryKeys,
}) async {
final keys = requirePasswordKeys(session.serverpod);
if (!isWrappedKeys(recoveryKeys)) {
throw refusedFor(PasswordRefusal.invalidSetup);
}
// SELECT * FROM "account_password" WHERE "authUserId" = $1
final row = await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
);
if (row == null) {
throw refusedFor(PasswordRefusal.noPassword);
}
final confirmed = await confirmedAuthenticatorsOf(session, person);
if (confirmed.isEmpty) {
throw refusedFor(PasswordRefusal.setupIncomplete);
}
await requireAuthenticatorCode(session, keys, person, confirmed, code);
// UPDATE "account_password" SET "recoveryKeysNonce" = $1,
// "recoveryKeysCipherText" = $2, "updatedAt" = $3 WHERE "id" = $4
await AccountPassword.db.updateRow(
session,
row.copyWith(
recoveryKeysNonce: recoveryKeys.nonce,
recoveryKeysCipherText: recoveryKeys.cipherText,
updatedAt: clock.now().toUtc(),
),
);
}

View file

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
@ -36,4 +38,57 @@ class PasswordEndpoint extends Endpoint {
await becomeIdentified(session, person);
}
}
/// The first step of the caller proving their password to change it:
/// its salt and costs, and SRP's B. Refusals as `beginPasswordChange`'s.
Future<PasswordChallenge> beginChange(Session session) =>
AccountRegister.of(session.serverpod).beginPasswordChange(
session,
person: session.authenticated!.authUserId,
);
/// Changes the caller's password to [password] (the same keys, wrapped
/// under the new password's key on the device), with the old password's
/// proof for [challenge] ([beginChange]) and a [totpCode] or
/// [recoveryCode]. Every other session of theirs ends, in every service of
/// the instance; this one stays. Refusals as `changePassword`'s.
Future<void> change(
Session session, {
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required NewPassword password,
}) {
final authenticated = session.authenticated!;
return AccountRegister.of(session.serverpod).changePassword(
session,
person: authenticated.authUserId,
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
password: password,
// The session asking: a server-side session's ID.
keepSession: Uuid.isValidUUID(fromString: authenticated.authId)
? UuidValue.fromString(authenticated.authId)
: null,
);
}
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
/// it on the device, replace the old wrapping, for a [code] from one of
/// their authenticators. Refusals as `setRecoveryKeys`'.
Future<void> setRecoveryKeys(
Session session, {
required String code,
required WrappedKeys recoveryKeys,
}) => AccountRegister.of(session.serverpod).setRecoveryKeys(
session,
person: session.authenticated!.authUserId,
code: code,
recoveryKeys: recoveryKeys,
);
}

View file

@ -3,3 +3,6 @@
exception: PasswordRefusedException
fields:
reason: PasswordRefusal
### When the reset waiting takes effect, for [PasswordRefusal.notDue];
### null otherwise.
takesEffectAt: DateTime?

View file

@ -198,6 +198,10 @@ bool _areWrappedKeys(WrappedKeys keys) =>
keys.nonce.lengthInBytes == _wrappingNonceLength &&
keys.cipherText.lengthInBytes == wrappedKeysLength;
/// Whether [keys] are the account's private keys as halkyon_keys wraps them:
/// a nonce and both keys sealed, of their lengths.
bool isWrappedKeys(WrappedKeys keys) => _areWrappedKeys(keys);
/// Whether [verifier] is SRP's v, padded to N's length: 1 < v < N. Outside,
/// it would make every password right, or none.
bool _isVerifier(ByteData verifier) {
@ -589,6 +593,57 @@ Future<PasswordProof> completePasswordSignIn(
);
}
/// The first step of [person] proving their password while signed in, to
/// change it (`changePassword`): SRP's B for their account, with its salt
/// and costs; counted with the sign-ins begun for their address (10 an
/// hour, [PasswordRefusal.tooManyAttempts]). Refused for someone without a
/// password ([PasswordRefusal.noPassword]).
Future<PasswordChallenge> beginPasswordChange(
Session session, {
required UuidValue person,
}) async {
requirePasswordKeys(session.serverpod);
// SELECT * FROM "email_identity" WHERE "authUserId" = $1
final identity = await EmailIdentity.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
);
final password = await _passwordOf(session, person);
if (identity == null || password == null) {
throw refusedFor(PasswordRefusal.noPassword);
}
if (!await _beginsPerAddress.tryRecordAttempt(
session,
key: identity.addressHash,
)) {
throw refusedFor(PasswordRefusal.tooManyAttempts);
}
final challenge = srpChallenge(
passwordGroup,
numberOf(Uint8List.sublistView(password.verifier)),
);
// INSERT INTO "sign_in_challenge" (…) VALUES (…)
final kept = await SignInChallenge.db.insertRow(
session,
SignInChallenge(
authUserId: person,
purpose: SignInChallengePurpose.password,
addressHash: identity.addressHash,
secret: challenge.secret.toRadixString(16),
serverValue: challenge.publicValue.toRadixString(16),
createdAt: clock.now().toUtc(),
),
);
return PasswordChallenge(
challenge: kept.id!,
salt: password.salt,
costs: costsOf(password),
serverValue: ByteData.sublistView(
padded(challenge.publicValue, passwordGroup.length),
),
);
}
/// What SRP's second step proved: the account's password row, M2, and the
/// keyed hash of the address the sign-in began with.
typedef PasswordProved = ({
@ -723,7 +778,10 @@ Future<bool> secondFactor(
await _carryOutAuthenticatorReset(session, account);
return true;
} else if (reset != null) {
throw refusedFor(PasswordRefusal.notDue);
throw PasswordRefusedException(
reason: PasswordRefusal.notDue,
takesEffectAt: reset.takesEffectAt,
);
}
throw refusedFor(PasswordRefusal.wrongCredentials);
}

View file

@ -5,6 +5,7 @@ import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/register/accounts_mode.dart';
import 'package:halkyon_base_server/src/register/local_register.dart';
import 'package:halkyon_base_server/src/register/remote_register.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:serverpod/serverpod.dart';
/// The instance's register of accounts (ADR 0016): what one person is across
@ -264,8 +265,9 @@ abstract interface class AccountRegister {
});
/// Sets a new password with the recovery phrase's proof; the account, for
/// the caller's session. Refusals as `resetPasswordWithPhrase`'s.
Future<UuidValue> resetPasswordWithPhrase(
/// the caller's session made after its sessions ended. Refusals as
/// `resetPasswordWithPhrase`'s.
Future<AccountSessionsEnded> resetPasswordWithPhrase(
Session session, {
required UuidValue challenge,
required ByteData signature,
@ -285,8 +287,9 @@ abstract interface class AccountRegister {
});
/// Carries out the reset of a whole account; the account, for the
/// caller's session. Refusals as `completeAccountReset`'s.
Future<UuidValue> completeAccountReset(
/// caller's session made after its sessions ended. Refusals as
/// `completeAccountReset`'s.
Future<AccountSessionsEnded> completeAccountReset(
Session session, {
required String address,
required UuidValue request,
@ -294,6 +297,36 @@ abstract interface class AccountRegister {
required PasswordSetup setup,
});
/// The first step of [person] proving their password to change it.
/// Refusals as `beginPasswordChange`'s.
Future<PasswordChallenge> beginPasswordChange(
Session session, {
required UuidValue person,
});
/// Changes [person]'s password, ending their sessions everywhere but
/// [keepSession]. Refusals as `changePassword`'s.
Future<void> changePassword(
Session session, {
required UuidValue person,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required NewPassword password,
UuidValue? keepSession,
});
/// Replaces the wrapping of [person]'s keys under their recovery phrase.
/// Refusals as `setRecoveryKeys`'.
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
required String code,
required WrappedKeys recoveryKeys,
});
/// 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);

View file

@ -11,8 +11,13 @@ import 'package:serverpod/serverpod.dart';
/// gone (ADR 0016).
const accountGonePath = '/halkyon-base/member/account-gone';
/// The path on a member's web server where the register says an account's
/// sessions ended (ADR 0017: its password changed, or was reset).
const sessionsEndedPath = '/halkyon-base/member/sessions-ended';
/// Tells the members of an instance that an account is gone, so each deletes
/// that person's data too. Tests put their own ([use]).
/// that person's data too, or that its sessions ended, so each ends them
/// there too. Tests put their own ([use]).
abstract interface class MemberNotifier {
/// How [pod] tells its members: over HTTP with the instance's shared
/// secret.
@ -29,6 +34,15 @@ abstract interface class MemberNotifier {
/// Tells [member] (its web server) that [account] is gone; whether it
/// confirmed having deleted that person's data.
Future<bool> accountGone(Uri member, UuidValue account);
/// Tells [member] that [account]'s sessions created before [endedBefore]
/// ended, but [keepSession]; whether it confirmed having ended them.
Future<bool> sessionsEnded(
Uri member,
UuidValue account, {
required DateTime endedBefore,
UuidValue? keepSession,
});
}
/// [MemberNotifier] over HTTP: a POST to the member's [accountGonePath] with
@ -44,7 +58,27 @@ final class HttpMemberNotifier implements MemberNotifier {
final http.Client? client;
@override
Future<bool> accountGone(Uri member, UuidValue account) async {
Future<bool> accountGone(Uri member, UuidValue account) =>
_tell(member, accountGonePath, {'account': account.uuid});
@override
Future<bool> sessionsEnded(
Uri member,
UuidValue account, {
required DateTime endedBefore,
UuidValue? keepSession,
}) => _tell(member, sessionsEndedPath, {
'account': account.uuid,
'endedBefore': endedBefore.toUtc().toIso8601String(),
'keepSession': keepSession?.uuid,
});
/// POSTs [body] to [member]'s [path]; whether it answered 200.
Future<bool> _tell(
Uri member,
String path,
Map<String, Object?> body,
) async {
final secret = this.secret;
if (secret == null) {
return false;
@ -53,12 +87,12 @@ final class HttpMemberNotifier implements MemberNotifier {
try {
final response = await client
.post(
member.resolve(accountGonePath),
member.resolve(path),
headers: {
'authorization': 'Bearer $secret',
'content-type': 'application/json',
},
body: jsonEncode({'account': account.uuid}),
body: jsonEncode(body),
)
.timeout(const Duration(seconds: 10));
return response.statusCode == 200;

View file

@ -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) {

View file

@ -14,7 +14,7 @@ import 'package:serverpod/serverpod.dart';
/// Serves the calls between the instance's services (ADR 0016) on [pod]'s
/// web server, where it has one: the register's answers to its members
/// (`/halkyon-base/register/<operation>`) and a member's to the register
/// ([accountGonePath]); each answers only in its mode, and only with the
/// ([accountGonePath], [sessionsEndedPath]); each answers only in its mode, and only with the
/// instance's secret. Caddy never forwards them: the services reach each
/// other on the instance's own network. Throws as [checkInstanceSecret],
/// [checkPasswordKeys], [gitHubSignInEnds] and [trustedProxies] do, so a
@ -53,6 +53,16 @@ void addInstanceRoutes(Serverpod pod) {
),
),
accountGonePath,
)
..addRoute(
_InstanceRoute(
(session, request, body) => answerSessionsEnded(
session,
body,
authorization: request.headers['authorization']?.join(', '),
),
),
sessionsEndedPath,
);
}

View file

@ -11,6 +11,8 @@ import 'package:halkyon_base_server/src/password/account_recovery.dart'
as recovery;
import 'package:halkyon_base_server/src/password/authenticators.dart'
as authenticators;
import 'package:halkyon_base_server/src/password/password_change.dart'
as change;
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/password/passwords.dart' as passwords;
import 'package:halkyon_base_server/src/profile/link_verification.dart';
@ -18,6 +20,7 @@ import 'package:halkyon_base_server/src/profile/profile_rules.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
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/sessions_ended.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -557,7 +560,7 @@ final class LocalRegister implements AccountRegister {
}) => passwords.beginKeyProof(session, address: address, network: network);
@override
Future<UuidValue> resetPasswordWithPhrase(
Future<AccountSessionsEnded> resetPasswordWithPhrase(
Session session, {
required UuidValue challenge,
required ByteData signature,
@ -589,7 +592,7 @@ final class LocalRegister implements AccountRegister {
);
@override
Future<UuidValue> completeAccountReset(
Future<AccountSessionsEnded> completeAccountReset(
Session session, {
required String address,
required UuidValue request,
@ -603,6 +606,48 @@ final class LocalRegister implements AccountRegister {
setup: setup,
);
@override
Future<PasswordChallenge> beginPasswordChange(
Session session, {
required UuidValue person,
}) => passwords.beginPasswordChange(session, person: person);
@override
Future<void> changePassword(
Session session, {
required UuidValue person,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required NewPassword password,
UuidValue? keepSession,
}) => change.changePassword(
session,
person: person,
challenge: challenge,
clientValue: clientValue,
clientProof: clientProof,
totpCode: totpCode,
recoveryCode: recoveryCode,
password: password,
keepSession: keepSession,
);
@override
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
required String code,
required WrappedKeys recoveryKeys,
}) => change.setRecoveryKeys(
session,
person: person,
code: code,
recoveryKeys: recoveryKeys,
);
@override
Future<void> deleteAccount(Session session, UuidValue person) async {
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = $1

View file

@ -1,5 +1,7 @@
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -65,6 +67,26 @@ Future<AuthSuccess> issueSession(
);
}
/// A new session for [account] here, by [method], after the register ended
/// its sessions created before [endedBefore] (ADR 0017: a reset of the
/// password or of the account): those here end first, as the register's
/// notice to this server will end them, and the new one is made only once
/// this server's clock is past that moment, so the notice, whenever it
/// arrives, leaves it.
Future<AuthSuccess> issueSessionAfter(
Session session,
UuidValue account, {
required String method,
required DateTime endedBefore,
}) async {
await endSessionsHere(session, account, endedBefore: endedBefore);
final wait = endedBefore.difference(clock.now());
if (!wait.isNegative) {
await Future<void>.delayed(wait + const Duration(milliseconds: 1));
}
return await issueSession(session, account, method: method);
}
/// What [person] becomes here once a way in is theirs, as linking makes them
/// on the register in the same transaction: `identified` (on their sessions
/// too), and no longer anonymous. A member does it after the register linked

View file

@ -48,6 +48,9 @@ abstract final class RegisterOperation {
static const resetPasswordWithPhrase = 'reset-password-with-phrase';
static const startAccountReset = 'start-account-reset';
static const completeAccountReset = 'complete-account-reset';
static const beginPasswordChange = 'begin-password-change';
static const changePassword = 'change-password';
static const setRecoveryKeys = 'set-recovery-keys';
}
/// A refusal the register answered, as its JSON: the exception the module's
@ -65,9 +68,10 @@ Map<String, Object?>? refusalJson(Object error) => switch (error) {
'kind': 'profile',
'reason': reason.name,
},
PasswordRefusedException(:final reason) => {
PasswordRefusedException(:final reason, :final takesEffectAt) => {
'kind': 'password',
'reason': reason.name,
'takesEffectAt': ?takesEffectAt?.toUtc().toIso8601String(),
},
_ => null,
};
@ -95,7 +99,13 @@ Exception? refusalFrom(Object? json) => switch (json) {
{'kind': 'password', 'reason': final String reason} => switch (PasswordRefusal
.values
.asNameMap()[reason]) {
final reason? => PasswordRefusedException(reason: reason),
final reason? => PasswordRefusedException(
reason: reason,
takesEffectAt: switch (json['takesEffectAt']) {
final String at => DateTime.tryParse(at)?.toUtc(),
_ => null,
},
),
null => null,
},
_ => null,

Some files were not shown because too many files have changed in this diff Show more