Compare commits

...

1 Commits

Author SHA1 Message Date
20095a79db v0.1.9 - 인증 API 추가 2026-04-21 17:56:47 +09:00
10 changed files with 310 additions and 6 deletions

View File

@@ -4,7 +4,7 @@
- 프로젝트명: 10 Minute Planner 웹 UI - 프로젝트명: 10 Minute Planner 웹 UI
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
- 현재 기준 버전: `v0.1.8` - 현재 기준 버전: `v0.1.9`
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
## 기준 디자인 ## 기준 디자인
@@ -28,6 +28,8 @@
- 우측 달력 컴포넌트: `src/components/MiniCalendar.vue` - 우측 달력 컴포넌트: `src/components/MiniCalendar.vue`
- 백엔드 엔트리 포인트: `backend/src/server.js` - 백엔드 엔트리 포인트: `backend/src/server.js`
- 백엔드 DB 스키마: `backend/src/db/schema.js` - 백엔드 DB 스키마: `backend/src/db/schema.js`
- 백엔드 인증 라우트: `backend/src/routes/auth.js`
- 백엔드 비밀번호/세션 유틸: `backend/src/lib/password.js`
- Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다. - Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다.
- 현재 선택 날짜는 시스템 날짜 기준으로 시작한다. - 현재 선택 날짜는 시스템 날짜 기준으로 시작한다.
- `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다. - `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다.
@@ -63,6 +65,9 @@
- `1-UP`은 세로 가운데 정렬을 없애고 상단 기준으로 붙여야 여백이 덜 커 보인다. - `1-UP`은 세로 가운데 정렬을 없애고 상단 기준으로 붙여야 여백이 덜 커 보인다.
- 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다. - 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다.
- 백엔드 초안은 `Fastify + Drizzle + SQLite` 조합이며, 현재는 `/health`, `/api/meta` 정도의 기본 라우트만 있다. - 백엔드 초안은 `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`은 코드 자체보다 실행 환경 제약에 가깝다. - 현재 샌드박스에서는 포트 바인딩 제한 때문에 백엔드 실제 리슨 확인이 막힐 수 있다. `listen EPERM 0.0.0.0:3001`은 코드 자체보다 실행 환경 제약에 가깝다.
## 확정된 결정사항 ## 확정된 결정사항
@@ -102,6 +107,7 @@
- DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다. - DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다.
- 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + SQLite 또는 PostgreSQL`이다. - 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + SQLite 또는 PostgreSQL`이다.
- 현재는 SQLite로 시작하되, 확장 시 PostgreSQL로 옮길 수 있게 Drizzle 기반 스키마를 유지한다. - 현재는 SQLite로 시작하되, 확장 시 PostgreSQL로 옮길 수 있게 Drizzle 기반 스키마를 유지한다.
- 현재 인증 방식은 Bearer 토큰 기반의 간단한 세션 구조이며, 추후 쿠키/리프레시 토큰 전략으로 확장할 수 있다.
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
- Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다.

View File

@@ -49,6 +49,7 @@
- [x] 입력 상태가 새로고침 후에도 유지되도록 만든다. - [x] 입력 상태가 새로고침 후에도 유지되도록 만든다.
- [x] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다. - [x] DB 전환 시점을 잡을 수 있도록 저장 레이어를 분리한다.
- [ ] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다. - [ ] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [x] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다.
- [ ] 사용자별 문서 저장/조회 흐름을 정리한다. - [ ] 사용자별 문서 저장/조회 흐름을 정리한다.
- [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다. - [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다.
@@ -68,6 +69,7 @@
## 6단계: 계정 및 서비스 확장 ## 6단계: 계정 및 서비스 확장
- [ ] 회원 가입 / 로그인 방식 후보를 정리한다. - [ ] 회원 가입 / 로그인 방식 후보를 정리한다.
- [x] 회원 가입 / 로그인 방식 후보를 정리한다.
- [ ] 사용자별 문서 분리 저장 구조를 설계한다. - [ ] 사용자별 문서 분리 저장 구조를 설계한다.
- [ ] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다. - [ ] 공유가 아닌 개인 보관용 서비스 흐름으로 요구사항을 정리한다.
- [x] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다. - [x] 향후 출력 기능을 위한 인쇄 레이아웃 요구사항을 정리한다.
@@ -89,4 +91,5 @@
- 최종 배포는 UGREEN NAS에서 Docker 기반으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다. - 최종 배포는 UGREEN NAS에서 Docker 기반으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다.
- 백엔드는 빠른 목업이면 PocketBase도 가능하지만, 현재 방향상 커스텀 로직과 확장성을 생각하면 전용 Node.js API + DB 조합을 우선 검토한다. - 백엔드는 빠른 목업이면 PocketBase도 가능하지만, 현재 방향상 커스텀 로직과 확장성을 생각하면 전용 Node.js API + DB 조합을 우선 검토한다.
- 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + SQLite` 기준 초안이 추가되었다. - 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + SQLite` 기준 초안이 추가되었다.
- 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다.
- 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다.

View File

@@ -12,6 +12,7 @@ const envSchema = z.object({
PORT: z.coerce.number().default(3001), PORT: z.coerce.number().default(3001),
DB_FILE: z.string().default('./data/planner.sqlite'), DB_FILE: z.string().default('./data/planner.sqlite'),
CORS_ORIGIN: z.string().default('http://localhost:5173'), CORS_ORIGIN: z.string().default('http://localhost:5173'),
SESSION_TTL_DAYS: z.coerce.number().default(30),
}) })
export const env = envSchema.parse(process.env) export const env = envSchema.parse(process.env)

36
backend/src/db/init.js Normal file
View File

@@ -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);
`)
}

View File

@@ -9,6 +9,14 @@ export const users = sqliteTable('users', {
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), 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', { export const plannerEntries = sqliteTable('planner_entries', {
id: integer('id').primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),

View File

@@ -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')
}

197
backend/src/routes/auth.js Normal file
View File

@@ -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),
}
})
}

View File

@@ -2,16 +2,22 @@ import Fastify from 'fastify'
import cors from '@fastify/cors' import cors from '@fastify/cors'
import { env } from './config.js' import { env } from './config.js'
import { sqlite } from './db/client.js' import { sqlite } from './db/client.js'
import { ensureDatabaseSchema } from './db/init.js'
import { registerAuthRoutes } from './routes/auth.js'
const app = Fastify({ const app = Fastify({
logger: true, logger: true,
}) })
ensureDatabaseSchema()
await app.register(cors, { await app.register(cors, {
origin: env.CORS_ORIGIN, origin: env.CORS_ORIGIN,
credentials: true, credentials: true,
}) })
await registerAuthRoutes(app)
app.get('/health', async () => { app.get('/health', async () => {
const version = sqlite.prepare('select sqlite_version() as version').get() const version = sqlite.prepare('select sqlite_version() as version').get()
@@ -26,11 +32,11 @@ app.get('/health', async () => {
}) })
app.get('/api/meta', async () => ({ app.get('/api/meta', async () => ({
auth: 'planned', auth: 'active',
storage: 'sqlite', storage: 'sqlite',
orm: 'drizzle', orm: 'drizzle',
notes: [ notes: [
'회원가입 로그인 API는 다음 단계에서 추가 예정', '회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.',
'플래너 저장 API는 로컬 저장 레이어 분리 이후 연결 예정', '플래너 저장 API는 로컬 저장 레이어 분리 이후 연결 예정',
], ],
})) }))

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"version": "0.1.8", "version": "0.1.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"version": "0.1.8", "version": "0.1.9",
"dependencies": { "dependencies": {
"vue": "^3.5.13" "vue": "^3.5.13"
}, },

View File

@@ -1,7 +1,7 @@
{ {
"name": "ten-minute-planner", "name": "ten-minute-planner",
"private": true, "private": true,
"version": "0.1.8", "version": "0.1.9",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",