1
0
forked from baron/baron-sso

fix userfront verify link routing

This commit is contained in:
2026-05-21 19:35:45 +09:00
parent 9fc6459636
commit dc68b7da41
9 changed files with 330 additions and 20 deletions

View File

@@ -7,6 +7,7 @@ bool isPublicAuthPath(String path, Uri uri) {
path == '/registration' ||
path == '/verify' ||
path == '/verification' ||
path == '/verify-complete' ||
path.startsWith('/verify/') ||
path.startsWith('/l/') ||
path == '/approve' ||

View File

@@ -0,0 +1,67 @@
import '../../../core/i18n/locale_utils.dart';
const verificationRoutePath = '/verify';
const verificationCompletionRoutePath = '/verify-complete';
const verificationCompletionRouteName = 'verify-complete';
String buildLocalizedVerificationCompletePath(String localeCode) {
return '/$localeCode$verificationCompletionRoutePath';
}
bool isDedicatedVerificationRoute(Uri uri) {
final path = stripLocalePath(uri);
return path == verificationRoutePath ||
path == '/verification' ||
path.startsWith('/verify/') ||
path.startsWith('/l/');
}
bool hasVerificationPayload(Uri uri) {
final query = uri.queryParameters;
final token = query['t'];
final loginId = query['loginId'];
final code = query['code'];
return (token != null && token.isNotEmpty) ||
(loginId != null &&
loginId.isNotEmpty &&
code != null &&
code.isNotEmpty);
}
String? buildDedicatedVerificationRedirect(
Uri uri, {
required String localeCode,
}) {
if (isDedicatedVerificationRoute(uri)) {
return null;
}
final query = uri.queryParameters;
final token = query['t'];
final loginId = query['loginId'];
final code = query['code'];
final pendingRef = query['pendingRef'];
final sanitizedQuery = <String, String>{};
if (token != null && token.isNotEmpty) {
sanitizedQuery['t'] = token;
} else if (loginId != null &&
loginId.isNotEmpty &&
code != null &&
code.isNotEmpty) {
sanitizedQuery['loginId'] = loginId;
sanitizedQuery['code'] = code;
if (pendingRef != null && pendingRef.isNotEmpty) {
sanitizedQuery['pendingRef'] = pendingRef;
}
}
if (sanitizedQuery.isEmpty) {
return null;
}
return Uri(
path: '/$localeCode$verificationRoutePath',
queryParameters: sanitizedQuery,
).toString();
}

View File

@@ -18,6 +18,7 @@ import '../../../core/notifiers/auth_notifier.dart';
import '../domain/login_challenge_resolver.dart';
import '../domain/cookie_session_policy.dart';
import '../domain/login_link_route_policy.dart';
import '../domain/verification_completion_route.dart';
import '../../profile/domain/notifiers/profile_notifier.dart';
import '../../../core/services/web_window.dart';
import '../../../core/ui/toast_service.dart';
@@ -26,12 +27,14 @@ class LoginScreen extends ConsumerStatefulWidget {
final String? verificationToken;
final String? loginChallenge;
final String? redirectUrl;
final bool verificationCompleteOnly;
const LoginScreen({
super.key,
this.verificationToken,
this.loginChallenge,
this.redirectUrl,
this.verificationCompleteOnly = false,
});
@override
@@ -88,6 +91,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
bool _noticeHandled = false;
bool _drySendEnabled = false;
bool _oidcAutoAcceptTried = false;
bool _verificationHandoffStarted = false;
@override
void initState() {
@@ -100,7 +104,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_redirectUrl = widget.redirectUrl;
_passwordFocusNode.addListener(_handlePasswordFocusChange);
HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent);
_verificationOnly = _isVerificationOnlyUri(Uri.base);
_verificationOnly =
widget.verificationCompleteOnly || _isVerificationOnlyUri(Uri.base);
WidgetsBinding.instance.addPostFrameCallback((_) async {
final uri = Uri.base;
@@ -124,12 +129,43 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final pendingRefParam = uri.queryParameters['pendingRef'];
final shortCodeFromPath = extractLoginShortCode(uri);
final hasShortCodePath = shortCodeFromPath != null;
final hasTokenParam = uri.queryParameters.containsKey('t');
final acceptsVerificationPayload = isDedicatedVerificationRoute(uri);
final waitsForVerificationHandoff =
!acceptsVerificationPayload && hasVerificationPayload(uri);
final hasTokenParam =
acceptsVerificationPayload && uri.queryParameters.containsKey('t');
final hasVerificationToken =
widget.verificationToken != null || hasTokenParam;
final hasLoginCode = loginIdParam != null && codeParam != null;
final hasLoginCode =
acceptsVerificationPayload &&
loginIdParam != null &&
codeParam != null;
final notice = uri.queryParameters['notice'];
if (widget.verificationCompleteOnly) {
_markVerificationApproved(
tr('msg.userfront.login.verification.approved_remote'),
title: tr('ui.userfront.login.verification.title_remote'),
actionLabel: tr('ui.userfront.login.verification.action_label_close'),
onAction: _closeVerificationWindowIfPossible,
);
return;
}
if (waitsForVerificationHandoff) {
final localeCode =
extractLocaleFromPath(uri) ?? resolvePreferredLocaleCode();
final target = buildDedicatedVerificationRedirect(
uri,
localeCode: localeCode,
);
if (target != null && mounted) {
context.go(target);
_startVerificationHandoff(Uri.parse(target));
}
return;
}
if (hasShortCodePath) {
_verifyShortCode(shortCodeFromPath);
}
@@ -174,6 +210,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
bool _isVerificationOnlyUri(Uri uri) {
if (!isDedicatedVerificationRoute(uri) &&
widget.verificationToken == null) {
return false;
}
final loginIdParam = uri.queryParameters['loginId'];
final codeParam = uri.queryParameters['code'];
return widget.verificationToken != null ||
@@ -182,6 +222,33 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
extractLoginShortCode(uri) != null;
}
void _startVerificationHandoff(Uri targetUri) {
if (_verificationHandoffStarted) {
return;
}
_verificationHandoffStarted = true;
if (mounted) {
setState(() {
_verificationOnly = true;
});
} else {
_verificationOnly = true;
}
final loginIdParam = targetUri.queryParameters['loginId'];
final codeParam = targetUri.queryParameters['code'];
final pendingRefParam = targetUri.queryParameters['pendingRef'];
final tokenParam = targetUri.queryParameters['t'];
if (loginIdParam != null && codeParam != null) {
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
return;
}
if (tokenParam != null && tokenParam.isNotEmpty) {
_verifyToken(tokenParam);
}
}
void _handlePasswordFocusChange() {
if (!mounted) {
return;
@@ -730,6 +797,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return false;
}
bool _moveVerificationOnlyResultToCleanRoute() {
if (!_verificationOnly || widget.verificationCompleteOnly) {
return false;
}
final localeCode =
extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode();
context.go(buildLocalizedVerificationCompletePath(localeCode));
return true;
}
void _markVerificationApproved(
String message, {
String? title,
@@ -746,6 +823,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
pageTitle ?? tr('ui.userfront.login.verification.page_title');
final resolvedActionLabel =
actionLabel ?? tr('ui.userfront.login.verification.action_label');
if (_moveVerificationOnlyResultToCleanRoute()) {
return;
}
setState(() {
_verificationApproved = true;
_verificationMessage = message;

View File

@@ -16,6 +16,7 @@ import 'features/auth/presentation/forgot_password_screen.dart';
import 'features/auth/presentation/reset_password_screen.dart';
import 'features/auth/presentation/error_screen.dart';
import 'features/auth/domain/login_link_route_policy.dart';
import 'features/auth/domain/verification_completion_route.dart';
import 'features/dashboard/presentation/dashboard_screen.dart';
import 'features/admin/presentation/user_management_screen.dart';
import 'features/profile/presentation/pages/profile_page.dart';
@@ -154,10 +155,19 @@ Future<void> _silentSessionRecovery() async {
bool _shouldRunStartupSessionRecovery(Uri uri) {
final requestedLocale = extractLocaleFromPath(uri);
final path = stripLocalePath(uri);
final verificationPayloadRedirect = buildDedicatedVerificationRedirect(
uri,
localeCode: requestedLocale ?? resolvePreferredLocaleCode(),
);
if (verificationPayloadRedirect != null ||
isDedicatedVerificationRoute(uri) ||
path == verificationCompletionRoutePath) {
return false;
}
if (requestedLocale == null) {
return true;
}
final path = stripLocalePath(uri);
return !isPublicAuthPath(path, uri);
}
@@ -283,6 +293,18 @@ final _router = GoRouter(
);
},
),
GoRoute(
path: verificationCompletionRouteName,
builder: (context, state) {
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
key: state.pageKey,
verificationCompleteOnly: true,
),
);
},
),
GoRoute(
path: 'login',
builder: (context, state) {
@@ -482,6 +504,13 @@ final _router = GoRouter(
final uri = state.uri;
final requestedLocale = extractLocaleFromPath(uri);
final preferredLocale = resolvePreferredLocaleCode();
final verificationPayloadRedirect = buildDedicatedVerificationRedirect(
uri,
localeCode: requestedLocale ?? preferredLocale,
);
if (verificationPayloadRedirect != null) {
return verificationPayloadRedirect;
}
if (requestedLocale == null) {
final localizedPath = buildLocalizedPath(preferredLocale, uri);

View File

@@ -48,7 +48,11 @@ server {
# --- UserFront Static Files ---
location = / {
return 302 /ko/signin;
if ($args = "") {
return 302 /ko/signin;
}
add_header Cache-Control "no-cache, max-age=0, must-revalidate";
try_files /index.html =404;
}
# App shell and Flutter bootstrap files must revalidate on each deployment.

View File

@@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/core/i18n/locale_registry.dart';
import 'package:userfront/features/auth/domain/verification_completion_route.dart';
void main() {
setUpAll(LocaleRegistry.primeWithDefaults);
group('verification route policy', () {
test('루트 인증 payload는 전용 verify 라우트로 정리한다', () {
final redirect = buildDedicatedVerificationRedirect(
Uri.parse(
'/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop',
),
localeCode: 'ko',
);
expect(
redirect,
'/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-root',
);
});
test('signin 인증 payload도 로그인 화면에서 직접 소비하지 않는다', () {
final redirect = buildDedicatedVerificationRedirect(
Uri.parse('/ko/signin?t=magic-token&utm=drop'),
localeCode: 'ko',
);
expect(redirect, '/ko/verify?t=magic-token');
});
test('인증 payload 여부를 식별한다', () {
expect(hasVerificationPayload(Uri.parse('/?t=magic-token')), isTrue);
expect(
hasVerificationPayload(
Uri.parse('/ko/signin?loginId=e2e%40example.com&code=654321'),
),
isTrue,
);
expect(
hasVerificationPayload(Uri.parse('/ko/signin?code=654321')),
isFalse,
);
});
test('이미 전용 verify 라우트면 다시 리다이렉트하지 않는다', () {
final redirect = buildDedicatedVerificationRedirect(
Uri.parse('/ko/verify?loginId=e2e%40example.com&code=654321'),
localeCode: 'ko',
);
expect(redirect, isNull);
});
});
}