fix(auth): a member's clock off the register's ends the sessions of its gap, waiting for nothing
The reading of the full reading's fixes found (C1) that refusing a member whose clock ran ahead protected only the reset's own session: a session made on that member in the gap, by whoever knew the old password, outlived both the member's ending and the register's notice. issueSessionAfter now ends every session of the account here made before the register's moment or before now, whichever is later, then makes the new one, neither waiting nor refusing; and the register sends its clock with each notice (sentAt), so a member takes the moment by its own clock (as long before now as it was before it was sent), ending the gap's sessions and leaving those made since. The passwordChanged refusal, sent only by the clock checks, goes with them (unreleased). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
274b796faa
commit
b0e1557153
16 changed files with 180 additions and 176 deletions
|
|
@ -219,7 +219,6 @@
|
||||||
"passwordAddressUnknown": "Gib die E-Mail-Adresse deines Kontos ein: Dorthin geht der Code.",
|
"passwordAddressUnknown": "Gib die E-Mail-Adresse deines Kontos ein: Dorthin geht der Code.",
|
||||||
"passwordDeleteIntroByMail": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Keiner seiner Authenticators funktioniert gerade, daher bestätigt es ein Code per E-Mail: Gib dein Passwort ein, dann den Code, den wir an deine Adresse schicken.",
|
"passwordDeleteIntroByMail": "Dein Konto wird mit allem darin gelöscht, in jedem Dienst dieser Instanz. Keiner seiner Authenticators funktioniert gerade, daher bestätigt es ein Code per E-Mail: Gib dein Passwort ein, dann den Code, den wir an deine Adresse schicken.",
|
||||||
"passwordDeleteSendCode": "Code per E-Mail schicken",
|
"passwordDeleteSendCode": "Code per E-Mail schicken",
|
||||||
"passwordChangedSignInAgain": "Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an.",
|
|
||||||
"passwordSignInAgain": "Um einem Konto, das sich mit GitHub anmeldet, ein Passwort zu geben, melde dich erst wieder mit GitHub an: Es ist mehr als ein paar Minuten her.",
|
"passwordSignInAgain": "Um einem Konto, das sich mit GitHub anmeldet, ein Passwort zu geben, melde dich erst wieder mit GitHub an: Es ist mehr als ein paar Minuten her.",
|
||||||
"passwordSignInWithGitHubAgain": "Wieder mit GitHub anmelden",
|
"passwordSignInWithGitHubAgain": "Wieder mit GitHub anmelden",
|
||||||
"passwordOfferTitle": "Richte ein Passwort ein",
|
"passwordOfferTitle": "Richte ein Passwort ein",
|
||||||
|
|
|
||||||
|
|
@ -483,7 +483,6 @@
|
||||||
"passwordAddressUnknown": "Enter your account's email address: the code goes there.",
|
"passwordAddressUnknown": "Enter your account's email address: the code goes there.",
|
||||||
"passwordDeleteIntroByMail": "Your account goes with everything in it, in every service of this instance. None of its authenticators works now, so a code by mail confirms it: type your password, then the code we email to your address.",
|
"passwordDeleteIntroByMail": "Your account goes with everything in it, in every service of this instance. None of its authenticators works now, so a code by mail confirms it: type your password, then the code we email to your address.",
|
||||||
"passwordDeleteSendCode": "Email me a code",
|
"passwordDeleteSendCode": "Email me a code",
|
||||||
"passwordChangedSignInAgain": "Your password was changed, but this service couldn't sign you in just yet. Sign in again with the new password.",
|
|
||||||
"passwordSignInAgain": "To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.",
|
"passwordSignInAgain": "To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.",
|
||||||
"passwordSignInWithGitHubAgain": "Sign in with GitHub again",
|
"passwordSignInWithGitHubAgain": "Sign in with GitHub again",
|
||||||
"passwordOfferTitle": "Set up a password",
|
"passwordOfferTitle": "Set up a password",
|
||||||
|
|
|
||||||
|
|
@ -1417,12 +1417,6 @@ abstract class HalkyonAppLocalizations {
|
||||||
/// **'Email me a code'**
|
/// **'Email me a code'**
|
||||||
String get passwordDeleteSendCode;
|
String get passwordDeleteSendCode;
|
||||||
|
|
||||||
/// No description provided for @passwordChangedSignInAgain.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.'**
|
|
||||||
String get passwordChangedSignInAgain;
|
|
||||||
|
|
||||||
/// No description provided for @passwordSignInAgain.
|
/// No description provided for @passwordSignInAgain.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -824,10 +824,6 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get passwordDeleteSendCode => 'Code per E-Mail schicken';
|
String get passwordDeleteSendCode => 'Code per E-Mail schicken';
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordChangedSignInAgain =>
|
|
||||||
'Dein Passwort wurde geändert, aber dieser Dienst konnte dich gerade noch nicht anmelden. Melde dich wieder mit dem neuen Passwort an.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordSignInAgain =>
|
String get passwordSignInAgain =>
|
||||||
'Um einem Konto, das sich mit GitHub anmeldet, ein Passwort zu geben, melde dich erst wieder mit GitHub an: Es ist mehr als ein paar Minuten her.';
|
'Um einem Konto, das sich mit GitHub anmeldet, ein Passwort zu geben, melde dich erst wieder mit GitHub an: Es ist mehr als ein paar Minuten her.';
|
||||||
|
|
|
||||||
|
|
@ -807,10 +807,6 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get passwordDeleteSendCode => 'Email me a code';
|
String get passwordDeleteSendCode => 'Email me a code';
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordChangedSignInAgain =>
|
|
||||||
'Your password was changed, but this service couldn\'t sign you in just yet. Sign in again with the new password.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordSignInAgain =>
|
String get passwordSignInAgain =>
|
||||||
'To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.';
|
'To give an account that signs in with GitHub its password, sign in with GitHub again first: it has been more than a few minutes.';
|
||||||
|
|
|
||||||
|
|
@ -464,7 +464,6 @@ PasswordProblem _problemOf(protocol.PasswordRefusal reason) => switch (reason) {
|
||||||
protocol.PasswordRefusal.otherInstance => PasswordProblem.instanceUnproven,
|
protocol.PasswordRefusal.otherInstance => PasswordProblem.instanceUnproven,
|
||||||
protocol.PasswordRefusal.codesWait => PasswordProblem.codesWait,
|
protocol.PasswordRefusal.codesWait => PasswordProblem.codesWait,
|
||||||
protocol.PasswordRefusal.sessionEnded => PasswordProblem.sessionEnded,
|
protocol.PasswordRefusal.sessionEnded => PasswordProblem.sessionEnded,
|
||||||
protocol.PasswordRefusal.passwordChanged => PasswordProblem.passwordChanged,
|
|
||||||
protocol.PasswordRefusal.signInAgain => PasswordProblem.signInAgain,
|
protocol.PasswordRefusal.signInAgain => PasswordProblem.signInAgain,
|
||||||
protocol.PasswordRefusal.signedInBefore => PasswordProblem.signedInBefore,
|
protocol.PasswordRefusal.signedInBefore => PasswordProblem.signedInBefore,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,6 @@ enum PasswordProblem() {
|
||||||
/// device signs in again.
|
/// device signs in again.
|
||||||
sessionEnded,
|
sessionEnded,
|
||||||
|
|
||||||
/// The password was changed by a reset, but this service couldn't open a
|
|
||||||
/// session for it yet: sign in again with the new password.
|
|
||||||
passwordChanged,
|
|
||||||
|
|
||||||
/// An account that signs in with GitHub alone takes its first password
|
/// An account that signs in with GitHub alone takes its first password
|
||||||
/// only within minutes of a GitHub sign-in: sign in with GitHub again,
|
/// only within minutes of a GitHub sign-in: sign in with GitHub again,
|
||||||
/// then go on.
|
/// then go on.
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ String passwordProblemText(BuildContext context, PasswordFailed failed) {
|
||||||
null => l10n.passwordTooManyAttempts,
|
null => l10n.passwordTooManyAttempts,
|
||||||
},
|
},
|
||||||
PasswordProblem.sessionEnded => l10n.passwordSessionEnded,
|
PasswordProblem.sessionEnded => l10n.passwordSessionEnded,
|
||||||
PasswordProblem.passwordChanged => l10n.passwordChangedSignInAgain,
|
|
||||||
PasswordProblem.signInAgain => l10n.passwordSignInAgain,
|
PasswordProblem.signInAgain => l10n.passwordSignInAgain,
|
||||||
PasswordProblem.signedInBefore => l10n.passwordSignedInBefore,
|
PasswordProblem.signedInBefore => l10n.passwordSignedInBefore,
|
||||||
PasswordProblem.noReset => l10n.passwordNoReset,
|
PasswordProblem.noReset => l10n.passwordNoReset,
|
||||||
|
|
|
||||||
|
|
@ -63,12 +63,5 @@ void main() {
|
||||||
),
|
),
|
||||||
contains('signed out'),
|
contains('signed out'),
|
||||||
);
|
);
|
||||||
expect(
|
|
||||||
passwordProblemText(
|
|
||||||
context,
|
|
||||||
const PasswordFailed(PasswordProblem.passwordChanged),
|
|
||||||
),
|
|
||||||
allOf(contains('was changed'), contains('Sign in again')),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,12 +84,6 @@ enum PasswordRefusal implements _isc.SerializableModel {
|
||||||
/// sign in again.
|
/// sign in again.
|
||||||
sessionEnded,
|
sessionEnded,
|
||||||
|
|
||||||
/// The password was changed (a reset carried out), but this service
|
|
||||||
/// couldn't open a session for it yet (its clock is behind the
|
|
||||||
/// register's): sign in again with the new password. Never "this
|
|
||||||
/// instance has no password sign-in" (the re-review's tenth finding).
|
|
||||||
passwordChanged,
|
|
||||||
|
|
||||||
/// A first password for an account that signs in with GitHub alone takes
|
/// A first password for an account that signs in with GitHub alone takes
|
||||||
/// a GitHub sign-in of the last 10 minutes (the fourth reading: a session
|
/// a GitHub sign-in of the last 10 minutes (the fourth reading: a session
|
||||||
/// alone sets none): sign in with GitHub again, then set it.
|
/// alone sets none): sign in with GitHub again, then set it.
|
||||||
|
|
@ -141,8 +135,6 @@ enum PasswordRefusal implements _isc.SerializableModel {
|
||||||
return PasswordRefusal.codesWait;
|
return PasswordRefusal.codesWait;
|
||||||
case 'sessionEnded':
|
case 'sessionEnded':
|
||||||
return PasswordRefusal.sessionEnded;
|
return PasswordRefusal.sessionEnded;
|
||||||
case 'passwordChanged':
|
|
||||||
return PasswordRefusal.passwordChanged;
|
|
||||||
case 'signInAgain':
|
case 'signInAgain':
|
||||||
return PasswordRefusal.signInAgain;
|
return PasswordRefusal.signInAgain;
|
||||||
case 'signedInBefore':
|
case 'signedInBefore':
|
||||||
|
|
|
||||||
|
|
@ -84,12 +84,6 @@ enum PasswordRefusal implements _is.SerializableModel {
|
||||||
/// sign in again.
|
/// sign in again.
|
||||||
sessionEnded,
|
sessionEnded,
|
||||||
|
|
||||||
/// The password was changed (a reset carried out), but this service
|
|
||||||
/// couldn't open a session for it yet (its clock is behind the
|
|
||||||
/// register's): sign in again with the new password. Never "this
|
|
||||||
/// instance has no password sign-in" (the re-review's tenth finding).
|
|
||||||
passwordChanged,
|
|
||||||
|
|
||||||
/// A first password for an account that signs in with GitHub alone takes
|
/// A first password for an account that signs in with GitHub alone takes
|
||||||
/// a GitHub sign-in of the last 10 minutes (the fourth reading: a session
|
/// a GitHub sign-in of the last 10 minutes (the fourth reading: a session
|
||||||
/// alone sets none): sign in with GitHub again, then set it.
|
/// alone sets none): sign in with GitHub again, then set it.
|
||||||
|
|
@ -141,8 +135,6 @@ enum PasswordRefusal implements _is.SerializableModel {
|
||||||
return PasswordRefusal.codesWait;
|
return PasswordRefusal.codesWait;
|
||||||
case 'sessionEnded':
|
case 'sessionEnded':
|
||||||
return PasswordRefusal.sessionEnded;
|
return PasswordRefusal.sessionEnded;
|
||||||
case 'passwordChanged':
|
|
||||||
return PasswordRefusal.passwordChanged;
|
|
||||||
case 'signInAgain':
|
case 'signInAgain':
|
||||||
return PasswordRefusal.signInAgain;
|
return PasswordRefusal.signInAgain;
|
||||||
case 'signedInBefore':
|
case 'signedInBefore':
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,6 @@ values:
|
||||||
### code, `OwnerProof`) were wrong: the session ended (the third reading);
|
### code, `OwnerProof`) were wrong: the session ended (the third reading);
|
||||||
### sign in again.
|
### sign in again.
|
||||||
- sessionEnded
|
- sessionEnded
|
||||||
### The password was changed (a reset carried out), but this service
|
|
||||||
### couldn't open a session for it yet (its clock is behind the
|
|
||||||
### register's): sign in again with the new password. Never "this
|
|
||||||
### instance has no password sign-in" (the re-review's tenth finding).
|
|
||||||
- passwordChanged
|
|
||||||
### A first password for an account that signs in with GitHub alone takes
|
### A first password for an account that signs in with GitHub alone takes
|
||||||
### a GitHub sign-in of the last 10 minutes (the fourth reading: a session
|
### a GitHub sign-in of the last 10 minutes (the fourth reading: a session
|
||||||
### alone sets none): sign in with GitHub again, then set it.
|
### alone sets none): sign in with GitHub again, then set it.
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@ abstract interface class MemberNotifier {
|
||||||
Future<bool> accountGone(Uri member, UuidValue account);
|
Future<bool> accountGone(Uri member, UuidValue account);
|
||||||
|
|
||||||
/// Tells [member] that [account]'s sessions created before [endedBefore]
|
/// Tells [member] that [account]'s sessions created before [endedBefore]
|
||||||
/// ended, but [keepSession]; whether it confirmed having ended them.
|
/// ended, but [keepSession]; whether it confirmed having ended them. Sent
|
||||||
|
/// with this server's clock (`sentAt`), so the member takes the moment by
|
||||||
|
/// its own (the reading of the fixes' C1).
|
||||||
Future<bool> sessionsEnded(
|
Future<bool> sessionsEnded(
|
||||||
Uri member,
|
Uri member,
|
||||||
UuidValue account, {
|
UuidValue account, {
|
||||||
|
|
@ -70,6 +72,7 @@ final class HttpMemberNotifier implements MemberNotifier {
|
||||||
}) => _tell(member, sessionsEndedPath, {
|
}) => _tell(member, sessionsEndedPath, {
|
||||||
'account': account.uuid,
|
'account': account.uuid,
|
||||||
'endedBefore': endedBefore.toUtc().toIso8601String(),
|
'endedBefore': endedBefore.toUtc().toIso8601String(),
|
||||||
|
'sentAt': clock.now().toUtc().toIso8601String(),
|
||||||
'keepSession': keepSession?.uuid,
|
'keepSession': keepSession?.uuid,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
import 'package:halkyon_base_server/src/auth/account.dart';
|
import 'package:halkyon_base_server/src/auth/account.dart';
|
||||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
||||||
import 'package:halkyon_base_server/src/halkyon_base.dart';
|
import 'package:halkyon_base_server/src/halkyon_base.dart';
|
||||||
|
|
@ -472,11 +473,13 @@ Future<InstanceAnswer> answerSessionsEnded(
|
||||||
}
|
}
|
||||||
final UuidValue account;
|
final UuidValue account;
|
||||||
final DateTime endedBefore;
|
final DateTime endedBefore;
|
||||||
|
final DateTime? sentAt;
|
||||||
final UuidValue? keepSession;
|
final UuidValue? keepSession;
|
||||||
try {
|
try {
|
||||||
final read = _Arguments(arguments);
|
final read = _Arguments(arguments);
|
||||||
account = read.id('account');
|
account = read.id('account');
|
||||||
endedBefore = DateTime.parse(read.string('endedBefore')).toUtc();
|
endedBefore = DateTime.parse(read.string('endedBefore')).toUtc();
|
||||||
|
sentAt = read.optionalTime('sentAt');
|
||||||
keepSession = read.optionalId('keepSession');
|
keepSession = read.optionalId('keepSession');
|
||||||
} on FormatException {
|
} on FormatException {
|
||||||
return _unreadable;
|
return _unreadable;
|
||||||
|
|
@ -484,12 +487,26 @@ Future<InstanceAnswer> answerSessionsEnded(
|
||||||
await endSessionsHere(
|
await endSessionsHere(
|
||||||
session,
|
session,
|
||||||
account,
|
account,
|
||||||
endedBefore: endedBefore,
|
endedBefore: _byThisClock(endedBefore, sentAt: sentAt),
|
||||||
keepSession: keepSession,
|
keepSession: keepSession,
|
||||||
);
|
);
|
||||||
return (status: 200, body: const <String, Object?>{});
|
return (status: 200, body: const <String, Object?>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The register's moment [endedBefore] by this server's clock: as long
|
||||||
|
/// before now as it was before the register sent it ([sentAt], by the
|
||||||
|
/// register's clock), so the clocks' offset drops out (the reading of the
|
||||||
|
/// fixes' C1; one way's travel makes it a moment later, ending a session
|
||||||
|
/// made in that instant too). A moment sent before it was taken (a clock set
|
||||||
|
/// back) is taken as now; without [sentAt] (an older register), as sent.
|
||||||
|
DateTime _byThisClock(DateTime endedBefore, {required DateTime? sentAt}) {
|
||||||
|
if (sentAt == null) {
|
||||||
|
return endedBefore;
|
||||||
|
}
|
||||||
|
final age = sentAt.difference(endedBefore);
|
||||||
|
return clock.now().toUtc().subtract(age.isNegative ? Duration.zero : age);
|
||||||
|
}
|
||||||
|
|
||||||
/// A request's arguments, each read as what it must be; a [FormatException]
|
/// A request's arguments, each read as what it must be; a [FormatException]
|
||||||
/// for anything else.
|
/// for anything else.
|
||||||
extension type const _Arguments(Map<String, Object?> _map) {
|
extension type const _Arguments(Map<String, Object?> _map) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:halkyon_base_server/src/auth/identified.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/auth/scopes.dart';
|
||||||
import 'package:halkyon_base_server/src/generated/protocol.dart';
|
|
||||||
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
|
import 'package:halkyon_base_server/src/register/sessions_ended.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';
|
||||||
|
|
@ -70,56 +69,28 @@ Future<AuthSuccess> issueSession(
|
||||||
|
|
||||||
/// A new session for [account] here, by [method], after the register ended
|
/// A new session for [account] here, by [method], after the register ended
|
||||||
/// its sessions created before [endedBefore] (ADR 0017: a reset of the
|
/// its sessions created before [endedBefore] (ADR 0017: a reset of the
|
||||||
/// password or of the account): those here end first, as the register's
|
/// password or of the account), by the register's clock: every session of
|
||||||
/// notice to this server will end them, and the new one is made only once
|
/// the account here made before that moment or before now, whichever is
|
||||||
/// this server's clock is past that moment, so the notice, whenever it
|
/// later, ends first, then the new one is made. So a clock here running
|
||||||
/// arrives, leaves it. A clock more than [maxClockSkew] behind, or ahead
|
/// ahead of the register's leaves no session made in its gap (the reading
|
||||||
/// (the full reading's L10: sessions made here in the gap would outlive the
|
/// of the fixes' C1), and one behind neither waits nor refuses. The
|
||||||
/// notice), is refused ([PasswordRefusal.passwordChanged]): the reset is
|
/// register's notice, whenever it arrives, takes its moment by this
|
||||||
/// done, and the device signs in again with the new password.
|
/// server's own clock (`answerSessionsEnded`), so it leaves the new one.
|
||||||
Future<AuthSuccess> issueSessionAfter(
|
Future<AuthSuccess> issueSessionAfter(
|
||||||
Session session,
|
Session session,
|
||||||
UuidValue account, {
|
UuidValue account, {
|
||||||
required String method,
|
required String method,
|
||||||
required DateTime endedBefore,
|
required DateTime endedBefore,
|
||||||
}) async {
|
}) async {
|
||||||
await endSessionsHere(session, account, endedBefore: endedBefore);
|
final now = clock.now().toUtc();
|
||||||
final wait = endedBefore.difference(clock.now());
|
await endSessionsHere(
|
||||||
if (wait > maxClockSkew) {
|
session,
|
||||||
// Its type of trouble only, never whose: the clocks need setting right.
|
account,
|
||||||
session.log(
|
endedBefore: endedBefore.isAfter(now) ? endedBefore : now,
|
||||||
"This server's clock is ${wait.inSeconds} s behind the register's: "
|
|
||||||
'set both by NTP.',
|
|
||||||
level: LogLevel.warning,
|
|
||||||
);
|
);
|
||||||
// The reset is done: the device signs in again, with the new password.
|
|
||||||
throw PasswordRefusedException(reason: PasswordRefusal.passwordChanged);
|
|
||||||
}
|
|
||||||
// Ahead as far, sessions made here in the gap before the register's
|
|
||||||
// moment would outlive its notice (the full reading's L10): refused alike.
|
|
||||||
// (The register answered moments ago: its moment can't be further back
|
|
||||||
// than that, but for a clock running ahead.)
|
|
||||||
if (-wait > maxClockSkew) {
|
|
||||||
session.log(
|
|
||||||
"This server's clock is ${(-wait).inSeconds} s ahead of the "
|
|
||||||
"register's: set both by NTP.",
|
|
||||||
level: LogLevel.warning,
|
|
||||||
);
|
|
||||||
throw PasswordRefusedException(reason: PasswordRefusal.passwordChanged);
|
|
||||||
}
|
|
||||||
if (!wait.isNegative) {
|
|
||||||
await Future<void>.delayed(wait + const Duration(milliseconds: 1));
|
|
||||||
}
|
|
||||||
return await issueSession(session, account, method: method);
|
return await issueSession(session, account, method: method);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How far behind the register's clock a member's may run for
|
|
||||||
/// [issueSessionAfter] to wait for it: further, the request would hang as
|
|
||||||
/// long, so it is refused; as far ahead, alike. Clocks set by NTP differ by
|
|
||||||
/// milliseconds; a member's clock running ahead of the register's by up to
|
|
||||||
/// this much leaves sessions made in that gap, which the ADR accepts.
|
|
||||||
const maxClockSkew = Duration(seconds: 5);
|
|
||||||
|
|
||||||
/// What [person] becomes here once a way in is theirs, as linking makes them
|
/// 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
|
/// on the register in the same transaction: `identified` (on their sessions
|
||||||
/// too), and no longer anonymous. A member does it after the register linked
|
/// too), and no longer anonymous. A member does it after the register linked
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import 'package:clock/clock.dart';
|
||||||
import 'package:halkyon_base_server/base.dart';
|
import 'package:halkyon_base_server/base.dart';
|
||||||
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
|
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
|
||||||
import 'package:halkyon_base_server/src/register/instance_api.dart';
|
import 'package:halkyon_base_server/src/register/instance_api.dart';
|
||||||
import 'package:halkyon_base_server/src/register/people.dart' show maxClockSkew;
|
|
||||||
import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
||||||
import 'package:halkyon_base_server/src/register/remote_register.dart';
|
import 'package:halkyon_base_server/src/register/remote_register.dart';
|
||||||
import 'package:serverpod/serverpod.dart';
|
import 'package:serverpod/serverpod.dart';
|
||||||
|
|
@ -1181,6 +1180,76 @@ void main() {
|
||||||
expect(register.asked, isEmpty);
|
expect(register.asked, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"a moment sent with the register's clock is taken by this "
|
||||||
|
"server's own: a clock ahead ends the sessions made here in its "
|
||||||
|
"gap, those made since stay (the reading of the fixes' C1)",
|
||||||
|
() async {
|
||||||
|
// This server runs 60 s ahead of the register. By its clock, a
|
||||||
|
// session was made 20 s ago, the register ended the account's
|
||||||
|
// sessions 10 s ago, and another was made 5 s ago.
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
Future<UuidValue> madeAgo(Duration ago) async {
|
||||||
|
final id = await signInNewDevice(session, ben);
|
||||||
|
final row = (await ServerSideSession.db.findById(session, id))!;
|
||||||
|
await ServerSideSession.db.updateRow(
|
||||||
|
session,
|
||||||
|
row.copyWith(createdAt: now.subtract(ago)),
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
final inTheGap = await madeAgo(const Duration(seconds: 20));
|
||||||
|
final since = await madeAgo(const Duration(seconds: 5));
|
||||||
|
const ahead = Duration(seconds: 60);
|
||||||
|
final endedBefore = now
|
||||||
|
.subtract(const Duration(seconds: 10))
|
||||||
|
.subtract(ahead);
|
||||||
|
|
||||||
|
final answer = await tell({
|
||||||
|
'account': ben.uuid,
|
||||||
|
'endedBefore': endedBefore.toIso8601String(),
|
||||||
|
'sentAt': now.subtract(ahead).toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(answer.status, 200);
|
||||||
|
expect(
|
||||||
|
await ServerSideSession.db.findById(session, inTheGap),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await ServerSideSession.db.findById(session, since),
|
||||||
|
isNotNull,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a moment sent before it was taken (a clock set back) ends '
|
||||||
|
'every session made here until now, rather than none', () async {
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
final bensPhone = await signInNewDevice(session, ben);
|
||||||
|
final answer = await tell({
|
||||||
|
'account': ben.uuid,
|
||||||
|
'endedBefore': now
|
||||||
|
.subtract(const Duration(hours: 1))
|
||||||
|
.toIso8601String(),
|
||||||
|
'sentAt': now.subtract(const Duration(hours: 2)).toIso8601String(),
|
||||||
|
});
|
||||||
|
expect(answer.status, 200);
|
||||||
|
expect(
|
||||||
|
await ServerSideSession.db.findById(session, bensPhone),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await tell({
|
||||||
|
'account': ben.uuid,
|
||||||
|
'endedBefore': now.toIso8601String(),
|
||||||
|
'sentAt': 'then',
|
||||||
|
})).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('refuses without the secret and what it cannot read, and is '
|
test('refuses without the secret and what it cannot read, and is '
|
||||||
'answered by no register, ending nothing', () async {
|
'answered by no register, ending nothing', () async {
|
||||||
final everything = {
|
final everything = {
|
||||||
|
|
@ -1418,11 +1487,7 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("waits a few seconds at most for the register's moment: a "
|
PasswordProof bensProof() => PasswordProof(
|
||||||
'clock further behind is refused at once, and signs nobody in, '
|
|
||||||
'rather than holding the request', () async {
|
|
||||||
register
|
|
||||||
..proof = PasswordProof(
|
|
||||||
account: ben,
|
account: ben,
|
||||||
serverProof: ByteData(32),
|
serverProof: ByteData(32),
|
||||||
encryptionPublicKey: ByteData(32),
|
encryptionPublicKey: ByteData(32),
|
||||||
|
|
@ -1432,13 +1497,9 @@ void main() {
|
||||||
cipherText: ByteData(112),
|
cipherText: ByteData(112),
|
||||||
),
|
),
|
||||||
needsAuthenticator: false,
|
needsAuthenticator: false,
|
||||||
)
|
|
||||||
..endedBefore = DateTime.now().toUtc().add(
|
|
||||||
const Duration(minutes: 1),
|
|
||||||
);
|
);
|
||||||
final watch = Stopwatch()..start();
|
|
||||||
|
|
||||||
await expectLater(
|
Future<AuthSuccess> resetBensPassword() =>
|
||||||
endpoints.accountRecovery.resetPassword(
|
endpoints.accountRecovery.resetPassword(
|
||||||
notSignedIn(),
|
notSignedIn(),
|
||||||
challenge: const Uuid().v7obj(),
|
challenge: const Uuid().v7obj(),
|
||||||
|
|
@ -1456,78 +1517,80 @@ void main() {
|
||||||
instanceUrl: testInstanceUrl,
|
instanceUrl: testInstanceUrl,
|
||||||
address: 'anna@example.org',
|
address: 'anna@example.org',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
),
|
|
||||||
throwsA(
|
|
||||||
isA<PasswordRefusedException>().having(
|
|
||||||
(error) => error.reason,
|
|
||||||
'reason',
|
|
||||||
PasswordRefusal.passwordChanged,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
expect(watch.elapsed, lessThan(maxClockSkew));
|
|
||||||
|
test("a clock behind the register's neither waits nor refuses: the "
|
||||||
|
"sessions here end, and the new one stays when the register's "
|
||||||
|
'notice, sent with its clock, arrives', () async {
|
||||||
|
final endedBefore = DateTime.now().toUtc().add(
|
||||||
|
const Duration(minutes: 1),
|
||||||
|
);
|
||||||
|
register
|
||||||
|
..proof = bensProof()
|
||||||
|
..endedBefore = endedBefore;
|
||||||
|
final watch = Stopwatch()..start();
|
||||||
|
|
||||||
|
final signedIn = await resetBensPassword();
|
||||||
|
|
||||||
|
expect(watch.elapsed, lessThan(const Duration(seconds: 5)));
|
||||||
|
final left = await ServerSideSession.db.find(
|
||||||
|
session,
|
||||||
|
where: (t) => t.authUserId.equals(ben),
|
||||||
|
);
|
||||||
|
expect(left.single.id, isNot(bensLaptop));
|
||||||
|
expect(signedIn.authUserId, ben);
|
||||||
|
final answer = await answerSessionsEnded(
|
||||||
|
session,
|
||||||
|
jsonEncode({
|
||||||
|
'account': ben.uuid,
|
||||||
|
'endedBefore': endedBefore.toIso8601String(),
|
||||||
|
'sentAt': endedBefore
|
||||||
|
.add(const Duration(seconds: 1))
|
||||||
|
.toIso8601String(),
|
||||||
|
}),
|
||||||
|
authorization: 'Bearer $_secret',
|
||||||
|
);
|
||||||
|
expect(answer.status, 200);
|
||||||
|
expect(
|
||||||
|
await ServerSideSession.db.findById(session, left.single.id!),
|
||||||
|
isNotNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a clock ahead of the register's ends the sessions made here in "
|
||||||
|
"its gap too (the reading of the fixes' C1)", () async {
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
register
|
||||||
|
..proof = bensProof()
|
||||||
|
..endedBefore = now.subtract(const Duration(minutes: 1));
|
||||||
|
// Whoever knew the old password signed in here 20 s before the
|
||||||
|
// reset: by this server's clock, 40 s after the register's moment.
|
||||||
|
final inTheGap = await signInNewDevice(
|
||||||
|
session,
|
||||||
|
ben,
|
||||||
|
scopes: {identified},
|
||||||
|
);
|
||||||
|
final row = (await ServerSideSession.db.findById(session, inTheGap))!;
|
||||||
|
await ServerSideSession.db.updateRow(
|
||||||
|
session,
|
||||||
|
row.copyWith(createdAt: now.subtract(const Duration(seconds: 20))),
|
||||||
|
);
|
||||||
|
|
||||||
|
final signedIn = await resetBensPassword();
|
||||||
|
|
||||||
|
expect(signedIn.authUserId, ben);
|
||||||
|
expect(
|
||||||
|
await ServerSideSession.db.findById(session, inTheGap),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
await ServerSideSession.db.count(
|
await ServerSideSession.db.count(
|
||||||
session,
|
session,
|
||||||
where: (t) => t.authUserId.equals(ben),
|
where: (t) => t.authUserId.equals(ben),
|
||||||
),
|
),
|
||||||
0,
|
1,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refuses a clock further ahead of the register's alike, as "
|
|
||||||
'sessions made here in the gap would outlive its notice (the '
|
|
||||||
"full reading's L10)", () async {
|
|
||||||
register
|
|
||||||
..proof = PasswordProof(
|
|
||||||
account: ben,
|
|
||||||
serverProof: ByteData(32),
|
|
||||||
encryptionPublicKey: ByteData(32),
|
|
||||||
signingPublicKey: ByteData(32),
|
|
||||||
passwordKeys: WrappedKeys(
|
|
||||||
nonce: ByteData(24),
|
|
||||||
cipherText: ByteData(112),
|
|
||||||
),
|
|
||||||
needsAuthenticator: false,
|
|
||||||
)
|
|
||||||
..endedBefore = DateTime.now().toUtc().subtract(
|
|
||||||
const Duration(minutes: 1),
|
|
||||||
);
|
|
||||||
Future<int> sessionsOfBen() => ServerSideSession.db.count(
|
|
||||||
session,
|
|
||||||
where: (t) => t.authUserId.equals(ben),
|
|
||||||
);
|
|
||||||
final before = await sessionsOfBen();
|
|
||||||
await expectLater(
|
|
||||||
endpoints.accountRecovery.resetPassword(
|
|
||||||
notSignedIn(),
|
|
||||||
challenge: const Uuid().v7obj(),
|
|
||||||
signature: ByteData(64),
|
|
||||||
password: NewPassword(
|
|
||||||
salt: ByteData(16),
|
|
||||||
costs: newPasswordCosts,
|
|
||||||
verifier: ByteData(384),
|
|
||||||
passwordKeys: WrappedKeys(
|
|
||||||
nonce: ByteData(24),
|
|
||||||
cipherText: ByteData(112),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
totpCode: '123456',
|
|
||||||
instanceUrl: testInstanceUrl,
|
|
||||||
address: 'anna@example.org',
|
|
||||||
language: 'en',
|
|
||||||
),
|
|
||||||
throwsA(
|
|
||||||
isA<PasswordRefusedException>().having(
|
|
||||||
(error) => error.reason,
|
|
||||||
'reason',
|
|
||||||
PasswordRefusal.passwordChanged,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// Nobody signed in: no session made.
|
|
||||||
expect(await sessionsOfBen(), before);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
group('pruning', () {
|
group('pruning', () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue