이메일 인증과 비밀번호 재설정 추가
This commit is contained in:
@@ -4,6 +4,11 @@
|
||||
API_PORT=3000
|
||||
JWT_SECRET=여기에-32자-이상-랜덤-시크릿-입력
|
||||
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) ---
|
||||
WEB_PORT=44082
|
||||
|
||||
@@ -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분 동안 한 번만 사용할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,9 @@ services:
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- 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_PASSWORD=${ADMIN_PASSWORD}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
@@ -49,6 +52,9 @@ services:
|
||||
- ./result.html:/usr/share/nginx/html/result.html:ro
|
||||
- ./login.html:/usr/share/nginx/html/login.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
|
||||
- ./preset.html:/usr/share/nginx/html/preset.html:ro
|
||||
- ./profile.html:/usr/share/nginx/html/profile.html:ro
|
||||
|
||||
@@ -9,6 +9,9 @@ services:
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- 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_PASSWORD=${ADMIN_PASSWORD}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 변경사항
|
||||
|
||||
## v4.0.12
|
||||
|
||||
### 이메일 인증 회원가입
|
||||
|
||||
새로 가입하는 사용자는 가입한 이메일로 전송된 링크를 눌러야 로그인할 수 있습니다. 인증 메일을 받지 못한 경우 인증 화면에서 다시 요청할 수 있습니다.
|
||||
|
||||
### 비밀번호 직접 재설정
|
||||
|
||||
로그인 화면의 `비밀번호를 잊으셨나요?`에서 가입 이메일로 재설정 링크를 받아 새 비밀번호를 설정할 수 있습니다.
|
||||
|
||||
### 중복 가입 검증 강화
|
||||
|
||||
이메일 형식을 서버에서도 확인하며, 사용자명은 영문 대소문자만 다른 경우에도 중복 가입을 차단합니다.
|
||||
|
||||
## v4.0.11
|
||||
|
||||
### 로그인 유지 선택
|
||||
|
||||
@@ -29,6 +29,9 @@ JWT_SECRET=32자_이상의_랜덤_문자열
|
||||
ADMIN_EMAIL=관리자_이메일
|
||||
ADMIN_PASSWORD=8자_이상의_비밀번호
|
||||
WEB_PORT=44082
|
||||
APP_BASE_URL=http://localhost:44082
|
||||
RESEND_API_KEY=Resend_API_Key
|
||||
RESEND_FROM_EMAIL=UNIARE <인증된_발신주소>
|
||||
```
|
||||
|
||||
랜덤 문자열은 `openssl rand -hex 32`로 만들 수 있다.
|
||||
@@ -59,15 +62,27 @@ ADMIN_PASSWORD=8자_이상의_비밀번호
|
||||
ADMIN_USERNAME=admin
|
||||
WEB_PORT=44082
|
||||
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 compose -f docker-compose.prod.yml ps`
|
||||
|
||||
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`로 연결한다.
|
||||
|
||||
@@ -142,6 +157,8 @@ curl -s http://127.0.0.1:44082/script/deck-favorites.js | tail -1
|
||||
|
||||
HTML·`style/`·`script/`만 바뀐 경우 API 컨테이너 재시작은 필요 없다.
|
||||
|
||||
이메일 기능은 API와 Compose 환경 변수가 함께 바뀌므로 `git pull`만으로 반영되지 않는다. 반드시 `.env` 설정 후 전체 스택을 다시 빌드한다.
|
||||
|
||||
## 4. 전체 업데이트
|
||||
|
||||
`api/`, `nginx/`, Dockerfile, Compose 설정을 변경했다면 NAS 프로젝트 폴더에서 다음 명령을 실행한다.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-25 v4.0.12 — 신규 가입부터 이메일 소유 확인 적용
|
||||
|
||||
💡 기존 회원을 잠그지 않으면서 허위 이메일 가입과 관리자 의존 비밀번호 초기화를 막기 위함.
|
||||
🔧 기존 회원은 인증 완료로 이관하고 신규 회원은 Resend 인증 후 로그인, 재설정 토큰은 해시·1회용·30분 만료로 저장.
|
||||
|
||||
## 2026-06-24 v4.0.11 — 로그인 유지와 실제 접속 시각을 분리 관리
|
||||
|
||||
💡 브라우저 종료 후 로그인 유지 여부는 사용자가 선택하고, 접속 기록은 재로그인 없이도 실제 이용을 반영해야 하기 때문.
|
||||
|
||||
14
docs/map.md
14
docs/map.md
@@ -18,6 +18,15 @@ login.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
|
||||
|
||||
@@ -110,9 +119,12 @@ api/src/db.js
|
||||
JWT 인증·권한
|
||||
api/src/middleware/auth.js
|
||||
|
||||
회원가입·로그인·프로필·인증 세션 접속 시각 갱신
|
||||
회원가입·이메일 인증·비밀번호 재설정·로그인·프로필·인증 세션 접속 시각 갱신
|
||||
api/src/routes/auth.js
|
||||
|
||||
Resend 인증·비밀번호 재설정 메일
|
||||
api/src/services/emailService.js
|
||||
|
||||
덱 저장·조회·공개 목록
|
||||
api/src/routes/decks.js
|
||||
|
||||
|
||||
29
docs/spec.md
29
docs/spec.md
@@ -3,7 +3,7 @@
|
||||
## 프로젝트 개요
|
||||
|
||||
- 서비스명: `UNIARE - Union Arena Deck Builder`
|
||||
- 현재 버전: `v4.0.11`
|
||||
- 현재 버전: `v4.0.12`
|
||||
- 프론트: 정적 HTML, CSS, JavaScript
|
||||
- 백엔드: Express, SQLite(`better-sqlite3`)
|
||||
- 운영: Docker Compose 기반 Nginx + API
|
||||
@@ -20,6 +20,9 @@
|
||||
| `/result.html` | 덱 미리보기, PNG 다운로드, 회원 덱 저장 |
|
||||
| `/login.html` | 로그인 |
|
||||
| `/signup.html` | 회원가입 |
|
||||
| `/verify-email.html` | 가입 이메일 인증·인증 메일 재전송 |
|
||||
| `/forgot-password.html` | 비밀번호 재설정 메일 요청 |
|
||||
| `/reset-password.html?token=<token>` | 1회용 토큰으로 비밀번호 재설정 |
|
||||
| `/profile.html` | 이메일·사용자명·아바타·비밀번호·덱 공개 기본값 관리 |
|
||||
| `/deckHistory.html` | 내 덱 검색·필터·이름 변경·공개 설정·삭제·불러오기 |
|
||||
| `/preset.html` | 공개 사용자 덱 검색·필터·즐겨찾기·불러오기 |
|
||||
@@ -85,6 +88,12 @@
|
||||
## 인증 및 권한
|
||||
|
||||
- 비밀번호는 bcrypt 비용 12로 해시한다.
|
||||
- 신규 회원은 Resend 인증 링크로 이메일 소유 확인을 완료해야 로그인할 수 있다.
|
||||
- 기존 회원은 이메일 인증 기능 배포 시 인증 완료 상태로 마이그레이션한다.
|
||||
- 인증 링크는 24시간, 비밀번호 재설정 링크는 30분 동안 한 번만 사용할 수 있다.
|
||||
- 인증·재설정 원문 토큰은 DB에 저장하지 않고 SHA-256 해시만 저장한다.
|
||||
- 사용자명은 대소문자를 구분하지 않고 중복을 차단한다.
|
||||
- 인증된 이메일은 프로필에서 직접 변경할 수 없다.
|
||||
- JWT 기본 만료는 7일이며 `JWT_EXPIRES`로 변경할 수 있다.
|
||||
- 로그인 유지 선택 시 브라우저를 닫아도 인증 정보를 유지하고, 미선택 시 현재 브라우저 세션에서만 유지한다.
|
||||
- 인증된 화면 진입 시 `/auth/me` 검증과 함께 `last_login_at`을 갱신한다.
|
||||
@@ -123,8 +132,19 @@
|
||||
| `default_deck_public` | 덱 저장 기본 공개 여부 |
|
||||
| `skip_deck_public_prompt` | 저장 시 공개 확인 생략 여부 |
|
||||
| `last_login_at` | 마지막 로그인 또는 인증 세션 확인 시각 |
|
||||
| `email_verified_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`
|
||||
|
||||
| 컬럼 | 설명 |
|
||||
@@ -162,8 +182,12 @@
|
||||
|
||||
| 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/forgot-password` | 공개 | 비밀번호 재설정 메일 요청 |
|
||||
| POST | `/api/v1/auth/reset-password` | 공개 | 1회용 토큰으로 비밀번호 변경 |
|
||||
| GET | `/api/v1/auth/me` | 회원 | 내 정보와 인증 세션 확인, `last_login_at` 갱신 |
|
||||
| PUT | `/api/v1/auth/me` | 회원 | 프로필·덱 저장 설정 변경 |
|
||||
| POST | `/api/v1/auth/me/password` | 회원 | 비밀번호 변경 |
|
||||
@@ -216,3 +240,4 @@
|
||||
- `admin.html`은 3개 언어 번역 적용이 완료되지 않았다.
|
||||
- Homepage 전용 `GET /api/homepage` 엔드포인트는 아직 구현되지 않았다.
|
||||
- 현재 Compose의 컨테이너명(`uniare-api`, `uniare-web`)과 데이터 볼륨명(`uniare-data`)은 고정값이며 환경 변수화되지 않았다.
|
||||
- Resend 발송은 운영 `.env`의 API 키·발신 주소와 Resend에서 인증된 발신 도메인이 필요하다.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
- NAS에서 `uniare-web`, `uniare-api` 스택의 재기동과 헬스 체크를 최종 확인한다.
|
||||
- SEO 메타데이터 배포 후 Google Search Console에서 메인 URL 재색인을 요청한다.
|
||||
- 운영 `.env`의 `JWT_SECRET`, 관리자 계정, CORS 허용 도메인을 실제 환경 기준으로 점검한다.
|
||||
- Resend에서 발신용 하위 도메인을 인증하고 운영 `.env`의 `RESEND_API_KEY`, `RESEND_FROM_EMAIL`, `APP_BASE_URL`을 설정한 뒤 실제 수신을 확인한다.
|
||||
- DB와 업로드 이미지(`api/data/previews`, `api/data/avatars`)의 정기 백업·복구 절차를 검증한다.
|
||||
- 원격 저장소의 Git LFS 업로드 상태와 용량 정책을 확인한다.
|
||||
- Compose의 컨테이너명과 데이터 볼륨명을 환경 변수로 관리할지 결정하고, 적용 시 운영 마이그레이션 절차를 작성한다.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## 2026-06-25 / v4.0.12 — 이메일 인증·비밀번호 재설정
|
||||
|
||||
### 수행 작업
|
||||
- 신규 회원가입 시 Resend 이메일 인증 링크 발송, 인증 완료 전 로그인 차단
|
||||
- 인증 메일 재전송과 24시간·1회용 인증 토큰 적용
|
||||
- 비밀번호 찾기·30분 만료 재설정 링크·사용자 직접 비밀번호 변경 화면 추가
|
||||
- 이메일 형식 서버 검증과 사용자명 대소문자 무시 중복 검사 적용
|
||||
- 기존 회원은 배포 시 이메일 인증 완료 상태로 마이그레이션
|
||||
- Resend 환경 변수와 운영 배포·확인 절차 문서화
|
||||
|
||||
## 2026-06-24 / v4.0.11 — 로그인 유지·마지막 접속 갱신 개선
|
||||
|
||||
### 수행 작업
|
||||
|
||||
43
forgot-password.html
Normal file
43
forgot-password.html
Normal 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>
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.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" />
|
||||
<title>로그인 - UNIARE</title>
|
||||
</head>
|
||||
@@ -44,12 +44,11 @@
|
||||
<a href="/signup.html" class="font-bold text-blue-600 hover:underline" data-i18n="authSignup">회원가입</a>
|
||||
</p>
|
||||
<p class="mt-3 text-center text-xs leading-relaxed text-slate-400">
|
||||
비밀번호를 잊으셨다면 가입한 이메일 주소를 적어
|
||||
<a href="mailto:zenn@sori.studio" class="font-semibold text-slate-500 hover:underline">zenn@sori.studio</a>로 문의해 주세요.
|
||||
<a href="/forgot-password.html" class="font-semibold text-slate-500 hover:underline">비밀번호를 잊으셨나요?</a>
|
||||
</p>
|
||||
</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/api.js"></script>
|
||||
|
||||
@@ -3,7 +3,7 @@ FROM nginx:alpine
|
||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 정적 사이트 (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 style /usr/share/nginx/html/style
|
||||
COPY i18n /usr/share/nginx/html/i18n
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
<div class="profile-body">
|
||||
<div class="profile-field">
|
||||
<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 class="profile-field">
|
||||
<label class="profile-label" for="profile-username" data-i18n="usernameLabel">사용자명</label>
|
||||
|
||||
55
reset-password.html
Normal file
55
reset-password.html
Normal 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>
|
||||
@@ -147,6 +147,34 @@ const UaDeckApi = (() => {
|
||||
register: (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'),
|
||||
|
||||
/** @param {object} body */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.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" />
|
||||
<title>회원가입 - UNIARE</title>
|
||||
</head>
|
||||
@@ -71,9 +71,8 @@
|
||||
username: document.getElementById('username').value,
|
||||
password,
|
||||
};
|
||||
const { token, user } = await UaDeckApi.register(body);
|
||||
UaDeckApi.saveAuth(token, user);
|
||||
location.href = '/';
|
||||
const result = await UaDeckApi.register(body);
|
||||
location.href = `/verify-email.html?email=${encodeURIComponent(result.email)}`;
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.classList.remove('hidden');
|
||||
|
||||
File diff suppressed because one or more lines are too long
67
verify-email.html
Normal file
67
verify-email.html
Normal 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>
|
||||
Reference in New Issue
Block a user