auth.js 8.71 KB
/**
 * 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;