/** * 인증 라우트 * 회원가입, 이메일 인증, 로그인, 로그아웃 * * 보안 기능: * - bcrypt 비밀번호 해시 * - JWT 토큰 인증 * - 로그인 실패 횟수 제한 (IP 기반) * - 이메일 인증 필수 * - 랜덤 닉네임 생성 * - 랜덤 프로필 사진 */ import express from "express"; import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; import { v4 as uuidv4 } from "uuid"; import { pool } from "../lib/db.js"; import { sendVerificationEmail } from "../services/emailService.js"; const router = express.Router(); const JWT_SECRET = process.env.JWT_SECRET || "minecraft-jwt-secret"; const S3_ENDPOINT = process.env.S3_ENDPOINT || "http://rustfs:9000"; // 로그인 실패 추적 (IP 기반 rate limiting) const loginAttempts = new Map(); const MAX_ATTEMPTS = 5; const LOCKOUT_TIME = 15 * 60 * 1000; // 15분 /** * 클라이언트 IP 추출 */ function getClientIp(req) { return ( req.headers["x-forwarded-for"]?.split(",")[0].trim() || req.headers["x-real-ip"] || req.connection.remoteAddress || req.ip ); } /** * 로그인 시도 횟수 확인 */ function checkLoginAttempts(ip) { const attempts = loginAttempts.get(ip); if (!attempts) return { allowed: true, remaining: MAX_ATTEMPTS }; if (Date.now() - attempts.lastAttempt > LOCKOUT_TIME) { loginAttempts.delete(ip); return { allowed: true, remaining: MAX_ATTEMPTS }; } if (attempts.count >= MAX_ATTEMPTS) { const remainingTime = Math.ceil( (LOCKOUT_TIME - (Date.now() - attempts.lastAttempt)) / 1000 / 60 ); return { allowed: false, remaining: 0, lockoutMinutes: remainingTime }; } return { allowed: true, remaining: MAX_ATTEMPTS - attempts.count }; } function recordFailedAttempt(ip) { const attempts = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 }; attempts.count += 1; attempts.lastAttempt = Date.now(); loginAttempts.set(ip, attempts); } function clearLoginAttempts(ip) { loginAttempts.delete(ip); } /** * 랜덤 닉네임 생성 (형용사 + 명사) */ async function generateRandomNickname() { try { // 랜덤 형용사 const [adjectives] = await pool.query( "SELECT word FROM nickname_words WHERE type = 'adjective' ORDER BY RAND() LIMIT 1" ); // 랜덤 명사 const [nouns] = await pool.query( "SELECT word FROM nickname_words WHERE type = 'noun' ORDER BY RAND() LIMIT 1" ); if (adjectives.length > 0 && nouns.length > 0) { return `${adjectives[0].word} ${nouns[0].word}`; } // 폴백: 기본 닉네임 return `유저${Math.floor(Math.random() * 10000)}`; } catch (error) { console.error("[Auth] 닉네임 생성 실패:", error); return `유저${Math.floor(Math.random() * 10000)}`; } } /** * 랜덤 프로필 사진 URL 생성 * RustFS minecraft 버킷의 profile 폴더에서 랜덤 선택 */ function getRandomProfileUrl() { // 기본 프로필 사진 개수 (1~16까지) const profileCount = 16; const randomIndex = Math.floor(Math.random() * profileCount) + 1; // 외부 접근 가능한 S3 URL 사용 return `https://s3.caadiq.co.kr/minecraft/profile/default_profile_${randomIndex}.png`; } /** * POST /auth/register - 회원가입 */ router.post("/register", async (req, res) => { const { email, password } = req.body; // 입력 검증 if (!email || !password) { return res.status(400).json({ error: "이메일과 비밀번호를 입력해주세요." }); } // 이메일 형식 검증 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return res.status(400).json({ error: "올바른 이메일 형식이 아닙니다." }); } // 비밀번호 길이 검증 if (password.length < 8) { return res.status(400).json({ error: "비밀번호는 8자 이상이어야 합니다." }); } try { // 이메일 중복 확인 const [existing] = await pool.query( "SELECT id FROM users WHERE email = ?", [email] ); if (existing.length > 0) { return res.status(409).json({ error: "이미 등록된 이메일입니다." }); } // 비밀번호 해시 const hashedPassword = await bcrypt.hash(password, 10); // 인증 토큰 생성 const verifyToken = uuidv4(); const verifyExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24시간 // 랜덤 닉네임 생성 const randomName = await generateRandomNickname(); // 랜덤 프로필 사진 URL const profileUrl = getRandomProfileUrl(); // 사용자 저장 await pool.query( `INSERT INTO users (email, password, name, profile_url, verify_token, verify_expires) VALUES (?, ?, ?, ?, ?, ?)`, [ email, hashedPassword, randomName, profileUrl, verifyToken, verifyExpires, ] ); // 인증 이메일 발송 try { await sendVerificationEmail(email, verifyToken, randomName); } catch (emailError) { console.error("[Auth] 인증 메일 발송 실패:", emailError); // 이메일 발송 실패해도 가입은 완료 (재발송 가능) } console.log(`[Auth] 회원가입: ${email} (닉네임: ${randomName})`); res.json({ success: true, message: "회원가입이 완료되었습니다. 이메일을 확인해주세요.", }); } catch (error) { console.error("[Auth] 회원가입 오류:", error); res.status(500).json({ error: "서버 오류가 발생했습니다." }); } }); /** * GET /auth/verify/:token - 이메일 인증 */ router.get("/verify/:token", async (req, res) => { const { token } = req.params; try { const [users] = await pool.query( "SELECT id, email, email_verified, verify_expires FROM users WHERE verify_token = ?", [token] ); if (users.length === 0) { return res.status(400).json({ error: "유효하지 않은 인증 링크입니다." }); } const user = users[0]; if (user.email_verified) { return res.json({ success: true, message: "이미 인증된 이메일입니다." }); } if (new Date() > new Date(user.verify_expires)) { return res .status(400) .json({ error: "인증 링크가 만료되었습니다. 다시 가입해주세요." }); } // 인증 완료 처리 await pool.query( "UPDATE users SET email_verified = TRUE, verify_token = NULL, verify_expires = NULL WHERE id = ?", [user.id] ); console.log(`[Auth] 이메일 인증 완료: ${user.email}`); res.json({ success: true, message: "이메일 인증이 완료되었습니다. 로그인해주세요.", }); } catch (error) { console.error("[Auth] 이메일 인증 오류:", error); res.status(500).json({ error: "서버 오류가 발생했습니다." }); } }); /** * POST /auth/login - 로그인 */ router.post("/login", async (req, res) => { const ip = getClientIp(req); // 로그인 시도 횟수 확인 const attemptCheck = checkLoginAttempts(ip); if (!attemptCheck.allowed) { console.log(`[Auth] 로그인 잠금 - IP: ${ip}`); return res.status(429).json({ error: `너무 많은 로그인 시도. ${attemptCheck.lockoutMinutes}분 후에 다시 시도해주세요.`, }); } const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: "이메일과 비밀번호를 입력해주세요." }); } try { const [users] = await pool.query( "SELECT id, email, password, name, profile_url, email_verified, is_admin FROM users WHERE email = ?", [email] ); if (users.length === 0) { recordFailedAttempt(ip); return res .status(401) .json({ error: "이메일 또는 비밀번호가 올바르지 않습니다." }); } const user = users[0]; // 비밀번호 확인 const isMatch = await bcrypt.compare(password, user.password); if (!isMatch) { recordFailedAttempt(ip); return res .status(401) .json({ error: "이메일 또는 비밀번호가 올바르지 않습니다." }); } // 이메일 인증 확인 if (!user.email_verified) { return res .status(403) .json({ error: "이메일 인증이 필요합니다. 메일함을 확인해주세요." }); } // 로그인 성공 clearLoginAttempts(ip); // JWT 토큰 생성 const token = jwt.sign( { id: user.id, email: user.email, isAdmin: user.is_admin }, JWT_SECRET, { expiresIn: "24h" } ); console.log(`[Auth] 로그인 성공: ${user.email} - IP: ${ip}`); res.json({ success: true, token, user: { id: user.id, email: user.email, name: user.name, profileUrl: user.profile_url, isAdmin: user.is_admin, }, }); } catch (error) { console.error("[Auth] 로그인 오류:", error); res.status(500).json({ error: "서버 오류가 발생했습니다." }); } }); /** * GET /auth/me - 현재 사용자 정보 */ router.get("/me", async (req, res) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith("Bearer ")) { return res.json({ loggedIn: false }); } const token = authHeader.split(" ")[1]; try { const decoded = jwt.verify(token, JWT_SECRET); const [users] = await pool.query( "SELECT id, email, name, profile_url, is_admin FROM users WHERE id = ?", [decoded.id] ); if (users.length === 0) { return res.json({ loggedIn: false }); } const user = users[0]; res.json({ loggedIn: true, user: { id: user.id, email: user.email, name: user.name, profileUrl: user.profile_url, isAdmin: user.is_admin, }, }); } catch (error) { return res.json({ loggedIn: false }); } }); /** * POST /auth/logout - 로그아웃 (클라이언트에서 토큰 삭제) */ router.post("/logout", (req, res) => { res.json({ success: true }); }); export default router;