diff --git a/package-lock.json b/package-lock.json index 052f985..3c41489 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,14 @@ "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "drizzle-orm": "^0.40.0", + "jsonwebtoken": "^9.0.2", "postgres": "^3.4.5" }, "devDependencies": { "@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22", "drizzle-kit": "^0.30.2", "svelte": "^5.0.0", @@ -1696,6 +1698,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.15.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.33.tgz", @@ -1739,6 +1759,12 @@ "node": ">= 0.4" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1955,6 +1981,15 @@ } } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -2121,6 +2156,49 @@ "node": ">=16" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -2138,6 +2216,48 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -2172,7 +2292,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "devOptional": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2333,11 +2452,30 @@ "node": ">=6" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/package.json b/package.json index 94e8832..0cc8022 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22", "drizzle-kit": "^0.30.2", "svelte": "^5.0.0", @@ -31,6 +32,7 @@ "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "drizzle-orm": "^0.40.0", + "jsonwebtoken": "^9.0.2", "postgres": "^3.4.5" } } diff --git a/src/app.d.ts b/src/app.d.ts index a0bf614..2bd3b57 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,11 +1,8 @@ -// for information about these interfaces -declare global { - namespace App { - interface Locals { - user: import('$lib/server/auth').SessionValidationResult['user']; - session: import('$lib/server/auth').SessionValidationResult['session'] - } +// src/app.d.ts +declare namespace App { + interface Locals { + user: { + id: string; + } | null; } -} - -export {}; +} \ No newline at end of file diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 94001b6..e9532a8 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,26 +1,63 @@ import type { Handle } from '@sveltejs/kit'; import * as auth from '$lib/server/auth'; +import { db } from '$lib/server/db'; +import * as table from '$lib/server/db/schema'; const handleAuth: Handle = async ({ event, resolve }) => { - const sessionToken = event.cookies.get(auth.sessionCookieName); + // 1. 먼저 액세스 토큰 확인 + const accessToken = event.cookies.get(auth.jwtCookieName); - if (!sessionToken) { + if (accessToken) { + const { user, isValid } = auth.validateJwtToken(accessToken); + + if (isValid && user) { + event.locals.user = user; + return resolve(event); + } + // 액세스 토큰이 유효하지 않으면 refresh 시도 + } + + // 2. 액세스 토큰이 없거나 유효하지 않을 때 리프레시 토큰 확인 + const refreshToken = event.cookies.get(auth.refreshCookieName); + + if (!refreshToken) { + // 두 토큰 모두 없거나 유효하지 않으면 인증되지 않은 사용자로 처리 event.locals.user = null; - event.locals.session = null; return resolve(event); } - const { session, user } = await auth.validateSessionToken(sessionToken); + // 리프레시 토큰 검증 + const { userId, isValid } = auth.validateRefreshToken(refreshToken); - if (session) { - auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); - } else { - auth.deleteSessionTokenCookie(event); + if (!isValid || !userId) { + // 리프레시 토큰이 유효하지 않으면 모든 쿠키 삭제 + auth.deleteAuthCookies(event); + event.locals.user = null; + return resolve(event); } - event.locals.user = user; - event.locals.session = session; + // 유효한 리프레시 토큰이 있으면 사용자 정보 조회 + const user = await db.query.users.findFirst({ + where: (users, { eq }) => eq(users.id, userId) + }); + + if (!user) { + auth.deleteAuthCookies(event); + event.locals.user = null; + return resolve(event); + } + + // 새 액세스 토큰 발급 + const newAccessToken = auth.generateAccessToken(user.id, user.email); + auth.setAccessTokenCookie(event, newAccessToken); + + // 사용자 정보 설정 + event.locals.user = { + id: user.id, + email: user.email + }; + return resolve(event); }; -export const handle: Handle = handleAuth; +export const handle: Handle = handleAuth; \ No newline at end of file diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 38c9930..ad39082 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,81 +1,67 @@ import type { RequestEvent } from '@sveltejs/kit'; -import { eq } from 'drizzle-orm'; -import { sha256 } from '@oslojs/crypto/sha2'; -import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding'; +import jwt from 'jsonwebtoken'; import { db } from '$lib/server/db'; import * as table from '$lib/server/db/schema'; -const DAY_IN_MS = 1000 * 60 * 60 * 24; -export const sessionCookieName = 'auth-session'; +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; +const REFRESH_SECRET = process.env.REFRESH_SECRET || 'refresh-secret-key'; // 별도의 키 사용 권장 +export const jwtCookieName = 'auth-token'; +export const refreshCookieName = 'refresh-token'; -export function generateSessionToken() { - const bytes = crypto.getRandomValues(new Uint8Array(18)); - const token = encodeBase64url(bytes); - return token; +// 액세스 토큰 생성 (15분 유효) +export function generateAccessToken(userId: string) { + return jwt.sign( + { userId }, + JWT_SECRET, + { expiresIn: '15m' } + ); } -export async function createSession(token: string, userId: string) { - const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); - const session: table.Session = { - id: sessionId, - userId, - expiresAt: new Date(Date.now() + DAY_IN_MS * 30) - }; - await db.insert(table.session).values(session); - return session; +// 리프레시 토큰 생성 (30일 유효) +export function generateRefreshToken(userId: string) { + return jwt.sign( + { userId, type: 'refresh' }, + REFRESH_SECRET, + { expiresIn: '30d' } + ); } -export async function validateSessionToken(token: string) { - const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); - const [result] = await db - .select({ - // Adjust user table here to tweak returned data - user: { id: table.user.id, username: table.user.username }, - session: table.session - }) - .from(table.session) - .innerJoin(table.user, eq(table.session.userId, table.user.id)) - .where(eq(table.session.id, sessionId)); - - if (!result) { - return { session: null, user: null }; +// 리프레시 토큰 검증 +export function validateRefreshToken(token: string) { + try { + const decoded = jwt.verify(token, REFRESH_SECRET) as { userId: string, type: string }; + if (decoded.type !== 'refresh') { + return { userId: null, isValid: false }; + } + return { userId: decoded.userId, isValid: true }; + } catch (error) { + return { userId: null, isValid: false }; } - const { session, user } = result; - - const sessionExpired = Date.now() >= session.expiresAt.getTime(); - if (sessionExpired) { - await db.delete(table.session).where(eq(table.session.id, session.id)); - return { session: null, user: null }; - } - - const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15; - if (renewSession) { - session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30); - await db - .update(table.session) - .set({ expiresAt: session.expiresAt }) - .where(eq(table.session.id, session.id)); - } - - return { session, user }; } -export type SessionValidationResult = Awaited>; - -export async function invalidateSession(sessionId: string) { - await db.delete(table.session).where(eq(table.session.id, sessionId)); -} - -export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) { - event.cookies.set(sessionCookieName, token, { - expires: expiresAt, - path: '/' +// 쿠키 설정 함수들 +export function setAccessTokenCookie(event: RequestEvent, token: string) { + event.cookies.set(jwtCookieName, token, { + expires: new Date(Date.now() + 15 * 60 * 1000), // 15분 + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' }); } -export function deleteSessionTokenCookie(event: RequestEvent) { - event.cookies.delete(sessionCookieName, { - path: '/' +export function setRefreshTokenCookie(event: RequestEvent, token: string) { + event.cookies.set(refreshCookieName, token, { + expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' }); } + +export function deleteAuthCookies(event: RequestEvent) { + event.cookies.delete(jwtCookieName, { path: '/' }); + event.cookies.delete(refreshCookieName, { path: '/' }); +} \ No newline at end of file diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index e0e2239..a80fe94 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -2,17 +2,16 @@ import { pgTable, serial, integer, text, timestamp } from 'drizzle-orm/pg-core'; export const user = pgTable('user', { id: text('id').primaryKey(), - age: integer('age'), - username: text('username').notNull().unique(), + email: text('email').notNull().unique(), passwordHash: text('password_hash').notNull() }); -export const session = pgTable('session', { +export const refreshTokens = pgTable('refresh_tokens', { id: text('id').primaryKey(), - userId: text('user_id').notNull().references(() => user.id), - expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull() + userId: text('user_id').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull() }); -export type Session = typeof session.$inferSelect; export type User = typeof user.$inferSelect; diff --git a/src/routes/demo/lucia/+page.server.ts b/src/routes/demo/lucia/+page.server.ts index a123e7e..a885062 100644 --- a/src/routes/demo/lucia/+page.server.ts +++ b/src/routes/demo/lucia/+page.server.ts @@ -10,12 +10,7 @@ export const load: PageServerLoad = async () => { export const actions: Actions = { logout: async (event) => { - if (!event.locals.session) { - return fail(401); - } - await auth.invalidateSession(event.locals.session.id); - auth.deleteSessionTokenCookie(event); - + auth.deleteAuthCookies(event); return redirect(302, '/demo/lucia/login'); }, }; diff --git a/src/routes/demo/lucia/login/+page.server.ts b/src/routes/demo/lucia/login/+page.server.ts index d8e0ddd..2f19012 100644 --- a/src/routes/demo/lucia/login/+page.server.ts +++ b/src/routes/demo/lucia/login/+page.server.ts @@ -17,20 +17,20 @@ export const load: PageServerLoad = async (event) => { export const actions: Actions = { login: async (event) => { const formData = await event.request.formData(); - const username = formData.get('username'); + const email = formData.get('email'); const password = formData.get('password'); - - if (!validateUsername(username)) { - return fail(400, { message: 'Invalid username (min 3, max 31 characters, alphanumeric only)' }); - } - if (!validatePassword(password)) { - return fail(400, { message: 'Invalid password (min 6, max 255 characters)' }); + // 타입 체크 및 검증 + if (!email || typeof email !== 'string') { + return fail(400, { message: '이메일이 필요합니다' }); } + if (!password || typeof password !== 'string') { + return fail(400, { message: '비밀번호가 필요합니다' }); + } const results = await db .select() .from(table.user) - .where(eq(table.user.username, username)); + .where(eq(table.user.email, email)); const existingUser = results.at(0); if (!existingUser) { @@ -47,25 +47,31 @@ export const actions: Actions = { return fail(400, { message: 'Incorrect username or password' }); } - const sessionToken = auth.generateSessionToken(); - const session = await auth.createSession(sessionToken, existingUser.id); - auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); + // 두 토큰 모두 생성 + const accessToken = auth.generateAccessToken(existingUser.id); + const refreshToken = auth.generateRefreshToken(existingUser.id); - return redirect(302, '/demo/lucia'); + // 두 쿠키 모두 설정 + auth.setAccessTokenCookie(event, accessToken); + auth.setRefreshTokenCookie(event, refreshToken); + + return redirect(302, '/dashboard'); }, register: async (event) => { const formData = await event.request.formData(); - const username = formData.get('username'); + const email = formData.get('email'); const password = formData.get('password'); - if (!validateUsername(username)) { - return fail(400, { message: 'Invalid username' }); - } - if (!validatePassword(password)) { - return fail(400, { message: 'Invalid password' }); - } const userId = generateUserId(); + // 타입 체크 및 검증 + if (!email || typeof email !== 'string') { + return fail(400, { message: '이메일이 필요합니다' }); + } + + if (!password || typeof password !== 'string') { + return fail(400, { message: '비밀번호가 필요합니다' }); + } const passwordHash = await hash(password, { // recommended minimum parameters memoryCost: 19456, @@ -75,11 +81,16 @@ export const actions: Actions = { }); try { - await db.insert(table.user).values({ id: userId, username, passwordHash }); + await db.insert(table.user).values({ id: userId, email, passwordHash }); + + // 두 토큰 모두 생성 + const accessToken = auth.generateAccessToken(userId); + const refreshToken = auth.generateRefreshToken(userId); + + // 두 쿠키 모두 설정 + auth.setAccessTokenCookie(event, accessToken); + auth.setRefreshTokenCookie(event, refreshToken); - const sessionToken = auth.generateSessionToken(); - const session = await auth.createSession(sessionToken, userId); - auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); } catch { return fail(500, { message: 'An error has occurred' }); } @@ -93,20 +104,3 @@ function generateUserId() { const id = encodeBase32LowerCase(bytes); return id; } - -function validateUsername(username: unknown): username is string { - return ( - typeof username === 'string' && - username.length >= 3 && - username.length <= 31 && - /^[a-z0-9_-]+$/.test(username) - ); -} - -function validatePassword(password: unknown): password is string { - return ( - typeof password === 'string' && - password.length >= 6 && - password.length <= 255 - ); -} diff --git a/src/routes/demo/lucia/login/+page.svelte b/src/routes/demo/lucia/login/+page.svelte index c4c36c8..bce8de8 100644 --- a/src/routes/demo/lucia/login/+page.svelte +++ b/src/routes/demo/lucia/login/+page.svelte @@ -8,9 +8,9 @@

Login/Register