199 lines
5.0 KiB
TypeScript
199 lines
5.0 KiB
TypeScript
import crypto from 'crypto'
|
|
import { query, queryOne, execute, insertReturning } from './db'
|
|
|
|
// 세션 설정
|
|
const SESSION_TIMEOUT_MINUTES = 10 // 10분 타임아웃
|
|
const SESSION_COOKIE_NAME = 'session_token'
|
|
const SESSION_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 // 쿠키는 7일 (세션 만료와 별개)
|
|
|
|
interface Session {
|
|
sessionId: string
|
|
employeeId: number
|
|
loginHistoryId: number | null
|
|
createdAt: Date
|
|
lastAccessAt: Date
|
|
expiresAt: Date
|
|
loginIp: string | null
|
|
userAgent: string | null
|
|
}
|
|
|
|
/**
|
|
* 랜덤 세션 토큰 생성 (Spring Session과 유사)
|
|
*/
|
|
function generateSessionToken(): string {
|
|
return crypto.randomBytes(32).toString('hex') // 64자 hex 문자열
|
|
}
|
|
|
|
/**
|
|
* 세션 생성
|
|
*/
|
|
export async function createSession(
|
|
employeeId: number,
|
|
loginHistoryId: number,
|
|
loginIp: string | null,
|
|
userAgent: string | null
|
|
): Promise<string> {
|
|
const sessionId = generateSessionToken()
|
|
const expiresAt = new Date(Date.now() + SESSION_TIMEOUT_MINUTES * 60 * 1000)
|
|
|
|
await execute(`
|
|
INSERT INTO wr_session (session_id, employee_id, login_history_id, expires_at, login_ip, user_agent)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
`, [sessionId, employeeId, loginHistoryId, expiresAt, loginIp, userAgent])
|
|
|
|
return sessionId
|
|
}
|
|
|
|
/**
|
|
* 세션 조회 (유효한 세션만)
|
|
*/
|
|
export async function getDbSession(sessionId: string): Promise<Session | null> {
|
|
if (!sessionId) return null
|
|
|
|
const row = await queryOne<any>(`
|
|
SELECT
|
|
session_id,
|
|
employee_id,
|
|
login_history_id,
|
|
created_at,
|
|
last_access_at,
|
|
expires_at,
|
|
login_ip,
|
|
user_agent
|
|
FROM wr_session
|
|
WHERE session_id = $1 AND expires_at > NOW()
|
|
`, [sessionId])
|
|
|
|
if (!row) return null
|
|
|
|
return {
|
|
sessionId: row.session_id,
|
|
employeeId: row.employee_id,
|
|
loginHistoryId: row.login_history_id,
|
|
createdAt: row.created_at,
|
|
lastAccessAt: row.last_access_at,
|
|
expiresAt: row.expires_at,
|
|
loginIp: row.login_ip,
|
|
userAgent: row.user_agent
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 세션 갱신 (Sliding Expiration)
|
|
* - 마지막 접근 시간 업데이트
|
|
* - 만료 시간 연장
|
|
*/
|
|
export async function refreshSession(sessionId: string): Promise<boolean> {
|
|
const newExpiresAt = new Date(Date.now() + SESSION_TIMEOUT_MINUTES * 60 * 1000)
|
|
|
|
const result = await execute(`
|
|
UPDATE wr_session
|
|
SET last_access_at = NOW(), expires_at = $1
|
|
WHERE session_id = $2 AND expires_at > NOW()
|
|
`, [newExpiresAt, sessionId])
|
|
|
|
return result.rowCount > 0
|
|
}
|
|
|
|
/**
|
|
* 세션 삭제 (로그아웃)
|
|
*/
|
|
export async function deleteSession(sessionId: string): Promise<boolean> {
|
|
const result = await execute(`
|
|
DELETE FROM wr_session WHERE session_id = $1
|
|
`, [sessionId])
|
|
|
|
return result.rowCount > 0
|
|
}
|
|
|
|
/**
|
|
* 사용자의 모든 세션 삭제 (모든 기기에서 로그아웃)
|
|
*/
|
|
export async function deleteAllUserSessions(employeeId: number): Promise<number> {
|
|
const result = await execute(`
|
|
DELETE FROM wr_session WHERE employee_id = $1
|
|
`, [employeeId])
|
|
|
|
return result.rowCount
|
|
}
|
|
|
|
/**
|
|
* 만료된 세션 정리 (배치용)
|
|
*/
|
|
export async function cleanupExpiredSessions(): Promise<number> {
|
|
const result = await execute(`
|
|
DELETE FROM wr_session WHERE expires_at < NOW()
|
|
`)
|
|
|
|
return result.rowCount
|
|
}
|
|
|
|
/**
|
|
* 세션 쿠키 설정
|
|
*/
|
|
export function setSessionCookie(event: any, sessionId: string) {
|
|
setCookie(event, SESSION_COOKIE_NAME, sessionId, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: SESSION_COOKIE_MAX_AGE,
|
|
path: '/'
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 세션 쿠키 삭제
|
|
*/
|
|
export function deleteSessionCookie(event: any) {
|
|
deleteCookie(event, SESSION_COOKIE_NAME)
|
|
}
|
|
|
|
/**
|
|
* 세션 쿠키에서 세션 ID 가져오기
|
|
*/
|
|
export function getSessionIdFromCookie(event: any): string | null {
|
|
return getCookie(event, SESSION_COOKIE_NAME) || null
|
|
}
|
|
|
|
// 설정값 export
|
|
export { SESSION_TIMEOUT_MINUTES, SESSION_COOKIE_NAME }
|
|
|
|
/**
|
|
* 인증된 사용자 ID 가져오기 (다른 API에서 사용)
|
|
* - 세션이 없거나 만료되면 null 반환
|
|
* - 세션이 유효하면 자동 갱신
|
|
* - [호환성] 기존 user_id 쿠키도 지원 (마이그레이션 기간)
|
|
*/
|
|
export async function getAuthenticatedUserId(event: any): Promise<number | null> {
|
|
// 1. 새로운 세션 토큰 확인
|
|
const sessionId = getSessionIdFromCookie(event)
|
|
if (sessionId) {
|
|
const session = await getDbSession(sessionId)
|
|
if (session) {
|
|
await refreshSession(sessionId)
|
|
return session.employeeId
|
|
}
|
|
// 세션 만료 → 쿠키 삭제
|
|
deleteSessionCookie(event)
|
|
}
|
|
|
|
// 2. [호환성] 기존 user_id 쿠키 확인 (마이그레이션 기간)
|
|
const legacyUserId = getCookie(event, 'user_id')
|
|
if (legacyUserId) {
|
|
return parseInt(legacyUserId)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* 인증 필수 API용 - 미인증시 에러 throw
|
|
*/
|
|
export async function requireAuth(event: any): Promise<number> {
|
|
const userId = await getAuthenticatedUserId(event)
|
|
if (!userId) {
|
|
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
|
}
|
|
return userId
|
|
}
|