diff --git a/app/assets/security/README.md b/app/assets/security/README.md index b0510a9..ad8625b 100644 --- a/app/assets/security/README.md +++ b/app/assets/security/README.md @@ -23,13 +23,18 @@ Commit `68bcd12457978649d41ae4f56e3b5d2db2b72560`, Verzeichnis | `Language-Specific/German_common-password-list-top-100000.txt` | die ersten 4.000 Einträge mit mindestens 10 Zeichen | | `xato-net-10-million-passwords-1000000.txt` | die ersten 6.000 weiteren Einträge mit mindestens 10 Zeichen | -**Offene Lizenzfrage:** Die MIT-Lizenz gilt für das SecLists-Repository. Die -einzelnen Listen stammen aus Datenlecks bzw. Sammlungen Dritter (die xato-Liste -von Mark Burnett); eine eigene Lizenz je Liste nennt SecLists nicht. Die Spec -(§ 5.6) nennt SecLists (MIT) als Kandidaten. Die abschließende Freigabe liegt -bei Chris. Die Liste enthält ausschließlich Passwörter, keine Konten oder +**Lizenzfrage entschieden (Chris, 2026-11-03):** Die MIT-Lizenz gilt für das +SecLists-Repository. Die einzelnen Listen stammen aus Datenlecks bzw. +Sammlungen Dritter (die xato-Liste von Mark Burnett); eine eigene Lizenz je +Liste nennt SecLists nicht. Chris hat entschieden, die SecLists-Listen mit +dokumentierter Herkunft (diese Datei) und MIT-Lizenztext (`app/THIRD_PARTY.md`) +zu nutzen. Die Liste enthält ausschließlich Passwörter, keine Konten oder E-Mail-Adressen. +Der Lizenztext wird zur Laufzeit über `LicenseRegistry` ausgeliefert +(`lib/core/licenses/third_party_licenses.dart`, Asset `THIRD_PARTY.md`) und +erscheint damit in `showLicensePage`. + ## Neu erzeugen ```bash diff --git a/app/lib/core/licenses/third_party_licenses.dart b/app/lib/core/licenses/third_party_licenses.dart new file mode 100644 index 0000000..6ad7d2e --- /dev/null +++ b/app/lib/core/licenses/third_party_licenses.dart @@ -0,0 +1,18 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Asset with the license texts of bundled third-party material. +const thirdPartyAsset = 'THIRD_PARTY.md'; + +/// Package name under which the entry appears in `showLicensePage`. +const thirdPartyLicensePackage = 'SecLists (common passwords list)'; + +/// Registers the license text of the bundled password list (SecLists, MIT) in +/// the [LicenseRegistry], so that it shows up in `showLicensePage`. The text +/// is read lazily from [thirdPartyAsset] when the license page opens. +void registerThirdPartyLicenses({AssetBundle? bundle}) { + LicenseRegistry.addLicense(() async* { + final text = await (bundle ?? rootBundle).loadString(thirdPartyAsset); + yield LicenseEntryWithLineBreaks([thirdPartyLicensePackage], text); + }); +} diff --git a/app/lib/core/platform/io_error.dart b/app/lib/core/platform/io_error.dart new file mode 100644 index 0000000..75489ca --- /dev/null +++ b/app/lib/core/platform/io_error.dart @@ -0,0 +1,6 @@ +library; + +/// Detects `dart:io` network errors without importing `dart:io` on the web +/// (conditional import). Type checks with `is` keep working in minified +/// dart2js builds, unlike `runtimeType.toString()`. +export 'io_error_stub.dart' if (dart.library.io) 'io_error_io.dart'; diff --git a/app/lib/core/platform/io_error_io.dart b/app/lib/core/platform/io_error_io.dart new file mode 100644 index 0000000..6787046 --- /dev/null +++ b/app/lib/core/platform/io_error_io.dart @@ -0,0 +1,7 @@ +import 'dart:io'; + +/// Network errors of `dart:io` (no connection, DNS, TLS handshake). +bool isIoNetworkError(Object error) => + error is SocketException || + error is HandshakeException || + error is TlsException; diff --git a/app/lib/core/platform/io_error_stub.dart b/app/lib/core/platform/io_error_stub.dart new file mode 100644 index 0000000..03d657e --- /dev/null +++ b/app/lib/core/platform/io_error_stub.dart @@ -0,0 +1,2 @@ +/// Web and other platforms without `dart:io`: no `dart:io` network errors. +bool isIoNetworkError(Object error) => false; diff --git a/app/lib/core/platform/url_opener.dart b/app/lib/core/platform/url_opener.dart index ac94a4b..aecef0a 100644 --- a/app/lib/core/platform/url_opener.dart +++ b/app/lib/core/platform/url_opener.dart @@ -9,13 +9,30 @@ typedef UrlOpener = Future Function(Uri uri); final _log = AppLogger('UrlOpener'); +/// Only `http`/`https` URLs with a host may leave the app (no `javascript:`, +/// `file:`, `intent:` and the like). The check lives here so that no caller +/// can forget it. +bool isOpenableExternalUrl(Uri uri) => + (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty; + +/// Wraps [launch] with the scheme check of [isOpenableExternalUrl] and error +/// handling. Public for tests. +UrlOpener guardedUrlOpener(Future Function(Uri uri) launch) => + (uri) async { + if (!isOpenableExternalUrl(uri)) { + _log.warning('refused to open url with scheme "${uri.scheme}"'); + return false; + } + try { + return await launch(uri); + } on Object catch (error) { + _log.warning('launchUrl failed: ${error.runtimeType}'); + return false; + } + }; + /// The only place that touches `url_launcher`. Tests override this provider. @Riverpod(keepAlive: true) -UrlOpener urlOpener(Ref ref) => (uri) async { - try { - return await launchUrl(uri, mode: LaunchMode.externalApplication); - } on Object catch (error) { - _log.warning('launchUrl failed: ${error.runtimeType}'); - return false; - } -}; +UrlOpener urlOpener(Ref ref) => guardedUrlOpener( + (uri) => launchUrl(uri, mode: LaunchMode.externalApplication), +); diff --git a/app/lib/core/platform/url_opener.g.dart b/app/lib/core/platform/url_opener.g.dart index 061a48e..d4a4f55 100644 --- a/app/lib/core/platform/url_opener.g.dart +++ b/app/lib/core/platform/url_opener.g.dart @@ -52,4 +52,4 @@ final class UrlOpenerProvider } } -String _$urlOpenerHash() => r'f5f1e650e073776595fac2981548a91f815c66ca'; +String _$urlOpenerHash() => r'e86eb081c587907d3c162ed619b42abbbfb195a5'; diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index 44348ba..1d279ed 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -3,6 +3,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shuffle/core/routing/app_routes.dart'; import 'package:shuffle/core/routing/not_found_screen.dart'; import 'package:shuffle/core/routing/redirect_source.dart'; +import 'package:shuffle/core/routing/volatile_extra_codec.dart'; import 'package:shuffle/features/auth/presentation/auth_routes.dart'; import 'package:shuffle/features/home/presentation/home_screen.dart'; @@ -14,6 +15,7 @@ GoRouter appRouter(Ref ref) { final router = GoRouter( initialLocation: AppRoutes.home, + extraCodec: const VolatileExtraCodec(), refreshListenable: source.changes, redirect: (context, state) => source.redirect(state), errorBuilder: (context, state) => const NotFoundScreen(), diff --git a/app/lib/core/routing/app_router.g.dart b/app/lib/core/routing/app_router.g.dart index bb8230a..34d5139 100644 --- a/app/lib/core/routing/app_router.g.dart +++ b/app/lib/core/routing/app_router.g.dart @@ -48,4 +48,4 @@ final class AppRouterProvider } } -String _$appRouterHash() => r'f4f391f8abc008f4a7b1a88149822bda24f42137'; +String _$appRouterHash() => r'39ea53d71fba02f9e9cc5e42df61144e55eca3d9'; diff --git a/app/lib/core/routing/volatile_extra_codec.dart b/app/lib/core/routing/volatile_extra_codec.dart new file mode 100644 index 0000000..7169a38 --- /dev/null +++ b/app/lib/core/routing/volatile_extra_codec.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +/// `extraCodec` for go_router that does not persist `extra`. +/// +/// go_router 18 JSON-encodes `extra` into the route information, and on the +/// web into the browser history state (`RouteMatchListCodec`, +/// `match.dart`). Data that must not outlive the page (here: the e-mail +/// address on "check your inbox") would stay in the history entry. With this +/// codec `extra` works in memory during navigation, but is dropped when the +/// state is serialized (reload, restoring from history). +class VolatileExtraCodec extends Codec { + const VolatileExtraCodec(); + + @override + Converter get encoder => const _DropConverter(); + + @override + Converter get decoder => const _DropConverter(); +} + +class _DropConverter extends Converter { + const _DropConverter(); + + @override + Object? convert(Object? input) => null; +} diff --git a/app/lib/core/widgets/app_checkbox_field.dart b/app/lib/core/widgets/app_checkbox_field.dart index a68b03f..3a0861d 100644 --- a/app/lib/core/widgets/app_checkbox_field.dart +++ b/app/lib/core/widgets/app_checkbox_field.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/semantics.dart'; import 'package:shuffle/core/theme/app_spacing.dart'; import 'package:shuffle/core/widgets/app_field_error.dart'; @@ -8,8 +7,8 @@ import 'package:shuffle/core/widgets/app_field_error.dart'; class AppLabelSegment { const AppLabelSegment.text(this.text) : onTap = null; - /// A link. It is focusable via keyboard and exposed to screen readers as a - /// custom action named [text]. + /// A link. It is focusable via keyboard and exposed to screen readers as its + /// own semantics node (`link: true`) with [text] as label. const AppLabelSegment.link(this.text, this.onTap); final String text; @@ -60,11 +59,8 @@ class AppCheckboxField extends StatelessWidget { decorationColor: theme.colorScheme.primary, ); + final hasLinks = segments.any((s) => s.isLink); final plainLabel = segments.map((s) => s.text).join(); - final linkActions = { - for (final s in segments.where((s) => s.isLink)) - CustomSemanticsAction(label: s.text): s.onTap!, - }; final label = Text.rich( TextSpan( @@ -82,7 +78,9 @@ class AppCheckboxField extends StatelessWidget { ), ) else - TextSpan(text: s.text), + // With links the text stays in the semantics tree (the links are its + // children) but is read via the checkbox label, not twice. + TextSpan(text: s.text, semanticsLabel: hasLinks ? '' : null), ], ), ); @@ -94,40 +92,40 @@ class AppCheckboxField extends StatelessWidget { Semantics( container: true, checked: value, - enabled: interactive, label: plainLabel, + enabled: interactive, onTap: interactive ? () => onChanged!(!value) : null, - customSemanticsActions: linkActions, - child: ExcludeSemantics( - child: InkWell( - canRequestFocus: false, - borderRadius: AppRadius.smAll, - onTap: interactive ? () => onChanged!(!value) : null, - child: ConstrainedBox( - constraints: const BoxConstraints( - minHeight: AppSpacing.minTouchTarget, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Checkbox( + child: InkWell( + canRequestFocus: false, + excludeFromSemantics: true, + borderRadius: AppRadius.smAll, + onTap: interactive ? () => onChanged!(!value) : null, + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppSpacing.minTouchTarget, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ExcludeSemantics( + child: Checkbox( value: value, isError: hasError, onChanged: interactive ? (v) => onChanged!(v ?? false) : null, ), - const SizedBox(width: AppSpacing.xs), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.sm, - ), - child: label, + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.sm, ), + child: label, ), - ], - ), + ), + ], ), ), ), @@ -158,11 +156,21 @@ class _InlineLink extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( + // Own semantics node (`link: true`), so that screen readers reach the + // link on the web, too (custom actions are not mapped to ARIA there). + return Semantics( + container: true, + link: true, + label: text, onTap: onTap, - borderRadius: BorderRadius.circular(AppRadius.sm / 2), - focusColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), - child: Text(text, style: style), + excludeSemantics: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.sm / 2), + focusColor: Theme.of(context).colorScheme.primary + .withValues(alpha: 0.2), + child: Text(text, style: style), + ), ); } } diff --git a/app/lib/core/widgets/app_field_error.dart b/app/lib/core/widgets/app_field_error.dart index 506c945..95c3b61 100644 --- a/app/lib/core/widgets/app_field_error.dart +++ b/app/lib/core/widgets/app_field_error.dart @@ -5,10 +5,16 @@ import 'package:shuffle/core/theme/app_spacing.dart'; /// /// Information is never conveyed by color alone: icon + text. The text is a /// polite live region, so screen readers announce it when it appears. +/// +/// With [live] set to `false` the message stays visible and readable but is +/// not announced on its own. Use it for messages that change on every +/// keystroke (e.g. "n characters missing") and switch to `true` when the +/// field is left. class AppFieldError extends StatelessWidget { - const AppFieldError(this.text, {super.key}); + const AppFieldError(this.text, {super.key, this.live = true}); final String text; + final bool live; @override Widget build(BuildContext context) { @@ -16,7 +22,7 @@ class AppFieldError extends StatelessWidget { final color = theme.colorScheme.error; return Semantics( container: true, - liveRegion: true, + liveRegion: live, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/app/lib/core/widgets/app_password_field.dart b/app/lib/core/widgets/app_password_field.dart index c5c6377..4e68505 100644 --- a/app/lib/core/widgets/app_password_field.dart +++ b/app/lib/core/widgets/app_password_field.dart @@ -17,6 +17,7 @@ class AppPasswordField extends StatefulWidget { this.focusNode, this.ruleHint, this.errorText, + this.liveError = true, this.isNewPassword = false, this.autofillHints, this.textInputAction, @@ -35,6 +36,9 @@ class AppPasswordField extends StatefulWidget { /// Rule hint (e.g. "at least 10 characters"), shown while there is no error. final String? ruleHint; final String? errorText; + + /// See [AppTextField.liveError]. + final bool liveError; final bool isNewPassword; /// Overrides the default hints (`password` / `newPassword`). @@ -60,6 +64,7 @@ class _AppPasswordFieldState extends State { focusNode: widget.focusNode, helperText: widget.ruleHint, errorText: widget.errorText, + liveError: widget.liveError, obscureText: _obscured, autocorrect: false, enableSuggestions: false, diff --git a/app/lib/core/widgets/app_text_field.dart b/app/lib/core/widgets/app_text_field.dart index 701e0ca..18d8898 100644 --- a/app/lib/core/widgets/app_text_field.dart +++ b/app/lib/core/widgets/app_text_field.dart @@ -17,6 +17,7 @@ class AppTextField extends StatelessWidget { this.focusNode, this.helperText, this.errorText, + this.liveError = true, this.keyboardType, this.textInputAction, this.autofillHints, @@ -41,6 +42,10 @@ class AppTextField extends StatelessWidget { final FocusNode? focusNode; final String? helperText; final String? errorText; + + /// Whether [errorText] is announced as a live region. Set to `false` while + /// the user is typing and the message changes with every keystroke. + final bool liveError; final TextInputType? keyboardType; final TextInputAction? textInputAction; final Iterable? autofillHints; @@ -122,7 +127,9 @@ class AppTextField extends StatelessWidget { decoration: InputDecoration( suffixIcon: suffixIcon, counterText: showCounter ? counterText : null, - error: hasError ? AppFieldError(errorText!) : null, + error: hasError + ? AppFieldError(errorText!, live: liveError) + : null, helper: !hasError && helper != null && helper.isNotEmpty ? Text( helper, diff --git a/app/lib/features/auth/data/auth_failure_mapper.dart b/app/lib/features/auth/data/auth_failure_mapper.dart index 825337a..6b2398b 100644 --- a/app/lib/features/auth/data/auth_failure_mapper.dart +++ b/app/lib/features/auth/data/auth_failure_mapper.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:http/http.dart' as http; +import 'package:shuffle/core/platform/io_error.dart'; import 'package:shuffle/features/auth/domain/auth_failure.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -108,13 +110,9 @@ RegistrationRejectionReason registrationRejectionReasonFrom(String message) { return RegistrationRejectionReason.unknown; } -/// Rohe Transportfehler (`SocketException`, `ClientException`, …). `dart:io` -/// und `package:http` werden bewusst nicht importiert (Web-Build, Lint); -/// `gotrue` verpackt Transportfehler normalerweise schon selbst. -bool _isTransportError(Object error) => switch (error.runtimeType.toString()) { - 'SocketException' || - 'ClientException' || - 'HandshakeException' || - 'TlsException' => true, - _ => false, -}; +/// Rohe Transportfehler. `gotrue` verpackt Transportfehler normalerweise +/// schon selbst; das hier ist die Rückfallebene. Erkennung per `is` +/// (`ClientException` aus `package:http`, `dart:io`-Fehler über bedingten +/// Import), nicht über `runtimeType`: Das bricht in minifiziertem dart2js. +bool _isTransportError(Object error) => + error is http.ClientException || isIoNetworkError(error); diff --git a/app/lib/features/auth/presentation/auth_routes.dart b/app/lib/features/auth/presentation/auth_routes.dart index 8a0752e..bee11e0 100644 --- a/app/lib/features/auth/presentation/auth_routes.dart +++ b/app/lib/features/auth/presentation/auth_routes.dart @@ -16,8 +16,10 @@ final List authRoutes = [ GoRoute( path: 'check-email', builder: (context, state) { - // The address travels as `extra`, never in the URL. After a web - // reload it is gone; the screen then asks for it. + // The address travels as `extra`, never in the URL. go_router would + // persist `extra` in the browser history; the router's + // `VolatileExtraCodec` prevents that. After a web reload it is gone; + // the screen then asks for it. final extra = state.extra; return CheckEmailScreen(email: extra is String ? extra : null); }, diff --git a/app/lib/features/auth/presentation/providers/auth_redirect.dart b/app/lib/features/auth/presentation/providers/auth_redirect.dart index 165290e..e318d83 100644 --- a/app/lib/features/auth/presentation/providers/auth_redirect.dart +++ b/app/lib/features/auth/presentation/providers/auth_redirect.dart @@ -73,8 +73,13 @@ String? safeFromPath(String? from) { if (uri == null || uri.hasScheme || uri.hasAuthority) { return null; } - if (_neverFromPaths.contains(uri.path) || - uri.path.startsWith(AppRoutes.authConfirm)) { + // `/login/` is the same page as `/login` (go_router ignores the trailing + // slash), so it is not allowed either. + final path = uri.path.length > 1 + ? uri.path.replaceFirst(RegExp(r'/+$'), '') + : uri.path; + if (_neverFromPaths.contains(path) || + path.startsWith(AppRoutes.authConfirm)) { return null; } return from; diff --git a/app/lib/features/auth/presentation/register/register_controller.dart b/app/lib/features/auth/presentation/register/register_controller.dart index eac8bce..3e75b6d 100644 --- a/app/lib/features/auth/presentation/register/register_controller.dart +++ b/app/lib/features/auth/presentation/register/register_controller.dart @@ -85,6 +85,9 @@ class RegisterController extends _$RegisterController { if (state.isSubmitting) { return false; } + // Set before the first await, otherwise a second trigger (button and + // Enter key) passes the guard while the list is still loading. + state = state.copyWith(status: RegisterStatus.submitting); await loadCommonPasswords(); if (!ref.mounted) { return false; @@ -100,13 +103,13 @@ class RegisterController extends _$RegisterController { final invalid = state.firstInvalidField; if (invalid != null) { state = state.copyWith( + status: RegisterStatus.editing, focusField: invalid, focusRequest: state.focusRequest + 1, ); return false; } - state = state.copyWith(status: RegisterStatus.submitting); final input = ref.read(registrationInputFactoryProvider)( email: EmailFormat.normalize(state.email), password: state.password, @@ -155,7 +158,7 @@ class RegisterController extends _$RegisterController { LegalDocument.terms => config.legalTermsUrl, }; final uri = Uri.tryParse(url); - if (uri == null || !uri.hasScheme) { + if (uri == null || !isOpenableExternalUrl(uri)) { _log.warning('invalid legal url for ${document.name}'); return false; } diff --git a/app/lib/features/auth/presentation/register/register_controller.g.dart b/app/lib/features/auth/presentation/register/register_controller.g.dart index 07fe2a4..5a223cb 100644 --- a/app/lib/features/auth/presentation/register/register_controller.g.dart +++ b/app/lib/features/auth/presentation/register/register_controller.g.dart @@ -45,7 +45,7 @@ final class RegisterControllerProvider } String _$registerControllerHash() => - r'25930050b4b5fbaff535c3a09abe4ca15c860759'; + r'78a2a99ae36292286f26aff1f9910504bc464636'; /// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). diff --git a/app/lib/features/auth/presentation/register/register_screen.dart b/app/lib/features/auth/presentation/register/register_screen.dart index dd93095..6b36515 100644 --- a/app/lib/features/auth/presentation/register/register_screen.dart +++ b/app/lib/features/auth/presentation/register/register_screen.dart @@ -40,6 +40,9 @@ class _RegisterScreenState extends ConsumerState { final _passwordFocus = FocusNode(); final _displayNameFocus = FocusNode(); + /// The user is typing in the password field (see `liveError` below). + bool _passwordTyping = false; + @override void initState() { super.initState(); @@ -54,6 +57,10 @@ class _RegisterScreenState extends ConsumerState { ); } _onFocusChange(_passwordFocus, RegisterField.password); + if (!_passwordFocus.hasFocus && _passwordTyping && mounted) { + // Field left: the length hint may be announced now. + setState(() => _passwordTyping = false); + } }); _displayNameFocus.addListener( () => _onFocusChange(_displayNameFocus, RegisterField.displayName), @@ -167,7 +174,15 @@ class _RegisterScreenState extends ConsumerState { enabled: !busy, ruleHint: l10n.authPasswordRule, errorText: _passwordError(l10n, state), - onChanged: controller.passwordChanged, + // "n missing" changes with every keystroke: announce it only + // when the field is left. The visible hint stays. + liveError: !_passwordTyping || !_isLengthHintOnly(state), + onChanged: (value) { + if (!_passwordTyping) { + setState(() => _passwordTyping = true); + } + controller.passwordChanged(value); + }, ), const SizedBox(height: AppSpacing.lg), AppTextField( @@ -258,6 +273,13 @@ class _RegisterScreenState extends ConsumerState { return null; } + /// The only password message shown is the "n missing" count (no server + /// answer, not "required"). + bool _isLengthHintOnly(RegisterState state) => + state.serverPasswordReasons == null && + !state.shownPasswordRequired && + state.shownPasswordError is PasswordTooShort; + String? _passwordError(AppLocalizations l10n, RegisterState state) { if (state.serverPasswordReasons case final reasons?) { return weakPasswordMessage(l10n, reasons); diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 5b12c86..1997a07 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -20,7 +20,7 @@ "authErrorNetwork": "Keine Verbindung. Für diesen Schritt brauchst du Internet.", "authErrorUnexpected": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", "authActionRetry": "Erneut versuchen", - "authPasswordTooShort": "Mindestens 10 Zeichen – es fehlen noch {missing}.", + "authPasswordTooShort": "Mindestens 10 Zeichen – {missing, plural, =1{es fehlt noch 1 Zeichen} other{es fehlen noch {missing} Zeichen}}.", "authPasswordCommon": "Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.", "authPasswordRule": "Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort.", "authPasswordRequired": "Gib ein Passwort ein.", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index b374bac..0dfc746 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -35,7 +35,7 @@ "authErrorNetwork": "No connection. You need internet access for this step.", "authErrorUnexpected": "Something went wrong. Please try again.", "authActionRetry": "Try again", - "authPasswordTooShort": "At least 10 characters – {missing} more needed.", + "authPasswordTooShort": "At least 10 characters – {missing, plural, =1{1 more character needed} other{{missing} more characters needed}}.", "@authPasswordTooShort": { "placeholders": { "missing": { diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 3971f2a..b13789c 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -221,7 +221,7 @@ abstract class AppLocalizations { /// No description provided for @authPasswordTooShort. /// /// In en, this message translates to: - /// **'At least 10 characters – {missing} more needed.'** + /// **'At least 10 characters – {missing, plural, =1{1 more character needed} other{{missing} more characters needed}}.'** String authPasswordTooShort(int missing); /// No description provided for @authPasswordCommon. diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 5699b4b..88e00cb 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -84,7 +84,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String authPasswordTooShort(int missing) { - return 'Mindestens 10 Zeichen – es fehlen noch $missing.'; + String _temp0 = intl.Intl.pluralLogic( + missing, + locale: localeName, + other: 'es fehlen noch $missing Zeichen', + one: 'es fehlt noch 1 Zeichen', + ); + return 'Mindestens 10 Zeichen – $_temp0.'; } @override diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index ff39cce..14bab3a 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -81,7 +81,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String authPasswordTooShort(int missing) { - return 'At least 10 characters – $missing more needed.'; + String _temp0 = intl.Intl.pluralLogic( + missing, + locale: localeName, + other: '$missing more characters needed', + one: '1 more character needed', + ); + return 'At least 10 characters – $_temp0.'; } @override diff --git a/app/lib/main.dart b/app/lib/main.dart index d182670..cfc4aa1 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shuffle/app.dart'; import 'package:shuffle/core/config/app_config.dart'; import 'package:shuffle/core/error/global_error_handler.dart'; +import 'package:shuffle/core/licenses/third_party_licenses.dart'; import 'package:shuffle/core/logging/app_logger.dart'; import 'package:shuffle/core/routing/url_strategy.dart'; import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; @@ -11,6 +12,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); AppLogger.configure(); + registerThirdPartyLicenses(); installGlobalErrorHandlers(); configurePathUrlStrategy(); diff --git a/app/pubspec.lock b/app/pubspec.lock index 7ffe1ab..aedf2c8 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -334,7 +334,7 @@ packages: source: hosted version: "2.3.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8bb4152..f57ecd5 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: json_annotation: 4.12.0 logging: 1.3.0 intl: 0.20.3 + http: 1.6.0 url_launcher: 6.3.3 dev_dependencies: @@ -40,3 +41,4 @@ flutter: generate: true assets: - assets/security/common-passwords.txt + - THIRD_PARTY.md diff --git a/app/test/core/licenses/third_party_licenses_test.dart b/app/test/core/licenses/third_party_licenses_test.dart new file mode 100644 index 0000000..b283b7b --- /dev/null +++ b/app/test/core/licenses/third_party_licenses_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/licenses/third_party_licenses.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('the SecLists MIT license shows up in the LicenseRegistry', () async { + registerThirdPartyLicenses(); + + final entries = await LicenseRegistry.licenses.toList(); + final entry = entries.firstWhere( + (e) => e.packages.contains(thirdPartyLicensePackage), + ); + final text = entry.paragraphs.map((p) => p.text).join('\n'); + expect(text, contains('MIT License')); + expect(text, contains('Copyright (c) 2018 Daniel Miessler')); + expect(text, contains('SecLists')); + }); + + test('the license asset is declared in the bundle', () async { + final text = await rootBundle.loadString(thirdPartyAsset); + expect(text, contains('Permission is hereby granted')); + }); +} diff --git a/app/test/core/platform/url_opener_test.dart b/app/test/core/platform/url_opener_test.dart new file mode 100644 index 0000000..de3af74 --- /dev/null +++ b/app/test/core/platform/url_opener_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/platform/url_opener.dart'; + +void main() { + group('guardedUrlOpener', () { + test('opens http and https URLs with a host', () async { + final launched = []; + final open = guardedUrlOpener((uri) async { + launched.add(uri); + return true; + }); + expect(await open(Uri.parse('https://shuffle.example/privacy')), isTrue); + expect(await open(Uri.parse('http://shuffle.example/terms')), isTrue); + expect(launched, hasLength(2)); + }); + + for (final url in [ + 'javascript:alert(1)', + 'file:///etc/passwd', + 'intent://scan/#Intent;scheme=zxing;end', + 'mailto:a@b.de', + 'https:///no-host', + '/relative/path', + 'data:text/html,hi', + ]) { + test('refuses $url', () async { + var launches = 0; + final open = guardedUrlOpener((uri) async { + launches++; + return true; + }); + expect(await open(Uri.parse(url)), isFalse); + expect(launches, 0); + }); + } + + test('returns false when launching throws', () async { + final open = guardedUrlOpener((uri) async => throw StateError('x')); + expect(await open(Uri.parse('https://shuffle.example')), isFalse); + }); + }); +} diff --git a/app/test/core/routing/volatile_extra_codec_test.dart b/app/test/core/routing/volatile_extra_codec_test.dart new file mode 100644 index 0000000..31e9851 --- /dev/null +++ b/app/test/core/routing/volatile_extra_codec_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/routing/volatile_extra_codec.dart'; + +void main() { + test('extra is not serialized (nothing lands in the browser history)', () { + const codec = VolatileExtraCodec(); + expect(codec.encode('anna@example.com'), isNull); + expect(codec.decode('anna@example.com'), isNull); + }); + + testWidgets('go_router history state does not contain the extra', ( + tester, + ) async { + Object? seen; + final router = GoRouter( + extraCodec: const VolatileExtraCodec(), + initialLocation: '/a', + routes: [ + GoRoute(path: '/a', builder: (_, _) => const SizedBox()), + GoRoute( + path: '/b', + builder: (_, state) { + seen = state.extra; + return const SizedBox(); + }, + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + + router.go('/b', extra: 'anna@example.com'); + await tester.pumpAndSettle(); + + expect(seen, 'anna@example.com', reason: 'extra works in memory'); + final info = router.routeInformationParser.restoreRouteInformation( + router.routerDelegate.currentConfiguration, + ); + expect(info?.state.toString(), isNot(contains('anna@example.com'))); + }); +} diff --git a/app/test/core/widgets/app_checkbox_field_test.dart b/app/test/core/widgets/app_checkbox_field_test.dart index effb51e..d7dd22b 100644 --- a/app/test/core/widgets/app_checkbox_field_test.dart +++ b/app/test/core/widgets/app_checkbox_field_test.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shuffle/core/widgets/app_checkbox_field.dart'; @@ -79,11 +78,13 @@ void main() { await tester.tap(find.text('terms')); expect(opened, 1); expect(toggled, 0); - await tester.tap(find.textContaining('I accept the')); + await tester.tapAt( + tester.getTopLeft(find.byType(AppCheckboxField)) + const Offset(30, 24), + ); expect(toggled, 1); }); - testWidgets('exposes checked state, full label and link actions', ( + testWidgets('exposes checked state and full label on the checkbox node', ( tester, ) async { final handle = tester.ensureSemantics(); @@ -107,12 +108,43 @@ void main() { hasEnabledState: true, isEnabled: true, hasTapAction: true, - customActions: [const CustomSemanticsAction(label: 'terms')], ), ); handle.dispose(); }); + testWidgets('every link is its own semantics node with isLink (web)', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + var privacy = 0; + var terms = 0; + await pumpWidgetUnderTest( + tester, + AppCheckboxField.rich( + value: false, + onChanged: (_) {}, + segments: [ + const AppLabelSegment.text('I accept the '), + AppLabelSegment.link('terms', () => terms++), + const AppLabelSegment.text(' and the '), + AppLabelSegment.link('privacy policy', () => privacy++), + ], + ), + ); + for (final name in ['terms', 'privacy policy']) { + expect( + tester.getSemantics(find.bySemanticsLabel(name)), + matchesSemantics(label: name, isLink: true, hasTapAction: true), + ); + } + tester.semantics.tap(find.semantics.byLabel('terms')); + tester.semantics.tap(find.semantics.byLabel('privacy policy')); + expect(terms, 1); + expect(privacy, 1); + handle.dispose(); + }); + testWidgets('error shows icon and text as live region, error border', ( tester, ) async { diff --git a/app/test/core/widgets/app_text_field_test.dart b/app/test/core/widgets/app_text_field_test.dart index ca8435f..9edb4f8 100644 --- a/app/test/core/widgets/app_text_field_test.dart +++ b/app/test/core/widgets/app_text_field_test.dart @@ -77,6 +77,26 @@ void main() { handle.dispose(); }); + testWidgets('liveError false keeps the error visible but silent', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await pumpWidgetUnderTest( + tester, + const AppTextField( + label: 'Email', + errorText: 'Broken', + liveError: false, + ), + ); + expect(find.text('Broken'), findsOneWidget); + expect( + tester.getSemantics(find.text('Broken')), + matchesSemantics(label: 'Broken', isLiveRegion: false), + ); + handle.dispose(); + }); + testWidgets('reports changes and keeps input', (tester) async { final controller = TextEditingController(); addTearDown(controller.dispose); diff --git a/app/test/features/auth/data/auth_failure_mapper_test.dart b/app/test/features/auth/data/auth_failure_mapper_test.dart index d10e8f7..c018e03 100644 --- a/app/test/features/auth/data/auth_failure_mapper_test.dart +++ b/app/test/features/auth/data/auth_failure_mapper_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; import 'package:shuffle/features/auth/data/auth_failure_mapper.dart'; import 'package:shuffle/features/auth/domain/auth_failure.dart'; import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser; @@ -10,10 +12,6 @@ AuthApiException _api(String code, {String status = '400'}) => const _triggerBody = '{"code":"P0001","message":"display_name_invalid"}'; -class SocketException implements Exception {} - -class ClientException implements Exception {} - void main() { group('Spec #1 § 4.4 error mapping', () { test('KTO-01 Kriterium 7: invalid_credentials', () { @@ -234,11 +232,15 @@ void main() { const AuthFailure.network(), ); expect( - mapAuthError(SocketException(), AuthOperation.signIn), + mapAuthError(const SocketException('x'), AuthOperation.signIn), const AuthFailure.network(), ); expect( - mapAuthError(ClientException(), AuthOperation.signIn), + mapAuthError(http.ClientException('x'), AuthOperation.signIn), + const AuthFailure.network(), + ); + expect( + mapAuthError(const HandshakeException('x'), AuthOperation.signIn), const AuthFailure.network(), ); }); diff --git a/app/test/features/auth/presentation/auth_redirect_test.dart b/app/test/features/auth/presentation/auth_redirect_test.dart index 1b23757..671b194 100644 --- a/app/test/features/auth/presentation/auth_redirect_test.dart +++ b/app/test/features/auth/presentation/auth_redirect_test.dart @@ -134,6 +134,13 @@ void main() { }); group('safeFromPath', () { + test('rejects auth pages with a trailing slash', () { + expect(safeFromPath('/login/'), isNull); + expect(safeFromPath('/login//'), isNull); + expect(safeFromPath('/login/?x=1'), isNull); + expect(safeFromPath('/reset-password/'), isNull); + }); + test('accepts internal paths with query', () { expect(safeFromPath('/games/1?x=2'), '/games/1?x=2'); }); diff --git a/app/test/features/auth/presentation/register/register_controller_test.dart b/app/test/features/auth/presentation/register/register_controller_test.dart index 25b5b14..ddc32e0 100644 --- a/app/test/features/auth/presentation/register/register_controller_test.dart +++ b/app/test/features/auth/presentation/register/register_controller_test.dart @@ -120,6 +120,31 @@ void main() { }); }); + test( + 'two triggers while the password list is still loading send only once', + () async { + final listLoaded = Completer(); + final container = makeContainer(commonPasswords: () => listLoaded.future); + fillValid(notifier(container)); + + final first = notifier(container).submit(); + final second = notifier(container).submit(); + expect(stateOf(container).isSubmitting, isTrue); + expect(await second, isFalse); + + listLoaded.complete(commonTestPasswords); + expect(await first, isTrue); + verify(() => repository.signUp(any())).called(1); + expect(stateOf(container).isSubmitting, isFalse); + }, + ); + + test('an invalid form leaves the submitting state again', () async { + final container = makeContainer(); + expect(await notifier(container).submit(), isFalse); + expect(stateOf(container).isSubmitting, isFalse); + }); + group('KTO-01 Kriterium 2: minimum age', () { test( 'without the age confirmation nothing is sent and the error shows', diff --git a/app/test/features/auth/presentation/register/register_screen_test.dart b/app/test/features/auth/presentation/register/register_screen_test.dart index 85c4504..95026c1 100644 --- a/app/test/features/auth/presentation/register/register_screen_test.dart +++ b/app/test/features/auth/presentation/register/register_screen_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shuffle/core/platform/url_opener.dart'; import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/core/widgets/app_field_error.dart'; import 'package:shuffle/features/auth/domain/auth_failure.dart'; import 'package:shuffle/features/auth/domain/registration_input.dart'; import 'package:shuffle/l10n/app_localizations_de.dart'; @@ -164,6 +165,33 @@ void main() { expect(find.textContaining('Mindestens 10 Zeichen –'), findsNothing); }); + testWidgets( + 'the length hint is announced when the field is left, not while typing', + (tester) async { + await open(tester); + + bool live() => tester + .widget( + find.descendant( + of: find.byKey(const Key('register_password')), + matching: find.byType(AppFieldError), + ), + ) + .live; + + await type(tester, 'register_password', 'abc'); + expect(find.text(_de.authPasswordTooShort(7)), findsOneWidget); + expect(live(), isFalse); + await type(tester, 'register_password', 'abcd'); + expect(live(), isFalse); + + await tapKey(tester, 'register_display_name'); + await tester.pump(); + expect(find.text(_de.authPasswordTooShort(6)), findsOneWidget); + expect(live(), isTrue); + }, + ); + testWidgets('names a common password', (tester) async { await open(tester); diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md index 0e7ed28..396cd9d 100644 --- a/docs/design/auth-screens.md +++ b/docs/design/auth-screens.md @@ -136,7 +136,7 @@ Neutrale Texte (Krit. 4, F12) verraten nie, ob eine Adresse registriert ist. | Schlüssel | de | en | |---|---|---| -| `authPasswordTooShort` | Mindestens 10 Zeichen – es fehlen noch {missing}. | At least 10 characters – {missing} more needed. | +| `authPasswordTooShort` | Mindestens 10 Zeichen – {missing, plural, =1{es fehlt noch 1 Zeichen} other{es fehlen noch {missing} Zeichen}}. | At least 10 characters – {missing, plural, =1{1 more character needed} other{{missing} more characters needed}}. | | `authPasswordCommon` | Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes. | This password is very common and easy to guess. Choose a different one. | | `authPasswordRule` | Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort. | At least 10 characters. Not a very common password. | | `authPasswordRequired` | Gib ein Passwort ein. | Enter a password. | @@ -209,6 +209,10 @@ teilt – Entscheidung beim flutter-dev (A2). - Banner und Fehler werden beim Erscheinen vorgelesen (Live-Region); keine reinen Farb-Signale. - Passwortfelder: `AppPasswordField` mit `isNewPassword: true` bei Register/Reset, sonst `false`; E-Mail mit `AutofillHints.email` (Login: `AutofillHints.username`/`email`), `TextInputType.emailAddress`. +- Links in Checkbox-Sätzen (`AppCheckboxField.rich`) sind eigene Semantics-Knoten (`link: true`, Label = Linktext, + Tap), keine Custom Actions (Flutter-Web setzt diese nicht in ARIA um). Der Checkbox-Knoten trägt den ganzen Satz. +- Meldungen, die sich bei jedem Tastendruck ändern („es fehlen noch n“), sind während des Tippens keine Live-Region + (`liveError: false`) und werden beim Verlassen des Feldes angesagt; der sichtbare Text bleibt. - Alle interaktiven Elemente >= 48 dp, Tab-Reihenfolge wie Lesereihenfolge, Fokus sichtbar. - Test je Screen mit `textScaler` 2.0 (Android 320 x 640), `androidTapTargetGuideline`, `labeledTapTargetGuideline`, `textContrastGuideline`, Hell und Dunkel (T2). diff --git a/docs/specs/1-auth-registrierung-anmeldung.md b/docs/specs/1-auth-registrierung-anmeldung.md index 947c256..3998809 100644 --- a/docs/specs/1-auth-registrierung-anmeldung.md +++ b/docs/specs/1-auth-registrierung-anmeldung.md @@ -82,7 +82,7 @@ Nur additiv: `create or replace function` und neue Objekte, keine Änderung an b | `public.handle_new_user()` | – | Trigger-Funktion | **ersetzt** (`create or replace`). Neue Regeln: (a) Alter und Zustimmung wie bisher. (b) `terms_version := public.trim_ws(meta->>'terms_version')`; ist der Wert `null`, leer oder länger als 50 Zeichen, wirft der Trigger `terms_version_required` (P0001). (c) `display_name := public.trim_ws(meta->>'display_name')`. Ist der Wert `null`, leer, länger als 30 Codepoints oder enthält U+200B, wirft der Trigger `display_name_invalid` (SQLSTATE P0001). **Kein Rückgriff auf das E-Mail-Präfix, kein Kürzen.** (d) Der getrimmte Name und `terms_version` werden gespeichert, `terms_accepted_at` und `age_confirmed_at` = `now()`. (e) `locale` aus `meta->>'locale'`, wenn `de`/`en`, **sonst `'en'`** (F6). | | `public.sync_profile_locale_to_auth()` | – | Trigger-Funktion, `security definer`, `search_path = ''` | `after update of locale on public.profiles`, wenn sich der Wert ändert: `update auth.users set raw_user_meta_data = coalesce(raw_user_meta_data,'{}') \|\| jsonb_build_object('locale', new.locale) where id = new.id`. Ausführen nur für den Trigger: `revoke all … from public, anon, authenticated`. | -**Trim-Regel (verbindlich für Client und Server, auch KTO-02):** Leerraum am Anfang und Ende wird entfernt. Danach muss der Name 1 bis 30 **Unicode-Codepoints** lang sein. Der Server prüft mit `char_length(public.trim_ws(…))`, der Client mit `value.trim().runes.length`, **nicht** mit `String.length` (UTF-16). Bekannte Abweichung: Dart-`trim()` entfernt auch Unicode-Leerraum (z. B. NBSP), `btrim` nur Leerzeichen. Der Client sendet bereits getrimmte Werte, die Abweichung ist deshalb nur über die rohe API erreichbar und unkritisch. **Verbotenes Zeichen (Entscheidung Chris):** Ein Anzeigename, der U+200B (Zero Width Space) an irgendeiner Stelle enthält, ist ungültig (Server: `display_name_invalid` bzw. Check `profiles_display_name_no_zwsp`, 23514). U+200B wird nicht getrimmt, sondern abgelehnt. U+200C/U+200D (ZWJ steckt in Emoji-Sequenzen) und U+FEFF (wird getrimmt) sind davon nicht betroffen. +**Trim-Regel (verbindlich für Client und Server, auch KTO-02):** Leerraum am Anfang und Ende wird entfernt. Danach muss der Name 1 bis 30 **Unicode-Codepoints** lang sein. Der Server prüft mit `char_length(public.trim_ws(…))`, der Client mit `value.trim().runes.length`, **nicht** mit `String.length` (UTF-16). `trim_ws` entfernt denselben Leerraum wie Dart-`trim()` (auch Unicode-Leerraum wie NBSP), Client und Server sind damit gleichwertig; es gibt keine bekannte Abweichung mehr (frühere Fassung: `btrim` nur Leerzeichen, siehe § 10). **Verbotenes Zeichen (Entscheidung Chris):** Ein Anzeigename, der U+200B (Zero Width Space) an irgendeiner Stelle enthält, ist ungültig (Server: `display_name_invalid` bzw. Check `profiles_display_name_no_zwsp`, 23514). U+200B wird nicht getrimmt, sondern abgelehnt. U+200C/U+200D (ZWJ steckt in Emoji-Sequenzen) und U+FEFF (wird getrimmt) sind davon nicht betroffen. **Keine neuen Tabellen.** `auth.users` und `auth.sessions` verwaltet GoTrue. @@ -265,7 +265,7 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ - Erfolg (jede 200-Antwort **und** `emailSendLimited`, F12) → `/register/check-email`. Text: „Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist …“. Dieselbe Formulierung gilt auch für bereits registrierte Adressen (Krit. 4). - `weak_password` vom Server wird ans Passwortfeld gemappt (praktisch nur die Mindestlänge, da kein HIBP). -**CheckEmailScreen** (Krit. 1, 6, 8): Die E-Mail-Adresse kommt per `extra` und steht nicht in der URL. Fehlt sie, etwa nach einem Reload im Web, zeigt der Screen ein E-Mail-Feld für das erneute Senden. Weiter: Hinweis, neutraler Zusatz „Die E-Mail kann einige Minuten dauern …“ (immer, F12), Gültigkeit „1 Stunde“ (Wert aus AUTH-CFG-07 als Konstante in `core/config`, mit Verweis auf die Doku), Aktion „E-Mail erneut senden“ (Modus Bestätigung) mit Rückmeldung und Sperre gegen Doppelklick; `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (F12, die Adresse kann hier frei eingegeben sein); `rateLimited` (IP-Drosselung) → Drosselungshinweis. Im Modus Reset gibt es keine Resend-Aktion, sondern einen Link zurück zu `/forgot-password`. +**CheckEmailScreen** (Krit. 1, 6, 8): Die E-Mail-Adresse kommt per `extra` und steht nicht in der URL. go_router 18 würde `extra` per JSON in den Browser-Verlaufszustand (History-State) schreiben (`RouteMatchListCodec`, `match.dart`); das verhindert der `VolatileExtraCodec` am Router (`extraCodec`, `extra` bleibt nur im Speicher). Fehlt sie, etwa nach einem Reload im Web, zeigt der Screen ein E-Mail-Feld für das erneute Senden. Weiter: Hinweis, neutraler Zusatz „Die E-Mail kann einige Minuten dauern …“ (immer, F12), Gültigkeit „1 Stunde“ (Wert aus AUTH-CFG-07 als Konstante in `core/config`, mit Verweis auf die Doku), Aktion „E-Mail erneut senden“ (Modus Bestätigung) mit Rückmeldung und Sperre gegen Doppelklick; `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (F12, die Adresse kann hier frei eingegeben sein); `rateLimited` (IP-Drosselung) → Drosselungshinweis. Im Modus Reset gibt es keine Resend-Aktion, sondern einen Link zurück zu `/forgot-password`. **LoginScreen** (01b, Krit. 5, 6, 7) @@ -291,12 +291,13 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ - `supabaseClientProvider` (Fundament), `authRepositoryProvider`, `commonPasswordListProvider` (FutureProvider, lazy). - `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`. +- **Abweichung (umgesetzt in `AuthStatusTracker`, bewusst):** Während einer laufenden Recovery heben `tokenRefreshed` und `signedIn` den Status `passwordRecovery` **nicht** auf. Der Guard endet nur mit `userUpdated` (Passwort geändert), `signedOut` oder dem Wegfall der Sitzung. Grund: Ein Token-Refresh (oder ein erneutes `signedIn` beim Wiederherstellen der Sitzung) im offenen Reset-Screen würde den Nutzer sonst ohne neues Passwort aus dem Guard entlassen. Der Zustand lebt weiter nur im Prozess (5.4, R12). - Je Screen ein `@riverpod`-Notifier (`RegisterController` usw.) mit Zustand `AsyncValue` bzw. freezed-Formzustand. Keine Logik in Widgets. ### 5.6 Liste häufiger Passwörter (Krit. 3) - Asset `app/assets/security/common-passwords.txt`, ca. 10.000 Einträge (Größenordnung 100 KB), in Kleinschreibung, mit deutschen Einträgen (z. B. `passwort1234`, `hallo12345`). Nur Einträge mit mindestens 10 Zeichen sind relevant, kürzere fängt schon die Längenregel. -- Quelle und Lizenz prüft `test-engineer` in Aufgabe A1 und dokumentiert sie in `app/assets/security/README.md`. Kandidat: SecLists (MIT); deutsche Ergänzung mit Quellenangabe. Keine Liste ohne klare Lizenz. +- Quelle und Lizenz dokumentiert `app/assets/security/README.md`. **Entschieden (Chris, 2026-11-03):** Die SecLists-Listen (MIT, Herkunft dokumentiert, Commit gepinnt) werden genutzt; die Frage, dass einzelne Listen aus Datenlecks bzw. Sammlungen Dritter stammen, ist damit abgenommen (die Liste enthält nur Passwörter, keine Konten oder E-Mail-Adressen). Der MIT-Text steht in `app/THIRD_PARTY.md`, wird als Asset gebündelt und zur Laufzeit über `LicenseRegistry` in `showLicensePage` ausgeliefert. - Vergleich ohne Groß-/Kleinschreibung, nach Trim. Die Liste wird lazy beim ersten Öffnen eines Passwortfeldes geladen; im Web fällt sie damit nicht beim Start ins Gewicht. ### 5.7 Neue Pakete (pub.dev geprüft 2026-11-02) @@ -492,3 +493,15 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien 12. **F12 ergänzt (Chris, 2026-11-02):** Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt `over_email_send_rate_limit` neutral; im LoginScreen nach `email_not_confirmed` (Krit. 6) bleibt der Drosselungshinweis. `ResendController` mit `ResendOrigin.checkEmail | login` (4.4, 5.4); Tests in 7.1 Krit. 6; R3, R6, F12 angepasst. 13. **AUTH-CFG-25** neu: `auth.email.secure_password_change = false`, Entscheidung in KTO-11 (N13); 9.4 verweist darauf. **ID-Abgleich** mit dem B2-Stand (`supabase/config.toml`, `docs/auth-konfiguration.md`): IDs 01–24 stimmen überein. Angeglichen: AUTH-CFG-03 CLI-Schlüssel `auth.email.enabled` (statt `auth.email.enable_signup`). Nachgezogen in B2: `secure_password_change` → AUTH-CFG-25 (erledigt). 14. **Anzeigename: U+200B verboten (Chris, 2026-11-02):** Ein Name mit U+200B an beliebiger Stelle ist ungültig (Trigger `display_name_invalid`, zusätzlicher Check `profiles_display_name_no_zwsp`, Update → 23514; Migration `20261102212208_auth_registration_rules.sql`, pgTAP `05_auth_registration`). U+200C/U+200D/U+FEFF unverändert. Betroffen: E6, 3.1, 3.2 (Check, Trigger, Trim-Regel), RegisterScreen (Client-Regel für 01a: `DisplayNameRule` meldet U+200B als Feldfehler, gleiche Meldung wie „ungültig"). + +### 2026-11-03 – Review-Findings zu 01a (Branch `feature/1-01a-registrierung`) + +1. **Passwortliste, Lizenz entschieden (Chris, 2026-11-03):** SecLists (MIT) wird genutzt, Herkunft in `app/assets/security/README.md` dokumentiert (5.6). Der Lizenztext wird zur Laufzeit in `showLicensePage` ausgeliefert (`LicenseRegistry`, Asset `THIRD_PARTY.md`). +2. **Rechtstext-Links im Web:** Die Links der Einwilligungs-Checkbox sind eigene Semantics-Knoten (`link: true`, Label, Tap) statt `CustomSemanticsAction` am Checkbox-Knoten; Flutter-Web setzt zusätzliche Aktionen nicht in ARIA um. Checkbox-Knoten behält Label, Zustand und Tap. +3. **Externe URLs:** `UrlOpener` öffnet nur `http`/`https` mit Host (kein `javascript:`, `file:`, `intent:`). +4. **Doppel-Submit:** `RegisterController.submit` setzt `submitting` vor dem Laden der Passwortliste. +5. **Transportfehler:** Erkennung per `is` (`http.ClientException`, `dart:io`-Fehler über bedingten Import) statt `runtimeType.toString()` (bricht in minifiziertem dart2js). `http` ist als direkte Abhängigkeit deklariert (`1.6.0`, bereits transitiv im Lock; keine neue Fremdabhängigkeit). +6. **Trim-Regel (3.2):** Der Satz „Bekannte Abweichung: Dart-`trim()` … `btrim` nur Leerzeichen“ war falsch. Der Server nutzt `trim_ws` (Abschnitt 3.2), das Verhalten ist mit Dart-`trim()` identisch. Satz korrigiert. +7. **Recovery-Guard (5.5):** Abweichung vom Wortlaut dokumentiert: `tokenRefreshed`/`signedIn` heben `passwordRecovery` nicht auf. +8. **`extra` und Browser-Verlauf (5.4):** Geprüft in go_router 18.1.2 (`match.dart`, `RouteMatchListCodec.encode`): Ohne `extraCodec` wird `extra` per `json.encoder` in den History-State geschrieben und überlebt damit einen Reload (nicht in der URL, aber im Verlaufseintrag). Die Annahme „nach einem Reload ist die Adresse weg“ stimmte nur mit einem Codec. Umsetzung: `VolatileExtraCodec` (`core/routing`) verwirft `extra` beim Serialisieren; im Speicher funktioniert es weiter. Nach einem Reload fragt der Screen die Adresse ab (wie spezifiziert). +9. **Live-Region am Passwortfeld:** Die Meldung „Es fehlen noch n“ wird während des Tippens nicht angesagt (`liveError: false`), bleibt aber sichtbar; beim Verlassen des Feldes wird sie zur Live-Region. Server- und „erforderlich“-Meldungen bleiben sofort live. Plural-Text (ICU) für `authPasswordTooShort`, `safeFromPath` lehnt `/login/` (Schrägstrich am Ende) ab.