Files
weeklyreport/backend/utils/session.ts

251 lines
6.5 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
}
/**
* 사용자 권한 조회
*/
export async function getUserRoles(employeeId: number): Promise<string[]> {
const rows = await query<any>(`
SELECT r.role_code
FROM wr_employee_role er
JOIN wr_role r ON er.role_id = r.role_id
WHERE er.employee_id = $1 AND r.is_active = true
`, [employeeId])
return rows.map(r => r.role_code)
}
/**
* 특정 권한 보유 여부 확인
*/
export async function hasRole(employeeId: number, roleCode: string): Promise<boolean> {
const roles = await getUserRoles(employeeId)
return roles.includes(roleCode)
}
/**
* 관리자 권한 필수 API용 - ROLE_ADMIN 없으면 에러 throw
*/
export async function requireAdmin(event: any): Promise<number> {
const userId = await requireAuth(event)
const isAdmin = await hasRole(userId, 'ROLE_ADMIN')
if (!isAdmin) {
throw createError({ statusCode: 403, message: '관리자 권한이 필요합니다.' })
}
return userId
}
/**
* 매니저 이상 권한 필수 API용 - ROLE_MANAGER 또는 ROLE_ADMIN 없으면 에러 throw
*/
export async function requireManager(event: any): Promise<number> {
const userId = await requireAuth(event)
const roles = await getUserRoles(userId)
const hasManagerRole = roles.includes('ROLE_MANAGER') || roles.includes('ROLE_ADMIN')
if (!hasManagerRole) {
throw createError({ statusCode: 403, message: '매니저 이상 권한이 필요합니다.' })
}
return userId
}