feat(server): anonymous sign-in
People can sign in without an account: AnonymousIdpEndpoint exposes Serverpod's anonymous identity provider. lib/src/auth/auth_config.dart holds the token managers and identity providers, shared by server.dart and the tests, so the tests check what production runs. At most 30 new anonymous accounts per IP address and hour (Serverpod's default is 100): a school class behind one address can still start together, while one abuser gets at most 720 a day. No migration: the tables for anonymous accounts and rate limiting already exist. Tests: signing in creates a person; the 31st account from one IP address within an hour is refused. They run without database rollback, since signing in uses concurrent transactions that the rollback can't wrap, and delete what they create instead. The server switches off the lint rules for Dart v3.13's constructor syntax: Serverpod v4.0.2's code generator can't parse it yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
518939ee84
commit
df99def5bc
10 changed files with 235 additions and 16 deletions
|
|
@ -63,7 +63,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- Every decision carries its reason (in `NEXT.md` or an ADR under `docs/adr/`).
|
- Every decision carries its reason (in `NEXT.md` or an ADR under `docs/adr/`).
|
||||||
- **Commits** are GPG-signed and carry a DCO `Signed-off-by` trailer (`git commit -s`; a local `prepare-commit-msg` hook adds it automatically).
|
- **Commits** are GPG-signed and carry a DCO `Signed-off-by` trailer (`git commit -s`; a local `prepare-commit-msg` hook adds it automatically).
|
||||||
- **Pushes** pass `tool/check.sh` first, through a local `pre-push` hook (in a fresh clone: `ln -s ../../tool/check.sh .git/hooks/pre-push`).
|
- **Pushes** pass `tool/check.sh` first, through a local `pre-push` hook (in a fresh clone: `ln -s ../../tool/check.sh .git/hooks/pre-push`).
|
||||||
- **Lints**: LeanCode's strict set (`leancode_lint`), configured once in the root `analysis_options.yaml`, which also enables its analyzer plugin. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include that file, so the server and the app include it; the generated client doesn't. Every `// ignore:` gets a comment saying why (`document_ignores`).
|
- **Lints**: LeanCode's strict set (`leancode_lint`), configured once in the root `analysis_options.yaml`, which also enables its analyzer plugin. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include that file, so the server and the app include it; the generated client doesn't. Every `// ignore:` gets a comment saying why (`document_ignores`). The server switches off the four rules for Dart v3.13's constructor syntax (`use_primary_constructors`, `use_declaring_parameters`, `empty_container_bodies`, `unnecessary_type_name_in_constructor`): Serverpod v4.0.2's code generator can't parse that syntax yet.
|
||||||
|
|
||||||
## App architecture
|
## App architecture
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
// ignore_for_file: invalid_use_of_internal_member
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'dart:async' as _ida;
|
||||||
import 'package:http/http.dart' as _i85jenna;
|
import 'package:http/http.dart' as _i85jenna;
|
||||||
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
||||||
as _iacc;
|
as _iacc;
|
||||||
|
|
@ -18,6 +19,28 @@ import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
|
||||||
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||||
import 'protocol.dart' as _il2as5qe;
|
import 'protocol.dart' as _il2as5qe;
|
||||||
|
|
||||||
|
/// Signs people in anonymously; the app calls it on first launch. Creating an
|
||||||
|
/// account is rate-limited per IP address (see `auth_config.dart`).
|
||||||
|
/// {@category Endpoint}
|
||||||
|
class EndpointAnonymousIdp extends _iaic.EndpointAnonymousIdpBase {
|
||||||
|
EndpointAnonymousIdp(_isc.EndpointCaller caller) : super(caller);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => 'anonymousIdp';
|
||||||
|
|
||||||
|
/// Creates a new anonymous account and returns its session.
|
||||||
|
///
|
||||||
|
/// Invokes the [AnonymousIdp.beforeAnonymousAccount] callback if configured,
|
||||||
|
/// which may prevent account creation if the endpoint is protected.
|
||||||
|
@override
|
||||||
|
_ida.Future<_iacc.AuthSuccess> login({String? token}) =>
|
||||||
|
caller.callServerEndpoint<_iacc.AuthSuccess>(
|
||||||
|
'anonymousIdp',
|
||||||
|
'login',
|
||||||
|
{'token': token},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class Modules {
|
class Modules {
|
||||||
Modules(Client client) {
|
Modules(Client client) {
|
||||||
serverpod_auth_idp = _iaic.Caller(client);
|
serverpod_auth_idp = _iaic.Caller(client);
|
||||||
|
|
@ -56,13 +79,18 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
disconnectStreamsOnLostInternetConnection,
|
disconnectStreamsOnLostInternetConnection,
|
||||||
httpClientOverride: httpClientOverride,
|
httpClientOverride: httpClientOverride,
|
||||||
) {
|
) {
|
||||||
|
anonymousIdp = EndpointAnonymousIdp(this);
|
||||||
modules = Modules(this);
|
modules = Modules(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
late final EndpointAnonymousIdp anonymousIdp;
|
||||||
|
|
||||||
late final Modules modules;
|
late final Modules modules;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, _isc.EndpointRef> get endpointRefLookup => {};
|
Map<String, _isc.EndpointRef> get endpointRefLookup => {
|
||||||
|
'anonymousIdp': anonymousIdp,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, _isc.ModuleEndpointCaller> get moduleLookup => {
|
Map<String, _isc.ModuleEndpointCaller> get moduleLookup => {
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,16 @@ analyzer:
|
||||||
- lib/src/generated/**
|
- lib/src/generated/**
|
||||||
- test/integration/test_tools/serverpod_test_tools.dart
|
- test/integration/test_tools/serverpod_test_tools.dart
|
||||||
|
|
||||||
|
# Serverpod v4.0.2's code generator reads this package with an analyzer that doesn't
|
||||||
|
# know Dart v3.13's primary constructors yet, and fails on them ("requires the
|
||||||
|
# 'primary-constructors' language feature"). So the server keeps the classic
|
||||||
|
# constructor and class-body syntax; switch these back on once Serverpod parses it.
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
empty_container_bodies: false
|
||||||
|
unnecessary_type_name_in_constructor: false
|
||||||
|
use_declaring_parameters: false
|
||||||
|
use_primary_constructors: false
|
||||||
|
|
||||||
formatter:
|
formatter:
|
||||||
trailing_commas: preserve
|
trailing_commas: preserve
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:halkyon_learn_server/src/auth/auth_config.dart';
|
||||||
import 'package:halkyon_learn_server/src/generated/serverpod.dart';
|
import 'package:halkyon_learn_server/src/generated/serverpod.dart';
|
||||||
import 'package:serverpod_auth_idp_server/core.dart';
|
import 'package:serverpod_auth_idp_server/core.dart';
|
||||||
|
|
||||||
|
|
@ -6,19 +7,11 @@ Future<void> run(List<String> args) async {
|
||||||
// Initialize Serverpod. The generated Serverpod class is already connected
|
// Initialize Serverpod. The generated Serverpod class is already connected
|
||||||
// with your project's generated code.
|
// with your project's generated code.
|
||||||
final pod = Serverpod(args)
|
final pod = Serverpod(args)
|
||||||
// Initialize authentication services for the server.
|
// Token managers validate and issue authentication keys; identity
|
||||||
// Token managers will be used to validate and issue authentication keys,
|
// providers are the ways people can sign in (see auth_config.dart).
|
||||||
// and the identity providers will be the authentication options available for users.
|
|
||||||
..initializeAuthServices(
|
..initializeAuthServices(
|
||||||
tokenManagerBuilders: [
|
tokenManagerBuilders: tokenManagerBuilders,
|
||||||
// Server-side sessions: revocable immediately, scopes live on the session row.
|
identityProviderBuilders: identityProviderBuilders,
|
||||||
// No absolute lifetime (anonymous session cannot be restored). The inactivity
|
|
||||||
// timeout matches the pruning policy for abandoned accounts.
|
|
||||||
ServerSideSessionsConfigFromPasswords(
|
|
||||||
defaultSessionInactivityTimeout: const Duration(days: 90),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
identityProviderBuilders: [],
|
|
||||||
)
|
)
|
||||||
// Files live in Postgres until the self-hosted S3-compatible storage arrives later.
|
// Files live in Postgres until the self-hosted S3-compatible storage arrives later.
|
||||||
..addCloudStorage(DatabaseCloudStorage('private'))
|
..addCloudStorage(DatabaseCloudStorage('private'))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import 'package:serverpod_auth_idp_server/providers/anonymous.dart';
|
||||||
|
|
||||||
|
/// Signs people in anonymously; the app calls it on first launch. Creating an
|
||||||
|
/// account is rate-limited per IP address (see `auth_config.dart`).
|
||||||
|
class AnonymousIdpEndpoint extends AnonymousIdpBaseEndpoint {}
|
||||||
26
halkyon_learn_server/lib/src/auth/auth_config.dart
Normal file
26
halkyon_learn_server/lib/src/auth/auth_config.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import 'package:serverpod_auth_idp_server/core.dart';
|
||||||
|
import 'package:serverpod_auth_idp_server/providers/anonymous.dart';
|
||||||
|
|
||||||
|
// How people authenticate. The server and its tests use this same
|
||||||
|
// configuration, so the tests check what production runs.
|
||||||
|
|
||||||
|
/// Server-side sessions: revocable immediately, scopes live on the session
|
||||||
|
/// row. No absolute lifetime (an anonymous session cannot be restored). The
|
||||||
|
/// inactivity timeout matches the pruning policy for abandoned accounts.
|
||||||
|
final List<TokenManagerBuilder> tokenManagerBuilders = [
|
||||||
|
ServerSideSessionsConfigFromPasswords(
|
||||||
|
defaultSessionInactivityTimeout: const Duration(days: 90),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Anonymous accounts, the trial mode for solo use. At most 30 new ones per IP
|
||||||
|
/// address and hour (Serverpod's default is 100): a school class behind one
|
||||||
|
/// address can still start together, while one abuser gets at most 720 a day.
|
||||||
|
const List<IdentityProviderBuilder> identityProviderBuilders = [
|
||||||
|
AnonymousIdpConfig(
|
||||||
|
perIpAddressRateLimit: RateLimit(
|
||||||
|
maxAttempts: 30,
|
||||||
|
timeframe: Duration(hours: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
@ -15,10 +15,45 @@ import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
|
||||||
as _iacs;
|
as _iacs;
|
||||||
import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
|
import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
|
||||||
as _iais;
|
as _iais;
|
||||||
|
import '../auth/anonymous_idp_endpoint.dart' as _in0zita6;
|
||||||
|
|
||||||
class Endpoints extends _is.EndpointDispatch {
|
class Endpoints extends _is.EndpointDispatch {
|
||||||
@override
|
@override
|
||||||
void initializeEndpoints(_is.Server server) {
|
void initializeEndpoints(_is.Server server) {
|
||||||
|
var endpoints = <String, _is.Endpoint>{
|
||||||
|
'anonymousIdp': _in0zita6.AnonymousIdpEndpoint()
|
||||||
|
..initialize(
|
||||||
|
server,
|
||||||
|
'anonymousIdp',
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
connectors['anonymousIdp'] = _is.EndpointConnector(
|
||||||
|
name: 'anonymousIdp',
|
||||||
|
endpoint: endpoints['anonymousIdp']!,
|
||||||
|
methodConnectors: {
|
||||||
|
'login': _is.MethodConnector(
|
||||||
|
name: 'login',
|
||||||
|
params: {
|
||||||
|
'token': _is.ParameterDescription(
|
||||||
|
name: 'token',
|
||||||
|
type: _is.getType<String?>(),
|
||||||
|
nullable: true,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
call:
|
||||||
|
(
|
||||||
|
_is.Session session,
|
||||||
|
Map<String, dynamic> params,
|
||||||
|
) async =>
|
||||||
|
(endpoints['anonymousIdp'] as _in0zita6.AnonymousIdpEndpoint)
|
||||||
|
.login(
|
||||||
|
session,
|
||||||
|
token: params['token'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
modules['serverpod_auth_idp'] = _iais.Endpoints()
|
modules['serverpod_auth_idp'] = _iais.Endpoints()
|
||||||
..initializeEndpoints(server);
|
..initializeEndpoints(server);
|
||||||
modules['serverpod_auth_core'] = _iacs.Endpoints()
|
modules['serverpod_auth_core'] = _iacs.Endpoints()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
anonymousIdp:
|
||||||
|
- login:
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import 'package:halkyon_learn_server/src/auth/auth_config.dart';
|
||||||
|
import 'package:serverpod/serverpod.dart';
|
||||||
|
import 'package:serverpod_auth_idp_server/core.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../test_tools/serverpod_test_tools.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
withServerpod(
|
||||||
|
'Anonymous sign-in',
|
||||||
|
// Signing in runs concurrent transactions (the rate limit and the new
|
||||||
|
// account), which the test's database rollback can't wrap. So these tests
|
||||||
|
// don't roll back; they delete what they create instead.
|
||||||
|
rollbackDatabase: RollbackDatabase.disabled,
|
||||||
|
(sessionBuilder, endpoints) {
|
||||||
|
setUp(() async {
|
||||||
|
// The same configuration the server runs with (lib/server.dart).
|
||||||
|
AuthServices.set(
|
||||||
|
tokenManagerBuilders: tokenManagerBuilders,
|
||||||
|
identityProviderBuilders: identityProviderBuilders,
|
||||||
|
);
|
||||||
|
await _deleteAll(sessionBuilder.build());
|
||||||
|
});
|
||||||
|
tearDown(() => _deleteAll(sessionBuilder.build()));
|
||||||
|
|
||||||
|
test('creates a person', () async {
|
||||||
|
final authSuccess = await endpoints.anonymousIdp.login(sessionBuilder);
|
||||||
|
|
||||||
|
final authUser = await AuthServices.instance.authUsers.get(
|
||||||
|
sessionBuilder.build(),
|
||||||
|
authUserId: authSuccess.authUserId,
|
||||||
|
);
|
||||||
|
expect(authUser.id, authSuccess.authUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'refuses the 31st account from one IP address within an hour',
|
||||||
|
() async {
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
await endpoints.anonymousIdp.login(sessionBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
endpoints.anonymousIdp.login(sessionBuilder),
|
||||||
|
throwsA(
|
||||||
|
isA<AnonymousAccountBlockedException>().having(
|
||||||
|
(e) => e.reason,
|
||||||
|
'reason',
|
||||||
|
AnonymousAccountBlockedExceptionReason.tooManyAttempts,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes every rate-limit record and every person; their anonymous accounts
|
||||||
|
/// and sessions go with them (`ON DELETE CASCADE`).
|
||||||
|
Future<void> _deleteAll(Session session) async {
|
||||||
|
await RateLimitedRequestAttempt.db.deleteWhere(
|
||||||
|
session,
|
||||||
|
where: (t) => t.id.notEquals(null),
|
||||||
|
);
|
||||||
|
await AuthUser.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
||||||
|
}
|
||||||
|
|
@ -11,8 +11,11 @@
|
||||||
// ignore_for_file: no_leading_underscores_for_local_identifiers
|
// ignore_for_file: no_leading_underscores_for_local_identifiers
|
||||||
|
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'dart:async' as _ida;
|
||||||
import 'dart:io' as _idi;
|
import 'dart:io' as _idi;
|
||||||
import 'package:serverpod/serverpod.dart' as _is;
|
import 'package:serverpod/serverpod.dart' as _is;
|
||||||
|
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
|
||||||
|
as _iacs;
|
||||||
import 'package:serverpod_test/serverpod_test.dart' as _ist;
|
import 'package:serverpod_test/serverpod_test.dart' as _ist;
|
||||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||||
import 'package:halkyon_learn_server/src/generated/endpoints.dart';
|
import 'package:halkyon_learn_server/src/generated/endpoints.dart';
|
||||||
|
|
@ -141,7 +144,9 @@ void withServerpod(
|
||||||
)(testClosure);
|
)(testClosure);
|
||||||
}
|
}
|
||||||
|
|
||||||
class TestEndpoints {}
|
class TestEndpoints {
|
||||||
|
late final _AnonymousIdpEndpoint anonymousIdp;
|
||||||
|
}
|
||||||
|
|
||||||
class _InternalTestEndpoints extends TestEndpoints
|
class _InternalTestEndpoints extends TestEndpoints
|
||||||
implements _ist.InternalTestEndpoints {
|
implements _ist.InternalTestEndpoints {
|
||||||
|
|
@ -149,5 +154,52 @@ class _InternalTestEndpoints extends TestEndpoints
|
||||||
void initialize(
|
void initialize(
|
||||||
_is.SerializationManager serializationManager,
|
_is.SerializationManager serializationManager,
|
||||||
_is.EndpointDispatch endpoints,
|
_is.EndpointDispatch endpoints,
|
||||||
) {}
|
) {
|
||||||
|
anonymousIdp = _AnonymousIdpEndpoint(
|
||||||
|
endpoints,
|
||||||
|
serializationManager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnonymousIdpEndpoint {
|
||||||
|
_AnonymousIdpEndpoint(
|
||||||
|
this._endpointDispatch,
|
||||||
|
this._serializationManager,
|
||||||
|
);
|
||||||
|
|
||||||
|
final _is.EndpointDispatch _endpointDispatch;
|
||||||
|
|
||||||
|
final _is.SerializationManager _serializationManager;
|
||||||
|
|
||||||
|
_ida.Future<_iacs.AuthSuccess> login(
|
||||||
|
_ist.TestSessionBuilder sessionBuilder, {
|
||||||
|
String? token,
|
||||||
|
}) async {
|
||||||
|
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||||
|
var _localUniqueSession =
|
||||||
|
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||||
|
endpoint: 'anonymousIdp',
|
||||||
|
method: 'login',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||||
|
createSessionCallback: (_) => _localUniqueSession,
|
||||||
|
endpointPath: 'anonymousIdp',
|
||||||
|
methodName: 'login',
|
||||||
|
parameters: _ist.testObjectToJson({'token': token}),
|
||||||
|
serializationManager: _serializationManager,
|
||||||
|
);
|
||||||
|
var _localReturnValue =
|
||||||
|
await (_localCallContext.method.call(
|
||||||
|
_localUniqueSession,
|
||||||
|
_localCallContext.arguments,
|
||||||
|
)
|
||||||
|
as _ida.Future<_iacs.AuthSuccess>);
|
||||||
|
return _localReturnValue;
|
||||||
|
} finally {
|
||||||
|
await _localUniqueSession.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue