이메일 인증과 비밀번호 재설정 추가

This commit is contained in:
2026-06-25 12:02:44 +09:00
parent edf48e53a5
commit 35a2976182
26 changed files with 770 additions and 43 deletions

View File

@@ -4,6 +4,11 @@
API_PORT=3000 API_PORT=3000
JWT_SECRET=여기에-32자-이상-랜덤-시크릿-입력 JWT_SECRET=여기에-32자-이상-랜덤-시크릿-입력
JWT_EXPIRES=7d JWT_EXPIRES=7d
APP_BASE_URL=https://uniare.sori.studio
# --- 이메일 (Resend) ---
RESEND_API_KEY=re_여기에_API_KEY_입력
RESEND_FROM_EMAIL=UNIARE <account@send.example.com>
# --- 웹 (docker-compose.prod.yml) --- # --- 웹 (docker-compose.prod.yml) ---
WEB_PORT=44082 WEB_PORT=44082

View File

@@ -1 +1 @@
v4.0.11 v4.0.12

View File

@@ -1,6 +1,6 @@
{ {
"name": "uadeck-api", "name": "uadeck-api",
"version": "4.0.11", "version": "4.0.12",
"type": "module", "type": "module",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

View File

@@ -64,6 +64,20 @@ export function initDb(dbPath) {
); );
CREATE INDEX IF NOT EXISTS idx_deck_favorites_share_code ON deck_favorites(share_code); 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(); 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')) { if (!userCols.some((c) => c.name === 'last_login_at')) {
db.exec('ALTER TABLE users ADD COLUMN last_login_at TEXT'); 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; return db;
} }
@@ -103,7 +143,10 @@ export function seedAdmin(db) {
const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email); const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existingUser) { if (existingUser) {
db.prepare(` 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 = ? WHERE id = ?
`).run(existingUser.id); `).run(existingUser.id);
return; return;
@@ -111,7 +154,9 @@ export function seedAdmin(db) {
const hash = bcrypt.hashSync(password, 12); const hash = bcrypt.hashSync(password, 12);
db.prepare(` db.prepare(`
INSERT INTO users (email, username, password_hash, display_name, role) INSERT INTO users (
VALUES (?, ?, ?, ?, 'admin') email, username, password_hash, display_name, role, email_verified_at
)
VALUES (?, ?, ?, ?, 'admin', datetime('now'))
`).run(email, username, hash, '관리자'); `).run(email, username, hash, '관리자');
} }

View File

@@ -29,7 +29,7 @@ app.use(cors({ origin: corsOrigins, credentials: true }));
app.use(express.json({ limit: '20mb' })); app.use(express.json({ limit: '20mb' }));
app.get('/api/v1/health', (_req, res) => { 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)); app.use('/api/v1/auth', createAuthRouter(db));
@@ -47,7 +47,7 @@ app.use((err, _req, res, _next) => {
}); });
app.listen(PORT, () => { 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(`DB: ${DB_PATH}`);
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`); console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
}); });

View File

@@ -160,10 +160,29 @@ export function createAdminRouter(db) {
const values = [nowIso()]; const values = [nowIso()];
if (trimmedEmail) { if (trimmedEmail) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail) || trimmedEmail.length > 254) {
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
}
fields.push('email = ?'); fields.push('email = ?');
values.push(trimmedEmail); values.push(trimmedEmail);
} }
if (trimmedUsername) { 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 = ?'); fields.push('username = ?', 'display_name = ?');
values.push(trimmedUsername, trimmedUsername); values.push(trimmedUsername, trimmedUsername);
} }

View File

@@ -1,9 +1,79 @@
import { Router } from 'express'; import { Router } from 'express';
import path from 'path'; import path from 'path';
import crypto from 'crypto';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { signToken, requireAuth } from '../middleware/auth.js'; import { signToken, requireAuth } from '../middleware/auth.js';
import { nowIso } from '../utils.js'; import { nowIso } from '../utils.js';
import { getAvatarFilePath, getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.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 응답 형식으로 변환 * 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 || {}; const { email, username, password } = req.body || {};
if (!email || !username || !password) { if (!email || !username || !password) {
return res.status(400).json({ error: '이메일, 사용자명, 비밀번호는 필수입니다.' }); 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자 이상이어야 합니다.' }); 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 { try {
const trimmedUsername = username.trim();
const result = db.prepare(` const result = db.prepare(`
INSERT INTO users (email, username, password_hash, display_name) INSERT INTO users (email, username, password_hash, display_name, email_verified_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, NULL)
`).run(email.trim().toLowerCase(), trimmedUsername, hash, trimmedUsername); `).run(normalizedEmail, trimmedUsername, hash, trimmedUsername);
userId = Number(result.lastInsertRowid);
const user = db.prepare(` const token = issueAuthToken(db, userId, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
SELECT id, email, username, role, created_at AS createdAt, updated_at AS updatedAt await sendVerificationEmail({
FROM users WHERE id = ? email: normalizedEmail,
`).get(result.lastInsertRowid); username: trimmedUsername,
token,
const safeUser = toUserResponse(user); });
const token = signToken(safeUser); res.status(201).json({
res.status(201).json({ token, user: safeUser }); ok: true,
email: normalizedEmail,
message: '인증 메일을 보냈습니다. 이메일 인증 후 로그인해 주세요.',
});
} catch (err) { } 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')) { if (String(err.message).includes('UNIQUE')) {
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' }); 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) => { router.post('/login', (req, res) => {
const { email, password } = req.body || {}; const { email, password } = req.body || {};
if (!email || !password) { if (!email || !password) {
return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' }); return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' });
} }
const normalizedEmail = String(email).trim().toLowerCase();
const rawPassword = String(password);
const user = db.prepare(` 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 = ? FROM users WHERE email = ?
`).get(email.trim().toLowerCase()); `).get(normalizedEmail);
if (!user || !user.is_active) { if (!user || !user.is_active) {
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' }); 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: '이메일 또는 비밀번호가 올바르지 않습니다.' }); 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); db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id);
const row = db.prepare(` const row = db.prepare(`
@@ -107,6 +276,80 @@ export function createAuthRouter(db) {
res.json({ token: signToken(safeUser), user: safeUser }); 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) => { router.get('/me', requireAuth, (req, res) => {
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ? AND is_active = 1').run( db.prepare('UPDATE users SET last_login_at = ? WHERE id = ? AND is_active = 1').run(
nowIso(), nowIso(),
@@ -139,10 +382,29 @@ export function createAuthRouter(db) {
const trimmedUsername = username?.trim(); const trimmedUsername = username?.trim();
if (trimmedEmail) { if (trimmedEmail) {
fields.push('email = ?'); const current = db.prepare('SELECT email FROM users WHERE id = ?').get(req.user.id);
values.push(trimmedEmail); if (!isValidEmail(trimmedEmail)) {
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
}
if (trimmedEmail !== current?.email) {
return res.status(400).json({
error: '인증된 이메일 주소는 현재 직접 변경할 수 없습니다.',
});
}
} }
if (trimmedUsername) { 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 = ?'); fields.push('username = ?', 'display_name = ?');
values.push(trimmedUsername, trimmedUsername); values.push(trimmedUsername, trimmedUsername);
} }

View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
/**
* 서비스 공개 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분 동안 한 번만 사용할 수 있습니다.`,
});
}

View File

@@ -14,6 +14,9 @@ services:
- PORT=3000 - PORT=3000
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES=${JWT_EXPIRES:-7d} - JWT_EXPIRES=${JWT_EXPIRES:-7d}
- APP_BASE_URL=${APP_BASE_URL}
- RESEND_API_KEY=${RESEND_API_KEY}
- RESEND_FROM_EMAIL=${RESEND_FROM_EMAIL}
- ADMIN_EMAIL=${ADMIN_EMAIL} - ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
@@ -49,6 +52,9 @@ services:
- ./result.html:/usr/share/nginx/html/result.html:ro - ./result.html:/usr/share/nginx/html/result.html:ro
- ./login.html:/usr/share/nginx/html/login.html:ro - ./login.html:/usr/share/nginx/html/login.html:ro
- ./signup.html:/usr/share/nginx/html/signup.html:ro - ./signup.html:/usr/share/nginx/html/signup.html:ro
- ./verify-email.html:/usr/share/nginx/html/verify-email.html:ro
- ./forgot-password.html:/usr/share/nginx/html/forgot-password.html:ro
- ./reset-password.html:/usr/share/nginx/html/reset-password.html:ro
- ./deckHistory.html:/usr/share/nginx/html/deckHistory.html:ro - ./deckHistory.html:/usr/share/nginx/html/deckHistory.html:ro
- ./preset.html:/usr/share/nginx/html/preset.html:ro - ./preset.html:/usr/share/nginx/html/preset.html:ro
- ./profile.html:/usr/share/nginx/html/profile.html:ro - ./profile.html:/usr/share/nginx/html/profile.html:ro

View File

@@ -9,6 +9,9 @@ services:
- PORT=3000 - PORT=3000
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES=${JWT_EXPIRES:-7d} - JWT_EXPIRES=${JWT_EXPIRES:-7d}
- APP_BASE_URL=${APP_BASE_URL}
- RESEND_API_KEY=${RESEND_API_KEY}
- RESEND_FROM_EMAIL=${RESEND_FROM_EMAIL}
- ADMIN_EMAIL=${ADMIN_EMAIL} - ADMIN_EMAIL=${ADMIN_EMAIL}
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}

View File

@@ -1,5 +1,19 @@
# 변경사항 # 변경사항
## v4.0.12
### 이메일 인증 회원가입
새로 가입하는 사용자는 가입한 이메일로 전송된 링크를 눌러야 로그인할 수 있습니다. 인증 메일을 받지 못한 경우 인증 화면에서 다시 요청할 수 있습니다.
### 비밀번호 직접 재설정
로그인 화면의 `비밀번호를 잊으셨나요?`에서 가입 이메일로 재설정 링크를 받아 새 비밀번호를 설정할 수 있습니다.
### 중복 가입 검증 강화
이메일 형식을 서버에서도 확인하며, 사용자명은 영문 대소문자만 다른 경우에도 중복 가입을 차단합니다.
## v4.0.11 ## v4.0.11
### 로그인 유지 선택 ### 로그인 유지 선택

View File

@@ -29,6 +29,9 @@ JWT_SECRET=32자_이상의_랜덤_문자열
ADMIN_EMAIL=관리자_이메일 ADMIN_EMAIL=관리자_이메일
ADMIN_PASSWORD=8자_이상의_비밀번호 ADMIN_PASSWORD=8자_이상의_비밀번호
WEB_PORT=44082 WEB_PORT=44082
APP_BASE_URL=http://localhost:44082
RESEND_API_KEY=Resend_API_Key
RESEND_FROM_EMAIL=UNIARE <인증된_발신주소>
``` ```
랜덤 문자열은 `openssl rand -hex 32`로 만들 수 있다. 랜덤 문자열은 `openssl rand -hex 32`로 만들 수 있다.
@@ -59,15 +62,27 @@ ADMIN_PASSWORD=8자_이상의_비밀번호
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
WEB_PORT=44082 WEB_PORT=44082
CORS_ORIGINS=https://uniare.sori.studio CORS_ORIGINS=https://uniare.sori.studio
APP_BASE_URL=https://uniare.sori.studio
RESEND_API_KEY=Resend_API_Key
RESEND_FROM_EMAIL=UNIARE <account@인증된_발신도메인>
``` ```
Resend에서 소유한 도메인 또는 발신용 하위 도메인을 먼저 추가하고 DNS 인증을 완료해야 한다. API Key에는 메일 발송 권한이 필요하며 `.env` 외에는 기록하지 않는다.
Docker 실행: `docker compose -f docker-compose.prod.yml up -d --build` Docker 실행: `docker compose -f docker-compose.prod.yml up -d --build`
상태 확인: `docker compose -f docker-compose.prod.yml ps` 상태 확인: `docker compose -f docker-compose.prod.yml ps`
API 확인: `curl -fsS http://localhost:44082/api/v1/health` API 확인: `curl -fsS http://localhost:44082/api/v1/health`
정상이면 `{"ok":true,"version":"4.0.11"}`이 표시된다. 정상이면 `{"ok":true,"version":"4.0.12"}`이 표시된다.
이메일 기능 확인:
1. 실제 수신 가능한 주소로 신규 가입한다.
2. 인증 메일 링크를 누르기 전 로그인이 차단되는지 확인한다.
3. 인증 후 로그인되는지 확인한다.
4. 로그인 화면의 `비밀번호를 잊으셨나요?`에서 재설정 메일과 새 비밀번호 로그인을 확인한다.
NAS 리버스 프록시는 `https://uniare.sori.studio`을 `http://<NAS_IP>:44082`로 연결한다. NAS 리버스 프록시는 `https://uniare.sori.studio`을 `http://<NAS_IP>:44082`로 연결한다.
@@ -142,6 +157,8 @@ curl -s http://127.0.0.1:44082/script/deck-favorites.js | tail -1
HTML·`style/`·`script/`만 바뀐 경우 API 컨테이너 재시작은 필요 없다. HTML·`style/`·`script/`만 바뀐 경우 API 컨테이너 재시작은 필요 없다.
이메일 기능은 API와 Compose 환경 변수가 함께 바뀌므로 `git pull`만으로 반영되지 않는다. 반드시 `.env` 설정 후 전체 스택을 다시 빌드한다.
## 4. 전체 업데이트 ## 4. 전체 업데이트
`api/`, `nginx/`, Dockerfile, Compose 설정을 변경했다면 NAS 프로젝트 폴더에서 다음 명령을 실행한다. `api/`, `nginx/`, Dockerfile, Compose 설정을 변경했다면 NAS 프로젝트 폴더에서 다음 명령을 실행한다.

View File

@@ -1,5 +1,10 @@
# 의사결정 이력 # 의사결정 이력
## 2026-06-25 v4.0.12 — 신규 가입부터 이메일 소유 확인 적용
💡 기존 회원을 잠그지 않으면서 허위 이메일 가입과 관리자 의존 비밀번호 초기화를 막기 위함.
🔧 기존 회원은 인증 완료로 이관하고 신규 회원은 Resend 인증 후 로그인, 재설정 토큰은 해시·1회용·30분 만료로 저장.
## 2026-06-24 v4.0.11 — 로그인 유지와 실제 접속 시각을 분리 관리 ## 2026-06-24 v4.0.11 — 로그인 유지와 실제 접속 시각을 분리 관리
💡 브라우저 종료 후 로그인 유지 여부는 사용자가 선택하고, 접속 기록은 재로그인 없이도 실제 이용을 반영해야 하기 때문. 💡 브라우저 종료 후 로그인 유지 여부는 사용자가 선택하고, 접속 기록은 재로그인 없이도 실제 이용을 반영해야 하기 때문.

View File

@@ -18,6 +18,15 @@ login.html
/signup.html /signup.html
signup.html signup.html
/verify-email.html
verify-email.html
/forgot-password.html
forgot-password.html
/reset-password.html
reset-password.html
/profile.html /profile.html
profile.html profile.html
@@ -110,9 +119,12 @@ api/src/db.js
JWT 인증·권한 JWT 인증·권한
api/src/middleware/auth.js api/src/middleware/auth.js
회원가입·로그인·프로필·인증 세션 접속 시각 갱신 회원가입·이메일 인증·비밀번호 재설정·로그인·프로필·인증 세션 접속 시각 갱신
api/src/routes/auth.js api/src/routes/auth.js
Resend 인증·비밀번호 재설정 메일
api/src/services/emailService.js
덱 저장·조회·공개 목록 덱 저장·조회·공개 목록
api/src/routes/decks.js api/src/routes/decks.js

View File

@@ -3,7 +3,7 @@
## 프로젝트 개요 ## 프로젝트 개요
- 서비스명: `UNIARE - Union Arena Deck Builder` - 서비스명: `UNIARE - Union Arena Deck Builder`
- 현재 버전: `v4.0.11` - 현재 버전: `v4.0.12`
- 프론트: 정적 HTML, CSS, JavaScript - 프론트: 정적 HTML, CSS, JavaScript
- 백엔드: Express, SQLite(`better-sqlite3`) - 백엔드: Express, SQLite(`better-sqlite3`)
- 운영: Docker Compose 기반 Nginx + API - 운영: Docker Compose 기반 Nginx + API
@@ -20,6 +20,9 @@
| `/result.html` | 덱 미리보기, PNG 다운로드, 회원 덱 저장 | | `/result.html` | 덱 미리보기, PNG 다운로드, 회원 덱 저장 |
| `/login.html` | 로그인 | | `/login.html` | 로그인 |
| `/signup.html` | 회원가입 | | `/signup.html` | 회원가입 |
| `/verify-email.html` | 가입 이메일 인증·인증 메일 재전송 |
| `/forgot-password.html` | 비밀번호 재설정 메일 요청 |
| `/reset-password.html?token=<token>` | 1회용 토큰으로 비밀번호 재설정 |
| `/profile.html` | 이메일·사용자명·아바타·비밀번호·덱 공개 기본값 관리 | | `/profile.html` | 이메일·사용자명·아바타·비밀번호·덱 공개 기본값 관리 |
| `/deckHistory.html` | 내 덱 검색·필터·이름 변경·공개 설정·삭제·불러오기 | | `/deckHistory.html` | 내 덱 검색·필터·이름 변경·공개 설정·삭제·불러오기 |
| `/preset.html` | 공개 사용자 덱 검색·필터·즐겨찾기·불러오기 | | `/preset.html` | 공개 사용자 덱 검색·필터·즐겨찾기·불러오기 |
@@ -85,6 +88,12 @@
## 인증 및 권한 ## 인증 및 권한
- 비밀번호는 bcrypt 비용 12로 해시한다. - 비밀번호는 bcrypt 비용 12로 해시한다.
- 신규 회원은 Resend 인증 링크로 이메일 소유 확인을 완료해야 로그인할 수 있다.
- 기존 회원은 이메일 인증 기능 배포 시 인증 완료 상태로 마이그레이션한다.
- 인증 링크는 24시간, 비밀번호 재설정 링크는 30분 동안 한 번만 사용할 수 있다.
- 인증·재설정 원문 토큰은 DB에 저장하지 않고 SHA-256 해시만 저장한다.
- 사용자명은 대소문자를 구분하지 않고 중복을 차단한다.
- 인증된 이메일은 프로필에서 직접 변경할 수 없다.
- JWT 기본 만료는 7일이며 `JWT_EXPIRES`로 변경할 수 있다. - JWT 기본 만료는 7일이며 `JWT_EXPIRES`로 변경할 수 있다.
- 로그인 유지 선택 시 브라우저를 닫아도 인증 정보를 유지하고, 미선택 시 현재 브라우저 세션에서만 유지한다. - 로그인 유지 선택 시 브라우저를 닫아도 인증 정보를 유지하고, 미선택 시 현재 브라우저 세션에서만 유지한다.
- 인증된 화면 진입 시 `/auth/me` 검증과 함께 `last_login_at`을 갱신한다. - 인증된 화면 진입 시 `/auth/me` 검증과 함께 `last_login_at`을 갱신한다.
@@ -123,8 +132,19 @@
| `default_deck_public` | 덱 저장 기본 공개 여부 | | `default_deck_public` | 덱 저장 기본 공개 여부 |
| `skip_deck_public_prompt` | 저장 시 공개 확인 생략 여부 | | `skip_deck_public_prompt` | 저장 시 공개 확인 생략 여부 |
| `last_login_at` | 마지막 로그인 또는 인증 세션 확인 시각 | | `last_login_at` | 마지막 로그인 또는 인증 세션 확인 시각 |
| `email_verified_at` | 이메일 인증 완료 시각. 기존 회원은 마이그레이션 시 채움 |
| `created_at`, `updated_at` | 생성·수정 시각 | | `created_at`, `updated_at` | 생성·수정 시각 |
### `auth_tokens`
| 컬럼 | 설명 |
|---|---|
| `user_id` | 인증 대상 사용자 |
| `type` | `verify_email` 또는 `reset_password` |
| `token_hash` | 원문 토큰의 SHA-256 해시 |
| `expires_at`, `used_at` | 만료·사용 시각 |
| `created_at` | 발급 시각 |
### `decks` ### `decks`
| 컬럼 | 설명 | | 컬럼 | 설명 |
@@ -162,8 +182,12 @@
| Method | Path | 권한 | 기능 | | Method | Path | 권한 | 기능 |
|---|---|---|---| |---|---|---|---|
| POST | `/api/v1/auth/register` | 공개 | 회원가입 | | POST | `/api/v1/auth/register` | 공개 | 미인증 회원 생성·가입 인증 메일 발송 |
| POST | `/api/v1/auth/resend-verification` | 공개 | 가입 인증 메일 재전송 |
| POST | `/api/v1/auth/verify-email` | 공개 | 이메일 인증 토큰 확인 |
| POST | `/api/v1/auth/login` | 공개 | 로그인과 JWT 발급, `last_login_at` 갱신 | | POST | `/api/v1/auth/login` | 공개 | 로그인과 JWT 발급, `last_login_at` 갱신 |
| POST | `/api/v1/auth/forgot-password` | 공개 | 비밀번호 재설정 메일 요청 |
| POST | `/api/v1/auth/reset-password` | 공개 | 1회용 토큰으로 비밀번호 변경 |
| GET | `/api/v1/auth/me` | 회원 | 내 정보와 인증 세션 확인, `last_login_at` 갱신 | | GET | `/api/v1/auth/me` | 회원 | 내 정보와 인증 세션 확인, `last_login_at` 갱신 |
| PUT | `/api/v1/auth/me` | 회원 | 프로필·덱 저장 설정 변경 | | PUT | `/api/v1/auth/me` | 회원 | 프로필·덱 저장 설정 변경 |
| POST | `/api/v1/auth/me/password` | 회원 | 비밀번호 변경 | | POST | `/api/v1/auth/me/password` | 회원 | 비밀번호 변경 |
@@ -216,3 +240,4 @@
- `admin.html`은 3개 언어 번역 적용이 완료되지 않았다. - `admin.html`은 3개 언어 번역 적용이 완료되지 않았다.
- Homepage 전용 `GET /api/homepage` 엔드포인트는 아직 구현되지 않았다. - Homepage 전용 `GET /api/homepage` 엔드포인트는 아직 구현되지 않았다.
- 현재 Compose의 컨테이너명(`uniare-api`, `uniare-web`)과 데이터 볼륨명(`uniare-data`)은 고정값이며 환경 변수화되지 않았다. - 현재 Compose의 컨테이너명(`uniare-api`, `uniare-web`)과 데이터 볼륨명(`uniare-data`)은 고정값이며 환경 변수화되지 않았다.
- Resend 발송은 운영 `.env`의 API 키·발신 주소와 Resend에서 인증된 발신 도메인이 필요하다.

View File

@@ -5,6 +5,7 @@
- NAS에서 `uniare-web`, `uniare-api` 스택의 재기동과 헬스 체크를 최종 확인한다. - NAS에서 `uniare-web`, `uniare-api` 스택의 재기동과 헬스 체크를 최종 확인한다.
- SEO 메타데이터 배포 후 Google Search Console에서 메인 URL 재색인을 요청한다. - SEO 메타데이터 배포 후 Google Search Console에서 메인 URL 재색인을 요청한다.
- 운영 `.env`의 `JWT_SECRET`, 관리자 계정, CORS 허용 도메인을 실제 환경 기준으로 점검한다. - 운영 `.env`의 `JWT_SECRET`, 관리자 계정, CORS 허용 도메인을 실제 환경 기준으로 점검한다.
- Resend에서 발신용 하위 도메인을 인증하고 운영 `.env`의 `RESEND_API_KEY`, `RESEND_FROM_EMAIL`, `APP_BASE_URL`을 설정한 뒤 실제 수신을 확인한다.
- DB와 업로드 이미지(`api/data/previews`, `api/data/avatars`)의 정기 백업·복구 절차를 검증한다. - DB와 업로드 이미지(`api/data/previews`, `api/data/avatars`)의 정기 백업·복구 절차를 검증한다.
- 원격 저장소의 Git LFS 업로드 상태와 용량 정책을 확인한다. - 원격 저장소의 Git LFS 업로드 상태와 용량 정책을 확인한다.
- Compose의 컨테이너명과 데이터 볼륨명을 환경 변수로 관리할지 결정하고, 적용 시 운영 마이그레이션 절차를 작성한다. - Compose의 컨테이너명과 데이터 볼륨명을 환경 변수로 관리할지 결정하고, 적용 시 운영 마이그레이션 절차를 작성한다.

View File

@@ -1,5 +1,15 @@
# 업데이트 이력 # 업데이트 이력
## 2026-06-25 / v4.0.12 — 이메일 인증·비밀번호 재설정
### 수행 작업
- 신규 회원가입 시 Resend 이메일 인증 링크 발송, 인증 완료 전 로그인 차단
- 인증 메일 재전송과 24시간·1회용 인증 토큰 적용
- 비밀번호 찾기·30분 만료 재설정 링크·사용자 직접 비밀번호 변경 화면 추가
- 이메일 형식 서버 검증과 사용자명 대소문자 무시 중복 검사 적용
- 기존 회원은 배포 시 이메일 인증 완료 상태로 마이그레이션
- Resend 환경 변수와 운영 배포·확인 절차 문서화
## 2026-06-24 / v4.0.11 — 로그인 유지·마지막 접속 갱신 개선 ## 2026-06-24 / v4.0.11 — 로그인 유지·마지막 접속 갱신 개선
### 수행 작업 ### 수행 작업

43
forgot-password.html Normal file
View File

@@ -0,0 +1,43 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.12" />
<title>비밀번호 찾기 - UNIARE</title>
</head>
<body class="app-page">
<nav class="app-nav">
<div class="app-nav-inner">
<a href="/" class="app-brand"><img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" /></a>
</div>
</nav>
<main class="mx-auto w-full max-w-md px-6 pt-12 pb-16 sm:pt-16 sm:pb-20">
<header class="mb-8 text-center">
<h1 class="text-3xl font-black tracking-tight text-slate-900">비밀번호 찾기</h1>
<p class="mt-2 text-sm leading-relaxed text-slate-500">가입한 이메일로 재설정 링크를 보내드립니다.</p>
</header>
<form id="forgot-form" class="rounded-2xl border border-slate-200 bg-white p-7 shadow-lg shadow-slate-900/5">
<label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="email">이메일</label>
<input type="email" id="email" required autocomplete="email" placeholder="you@example.com" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" />
<p id="status-msg" class="mt-4 hidden text-sm text-slate-500"></p>
<button type="submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700">재설정 메일 보내기</button>
</form>
<p class="mt-6 text-center"><a href="/login.html" class="text-sm font-bold text-blue-600 hover:underline">로그인으로 돌아가기</a></p>
</main>
<script src="/script/api.js"></script>
<script>
document.getElementById('forgot-form').addEventListener('submit', async (event) => {
event.preventDefault();
const statusEl = document.getElementById('status-msg');
statusEl.classList.remove('hidden');
try {
const result = await UaDeckApi.forgotPassword(document.getElementById('email').value);
statusEl.textContent = result.message;
} catch (err) {
statusEl.textContent = err.message;
}
});
</script>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.11" /> <link rel="stylesheet" href="./style/output.css?v=4.0.12" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" />
<title>로그인 - UNIARE</title> <title>로그인 - UNIARE</title>
</head> </head>
@@ -44,12 +44,11 @@
<a href="/signup.html" class="font-bold text-blue-600 hover:underline" data-i18n="authSignup">회원가입</a> <a href="/signup.html" class="font-bold text-blue-600 hover:underline" data-i18n="authSignup">회원가입</a>
</p> </p>
<p class="mt-3 text-center text-xs leading-relaxed text-slate-400"> <p class="mt-3 text-center text-xs leading-relaxed text-slate-400">
비밀번호를 잊으셨다면 가입한 이메일 주소를 적어 <a href="/forgot-password.html" class="font-semibold text-slate-500 hover:underline">비밀번호를 잊으셨나요?</a>
<a href="mailto:zenn@sori.studio" class="font-semibold text-slate-500 hover:underline">zenn@sori.studio</a>로 문의해 주세요.
</p> </p>
</main> </main>
<footer class="px-6 pb-5 text-center text-[0.625rem] font-medium tracking-wide text-slate-300" aria-hidden="true">v4.0.11</footer> <footer class="px-6 pb-5 text-center text-[0.625rem] font-medium tracking-wide text-slate-300" aria-hidden="true">v4.0.12</footer>
<script src="/script/page-i18n.js"></script> <script src="/script/page-i18n.js"></script>
<script src="/script/api.js"></script> <script src="/script/api.js"></script>

View File

@@ -3,7 +3,7 @@ FROM nginx:alpine
COPY nginx/default.conf /etc/nginx/conf.d/default.conf COPY nginx/default.conf /etc/nginx/conf.d/default.conf
# 정적 사이트 (docker-compose build context = 프로젝트 루트) # 정적 사이트 (docker-compose build context = 프로젝트 루트)
COPY index.html cardList.html result.html login.html signup.html deckHistory.html preset.html profile.html admin.html favicon.ico /usr/share/nginx/html/ COPY index.html cardList.html result.html login.html signup.html verify-email.html forgot-password.html reset-password.html deckHistory.html preset.html profile.html admin.html favicon.ico /usr/share/nginx/html/
COPY script /usr/share/nginx/html/script COPY script /usr/share/nginx/html/script
COPY style /usr/share/nginx/html/style COPY style /usr/share/nginx/html/style
COPY i18n /usr/share/nginx/html/i18n COPY i18n /usr/share/nginx/html/i18n

View File

@@ -59,7 +59,8 @@
<div class="profile-body"> <div class="profile-body">
<div class="profile-field"> <div class="profile-field">
<label class="profile-label" for="profile-email" data-i18n="emailLabel">이메일</label> <label class="profile-label" for="profile-email" data-i18n="emailLabel">이메일</label>
<input type="email" id="profile-email" class="profile-input" autocomplete="email" /> <input type="email" id="profile-email" class="profile-input" autocomplete="email" readonly aria-readonly="true" />
<p class="mt-1 text-xs text-slate-400">인증된 이메일 주소는 현재 직접 변경할 수 없습니다.</p>
</div> </div>
<div class="profile-field"> <div class="profile-field">
<label class="profile-label" for="profile-username" data-i18n="usernameLabel">사용자명</label> <label class="profile-label" for="profile-username" data-i18n="usernameLabel">사용자명</label>

55
reset-password.html Normal file
View File

@@ -0,0 +1,55 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.12" />
<title>비밀번호 재설정 - UNIARE</title>
</head>
<body class="app-page">
<nav class="app-nav">
<div class="app-nav-inner">
<a href="/" class="app-brand"><img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" /></a>
</div>
</nav>
<main class="mx-auto w-full max-w-md px-6 pt-12 pb-16 sm:pt-16 sm:pb-20">
<header class="mb-8 text-center">
<h1 class="text-3xl font-black tracking-tight text-slate-900">비밀번호 재설정</h1>
<p class="mt-2 text-sm leading-relaxed text-slate-500">새로 사용할 비밀번호를 입력하세요.</p>
</header>
<form id="reset-form" class="rounded-2xl border border-slate-200 bg-white p-7 shadow-lg shadow-slate-900/5">
<div class="mb-5">
<label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="password">새 비밀번호</label>
<input type="password" id="password" required minlength="8" autocomplete="new-password" placeholder="8자 이상" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" />
</div>
<label class="mb-1.5 block text-xs font-bold uppercase tracking-wider text-slate-400" for="password-confirm">비밀번호 확인</label>
<input type="password" id="password-confirm" required minlength="8" autocomplete="new-password" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" />
<p id="status-msg" class="mt-4 hidden text-sm text-slate-500"></p>
<button type="submit" class="mt-6 block w-full cursor-pointer rounded-xl border-0 bg-blue-600 px-4 py-3.5 text-sm font-bold text-white transition hover:bg-blue-700">비밀번호 변경</button>
</form>
<p class="mt-6 text-center"><a href="/login.html" class="text-sm font-bold text-blue-600 hover:underline">로그인으로 돌아가기</a></p>
</main>
<script src="/script/api.js"></script>
<script>
document.getElementById('reset-form').addEventListener('submit', async (event) => {
event.preventDefault();
const statusEl = document.getElementById('status-msg');
const password = document.getElementById('password').value;
const confirm = document.getElementById('password-confirm').value;
statusEl.classList.remove('hidden');
if (password !== confirm) {
statusEl.textContent = '비밀번호가 일치하지 않습니다.';
return;
}
try {
const token = new URLSearchParams(location.search).get('token') || '';
const result = await UaDeckApi.resetPassword(token, password);
statusEl.textContent = result.message;
document.querySelector('#reset-form button').disabled = true;
} catch (err) {
statusEl.textContent = err.message;
}
});
</script>
</body>
</html>

View File

@@ -147,6 +147,34 @@ const UaDeckApi = (() => {
register: (body) => register: (body) =>
request('/auth/register', { method: 'POST', body: JSON.stringify(body) }), request('/auth/register', { method: 'POST', body: JSON.stringify(body) }),
/** @param {string} email */
resendVerification: (email) =>
request('/auth/resend-verification', {
method: 'POST',
body: JSON.stringify({ email }),
}),
/** @param {string} token */
verifyEmail: (token) =>
request('/auth/verify-email', {
method: 'POST',
body: JSON.stringify({ token }),
}),
/** @param {string} email */
forgotPassword: (email) =>
request('/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
}),
/** @param {string} token @param {string} newPassword */
resetPassword: (token, newPassword) =>
request('/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, newPassword }),
}),
getMe: () => request('/auth/me'), getMe: () => request('/auth/me'),
/** @param {object} body */ /** @param {object} body */

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.6" /> <link rel="stylesheet" href="./style/output.css?v=4.0.12" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" />
<title>회원가입 - UNIARE</title> <title>회원가입 - UNIARE</title>
</head> </head>
@@ -71,9 +71,8 @@
username: document.getElementById('username').value, username: document.getElementById('username').value,
password, password,
}; };
const { token, user } = await UaDeckApi.register(body); const result = await UaDeckApi.register(body);
UaDeckApi.saveAuth(token, user); location.href = `/verify-email.html?email=${encodeURIComponent(result.email)}`;
location.href = '/';
} catch (err) { } catch (err) {
errEl.textContent = err.message; errEl.textContent = err.message;
errEl.classList.remove('hidden'); errEl.classList.remove('hidden');

File diff suppressed because one or more lines are too long

67
verify-email.html Normal file
View File

@@ -0,0 +1,67 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.12" />
<title>이메일 인증 - UNIARE</title>
</head>
<body class="app-page">
<nav class="app-nav">
<div class="app-nav-inner">
<a href="/" class="app-brand">
<img src="/images/assets/uniare-h48.png" alt="UNIARE" class="app-brand-logo" height="32" />
</a>
</div>
</nav>
<main class="mx-auto w-full max-w-md px-6 pt-12 pb-16 sm:pt-16 sm:pb-20">
<section class="rounded-2xl border border-slate-200 bg-white p-7 text-center shadow-lg shadow-slate-900/5">
<h1 class="text-2xl font-black tracking-tight text-slate-900">이메일 인증</h1>
<p id="status-msg" class="mt-3 text-sm leading-relaxed text-slate-500">가입한 이메일로 인증 링크를 보냈습니다.</p>
<form id="resend-form" class="mt-6">
<label class="mb-1.5 block text-left text-xs font-bold uppercase tracking-wider text-slate-400" for="email">이메일</label>
<input type="email" id="email" required autocomplete="email" class="box-border block w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/15" />
<button type="submit" class="mt-4 block w-full cursor-pointer rounded-xl border-0 bg-slate-100 px-4 py-3 text-sm font-bold text-slate-700 transition hover:bg-slate-200">인증 메일 다시 보내기</button>
</form>
<a href="/login.html" class="mt-5 inline-block text-sm font-bold text-blue-600 hover:underline">로그인으로 이동</a>
</section>
</main>
<script src="/script/api.js"></script>
<script>
const params = new URLSearchParams(location.search);
const token = params.get('token');
const email = params.get('email') || '';
const statusEl = document.getElementById('status-msg');
const emailEl = document.getElementById('email');
emailEl.value = email;
/**
* 이메일 인증 토큰 처리
* @returns {Promise<void>}
*/
async function verifyToken() {
if (!token) return;
document.getElementById('resend-form').classList.add('hidden');
statusEl.textContent = '이메일을 인증하는 중입니다...';
try {
const result = await UaDeckApi.verifyEmail(token);
statusEl.textContent = result.message;
} catch (err) {
statusEl.textContent = err.message;
document.getElementById('resend-form').classList.remove('hidden');
}
}
document.getElementById('resend-form').addEventListener('submit', async (event) => {
event.preventDefault();
try {
const result = await UaDeckApi.resendVerification(emailEl.value);
statusEl.textContent = result.message;
} catch (err) {
statusEl.textContent = err.message;
}
});
verifyToken();
</script>
</body>
</html>