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:
2026-04-03 17:56:57 +08:00
parent d1ee0bbad2
commit 49ca1f6e1f
10 changed files with 116 additions and 78 deletions

View File

@@ -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),

View File

@@ -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')}`);