이메일 인증과 비밀번호 재설정 추가
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uadeck-api",
|
||||
"version": "4.0.11",
|
||||
"version": "4.0.12",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -64,6 +64,20 @@ export function initDb(dbPath) {
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_deck_favorites_share_code ON deck_favorites(share_code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_type
|
||||
ON auth_tokens(user_id, type);
|
||||
`);
|
||||
|
||||
const deckCols = db.prepare('PRAGMA table_info(decks)').all();
|
||||
@@ -81,6 +95,32 @@ export function initDb(dbPath) {
|
||||
if (!userCols.some((c) => c.name === 'last_login_at')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN last_login_at TEXT');
|
||||
}
|
||||
if (!userCols.some((c) => c.name === 'email_verified_at')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN email_verified_at TEXT');
|
||||
db.exec(`
|
||||
UPDATE users
|
||||
SET email_verified_at = COALESCE(updated_at, created_at, datetime('now'))
|
||||
WHERE email_verified_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
const usernameCaseConflicts = db.prepare(`
|
||||
SELECT lower(username) AS normalized_username, COUNT(*) AS count
|
||||
FROM users
|
||||
GROUP BY lower(username)
|
||||
HAVING COUNT(*) > 1
|
||||
LIMIT 1
|
||||
`).get();
|
||||
if (!usernameCaseConflicts) {
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_nocase
|
||||
ON users(username COLLATE NOCASE)
|
||||
`);
|
||||
} else {
|
||||
console.warn(
|
||||
'대소문자만 다른 기존 사용자명이 있어 NOCASE 고유 인덱스를 만들지 못했습니다. 관리자 확인이 필요합니다.',
|
||||
);
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
@@ -103,7 +143,10 @@ export function seedAdmin(db) {
|
||||
const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
if (existingUser) {
|
||||
db.prepare(`
|
||||
UPDATE users SET role = 'admin', updated_at = datetime('now')
|
||||
UPDATE users
|
||||
SET role = 'admin',
|
||||
email_verified_at = COALESCE(email_verified_at, datetime('now')),
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(existingUser.id);
|
||||
return;
|
||||
@@ -111,7 +154,9 @@ export function seedAdmin(db) {
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
db.prepare(`
|
||||
INSERT INTO users (email, username, password_hash, display_name, role)
|
||||
VALUES (?, ?, ?, ?, 'admin')
|
||||
INSERT INTO users (
|
||||
email, username, password_hash, display_name, role, email_verified_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 'admin', datetime('now'))
|
||||
`).run(email, username, hash, '관리자');
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ app.use(cors({ origin: corsOrigins, credentials: true }));
|
||||
app.use(express.json({ limit: '20mb' }));
|
||||
|
||||
app.get('/api/v1/health', (_req, res) => {
|
||||
res.json({ ok: true, version: '4.0.11' });
|
||||
res.json({ ok: true, version: '4.0.12' });
|
||||
});
|
||||
|
||||
app.use('/api/v1/auth', createAuthRouter(db));
|
||||
@@ -47,7 +47,7 @@ app.use((err, _req, res, _next) => {
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`UNIARE API v4.0.11 listening on :${PORT}`);
|
||||
console.log(`UNIARE API v4.0.12 listening on :${PORT}`);
|
||||
console.log(`DB: ${DB_PATH}`);
|
||||
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
|
||||
});
|
||||
|
||||
@@ -160,10 +160,29 @@ export function createAdminRouter(db) {
|
||||
const values = [nowIso()];
|
||||
|
||||
if (trimmedEmail) {
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail) || trimmedEmail.length > 254) {
|
||||
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
||||
}
|
||||
fields.push('email = ?');
|
||||
values.push(trimmedEmail);
|
||||
}
|
||||
if (trimmedUsername) {
|
||||
if (
|
||||
trimmedUsername.length < 2 ||
|
||||
trimmedUsername.length > 30 ||
|
||||
!/^[\p{L}\p{N}_-]+$/u.test(trimmedUsername)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: '사용자명은 2~30자의 한글·영문·숫자·밑줄·하이픈만 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const duplicate = db.prepare(`
|
||||
SELECT id FROM users
|
||||
WHERE username = ? COLLATE NOCASE AND id != ?
|
||||
`).get(trimmedUsername, targetId);
|
||||
if (duplicate) {
|
||||
return res.status(409).json({ error: '이미 사용 중인 사용자명입니다.' });
|
||||
}
|
||||
fields.push('username = ?', 'display_name = ?');
|
||||
values.push(trimmedUsername, trimmedUsername);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,79 @@
|
||||
import { Router } from 'express';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { signToken, requireAuth } from '../middleware/auth.js';
|
||||
import { nowIso } from '../utils.js';
|
||||
import { getAvatarFilePath, getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
|
||||
import { sendPasswordResetEmail, sendVerificationEmail } from '../services/emailService.js';
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const USERNAME_PATTERN = /^[\p{L}\p{N}_-]+$/u;
|
||||
const VERIFICATION_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const PASSWORD_RESET_TOKEN_TTL_MS = 30 * 60 * 1000;
|
||||
const EMAIL_RESEND_COOLDOWN_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* 인증용 원문 토큰과 DB 저장용 해시 생성
|
||||
* @returns {{token: string, tokenHash: string}}
|
||||
*/
|
||||
function createOpaqueToken() {
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
return {
|
||||
token,
|
||||
tokenHash: crypto.createHash('sha256').update(token).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문 토큰 해시
|
||||
* @param {string} token
|
||||
* @returns {string}
|
||||
*/
|
||||
function hashToken(token) {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 인증 토큰 저장. 같은 목적의 기존 미사용 토큰은 폐기한다.
|
||||
* @param {import('better-sqlite3').Database} db
|
||||
* @param {number} userId
|
||||
* @param {'verify_email'|'reset_password'} type
|
||||
* @param {number} ttlMs
|
||||
* @returns {string}
|
||||
*/
|
||||
function issueAuthToken(db, userId, type, ttlMs) {
|
||||
const { token, tokenHash } = createOpaqueToken();
|
||||
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
|
||||
const transaction = db.transaction(() => {
|
||||
db.prepare('DELETE FROM auth_tokens WHERE user_id = ? AND type = ? AND used_at IS NULL')
|
||||
.run(userId, type);
|
||||
db.prepare(`
|
||||
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(userId, type, tokenHash, expiresAt);
|
||||
});
|
||||
transaction();
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 형식 검증
|
||||
* @param {string} email
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidEmail(email) {
|
||||
return email.length <= 254 && EMAIL_PATTERN.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자명 형식 검증
|
||||
* @param {string} username
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidUsername(username) {
|
||||
return username.length >= 2 && username.length <= 30 && USERNAME_PATTERN.test(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 사용자 행을 API 응답 형식으로 변환
|
||||
@@ -43,59 +113,158 @@ export function createAuthRouter(db) {
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/register', (req, res) => {
|
||||
router.post('/register', async (req, res, next) => {
|
||||
const { email, username, password } = req.body || {};
|
||||
|
||||
if (!email || !username || !password) {
|
||||
return res.status(400).json({ error: '이메일, 사용자명, 비밀번호는 필수입니다.' });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
const normalizedEmail = String(email).trim().toLowerCase();
|
||||
const trimmedUsername = String(username).trim();
|
||||
const rawPassword = String(password);
|
||||
if (!isValidEmail(normalizedEmail)) {
|
||||
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
||||
}
|
||||
if (!isValidUsername(trimmedUsername)) {
|
||||
return res.status(400).json({
|
||||
error: '사용자명은 2~30자의 한글·영문·숫자·밑줄·하이픈만 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
if (rawPassword.length < 8) {
|
||||
return res.status(400).json({ error: '비밀번호는 8자 이상이어야 합니다.' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
if (db.prepare('SELECT id FROM users WHERE email = ?').get(normalizedEmail)) {
|
||||
return res.status(409).json({ error: '이미 사용 중인 이메일입니다.' });
|
||||
}
|
||||
if (db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(trimmedUsername)) {
|
||||
return res.status(409).json({ error: '이미 사용 중인 사용자명입니다.' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(rawPassword, 12);
|
||||
let userId;
|
||||
try {
|
||||
const trimmedUsername = username.trim();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (email, username, password_hash, display_name)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(email.trim().toLowerCase(), trimmedUsername, hash, trimmedUsername);
|
||||
INSERT INTO users (email, username, password_hash, display_name, email_verified_at)
|
||||
VALUES (?, ?, ?, ?, NULL)
|
||||
`).run(normalizedEmail, trimmedUsername, hash, trimmedUsername);
|
||||
userId = Number(result.lastInsertRowid);
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, role, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
const safeUser = toUserResponse(user);
|
||||
const token = signToken(safeUser);
|
||||
res.status(201).json({ token, user: safeUser });
|
||||
const token = issueAuthToken(db, userId, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
|
||||
await sendVerificationEmail({
|
||||
email: normalizedEmail,
|
||||
username: trimmedUsername,
|
||||
token,
|
||||
});
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
email: normalizedEmail,
|
||||
message: '인증 메일을 보냈습니다. 이메일 인증 후 로그인해 주세요.',
|
||||
});
|
||||
} catch (err) {
|
||||
if (userId) {
|
||||
db.prepare('DELETE FROM users WHERE id = ? AND email_verified_at IS NULL').run(userId);
|
||||
}
|
||||
if (String(err.message).includes('UNIQUE')) {
|
||||
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' });
|
||||
}
|
||||
throw err;
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/resend-verification', async (req, res, next) => {
|
||||
const normalizedEmail = String(req.body?.email || '').trim().toLowerCase();
|
||||
if (!isValidEmail(normalizedEmail)) {
|
||||
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
||||
}
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, email_verified_at
|
||||
FROM users WHERE email = ? AND is_active = 1
|
||||
`).get(normalizedEmail);
|
||||
if (!user || user.email_verified_at) {
|
||||
return res.json({
|
||||
ok: true,
|
||||
message: '인증이 필요한 계정이라면 이메일을 다시 보냈습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const recent = db.prepare(`
|
||||
SELECT created_at
|
||||
FROM auth_tokens
|
||||
WHERE user_id = ? AND type = 'verify_email'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`).get(user.id);
|
||||
if (recent && Date.now() - new Date(`${recent.created_at}Z`).getTime() < EMAIL_RESEND_COOLDOWN_MS) {
|
||||
return res.status(429).json({ error: '인증 메일은 1분 후 다시 요청할 수 있습니다.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = issueAuthToken(db, user.id, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
|
||||
await sendVerificationEmail({ email: user.email, username: user.username, token });
|
||||
res.json({ ok: true, message: '인증 메일을 다시 보냈습니다.' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/verify-email', (req, res) => {
|
||||
const token = String(req.body?.token || '');
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: '인증 토큰이 없습니다.' });
|
||||
}
|
||||
const tokenHash = hashToken(token);
|
||||
const row = db.prepare(`
|
||||
SELECT t.id, t.user_id AS userId, t.expires_at AS expiresAt, t.used_at AS usedAt,
|
||||
u.email_verified_at AS emailVerifiedAt
|
||||
FROM auth_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = ? AND t.type = 'verify_email' AND u.is_active = 1
|
||||
`).get(tokenHash);
|
||||
if (!row || row.usedAt || new Date(row.expiresAt).getTime() <= Date.now()) {
|
||||
return res.status(400).json({ error: '인증 링크가 유효하지 않거나 만료되었습니다.' });
|
||||
}
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET email_verified_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(nowIso(), nowIso(), row.userId);
|
||||
db.prepare('UPDATE auth_tokens SET used_at = ? WHERE id = ?').run(nowIso(), row.id);
|
||||
});
|
||||
transaction();
|
||||
res.json({ ok: true, message: '이메일 인증이 완료되었습니다. 로그인해 주세요.' });
|
||||
});
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' });
|
||||
}
|
||||
const normalizedEmail = String(email).trim().toLowerCase();
|
||||
const rawPassword = String(password);
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, password_hash, display_name, avatar_url, role, is_active
|
||||
SELECT id, email, username, password_hash, display_name, avatar_url, role, is_active,
|
||||
email_verified_at
|
||||
FROM users WHERE email = ?
|
||||
`).get(email.trim().toLowerCase());
|
||||
`).get(normalizedEmail);
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
if (!bcrypt.compareSync(password, user.password_hash)) {
|
||||
if (!bcrypt.compareSync(rawPassword, user.password_hash)) {
|
||||
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
if (!user.email_verified_at) {
|
||||
return res.status(403).json({
|
||||
error: '이메일 인증이 필요합니다. 가입한 이메일의 인증 링크를 확인해 주세요.',
|
||||
code: 'EMAIL_NOT_VERIFIED',
|
||||
});
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id);
|
||||
const row = db.prepare(`
|
||||
@@ -107,6 +276,80 @@ export function createAuthRouter(db) {
|
||||
res.json({ token: signToken(safeUser), user: safeUser });
|
||||
});
|
||||
|
||||
router.post('/forgot-password', async (req, res, next) => {
|
||||
const normalizedEmail = String(req.body?.email || '').trim().toLowerCase();
|
||||
if (!isValidEmail(normalizedEmail)) {
|
||||
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
||||
}
|
||||
const genericResponse = {
|
||||
ok: true,
|
||||
message: '가입된 이메일이라면 비밀번호 재설정 링크를 보냈습니다.',
|
||||
};
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username
|
||||
FROM users
|
||||
WHERE email = ? AND is_active = 1 AND email_verified_at IS NOT NULL
|
||||
`).get(normalizedEmail);
|
||||
if (!user) {
|
||||
return res.json(genericResponse);
|
||||
}
|
||||
|
||||
try {
|
||||
const recent = db.prepare(`
|
||||
SELECT created_at
|
||||
FROM auth_tokens
|
||||
WHERE user_id = ? AND type = 'reset_password'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`).get(user.id);
|
||||
if (recent && Date.now() - new Date(`${recent.created_at}Z`).getTime() < EMAIL_RESEND_COOLDOWN_MS) {
|
||||
return res.json(genericResponse);
|
||||
}
|
||||
const token = issueAuthToken(db, user.id, 'reset_password', PASSWORD_RESET_TOKEN_TTL_MS);
|
||||
await sendPasswordResetEmail({ email: user.email, username: user.username, token });
|
||||
res.json(genericResponse);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/reset-password', (req, res) => {
|
||||
const token = String(req.body?.token || '');
|
||||
const newPassword = String(req.body?.newPassword || '');
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: '재설정 토큰이 없습니다.' });
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
|
||||
}
|
||||
|
||||
const row = db.prepare(`
|
||||
SELECT t.id, t.user_id AS userId, t.expires_at AS expiresAt, t.used_at AS usedAt
|
||||
FROM auth_tokens t
|
||||
JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = ? AND t.type = 'reset_password' AND u.is_active = 1
|
||||
`).get(hashToken(token));
|
||||
if (!row || row.usedAt || new Date(row.expiresAt).getTime() <= Date.now()) {
|
||||
return res.status(400).json({ error: '재설정 링크가 유효하지 않거나 만료되었습니다.' });
|
||||
}
|
||||
|
||||
const passwordHash = bcrypt.hashSync(newPassword, 12);
|
||||
const transaction = db.transaction(() => {
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET password_hash = ?, updated_at = ?
|
||||
WHERE id = ? AND is_active = 1
|
||||
`).run(passwordHash, nowIso(), row.userId);
|
||||
db.prepare(`
|
||||
UPDATE auth_tokens
|
||||
SET used_at = ?
|
||||
WHERE user_id = ? AND type = 'reset_password' AND used_at IS NULL
|
||||
`).run(nowIso(), row.userId);
|
||||
});
|
||||
transaction();
|
||||
res.json({ ok: true, message: '비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요.' });
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ? AND is_active = 1').run(
|
||||
nowIso(),
|
||||
@@ -139,10 +382,29 @@ export function createAuthRouter(db) {
|
||||
const trimmedUsername = username?.trim();
|
||||
|
||||
if (trimmedEmail) {
|
||||
fields.push('email = ?');
|
||||
values.push(trimmedEmail);
|
||||
const current = db.prepare('SELECT email FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!isValidEmail(trimmedEmail)) {
|
||||
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
||||
}
|
||||
if (trimmedEmail !== current?.email) {
|
||||
return res.status(400).json({
|
||||
error: '인증된 이메일 주소는 현재 직접 변경할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (trimmedUsername) {
|
||||
if (!isValidUsername(trimmedUsername)) {
|
||||
return res.status(400).json({
|
||||
error: '사용자명은 2~30자의 한글·영문·숫자·밑줄·하이픈만 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const duplicate = db.prepare(`
|
||||
SELECT id FROM users
|
||||
WHERE username = ? COLLATE NOCASE AND id != ?
|
||||
`).get(trimmedUsername, req.user.id);
|
||||
if (duplicate) {
|
||||
return res.status(409).json({ error: '이미 사용 중인 사용자명입니다.' });
|
||||
}
|
||||
fields.push('username = ?', 'display_name = ?');
|
||||
values.push(trimmedUsername, trimmedUsername);
|
||||
}
|
||||
|
||||
111
api/src/services/emailService.js
Normal file
111
api/src/services/emailService.js
Normal file
@@ -0,0 +1,111 @@
|
||||
const RESEND_API_URL = 'https://api.resend.com/emails';
|
||||
|
||||
/**
|
||||
* HTML 특수문자 이스케이프
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 공개 URL
|
||||
* @returns {string}
|
||||
*/
|
||||
function getAppBaseUrl() {
|
||||
const value = process.env.APP_BASE_URL?.trim().replace(/\/+$/, '');
|
||||
if (!value) {
|
||||
throw new Error('APP_BASE_URL 환경변수가 설정되지 않았습니다.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend 메일 전송
|
||||
* @param {{to: string, subject: string, html: string, text: string, idempotencyKey: string}} message
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function sendEmail(message) {
|
||||
const apiKey = process.env.RESEND_API_KEY?.trim();
|
||||
const from = process.env.RESEND_FROM_EMAIL?.trim();
|
||||
if (!apiKey || !from) {
|
||||
throw new Error('RESEND_API_KEY 또는 RESEND_FROM_EMAIL 환경변수가 설정되지 않았습니다.');
|
||||
}
|
||||
|
||||
const response = await fetch(RESEND_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': message.idempotencyKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
to: [message.to],
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
text: message.text,
|
||||
}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Resend 메일 전송 실패 (${response.status}): ${data.message || '알 수 없는 오류'}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 인증 메일 전송
|
||||
* @param {{email: string, username: string, token: string}} params
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export function sendVerificationEmail({ email, username, token }) {
|
||||
const url = `${getAppBaseUrl()}/verify-email.html?token=${encodeURIComponent(token)}`;
|
||||
const safeName = escapeHtml(username);
|
||||
const safeUrl = escapeHtml(url);
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: '[UNIARE] 이메일 주소를 인증해 주세요',
|
||||
idempotencyKey: `verify-${token.slice(0, 32)}`,
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#0f172a">
|
||||
<h1 style="font-size:22px">UNIARE 이메일 인증</h1>
|
||||
<p>${safeName}님, 아래 버튼을 눌러 회원가입을 완료해 주세요.</p>
|
||||
<p><a href="${safeUrl}" style="display:inline-block;padding:12px 18px;border-radius:10px;background:#2563eb;color:#fff;text-decoration:none;font-weight:700">이메일 인증하기</a></p>
|
||||
<p style="font-size:13px;color:#64748b">이 링크는 24시간 동안 한 번만 사용할 수 있습니다.</p>
|
||||
</div>
|
||||
`,
|
||||
text: `${username}님, 다음 링크에서 UNIARE 이메일 인증을 완료해 주세요.\n${url}\n\n이 링크는 24시간 동안 한 번만 사용할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 메일 전송
|
||||
* @param {{email: string, username: string, token: string}} params
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export function sendPasswordResetEmail({ email, username, token }) {
|
||||
const url = `${getAppBaseUrl()}/reset-password.html?token=${encodeURIComponent(token)}`;
|
||||
const safeName = escapeHtml(username);
|
||||
const safeUrl = escapeHtml(url);
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: '[UNIARE] 비밀번호 재설정 안내',
|
||||
idempotencyKey: `reset-${token.slice(0, 32)}`,
|
||||
html: `
|
||||
<div style="font-family:Arial,sans-serif;line-height:1.6;color:#0f172a">
|
||||
<h1 style="font-size:22px">UNIARE 비밀번호 재설정</h1>
|
||||
<p>${safeName}님, 요청하신 비밀번호 재설정을 진행해 주세요.</p>
|
||||
<p><a href="${safeUrl}" style="display:inline-block;padding:12px 18px;border-radius:10px;background:#2563eb;color:#fff;text-decoration:none;font-weight:700">비밀번호 재설정하기</a></p>
|
||||
<p style="font-size:13px;color:#64748b">이 링크는 30분 동안 한 번만 사용할 수 있습니다. 요청하지 않았다면 이 메일을 무시하세요.</p>
|
||||
</div>
|
||||
`,
|
||||
text: `${username}님, 다음 링크에서 UNIARE 비밀번호를 재설정해 주세요.\n${url}\n\n이 링크는 30분 동안 한 번만 사용할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user