/** * Auth Routes — 用户注册/登录/OAuth * * POST /api/auth/register 邮箱+密码注册 * POST /api/auth/login 邮箱+密码登录 * POST /api/auth/logout 退出 * GET /api/auth/me 当前用户 * GET /api/auth/github GitHub OAuth redirect * GET /api/auth/github/callback * GET /api/auth/google Google OAuth redirect * GET /api/auth/google/callback */ const express = require('express'); const bcrypt = require('bcryptjs'); const router = express.Router(); const userDb = require('../services/user-db'); const { signAndSetCookie, clearCookie, requireAuth, optionalAuth } = require('../middleware/jwt-auth'); const SALT_ROUNDS = 10; // ═══════════════════════════════════════ // Email + Password // ═══════════════════════════════════════ /** * POST /api/auth/register */ router.post('/register', async (req, res) => { try { const { email, password, displayName } = req.body; if (!email || !password) { return res.status(400).json({ error: 'email and password are required' }); } if (password.length < 6) { return res.status(400).json({ error: 'Password must be at least 6 characters' }); } // Email format check if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return res.status(400).json({ error: 'Invalid email format' }); } // Check existing const existing = userDb.findByEmail(email); if (existing) { return res.status(409).json({ error: 'Email already registered' }); } const passwordHash = await bcrypt.hash(password, SALT_ROUNDS); const user = userDb.createUser({ email, passwordHash, displayName: displayName || email.split('@')[0], provider: 'local', }); signAndSetCookie(res, user); res.status(201).json({ message: 'Registration successful', user: userDb.toPublicUser(user), }); } catch (err) { console.error('[Auth] Register error:', err); res.status(500).json({ error: err.message }); } }); /** * POST /api/auth/login */ router.post('/login', async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: 'email and password are required' }); } const user = userDb.findByEmail(email); if (!user) { return res.status(401).json({ error: 'Invalid email or password' }); } const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { return res.status(401).json({ error: 'Invalid email or password' }); } userDb.updateLastLogin(user.id); signAndSetCookie(res, user); res.json({ message: 'Login successful', user: userDb.toPublicUser(user), }); } catch (err) { console.error('[Auth] Login error:', err); res.status(500).json({ error: err.message }); } }); /** * POST /api/auth/logout */ router.post('/logout', (req, res) => { clearCookie(res); res.json({ message: 'Logged out' }); }); /** * GET /api/auth/me */ router.get('/me', optionalAuth, (req, res) => { res.json({ user: req.user || null }); }); // ═══════════════════════════════════════ // GitHub OAuth // ═══════════════════════════════════════ const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID; const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET; router.get('/github', (req, res) => { if (!GITHUB_CLIENT_ID) { return res.status(503).json({ error: 'GitHub OAuth not configured' }); } const redirectUri = `${getBaseUrl(req)}/api/auth/github/callback`; const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=read:user,user:email`; res.redirect(url); }); router.get('/github/callback', async (req, res) => { try { const { code } = req.query; if (!code) return res.status(400).send('Missing code'); // Exchange code for access token const tokenRes = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, client_secret: GITHUB_CLIENT_SECRET, code, }), }); const tokenData = await tokenRes.json(); console.log('[Auth] GitHub token response:', JSON.stringify(tokenData)); if (!tokenData.access_token) { console.error('[Auth] GitHub token exchange failed:', tokenData); return res.redirect('/login?error=github_failed'); } // Get user profile const profileRes = await fetch('https://api.github.com/user', { headers: { Authorization: `Bearer ${tokenData.access_token}` }, }); const profile = await profileRes.json(); console.log('[Auth] GitHub profile:', JSON.stringify(profile)); // Get email (may be private) let email = profile.email; if (!email) { try { const emailsRes = await fetch('https://api.github.com/user/emails', { headers: { Authorization: `Bearer ${tokenData.access_token}` }, }); const emails = await emailsRes.json(); if (Array.isArray(emails)) { const primary = emails.find(e => e.primary && e.verified); email = primary?.email || emails[0]?.email; } else { console.warn('[Auth] GitHub /user/emails returned non-array:', emails); } } catch (emailErr) { console.warn('[Auth] Failed to fetch GitHub emails:', emailErr.message); } } const user = userDb.findOrCreateOAuthUser({ provider: 'github', providerId: String(profile.id), email, displayName: profile.name || profile.login, avatarUrl: profile.avatar_url, githubLogin: profile.login, }); signAndSetCookie(res, user); res.redirect('/arena'); } catch (err) { console.error('[Auth] GitHub callback error:', err); res.redirect('/login?error=github_failed'); } }); // ═══════════════════════════════════════ // Google OAuth // ═══════════════════════════════════════ const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; router.get('/google', (req, res) => { if (!GOOGLE_CLIENT_ID) { return res.status(503).json({ error: 'Google OAuth not configured' }); } const redirectUri = `${getBaseUrl(req)}/api/auth/google/callback`; const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=email%20profile&access_type=offline`; res.redirect(url); }); router.get('/google/callback', async (req, res) => { try { const { code } = req.query; if (!code) return res.status(400).send('Missing code'); const redirectUri = `${getBaseUrl(req)}/api/auth/google/callback`; // Exchange code for access token const tokenRes = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET, code, grant_type: 'authorization_code', redirect_uri: redirectUri, }), }); const tokenData = await tokenRes.json(); if (!tokenData.access_token) { console.error('[Auth] Google token exchange failed:', tokenData); return res.redirect('/login?error=google_failed'); } // Get user profile const profileRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${tokenData.access_token}` }, }); const profile = await profileRes.json(); const user = userDb.findOrCreateOAuthUser({ provider: 'google', providerId: String(profile.id), email: profile.email, displayName: profile.name, avatarUrl: profile.picture, }); signAndSetCookie(res, user); res.redirect('/arena'); } catch (err) { console.error('[Auth] Google callback error:', err); res.redirect('/login?error=google_failed'); } }); // ─── Helpers ─── function getBaseUrl(req) { const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http'; const host = req.headers['x-forwarded-host'] || req.get('host'); return `${proto}://${host}`; } module.exports = router;