forked from baron/baron-sso
fix userfront verify link routing
This commit is contained in:
@@ -35,7 +35,7 @@ const contentTypes = {
|
|||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||||
const pathname = decodeURIComponent(url.pathname);
|
const pathname = decodeURIComponent(url.pathname);
|
||||||
if (pathname === '/') {
|
if (pathname === '/' && url.search === '') {
|
||||||
res.statusCode = 302;
|
res.statusCode = 302;
|
||||||
res.setHeader('Location', '/ko/signin');
|
res.setHeader('Location', '/ko/signin');
|
||||||
res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate');
|
res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate');
|
||||||
|
|||||||
@@ -252,9 +252,8 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
|
|
||||||
await page.goto('/ko/l/AB123456');
|
await page.goto('/ko/l/AB123456');
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/ko\/l\/AB123456$/);
|
|
||||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||||
await expect(page).toHaveURL(/\/ko\/l\/AB123456$/);
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
expect(verifyRequests[0].path).toContain(
|
expect(verifyRequests[0].path).toContain(
|
||||||
'/api/v1/auth/login/code/verify-short',
|
'/api/v1/auth/login/code/verify-short',
|
||||||
@@ -270,7 +269,7 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
});
|
});
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/ko\/l\/AB123456$/);
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/);
|
await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/);
|
||||||
expect(clientFailures).toEqual([]);
|
expect(clientFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -295,8 +294,8 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
|
|
||||||
await page.goto('/ko/l/AB123456');
|
await page.goto('/ko/l/AB123456');
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/ko\/l\/AB123456$/);
|
|
||||||
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
|
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
|
||||||
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
|
|
||||||
const viewport = page.viewportSize();
|
const viewport = page.viewportSize();
|
||||||
@@ -311,11 +310,85 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
await expect(page).toHaveURL(/\/ko\/l\/AB123456$/);
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/);
|
await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/);
|
||||||
expect(clientFailures).toEqual([]);
|
expect(clientFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('루트/로그인에 붙은 인증 payload는 전용 verify 라우트에서만 소비하고 완료 URL을 정리한다', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let userMeCalls = 0;
|
||||||
|
const verifyRequests: Array<{
|
||||||
|
path: string;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
}> = [];
|
||||||
|
const clientFailures = collectClientFailures(page);
|
||||||
|
|
||||||
|
await mockUserfrontApis(page, {
|
||||||
|
sessionStatus: 401,
|
||||||
|
captureUserMe: () => {
|
||||||
|
userMeCalls += 1;
|
||||||
|
},
|
||||||
|
captureVerify: (path, body) => {
|
||||||
|
verifyRequests.push({ path, body });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
'/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop',
|
||||||
|
);
|
||||||
|
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||||
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
|
expect(userMeCalls).toBe(0);
|
||||||
|
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
|
||||||
|
expect(verifyRequests[0].body).toMatchObject({
|
||||||
|
loginId: 'e2e@example.com',
|
||||||
|
code: '654321',
|
||||||
|
pendingRef: 'pending-root',
|
||||||
|
verifyOnly: true,
|
||||||
|
});
|
||||||
|
expect(page.url()).not.toContain('loginId=');
|
||||||
|
expect(page.url()).not.toContain('code=');
|
||||||
|
expect(page.url()).not.toContain('pendingRef=');
|
||||||
|
expect(page.url()).not.toContain('utm=');
|
||||||
|
expect(clientFailures).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('로그인 페이지에 붙은 인증 payload도 전용 verify 라우트로 넘긴다', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let userMeCalls = 0;
|
||||||
|
const verifyRequests: Array<{
|
||||||
|
path: string;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
}> = [];
|
||||||
|
const clientFailures = collectClientFailures(page);
|
||||||
|
|
||||||
|
await mockUserfrontApis(page, {
|
||||||
|
sessionStatus: 401,
|
||||||
|
captureUserMe: () => {
|
||||||
|
userMeCalls += 1;
|
||||||
|
},
|
||||||
|
captureVerify: (path, body) => {
|
||||||
|
verifyRequests.push({ path, body });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/ko/signin?loginId=e2e%40example.com&code=999999');
|
||||||
|
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||||
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
|
expect(userMeCalls).toBe(0);
|
||||||
|
expect(verifyRequests[0].body).toMatchObject({
|
||||||
|
loginId: 'e2e@example.com',
|
||||||
|
code: '999999',
|
||||||
|
verifyOnly: true,
|
||||||
|
});
|
||||||
|
expect(page.url()).not.toContain('loginId=');
|
||||||
|
expect(page.url()).not.toContain('code=');
|
||||||
|
expect(clientFailures).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('verifyOnly 승인 링크를 팝업에서 닫으면 창만 닫히고 부모는 이동하지 않는다', async ({
|
test('verifyOnly 승인 링크를 팝업에서 닫으면 창만 닫히고 부모는 이동하지 않는다', async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
@@ -346,8 +419,8 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
}, popupURL);
|
}, popupURL);
|
||||||
const popup = await popupPromise;
|
const popup = await popupPromise;
|
||||||
|
|
||||||
await expect(popup).toHaveURL(/\/ko\/l\/AB123456$/);
|
|
||||||
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
|
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
|
||||||
|
await expect(popup).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
|
|
||||||
const viewport = popup.viewportSize();
|
const viewport = popup.viewportSize();
|
||||||
@@ -390,8 +463,8 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
|
|
||||||
await page.goto('/ko/verify/e2e-email-token');
|
await page.goto('/ko/verify/e2e-email-token');
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/ko\/verify\/e2e-email-token$/);
|
|
||||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||||
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
expect(verifyRequests[0].path).toContain('/api/v1/auth/magic-link/verify');
|
expect(verifyRequests[0].path).toContain('/api/v1/auth/magic-link/verify');
|
||||||
expect(verifyRequests[0].body).toMatchObject({
|
expect(verifyRequests[0].body).toMatchObject({
|
||||||
@@ -411,7 +484,7 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
await expect(page).toHaveURL(/\/ko\/verify\/e2e-email-token$/);
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/);
|
await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/);
|
||||||
expect(clientFailures).toEqual([]);
|
expect(clientFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -441,10 +514,8 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
'/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-email',
|
'/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-email',
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(page).toHaveURL(
|
|
||||||
/\/ko\/verify\?loginId=e2e(?:%40|@)example\.com&code=654321&pendingRef=pending-email$/,
|
|
||||||
);
|
|
||||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||||
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
|
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
|
||||||
expect(verifyRequests[0].body).toMatchObject({
|
expect(verifyRequests[0].body).toMatchObject({
|
||||||
@@ -466,9 +537,7 @@ test.describe('UserFront WASM auth routing', () => {
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
expect(userMeCalls).toBe(0);
|
expect(userMeCalls).toBe(0);
|
||||||
await expect(page).toHaveURL(
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
/\/ko\/verify\?loginId=e2e(?:%40|@)example\.com&code=654321&pendingRef=pending-email$/,
|
|
||||||
);
|
|
||||||
await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/);
|
await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/);
|
||||||
expect(clientFailures).toEqual([]);
|
expect(clientFailures).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ test.describe('UserFront WASM route inventory (unauth)', () => {
|
|||||||
await expect(page).toHaveURL(/\/ko\/verification$/);
|
await expect(page).toHaveURL(/\/ko\/verification$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('route: /ko/verify-complete', async ({ page }) => {
|
||||||
|
await page.goto('/ko/verify-complete');
|
||||||
|
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||||
|
});
|
||||||
|
|
||||||
test('route: /ko/l/:shortCode', async ({ page }) => {
|
test('route: /ko/l/:shortCode', async ({ page }) => {
|
||||||
await page.goto('/ko/l/AB123456');
|
await page.goto('/ko/l/AB123456');
|
||||||
await expect(page).toHaveURL(/\/ko\/l\/AB123456$/);
|
await expect(page).toHaveURL(/\/ko\/l\/AB123456$/);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ bool isPublicAuthPath(String path, Uri uri) {
|
|||||||
path == '/registration' ||
|
path == '/registration' ||
|
||||||
path == '/verify' ||
|
path == '/verify' ||
|
||||||
path == '/verification' ||
|
path == '/verification' ||
|
||||||
|
path == '/verify-complete' ||
|
||||||
path.startsWith('/verify/') ||
|
path.startsWith('/verify/') ||
|
||||||
path.startsWith('/l/') ||
|
path.startsWith('/l/') ||
|
||||||
path == '/approve' ||
|
path == '/approve' ||
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import '../../../core/notifiers/auth_notifier.dart';
|
|||||||
import '../domain/login_challenge_resolver.dart';
|
import '../domain/login_challenge_resolver.dart';
|
||||||
import '../domain/cookie_session_policy.dart';
|
import '../domain/cookie_session_policy.dart';
|
||||||
import '../domain/login_link_route_policy.dart';
|
import '../domain/login_link_route_policy.dart';
|
||||||
|
import '../domain/verification_completion_route.dart';
|
||||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||||
import '../../../core/services/web_window.dart';
|
import '../../../core/services/web_window.dart';
|
||||||
import '../../../core/ui/toast_service.dart';
|
import '../../../core/ui/toast_service.dart';
|
||||||
@@ -26,12 +27,14 @@ class LoginScreen extends ConsumerStatefulWidget {
|
|||||||
final String? verificationToken;
|
final String? verificationToken;
|
||||||
final String? loginChallenge;
|
final String? loginChallenge;
|
||||||
final String? redirectUrl;
|
final String? redirectUrl;
|
||||||
|
final bool verificationCompleteOnly;
|
||||||
|
|
||||||
const LoginScreen({
|
const LoginScreen({
|
||||||
super.key,
|
super.key,
|
||||||
this.verificationToken,
|
this.verificationToken,
|
||||||
this.loginChallenge,
|
this.loginChallenge,
|
||||||
this.redirectUrl,
|
this.redirectUrl,
|
||||||
|
this.verificationCompleteOnly = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -88,6 +91,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
bool _noticeHandled = false;
|
bool _noticeHandled = false;
|
||||||
bool _drySendEnabled = false;
|
bool _drySendEnabled = false;
|
||||||
bool _oidcAutoAcceptTried = false;
|
bool _oidcAutoAcceptTried = false;
|
||||||
|
bool _verificationHandoffStarted = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -100,7 +104,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_redirectUrl = widget.redirectUrl;
|
_redirectUrl = widget.redirectUrl;
|
||||||
_passwordFocusNode.addListener(_handlePasswordFocusChange);
|
_passwordFocusNode.addListener(_handlePasswordFocusChange);
|
||||||
HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent);
|
HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent);
|
||||||
_verificationOnly = _isVerificationOnlyUri(Uri.base);
|
_verificationOnly =
|
||||||
|
widget.verificationCompleteOnly || _isVerificationOnlyUri(Uri.base);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
final uri = Uri.base;
|
final uri = Uri.base;
|
||||||
@@ -124,12 +129,43 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final pendingRefParam = uri.queryParameters['pendingRef'];
|
final pendingRefParam = uri.queryParameters['pendingRef'];
|
||||||
final shortCodeFromPath = extractLoginShortCode(uri);
|
final shortCodeFromPath = extractLoginShortCode(uri);
|
||||||
final hasShortCodePath = shortCodeFromPath != null;
|
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 =
|
final hasVerificationToken =
|
||||||
widget.verificationToken != null || hasTokenParam;
|
widget.verificationToken != null || hasTokenParam;
|
||||||
final hasLoginCode = loginIdParam != null && codeParam != null;
|
final hasLoginCode =
|
||||||
|
acceptsVerificationPayload &&
|
||||||
|
loginIdParam != null &&
|
||||||
|
codeParam != null;
|
||||||
final notice = uri.queryParameters['notice'];
|
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) {
|
if (hasShortCodePath) {
|
||||||
_verifyShortCode(shortCodeFromPath);
|
_verifyShortCode(shortCodeFromPath);
|
||||||
}
|
}
|
||||||
@@ -174,6 +210,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _isVerificationOnlyUri(Uri uri) {
|
bool _isVerificationOnlyUri(Uri uri) {
|
||||||
|
if (!isDedicatedVerificationRoute(uri) &&
|
||||||
|
widget.verificationToken == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
final loginIdParam = uri.queryParameters['loginId'];
|
final loginIdParam = uri.queryParameters['loginId'];
|
||||||
final codeParam = uri.queryParameters['code'];
|
final codeParam = uri.queryParameters['code'];
|
||||||
return widget.verificationToken != null ||
|
return widget.verificationToken != null ||
|
||||||
@@ -182,6 +222,33 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
extractLoginShortCode(uri) != null;
|
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() {
|
void _handlePasswordFocusChange() {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@@ -730,6 +797,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _moveVerificationOnlyResultToCleanRoute() {
|
||||||
|
if (!_verificationOnly || widget.verificationCompleteOnly) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final localeCode =
|
||||||
|
extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode();
|
||||||
|
context.go(buildLocalizedVerificationCompletePath(localeCode));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void _markVerificationApproved(
|
void _markVerificationApproved(
|
||||||
String message, {
|
String message, {
|
||||||
String? title,
|
String? title,
|
||||||
@@ -746,6 +823,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
pageTitle ?? tr('ui.userfront.login.verification.page_title');
|
pageTitle ?? tr('ui.userfront.login.verification.page_title');
|
||||||
final resolvedActionLabel =
|
final resolvedActionLabel =
|
||||||
actionLabel ?? tr('ui.userfront.login.verification.action_label');
|
actionLabel ?? tr('ui.userfront.login.verification.action_label');
|
||||||
|
if (_moveVerificationOnlyResultToCleanRoute()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_verificationApproved = true;
|
_verificationApproved = true;
|
||||||
_verificationMessage = message;
|
_verificationMessage = message;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'features/auth/presentation/forgot_password_screen.dart';
|
|||||||
import 'features/auth/presentation/reset_password_screen.dart';
|
import 'features/auth/presentation/reset_password_screen.dart';
|
||||||
import 'features/auth/presentation/error_screen.dart';
|
import 'features/auth/presentation/error_screen.dart';
|
||||||
import 'features/auth/domain/login_link_route_policy.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/dashboard/presentation/dashboard_screen.dart';
|
||||||
import 'features/admin/presentation/user_management_screen.dart';
|
import 'features/admin/presentation/user_management_screen.dart';
|
||||||
import 'features/profile/presentation/pages/profile_page.dart';
|
import 'features/profile/presentation/pages/profile_page.dart';
|
||||||
@@ -154,10 +155,19 @@ Future<void> _silentSessionRecovery() async {
|
|||||||
|
|
||||||
bool _shouldRunStartupSessionRecovery(Uri uri) {
|
bool _shouldRunStartupSessionRecovery(Uri uri) {
|
||||||
final requestedLocale = extractLocaleFromPath(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) {
|
if (requestedLocale == null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
final path = stripLocalePath(uri);
|
|
||||||
return !isPublicAuthPath(path, 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(
|
GoRoute(
|
||||||
path: 'login',
|
path: 'login',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@@ -482,6 +504,13 @@ final _router = GoRouter(
|
|||||||
final uri = state.uri;
|
final uri = state.uri;
|
||||||
final requestedLocale = extractLocaleFromPath(uri);
|
final requestedLocale = extractLocaleFromPath(uri);
|
||||||
final preferredLocale = resolvePreferredLocaleCode();
|
final preferredLocale = resolvePreferredLocaleCode();
|
||||||
|
final verificationPayloadRedirect = buildDedicatedVerificationRedirect(
|
||||||
|
uri,
|
||||||
|
localeCode: requestedLocale ?? preferredLocale,
|
||||||
|
);
|
||||||
|
if (verificationPayloadRedirect != null) {
|
||||||
|
return verificationPayloadRedirect;
|
||||||
|
}
|
||||||
|
|
||||||
if (requestedLocale == null) {
|
if (requestedLocale == null) {
|
||||||
final localizedPath = buildLocalizedPath(preferredLocale, uri);
|
final localizedPath = buildLocalizedPath(preferredLocale, uri);
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ server {
|
|||||||
# --- UserFront Static Files ---
|
# --- UserFront Static Files ---
|
||||||
|
|
||||||
location = / {
|
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.
|
# App shell and Flutter bootstrap files must revalidate on each deployment.
|
||||||
|
|||||||
55
userfront/test/verification_route_policy_test.dart
Normal file
55
userfront/test/verification_route_policy_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user