- 토큰 만료 시간 30일로 변경 - /auth/refresh 엔드포인트 추가 (만료 7일 전 갱신) - 프론트엔드에서 1시간마다 토큰 자동 갱신 체크
435 lines
12 KiB
JavaScript
435 lines
12 KiB
JavaScript
/**
|
|
* 인증 라우트
|
|
* 회원가입, 이메일 인증, 로그인, 로그아웃
|
|
*
|
|
* 보안 기능:
|
|
* - 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 토큰 생성 (30일 유효)
|
|
const token = jwt.sign(
|
|
{ id: user.id, email: user.email, isAdmin: user.is_admin },
|
|
JWT_SECRET,
|
|
{ expiresIn: "30d" }
|
|
);
|
|
|
|
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/refresh - 토큰 갱신 (슬라이딩 세션)
|
|
* 만료 7일 전이면 새 토큰 발급
|
|
*/
|
|
router.post("/refresh", async (req, res) => {
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
return res.status(401).json({ error: "토큰이 없습니다." });
|
|
}
|
|
|
|
const token = authHeader.split(" ")[1];
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, JWT_SECRET);
|
|
|
|
// 만료까지 남은 시간 계산 (초)
|
|
const expiresIn = decoded.exp - Math.floor(Date.now() / 1000);
|
|
const sevenDaysInSeconds = 7 * 24 * 60 * 60;
|
|
|
|
// 만료 7일 전이면 새 토큰 발급
|
|
if (expiresIn < sevenDaysInSeconds) {
|
|
const newToken = jwt.sign(
|
|
{ id: decoded.id, email: decoded.email, isAdmin: decoded.isAdmin },
|
|
JWT_SECRET,
|
|
{ expiresIn: "30d" }
|
|
);
|
|
console.log(`[Auth] 토큰 갱신: ${decoded.email}`);
|
|
return res.json({ refreshed: true, token: newToken });
|
|
}
|
|
|
|
// 아직 갱신 불필요
|
|
res.json({ refreshed: false });
|
|
} catch (error) {
|
|
// 토큰이 만료된 경우
|
|
return res.status(401).json({ error: "토큰이 만료되었습니다." });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /auth/logout - 로그아웃 (클라이언트에서 토큰 삭제)
|
|
*/
|
|
router.post("/logout", (req, res) => {
|
|
res.json({ success: true });
|
|
});
|
|
|
|
/**
|
|
* DELETE /auth/delete - 회원 탈퇴
|
|
*/
|
|
router.delete("/delete", async (req, res) => {
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
return res.status(401).json({ error: "로그인이 필요합니다." });
|
|
}
|
|
|
|
const token = authHeader.split(" ")[1];
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, JWT_SECRET);
|
|
|
|
// 연동 정보 삭제
|
|
await pool.query("DELETE FROM minecraft_links WHERE user_id = ?", [
|
|
decoded.id,
|
|
]);
|
|
|
|
// 사용자 삭제
|
|
await pool.query("DELETE FROM users WHERE id = ?", [decoded.id]);
|
|
|
|
console.log(`[Auth] 회원 탈퇴: id=${decoded.id}, email=${decoded.email}`);
|
|
|
|
res.json({ success: true, message: "회원 탈퇴가 완료되었습니다." });
|
|
} catch (error) {
|
|
console.error("[Auth] 회원 탈퇴 오류:", error);
|
|
res.status(500).json({ error: "서버 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
export default router;
|