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
34 changed files with 43506 additions and 126 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ authenticator:
|
|||
- newRecoveryCodes:
|
||||
password:
|
||||
- set:
|
||||
- beginChange:
|
||||
- change:
|
||||
- setRecoveryKeys:
|
||||
passwordSignIn:
|
||||
- begin:
|
||||
- complete:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,3 +3,6 @@
|
|||
exception: PasswordRefusedException
|
||||
fields:
|
||||
reason: PasswordRefusal
|
||||
### When the reset waiting takes effect, for [PasswordRefusal.notDue];
|
||||
### null otherwise.
|
||||
takesEffectAt: DateTime?
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue