fix(auth): a refusal past an address's ceiling answers before its resets move
The push of a waiting reset runs after the refusal, on a session of its own, as the members' notices are delivered (the short reading's L1). In the request, the same statements for every address still differed in time: an account with a reset waiting wrote it, and a write's commit waits for the database's log to reach the disk, so how long a refusal took told whether the address had an account with a reset waiting. Running the same work for every address would not have closed that; leaving it out of the request does. Tests wait for it (holdOutsCounted). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
6dfa989eb3
commit
372ed60eb5
2 changed files with 80 additions and 30 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
@ -20,6 +21,7 @@ import 'package:halkyon_base_server/src/srp/srp_math.dart';
|
||||||
import 'package:halkyon_base_server/src/srp/srp_server.dart';
|
import 'package:halkyon_base_server/src/srp/srp_server.dart';
|
||||||
import 'package:halkyon_base_server/src/totp/recovery_codes.dart';
|
import 'package:halkyon_base_server/src/totp/recovery_codes.dart';
|
||||||
import 'package:halkyon_base_server/src/totp/totp.dart';
|
import 'package:halkyon_base_server/src/totp/totp.dart';
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
import 'package:serverpod/serverpod.dart';
|
import 'package:serverpod/serverpod.dart';
|
||||||
import 'package:serverpod_auth_idp_server/core.dart';
|
import 'package:serverpod_auth_idp_server/core.dart';
|
||||||
|
|
||||||
|
|
@ -150,7 +152,7 @@ final _keyProofsPerAddressAndNetwork = DatabaseRateLimiter(
|
||||||
Future<void> _refuseAddressProvingTooOften(Session session, String hash) async {
|
Future<void> _refuseAddressProvingTooOften(Session session, String hash) async {
|
||||||
if (await _proofsPerAddress.countAttempts(session, key: hash) >=
|
if (await _proofsPerAddress.countAttempts(session, key: hash) >=
|
||||||
_proofsPerAddress.config.maxAttempts) {
|
_proofsPerAddress.config.maxAttempts) {
|
||||||
await _heldOut(session, hash);
|
_heldOut(session, hash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,26 +160,65 @@ Future<void> _refuseAddressProvingTooOften(Session session, String hash) async {
|
||||||
/// [hash]'s address past [_proofsPerAddress]' ceiling, the owner held out
|
/// [hash]'s address past [_proofsPerAddress]' ceiling, the owner held out
|
||||||
/// for its hour: the account's resets are pushed out past it (the full
|
/// for its hour: the account's resets are pushed out past it (the full
|
||||||
/// reading's H1), so whoever filled the ceiling can't carry one out
|
/// reading's H1), so whoever filled the ceiling can't carry one out
|
||||||
/// meanwhile. The same queries for an address nobody has.
|
/// meanwhile.
|
||||||
Future<Never> _heldOut(Session session, String hash) async {
|
///
|
||||||
// SELECT * FROM "email_identity" WHERE "addressHash" = $1
|
/// The push runs after the refusal, on a session of its own (the short
|
||||||
final identity = await EmailIdentity.db.findFirstRow(
|
/// reading's L1): an account with a waiting reset writes it, and a write's
|
||||||
session,
|
/// commit waits for the database's log to reach the disk, so done within
|
||||||
where: (t) => t.addressHash.equals(hash),
|
/// the request, how long the refusal took would tell whether the address
|
||||||
);
|
/// has an account with a reset waiting. Off the request's path, the
|
||||||
await pushOutResets(
|
/// refusal answers alike for every address.
|
||||||
session,
|
Never _heldOut(Session session, String hash) {
|
||||||
identity?.authUserId ?? UuidValue.fromString(_nobody),
|
final pushing = _pushOutResetsOf(
|
||||||
|
session.serverpod,
|
||||||
|
hash,
|
||||||
clock.now().toUtc().add(_proofsWindow),
|
clock.now().toUtc().add(_proofsWindow),
|
||||||
);
|
);
|
||||||
|
_holdOuts.add(pushing);
|
||||||
|
unawaited(pushing.whenComplete(() => _holdOuts.remove(pushing)));
|
||||||
throw refusedFor(PasswordRefusal.tooManyAttempts);
|
throw refusedFor(PasswordRefusal.tooManyAttempts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pushes [_heldOut] began, until they end.
|
||||||
|
final _holdOuts = <Future<void>>{};
|
||||||
|
|
||||||
|
/// Pushes the resets of [hash]'s account, if any, out to [heldOutUntil]
|
||||||
|
/// ([pushOutResets]), on a session of its own: the request's closes first.
|
||||||
|
/// It logs, so a failure is written.
|
||||||
|
Future<void> _pushOutResetsOf(
|
||||||
|
Serverpod pod,
|
||||||
|
String hash,
|
||||||
|
DateTime heldOutUntil,
|
||||||
|
) async {
|
||||||
|
final session = await pod.createSession();
|
||||||
|
try {
|
||||||
|
// SELECT * FROM "email_identity" WHERE "addressHash" = $1
|
||||||
|
final identity = await EmailIdentity.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.addressHash.equals(hash),
|
||||||
|
);
|
||||||
|
if (identity != null) {
|
||||||
|
await pushOutResets(session, identity.authUserId, heldOutUntil);
|
||||||
|
}
|
||||||
|
} on Object catch (error) {
|
||||||
|
session.log(
|
||||||
|
'Pushing resets out failed: ${error.runtimeType}',
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits for the pushes [_heldOut] began: tests do.
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> holdOutsCounted() => Future.wait([..._holdOuts]);
|
||||||
|
|
||||||
/// Counts a proof for [hash]'s address about to be checked; refused
|
/// Counts a proof for [hash]'s address about to be checked; refused
|
||||||
/// ([PasswordRefusal.tooManyAttempts]) past [_proofsPerAddress]' ceiling.
|
/// ([PasswordRefusal.tooManyAttempts]) past [_proofsPerAddress]' ceiling.
|
||||||
Future<void> _countProof(Session session, String hash) async {
|
Future<void> _countProof(Session session, String hash) async {
|
||||||
if (!await _proofsPerAddress.tryRecordAttempt(session, key: hash)) {
|
if (!await _proofsPerAddress.tryRecordAttempt(session, key: hash)) {
|
||||||
await _heldOut(session, hash);
|
_heldOut(session, hash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ import 'package:halkyon_base_server/base.dart';
|
||||||
import 'package:halkyon_base_server/src/password/account_resets.dart'
|
import 'package:halkyon_base_server/src/password/account_resets.dart'
|
||||||
show pushOutResets, resetLivesAtMost, resetWaitsAtMost;
|
show pushOutResets, resetLivesAtMost, resetWaitsAtMost;
|
||||||
import 'package:halkyon_base_server/src/password/passwords.dart'
|
import 'package:halkyon_base_server/src/password/passwords.dart'
|
||||||
show beginKeyProof, beginPasswordSignIn, completePasswordSignIn;
|
show
|
||||||
|
beginKeyProof,
|
||||||
|
beginPasswordSignIn,
|
||||||
|
completePasswordSignIn,
|
||||||
|
holdOutsCounted;
|
||||||
import 'package:serverpod/serverpod.dart';
|
import 'package:serverpod/serverpod.dart';
|
||||||
import 'package:serverpod_auth_idp_server/core.dart';
|
import 'package:serverpod_auth_idp_server/core.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
@ -121,28 +125,33 @@ void main() {
|
||||||
return enrolment;
|
return enrolment;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// A sign-in of Anna's from [network], with [totp] or none.
|
/// A sign-in of Anna's from [network], with [totp] or none; a refusal
|
||||||
|
/// past the address's ceiling once its push of her resets is done.
|
||||||
Future<PasswordProof> signInFrom(
|
Future<PasswordProof> signInFrom(
|
||||||
String network, {
|
String network, {
|
||||||
String? totp,
|
String? totp,
|
||||||
PasswordDevice? with_,
|
PasswordDevice? with_,
|
||||||
}) => atNow(() async {
|
}) => atNow(() async {
|
||||||
final challenge = await beginPasswordSignIn(
|
try {
|
||||||
session,
|
final challenge = await beginPasswordSignIn(
|
||||||
address: 'anna@example.org',
|
session,
|
||||||
network: network,
|
address: 'anna@example.org',
|
||||||
);
|
network: network,
|
||||||
final answer = (with_ ?? device).answer(challenge);
|
);
|
||||||
return await completePasswordSignIn(
|
final answer = (with_ ?? device).answer(challenge);
|
||||||
session,
|
return await completePasswordSignIn(
|
||||||
challenge: challenge.challenge,
|
session,
|
||||||
clientValue: answer.clientValue,
|
challenge: challenge.challenge,
|
||||||
clientProof: answer.clientProof,
|
clientValue: answer.clientValue,
|
||||||
totpCode: totp,
|
clientProof: answer.clientProof,
|
||||||
instanceUrl: testInstanceUrl,
|
totpCode: totp,
|
||||||
address: 'anna@example.org',
|
instanceUrl: testInstanceUrl,
|
||||||
language: 'en',
|
address: 'anna@example.org',
|
||||||
);
|
language: 'en',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await holdOutsCounted();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Whoever knows Anna's password and has her mailbox asks a reset of
|
/// Whoever knows Anna's password and has her mailbox asks a reset of
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue