fix(auth): a hold-out moves a reset on only by its own time, and a reset lives 30 days at most
The reading of the full reading's fixes found that every refusal at the address's ceiling moved a reset to 72 hours after it (A1): someone who knows only the address kept the owner's own reset from ever taking effect with one burst every three days, and kept a forgotten one alive (A2). Now a hold-out that begins before a reset takes effect moves it on only by the time it holds the owner out and no hold-out counted before did (account_reset.heldOutUntil, under a row lock), so the owner gets 72 hours outside hold-outs in total; and every reset not carried out lapses 30 days after it was asked (resetLivesAtMost), whatever moved it, besides a week after it took effect. Migration (module, Learn's and Todo's servers, Learn's device schema): ALTER TABLE "account_reset" ADD COLUMN "heldOutUntil" timestamp without time zone; nullable, existing rows counted from now on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a72b96d628
commit
274b796faa
4 changed files with 41748 additions and 65 deletions
|
|
@ -23,6 +23,7 @@ abstract class AccountReset
|
|||
required this.authUserId,
|
||||
required this.kind,
|
||||
required this.takesEffectAt,
|
||||
this.heldOutUntil,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ abstract class AccountReset
|
|||
required _is.UuidValue authUserId,
|
||||
required _itrk9fro.AccountResetKind kind,
|
||||
required DateTime takesEffectAt,
|
||||
DateTime? heldOutUntil,
|
||||
required DateTime createdAt,
|
||||
}) = _AccountResetImpl;
|
||||
|
||||
|
|
@ -46,6 +48,11 @@ abstract class AccountReset
|
|||
takesEffectAt: _is.DateTimeJsonExtension.fromJson(
|
||||
jsonSerialization['takesEffectAt'],
|
||||
),
|
||||
heldOutUntil: jsonSerialization['heldOutUntil'] == null
|
||||
? null
|
||||
: _is.DateTimeJsonExtension.fromJson(
|
||||
jsonSerialization['heldOutUntil'],
|
||||
),
|
||||
createdAt: _is.DateTimeJsonExtension.fromJson(
|
||||
jsonSerialization['createdAt'],
|
||||
),
|
||||
|
|
@ -68,6 +75,10 @@ abstract class AccountReset
|
|||
/// When it may be carried out.
|
||||
DateTime takesEffectAt;
|
||||
|
||||
/// Until when its owner's hold-outs were counted (`pushOutResets`), so
|
||||
/// a hold-out moves it on only by the time no earlier one covered.
|
||||
DateTime? heldOutUntil;
|
||||
|
||||
DateTime createdAt;
|
||||
|
||||
@override
|
||||
|
|
@ -81,6 +92,7 @@ abstract class AccountReset
|
|||
_is.UuidValue? authUserId,
|
||||
_itrk9fro.AccountResetKind? kind,
|
||||
DateTime? takesEffectAt,
|
||||
DateTime? heldOutUntil,
|
||||
DateTime? createdAt,
|
||||
});
|
||||
@override
|
||||
|
|
@ -91,6 +103,7 @@ abstract class AccountReset
|
|||
'authUserId': authUserId.toJson(),
|
||||
'kind': kind.toJson(),
|
||||
'takesEffectAt': takesEffectAt.toJson(),
|
||||
if (heldOutUntil != null) 'heldOutUntil': heldOutUntil?.toJson(),
|
||||
'createdAt': createdAt.toJson(),
|
||||
};
|
||||
}
|
||||
|
|
@ -136,12 +149,14 @@ class _AccountResetImpl extends AccountReset {
|
|||
required _is.UuidValue authUserId,
|
||||
required _itrk9fro.AccountResetKind kind,
|
||||
required DateTime takesEffectAt,
|
||||
DateTime? heldOutUntil,
|
||||
required DateTime createdAt,
|
||||
}) : super._(
|
||||
id: id,
|
||||
authUserId: authUserId,
|
||||
kind: kind,
|
||||
takesEffectAt: takesEffectAt,
|
||||
heldOutUntil: heldOutUntil,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
|
||||
|
|
@ -154,6 +169,7 @@ class _AccountResetImpl extends AccountReset {
|
|||
_is.UuidValue? authUserId,
|
||||
_itrk9fro.AccountResetKind? kind,
|
||||
DateTime? takesEffectAt,
|
||||
Object? heldOutUntil = _Undefined,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return AccountReset(
|
||||
|
|
@ -161,6 +177,9 @@ class _AccountResetImpl extends AccountReset {
|
|||
authUserId: authUserId ?? this.authUserId,
|
||||
kind: kind ?? this.kind,
|
||||
takesEffectAt: takesEffectAt ?? this.takesEffectAt,
|
||||
heldOutUntil: heldOutUntil is DateTime?
|
||||
? heldOutUntil
|
||||
: this.heldOutUntil,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
|
@ -189,6 +208,12 @@ class AccountResetUpdateTable extends _is.UpdateTable<AccountResetTable> {
|
|||
value,
|
||||
);
|
||||
|
||||
_is.ColumnValue<DateTime, DateTime> heldOutUntil(DateTime? value) =>
|
||||
_is.ColumnValue(
|
||||
table.heldOutUntil,
|
||||
value,
|
||||
);
|
||||
|
||||
_is.ColumnValue<DateTime, DateTime> createdAt(DateTime value) =>
|
||||
_is.ColumnValue(
|
||||
table.createdAt,
|
||||
|
|
@ -212,6 +237,10 @@ class AccountResetTable extends _is.Table<int?> {
|
|||
'takesEffectAt',
|
||||
this,
|
||||
);
|
||||
heldOutUntil = _is.ColumnDateTime(
|
||||
'heldOutUntil',
|
||||
this,
|
||||
);
|
||||
createdAt = _is.ColumnDateTime(
|
||||
'createdAt',
|
||||
this,
|
||||
|
|
@ -229,6 +258,10 @@ class AccountResetTable extends _is.Table<int?> {
|
|||
/// When it may be carried out.
|
||||
late final _is.ColumnDateTime takesEffectAt;
|
||||
|
||||
/// Until when its owner's hold-outs were counted (`pushOutResets`), so
|
||||
/// a hold-out moves it on only by the time no earlier one covered.
|
||||
late final _is.ColumnDateTime heldOutUntil;
|
||||
|
||||
late final _is.ColumnDateTime createdAt;
|
||||
|
||||
@override
|
||||
|
|
@ -237,6 +270,7 @@ class AccountResetTable extends _is.Table<int?> {
|
|||
authUserId,
|
||||
kind,
|
||||
takesEffectAt,
|
||||
heldOutUntil,
|
||||
createdAt,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -337,6 +337,12 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
isNullable: false,
|
||||
dartType: 'DateTime',
|
||||
),
|
||||
_isp.ColumnDefinition(
|
||||
name: 'heldOutUntil',
|
||||
columnType: _isp.ColumnType.timestampWithoutTimeZone,
|
||||
isNullable: true,
|
||||
dartType: 'DateTime?',
|
||||
),
|
||||
_isp.ColumnDefinition(
|
||||
name: 'createdAt',
|
||||
columnType: _isp.ColumnType.timestampWithoutTimeZone,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ fields:
|
|||
kind: AccountResetKind
|
||||
### When it may be carried out.
|
||||
takesEffectAt: DateTime
|
||||
### Until when its owner's hold-outs were counted (`pushOutResets`), so
|
||||
### a hold-out moves it on only by the time no earlier one covered.
|
||||
heldOutUntil: DateTime?
|
||||
createdAt: DateTime
|
||||
indexes:
|
||||
### One of each kind per account.
|
||||
|
|
|
|||
|
|
@ -3,38 +3,44 @@ import 'package:halkyon_base_server/src/generated/protocol.dart';
|
|||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
// Resets that wait (ADR 0017, D3), and how long they stay (the full
|
||||
// reading's M2): a reset that took effect and was never carried out lapses
|
||||
// a week later, as never asked, so an authenticator's reset asked and
|
||||
// forgotten doesn't leave the account to the password alone for good, nor
|
||||
// an account's reset to whoever holds the mailbox a year on. Asked again,
|
||||
// it waits anew, with a new mail. A reset of the authenticators carried out
|
||||
// never lapses: its row says the password alone signs in until an
|
||||
// authenticator is confirmed, which removes it.
|
||||
// reading's M2 and the reading of its fixes' A2): a reset that took effect
|
||||
// and was never carried out lapses a week later, as never asked, and every
|
||||
// reset not carried out lapses 30 days after it was asked, whatever moved
|
||||
// it, so an authenticator's reset asked and forgotten doesn't leave the
|
||||
// account to the password alone for good, nor an account's reset to
|
||||
// whoever holds the mailbox a year on, nor can anyone keep one alive. Asked
|
||||
// again, it waits anew, with a new mail. A reset of the authenticators
|
||||
// carried out never lapses: its row says the password alone signs in until
|
||||
// an authenticator is confirmed, which removes it.
|
||||
//
|
||||
// Nor does a reset take effect while its owner is held out of calling it
|
||||
// off (the full reading's H1): whoever knows the password made the codes
|
||||
// wait, or whoever knows the address filled its proofs' ceiling, and the
|
||||
// owner's sign-in, which calls every reset off, was refused. Each such
|
||||
// hold-out moves the account's resets not carried out to take effect no
|
||||
// sooner than [resetDelay] after it ends ([pushOutResets]), so the owner
|
||||
// always has that long, once let in, to call them off; and a signed-in
|
||||
// device shows them (`SignInWays`), whose owner calls them off with their
|
||||
// proof, which no wait holds back.
|
||||
// Nor does a reset take effect before its owner had [resetDelay] outside
|
||||
// hold-outs to call it off (the full reading's H1, as the reading of its
|
||||
// fixes' A1 bounded it): whoever knows the password made the codes wait,
|
||||
// or whoever knows the address filled its proofs' ceiling, and the owner's
|
||||
// sign-in, which calls every reset off, was refused. Each such hold-out
|
||||
// that begins before the reset takes effect moves it on by the time it
|
||||
// holds the owner out and no hold-out counted before did
|
||||
// ([pushOutResets]): a burst that holds the owner out for an hour buys an
|
||||
// hour, never another 72, and refusals within an hour already counted buy
|
||||
// nothing. A signed-in device shows the waiting resets (`SignInWays`),
|
||||
// whose owner calls them off with their proof, which no wait holds back.
|
||||
|
||||
/// How long a reset waits before it takes effect (ADR 0017, D3): long
|
||||
/// enough for the account's owner, told by mail, to call it off by signing
|
||||
/// in.
|
||||
const resetDelay = Duration(hours: 72);
|
||||
|
||||
/// How long a reset that took effect waits to be carried out before it
|
||||
/// lapses.
|
||||
const resetLapsesAfter = Duration(days: 7);
|
||||
|
||||
/// How long after it was asked a reset not carried out lapses, however far
|
||||
/// hold-outs moved it.
|
||||
const resetLivesAtMost = Duration(days: 30);
|
||||
|
||||
/// Whether [reset] lapsed at [now]: a week or more since it took effect.
|
||||
/// For a reset of the authenticators only while it isn't carried out (the
|
||||
/// account still has a confirmed authenticator): the caller knows which.
|
||||
bool resetLapsed(AccountReset reset, DateTime now) =>
|
||||
!reset.takesEffectAt.add(resetLapsesAfter).isAfter(now);
|
||||
!reset.takesEffectAt.add(resetLapsesAfter).isAfter(now) ||
|
||||
!reset.createdAt.add(resetLivesAtMost).isAfter(now);
|
||||
|
||||
/// Deletes the resets that lapsed at [now], and returns how many: an
|
||||
/// account's, and an authenticators' reset not carried out (its account
|
||||
|
|
@ -43,21 +49,23 @@ Future<int> forgetLapsedResets(
|
|||
Session session, {
|
||||
required DateTime now,
|
||||
}) async {
|
||||
final cutoff = now.toUtc().subtract(resetLapsesAfter);
|
||||
// DELETE FROM "account_reset"
|
||||
// WHERE "kind" = 'account' AND "takesEffectAt" <= $1 RETURNING *
|
||||
final tookEffectBy = now.toUtc().subtract(resetLapsesAfter);
|
||||
final askedBy = now.toUtc().subtract(resetLivesAtMost);
|
||||
// DELETE FROM "account_reset" WHERE "kind" = 'account'
|
||||
// AND ("takesEffectAt" <= $1 OR "createdAt" <= $2) RETURNING *
|
||||
final accounts = await AccountReset.db.deleteWhere(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.kind.equals(AccountResetKind.account) & (t.takesEffectAt <= cutoff),
|
||||
t.kind.equals(AccountResetKind.account) &
|
||||
((t.takesEffectAt <= tookEffectBy) | (t.createdAt <= askedBy)),
|
||||
);
|
||||
// SELECT * FROM "account_reset"
|
||||
// WHERE "kind" = 'authenticator' AND "takesEffectAt" <= $1
|
||||
// SELECT * FROM "account_reset" WHERE "kind" = 'authenticator'
|
||||
// AND ("takesEffectAt" <= $1 OR "createdAt" <= $2)
|
||||
final candidates = await AccountReset.db.find(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.kind.equals(AccountResetKind.authenticator) &
|
||||
(t.takesEffectAt <= cutoff),
|
||||
((t.takesEffectAt <= tookEffectBy) | (t.createdAt <= askedBy)),
|
||||
);
|
||||
if (candidates.isEmpty) {
|
||||
return accounts.length;
|
||||
|
|
@ -86,46 +94,65 @@ Future<int> forgetLapsedResets(
|
|||
return accounts.length + lapsed.length;
|
||||
}
|
||||
|
||||
/// Moves [account]'s resets not carried out and not lapsed to take effect
|
||||
/// no sooner than [resetDelay] after [heldOutUntil], when its owner, held
|
||||
/// out of signing in meanwhile, can call them off again (the full reading's
|
||||
/// H1). A reset of the authenticators carried out (none confirmed any more)
|
||||
/// stays as it is: its owner signs in with the password alone meanwhile.
|
||||
/// Moves [account]'s resets not carried out, nor lapsed, on by the time its
|
||||
/// owner is held out from now until [heldOutUntil] and no hold-out counted
|
||||
/// before covered, where that hold-out begins before the reset takes
|
||||
/// effect (see above). Each reset's row keeps until when hold-outs were
|
||||
/// counted, under a row lock, so refusals at once count once.
|
||||
Future<void> pushOutResets(
|
||||
Session session,
|
||||
UuidValue account,
|
||||
DateTime heldOutUntil, {
|
||||
Transaction? transaction,
|
||||
}) async {
|
||||
final takesEffectAt = heldOutUntil.toUtc().add(resetDelay);
|
||||
final notLapsedSince = clock.now().toUtc().subtract(resetLapsesAfter);
|
||||
// SELECT count(*) FROM "totp_authenticator"
|
||||
// WHERE "authUserId" = $1 AND "confirmedAt" IS NOT NULL
|
||||
final notCarriedOut =
|
||||
await TotpAuthenticator.db.count(
|
||||
DateTime heldOutUntil,
|
||||
) async {
|
||||
final now = clock.now().toUtc();
|
||||
final until = heldOutUntil.toUtc();
|
||||
if (!until.isAfter(now)) {
|
||||
return;
|
||||
}
|
||||
await session.db.transaction((transaction) async {
|
||||
// SELECT * FROM "account_reset" WHERE "authUserId" = $1 FOR UPDATE
|
||||
final rows = await AccountReset.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(account),
|
||||
lockMode: LockMode.forUpdate,
|
||||
transaction: transaction,
|
||||
);
|
||||
if (rows.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// SELECT count(*) FROM "totp_authenticator"
|
||||
// WHERE "authUserId" = $1 AND "confirmedAt" IS NOT NULL
|
||||
final notCarriedOut =
|
||||
await TotpAuthenticator.db.count(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.authUserId.equals(account) & t.confirmedAt.notEquals(null),
|
||||
transaction: transaction,
|
||||
) >
|
||||
0;
|
||||
for (final reset in rows) {
|
||||
if (resetLapsed(reset, now) ||
|
||||
(reset.kind == AccountResetKind.authenticator && !notCarriedOut)) {
|
||||
continue;
|
||||
}
|
||||
final counted = reset.heldOutUntil;
|
||||
final from = counted != null && counted.isAfter(now) ? counted : now;
|
||||
if (!until.isAfter(from) || !reset.takesEffectAt.isAfter(from)) {
|
||||
continue;
|
||||
}
|
||||
// UPDATE "account_reset" SET "takesEffectAt" = $1, "heldOutUntil" = $2
|
||||
// WHERE "id" = $3
|
||||
await AccountReset.db.updateRow(
|
||||
session,
|
||||
where: (t) =>
|
||||
t.authUserId.equals(account) & t.confirmedAt.notEquals(null),
|
||||
reset.copyWith(
|
||||
takesEffectAt: reset.takesEffectAt.add(until.difference(from)),
|
||||
heldOutUntil: until,
|
||||
),
|
||||
columns: (t) => [t.takesEffectAt, t.heldOutUntil],
|
||||
transaction: transaction,
|
||||
) >
|
||||
0;
|
||||
// UPDATE "account_reset" SET "takesEffectAt" = $1
|
||||
// WHERE "authUserId" = $2 AND "takesEffectAt" < $1
|
||||
// AND "takesEffectAt" > $3 [AND "kind" = 'account']
|
||||
await AccountReset.db.updateWhere(
|
||||
session,
|
||||
columnValues: (t) => [t.takesEffectAt(takesEffectAt)],
|
||||
where: (t) {
|
||||
final waiting =
|
||||
t.authUserId.equals(account) &
|
||||
(t.takesEffectAt < takesEffectAt) &
|
||||
(t.takesEffectAt > notLapsedSince);
|
||||
return notCarriedOut
|
||||
? waiting
|
||||
: waiting & t.kind.equals(AccountResetKind.account);
|
||||
},
|
||||
transaction: transaction,
|
||||
);
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// When [account]'s resets waiting (or due, not carried out and not lapsed)
|
||||
|
|
|
|||
Loading…
Reference in a new issue