minecraft-web/backend/routes/auth.js

367 lines
9.8 KiB
JavaScript
Raw Normal View History

/**
* 인증 라우트
* 회원가입, 이메일 인증, 로그인, 로그아웃
*
* 보안 기능:
* - 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;