From 20095a79dbc72d1f322df133356626e00c9fdb15 Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 21 Apr 2026 17:56:47 +0900 Subject: [PATCH] =?UTF-8?q?v0.1.9=20-=20=EC=9D=B8=EC=A6=9D=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 8 +- TODO.md | 3 + backend/src/config.js | 1 + backend/src/db/init.js | 36 +++++++ backend/src/db/schema.js | 8 ++ backend/src/lib/password.js | 47 +++++++++ backend/src/routes/auth.js | 197 ++++++++++++++++++++++++++++++++++++ backend/src/server.js | 10 +- package-lock.json | 4 +- package.json | 2 +- 10 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 backend/src/db/init.js create mode 100644 backend/src/lib/password.js create mode 100644 backend/src/routes/auth.js diff --git a/HANDOFF.md b/HANDOFF.md index 21c7e5c..c6550f6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.8` +- 현재 기준 버전: `v0.1.9` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -28,6 +28,8 @@ - 우측 달력 컴포넌트: `src/components/MiniCalendar.vue` - 백엔드 엔트리 포인트: `backend/src/server.js` - 백엔드 DB 스키마: `backend/src/db/schema.js` +- 백엔드 인증 라우트: `backend/src/routes/auth.js` +- 백엔드 비밀번호/세션 유틸: `backend/src/lib/password.js` - Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다. - 현재 선택 날짜는 시스템 날짜 기준으로 시작한다. - `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다. @@ -63,6 +65,9 @@ - `1-UP`은 세로 가운데 정렬을 없애고 상단 기준으로 붙여야 여백이 덜 커 보인다. - 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다. - 백엔드 초안은 `Fastify + Drizzle + SQLite` 조합이며, 현재는 `/health`, `/api/meta` 정도의 기본 라우트만 있다. +- 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다. +- 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다. +- 초기 실행 시 `backend/src/db/init.js`에서 테이블이 없으면 자동 생성하도록 맞춰두었다. - 현재 샌드박스에서는 포트 바인딩 제한 때문에 백엔드 실제 리슨 확인이 막힐 수 있다. `listen EPERM 0.0.0.0:3001`은 코드 자체보다 실행 환경 제약에 가깝다. ## 확정된 결정사항 @@ -102,6 +107,7 @@ - DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다. - 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + SQLite 또는 PostgreSQL`이다. - 현재는 SQLite로 시작하되, 확장 시 PostgreSQL로 옮길 수 있게 Drizzle 기반 스키마를 유지한다. +- 현재 인증 방식은 Bearer 토큰 기반의 간단한 세션 구조이며, 추후 쿠키/리프레시 토큰 전략으로 확장할 수 있다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. diff --git a/TODO.md b/TODO.md index 88b56b7..e8a91ca 100644 --- a/TODO.md +++ b/TODO.md @@ -49,6 +49,7 @@ - [x] 입력 상태가 새로고침 후에도 유지되도록 만든다. - [x] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다. - [ ] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다. +- [x] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다. - [ ] 사용자별 문서 저장/조회 흐름을 정리한다. - [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다. @@ -68,6 +69,7 @@ ## 6단계: 계정 및 서비스 확장 - [ ] 회원 가입 / 로그인 방식 후보를 정리한다. +- [x] 회원 가입 / 로그인 방식 후보를 정리한다. - [ ] 사용자별 문서 분리 저장 구조를 설계한다. - [ ] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다. - [x] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다. @@ -89,4 +91,5 @@ - 최종 배포는 UGREEN NAS에서 Docker 기반으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다. - 백엔드는 빠른 목업이면 PocketBase도 가능하지만, 현재 방향상 커스텀 로직과 확장성을 생각하면 전용 Node.js API + DB 조합을 우선 검토한다. - 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + SQLite` 기준 초안이 추가되었다. +- 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다. - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. diff --git a/backend/src/config.js b/backend/src/config.js index 940ea19..ba56472 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -12,6 +12,7 @@ const envSchema = z.object({ PORT: z.coerce.number().default(3001), DB_FILE: z.string().default('./data/planner.sqlite'), CORS_ORIGIN: z.string().default('http://localhost:5173'), + SESSION_TTL_DAYS: z.coerce.number().default(30), }) export const env = envSchema.parse(process.env) diff --git a/backend/src/db/init.js b/backend/src/db/init.js new file mode 100644 index 0000000..f2b8473 --- /dev/null +++ b/backend/src/db/init.js @@ -0,0 +1,36 @@ +import { sqlite } from './client.js' + +export function ensureDatabaseSchema() { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + nickname TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS auth_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS planner_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + entry_date TEXT NOT NULL, + payload TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE UNIQUE INDEX IF NOT EXISTS planner_entries_user_date_unique + ON planner_entries (user_id, entry_date); + `) +} diff --git a/backend/src/db/schema.js b/backend/src/db/schema.js index 38ff1b9..338b18d 100644 --- a/backend/src/db/schema.js +++ b/backend/src/db/schema.js @@ -9,6 +9,14 @@ export const users = sqliteTable('users', { updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), }) +export const authSessions = sqliteTable('auth_sessions', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + tokenHash: text('token_hash').notNull().unique(), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), +}) + export const plannerEntries = sqliteTable('planner_entries', { id: integer('id').primaryKey({ autoIncrement: true }), userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), diff --git a/backend/src/lib/password.js b/backend/src/lib/password.js new file mode 100644 index 0000000..3aa93f6 --- /dev/null +++ b/backend/src/lib/password.js @@ -0,0 +1,47 @@ +import crypto from 'node:crypto' + +const SCRYPT_KEY_LENGTH = 64 + +function scryptAsync(password, salt) { + return new Promise((resolve, reject) => { + crypto.scrypt(password, salt, SCRYPT_KEY_LENGTH, (error, derivedKey) => { + if (error) { + reject(error) + return + } + + resolve(derivedKey) + }) + }) +} + +export async function hashPassword(password) { + const salt = crypto.randomBytes(16).toString('hex') + const derivedKey = await scryptAsync(password, salt) + return `${salt}:${derivedKey.toString('hex')}` +} + +export async function verifyPassword(password, storedHash) { + const [salt, originalHash] = storedHash.split(':') + + if (!salt || !originalHash) { + return false + } + + const derivedKey = await scryptAsync(password, salt) + const originalBuffer = Buffer.from(originalHash, 'hex') + + if (originalBuffer.length !== derivedKey.length) { + return false + } + + return crypto.timingSafeEqual(originalBuffer, derivedKey) +} + +export function createSessionToken() { + return crypto.randomBytes(32).toString('hex') +} + +export function hashSessionToken(token) { + return crypto.createHash('sha256').update(token).digest('hex') +} diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..df31bfe --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,197 @@ +import { eq } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '../db/client.js' +import { authSessions, users } from '../db/schema.js' +import { env } from '../config.js' +import { + createSessionToken, + hashPassword, + hashSessionToken, + verifyPassword, +} from '../lib/password.js' + +const signupSchema = z.object({ + email: z.string().trim().email(), + password: z.string().min(8).max(72), + nickname: z.string().trim().min(2).max(30), +}) + +const loginSchema = z.object({ + email: z.string().trim().email(), + password: z.string().min(1).max(72), +}) + +function sanitizeUser(user) { + return { + id: user.id, + email: user.email, + nickname: user.nickname, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + } +} + +function getBearerToken(request) { + const authorization = request.headers.authorization + + if (!authorization?.startsWith('Bearer ')) { + return null + } + + return authorization.slice('Bearer '.length).trim() +} + +async function createSession(userId) { + const token = createSessionToken() + const tokenHash = hashSessionToken(token) + const now = Date.now() + const expiresAt = now + env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000 + + const [session] = await db + .insert(authSessions) + .values({ + userId, + tokenHash, + expiresAt: new Date(expiresAt), + createdAt: new Date(now), + }) + .returning() + + return { + token, + session, + } +} + +async function findAuthenticatedUser(request) { + const token = getBearerToken(request) + + if (!token) { + return null + } + + const tokenHash = hashSessionToken(token) + + const [session] = await db + .select() + .from(authSessions) + .where(eq(authSessions.tokenHash, tokenHash)) + .limit(1) + + if (!session || new Date(session.expiresAt).getTime() <= Date.now()) { + return null + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, session.userId)) + .limit(1) + + return user ?? null +} + +export async function registerAuthRoutes(app) { + app.post('/api/auth/signup', async (request, reply) => { + const payload = signupSchema.safeParse(request.body) + + if (!payload.success) { + return reply.code(400).send({ + message: '회원가입 입력값이 올바르지 않습니다.', + issues: payload.error.flatten(), + }) + } + + const { email, password, nickname } = payload.data + const normalizedEmail = email.toLowerCase() + + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.email, normalizedEmail)) + .limit(1) + + if (existingUser) { + return reply.code(409).send({ + message: '이미 사용 중인 이메일입니다.', + }) + } + + const now = new Date() + const passwordHash = await hashPassword(password) + + const [user] = await db + .insert(users) + .values({ + email: normalizedEmail, + passwordHash, + nickname, + createdAt: now, + updatedAt: now, + }) + .returning() + + const { token } = await createSession(user.id) + + return reply.code(201).send({ + message: '회원가입이 완료되었습니다.', + token, + user: sanitizeUser(user), + }) + }) + + app.post('/api/auth/login', async (request, reply) => { + const payload = loginSchema.safeParse(request.body) + + if (!payload.success) { + return reply.code(400).send({ + message: '로그인 입력값이 올바르지 않습니다.', + issues: payload.error.flatten(), + }) + } + + const normalizedEmail = payload.data.email.toLowerCase() + + const [user] = await db + .select() + .from(users) + .where(eq(users.email, normalizedEmail)) + .limit(1) + + if (!user) { + return reply.code(401).send({ + message: '이메일 또는 비밀번호가 올바르지 않습니다.', + }) + } + + const passwordMatches = await verifyPassword(payload.data.password, user.passwordHash) + + if (!passwordMatches) { + return reply.code(401).send({ + message: '이메일 또는 비밀번호가 올바르지 않습니다.', + }) + } + + const { token } = await createSession(user.id) + + return { + message: '로그인에 성공했습니다.', + token, + user: sanitizeUser(user), + } + }) + + app.get('/api/auth/me', async (request, reply) => { + const user = await findAuthenticatedUser(request) + + if (!user) { + return reply.code(401).send({ + message: '인증이 필요합니다.', + }) + } + + return { + user: sanitizeUser(user), + } + }) +} diff --git a/backend/src/server.js b/backend/src/server.js index 1f5e73e..e125325 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -2,16 +2,22 @@ import Fastify from 'fastify' import cors from '@fastify/cors' import { env } from './config.js' import { sqlite } from './db/client.js' +import { ensureDatabaseSchema } from './db/init.js' +import { registerAuthRoutes } from './routes/auth.js' const app = Fastify({ logger: true, }) +ensureDatabaseSchema() + await app.register(cors, { origin: env.CORS_ORIGIN, credentials: true, }) +await registerAuthRoutes(app) + app.get('/health', async () => { const version = sqlite.prepare('select sqlite_version() as version').get() @@ -26,11 +32,11 @@ app.get('/health', async () => { }) app.get('/api/meta', async () => ({ - auth: 'planned', + auth: 'active', storage: 'sqlite', orm: 'drizzle', notes: [ - '회원가입 및 로그인 API는 다음 단계에서 추가 예정', + '회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.', '플래너 저장 API는 로컬 저장 레이어 분리 이후 연결 예정', ], })) diff --git a/package-lock.json b/package-lock.json index fc48d38..0054e00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index bb189c8..749fc72 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.8", + "version": "0.1.9", "type": "module", "scripts": { "dev": "vite",