feat: OAuth 登录后返回来源页 + 登录页清理
- OAuth 流程透传 redirect 参数,登录后回到触发页面而非固定跳 Dashboard - 服务端校验 redirect 为相对路径,防止 Open Redirect 攻击 - 隐藏 Apple 登录按钮和邮箱注册入口 - Dark Mode 切换改为下拉菜单样式 - 提取 useClickOutside hook 消除重复代码 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,7 +57,7 @@ function getCallbackUrl(provider: Provider): string {
|
||||
return `${base}/api/auth/oauth/${provider}/callback`;
|
||||
}
|
||||
|
||||
const stateStore = new Map<string, { provider: string; createdAt: number }>();
|
||||
const stateStore = new Map<string, { provider: string; createdAt: number; redirect?: string }>();
|
||||
|
||||
const cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
@@ -67,22 +67,28 @@ const cleanupTimer = setInterval(() => {
|
||||
}, 5 * 60 * 1000);
|
||||
cleanupTimer.unref();
|
||||
|
||||
function generateState(provider: Provider): string {
|
||||
function isValidRedirect(redirect: string): boolean {
|
||||
return redirect.startsWith('/') && !redirect.startsWith('//');
|
||||
}
|
||||
|
||||
function generateState(provider: Provider, redirect?: string): string {
|
||||
const state = crypto.randomBytes(32).toString('hex');
|
||||
stateStore.set(state, { provider, createdAt: Date.now() });
|
||||
const safeRedirect = redirect && isValidRedirect(redirect) ? redirect : undefined;
|
||||
stateStore.set(state, { provider, createdAt: Date.now(), redirect: safeRedirect });
|
||||
return state;
|
||||
}
|
||||
|
||||
function validateState(state: string, provider: Provider): boolean {
|
||||
function validateState(state: string, provider: Provider): { valid: boolean; redirect?: string } {
|
||||
const entry = stateStore.get(state);
|
||||
if (!entry) return false;
|
||||
if (entry.provider !== provider) return false;
|
||||
if (!entry) return { valid: false };
|
||||
if (entry.provider !== provider) return { valid: false };
|
||||
if (Date.now() - entry.createdAt > 10 * 60 * 1000) {
|
||||
stateStore.delete(state);
|
||||
return false;
|
||||
return { valid: false };
|
||||
}
|
||||
const redirect = entry.redirect;
|
||||
stateStore.delete(state);
|
||||
return true;
|
||||
return { valid: true, redirect };
|
||||
}
|
||||
|
||||
function buildAppleClientSecret(): string {
|
||||
@@ -106,11 +112,11 @@ function buildAppleClientSecret(): string {
|
||||
return `${signingInput}.${sig.toString('base64url')}`;
|
||||
}
|
||||
|
||||
export function buildAuthUrl(provider: Provider): string {
|
||||
export function buildAuthUrl(provider: Provider, redirect?: string): string {
|
||||
const config = providers[provider];
|
||||
if (!config) throw new Error(`Unknown provider: ${provider}`);
|
||||
|
||||
const state = generateState(provider);
|
||||
const state = generateState(provider, redirect);
|
||||
const params = new URLSearchParams({
|
||||
client_id: getClientId(provider),
|
||||
redirect_uri: getCallbackUrl(provider),
|
||||
|
||||
@@ -20,7 +20,8 @@ router.get('/:provider', (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const url = buildAuthUrl(provider);
|
||||
const redirect = req.query.redirect as string | undefined;
|
||||
const url = buildAuthUrl(provider, redirect);
|
||||
res.redirect(url);
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: { code: 'OAUTH_ERROR', message: err instanceof Error ? err.message : 'Failed to build auth URL' } });
|
||||
@@ -56,7 +57,8 @@ async function handleOAuthCallback(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateState(state, provider)) {
|
||||
const stateResult = validateState(state, provider);
|
||||
if (!stateResult.valid) {
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Invalid or expired state')}`);
|
||||
return;
|
||||
}
|
||||
@@ -72,7 +74,8 @@ async function handleOAuthCallback(
|
||||
const user = await findOrCreateUser(provider, providerUser);
|
||||
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
||||
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}`);
|
||||
const redirectParam = stateResult.redirect ? `&redirect=${encodeURIComponent(stateResult.redirect)}` : '';
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}${redirectParam}`);
|
||||
} catch (err) {
|
||||
console.error(`OAuth callback error (${provider}):`, err);
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Authentication failed')}`);
|
||||
|
||||
Reference in New Issue
Block a user