diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-07 01:44:45 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-07 01:44:45 +0000 |
| commit | 90f79a7a691943a496f67f01c1e493256070e4de (patch) | |
| tree | 37275fde3ae08c2bca384fbbc8eb378de7e39230 /lib/users | |
| parent | fbb3b7f05737f9571b04b0a8f4f15c0928de8545 (diff) | |
(대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가
Diffstat (limited to 'lib/users')
| -rw-r--r-- | lib/users/auth/passwordUtil.ts | 2 | ||||
| -rw-r--r-- | lib/users/auth/verifyCredentails.ts | 11 | ||||
| -rw-r--r-- | lib/users/middleware/page-tracking.ts | 98 | ||||
| -rw-r--r-- | lib/users/session/helper.ts | 62 | ||||
| -rw-r--r-- | lib/users/session/repository.ts | 460 |
5 files changed, 623 insertions, 10 deletions
diff --git a/lib/users/auth/passwordUtil.ts b/lib/users/auth/passwordUtil.ts index ee4e13c2..54599761 100644 --- a/lib/users/auth/passwordUtil.ts +++ b/lib/users/auth/passwordUtil.ts @@ -380,7 +380,7 @@ async function sendSmsMessage(phoneNumber: string, message: string): Promise<boo const requestBody = { account: account, - type: 'SMS', + type: 'sms', from: fromNumber, to: to, country: country, diff --git a/lib/users/auth/verifyCredentails.ts b/lib/users/auth/verifyCredentails.ts index 1b67b874..ff3cd0e3 100644 --- a/lib/users/auth/verifyCredentails.ts +++ b/lib/users/auth/verifyCredentails.ts @@ -1,4 +1,5 @@ // lib/auth/verifyCredentials.ts +'use server' import bcrypt from 'bcryptjs'; import { eq, and, desc, gte, count } from 'drizzle-orm'; @@ -13,7 +14,7 @@ import { vendors } from '@/db/schema'; import { headers } from 'next/headers'; -import { generateAndSendSmsToken, verifySmsToken } from './passwordUtil'; +import { verifySmsToken } from './passwordUtil'; // 에러 타입 정의 export type AuthError = @@ -590,14 +591,6 @@ export async function authenticateWithSGips( const user = localUser[0]; - // 3. MFA 토큰 생성 (S-Gips 사용자는 항상 MFA 필요) - // const mfaToken = await generateMfaToken(user.id); - - // 4. SMS 전송 - if (user.phone) { - await generateAndSendSmsToken(user.id, user.phone); - } - return { success: true, user: { diff --git a/lib/users/middleware/page-tracking.ts b/lib/users/middleware/page-tracking.ts new file mode 100644 index 00000000..bd93fb82 --- /dev/null +++ b/lib/users/middleware/page-tracking.ts @@ -0,0 +1,98 @@ + +// lib/middleware/page-tracking.ts - 페이지 방문 추적 미들웨어 +import { NextRequest, NextResponse } from 'next/server' +import { getToken } from 'next-auth/jwt' +import { UAParser } from 'ua-parser-js'; +import { SessionRepository } from '../session/repository' + +export async function trackPageVisit(request: NextRequest) { + try { + const token = await getToken({ req: request }) + const url = new URL(request.url) + + // API 경로나 정적 파일은 추적하지 않음 + if (url.pathname.startsWith('/api') || + url.pathname.startsWith('/_next') || + url.pathname.includes('.')) { + return + } + + const userAgent = request.headers.get('user-agent') || '' + const parser = new UAParser(userAgent) + const result = parser.getResult() + + // 활성 세션 조회 + let sessionId = null + if (token?.id) { + const activeSession = await SessionRepository.getActiveSessionByUserId(token.id) + if (activeSession) { + sessionId = activeSession.id + // 세션 활동 시간 업데이트 + await SessionRepository.updateLoginSession(activeSession.id, { + lastActivityAt: new Date() + }) + } + } + + // 페이지 방문 기록 + await SessionRepository.recordPageVisit({ + userId: token?.id || undefined, + sessionId, + route: url.pathname, + pageTitle: extractPageTitle(url.pathname), // 구현 필요 + referrer: request.headers.get('referer') || undefined, + ipAddress: getClientIP(request), + userAgent, + queryParams: url.search ? url.search.substring(1) : undefined, + deviceType: getDeviceType(result.device.type), + browserName: result.browser.name, + osName: result.os.name, + }) + + } catch (error) { + console.error('Failed to track page visit:', error) + } +} + +function getClientIP(request: NextRequest): string { + const forwarded = request.headers.get('x-forwarded-for'); + const realIP = request.headers.get('x-real-ip'); + const cfConnectingIP = request.headers.get('cf-connecting-ip'); // Cloudflare + + if (cfConnectingIP) { + return cfConnectingIP; + } + + if (forwarded) { + return forwarded.split(',')[0].trim(); + } + + if (realIP) { + return realIP; + } + + // NextRequest에는 ip 프로퍼티가 없으므로 기본값 반환 + return '127.0.0.1'; +} + + +function getDeviceType(deviceType?: string): string { + if (!deviceType) return 'desktop' + if (deviceType === 'mobile') return 'mobile' + if (deviceType === 'tablet') return 'tablet' + return 'desktop' +} + +function extractPageTitle(pathname: string): string { + // 라우트 기반 페이지 제목 매핑 + const titleMap: Record<string, string> = { + '/': 'Home', + '/dashboard': 'Dashboard', + '/profile': 'Profile', + '/settings': 'Settings', + // 추가 필요 + } + + return titleMap[pathname] || pathname +} + diff --git a/lib/users/session/helper.ts b/lib/users/session/helper.ts new file mode 100644 index 00000000..439ab32d --- /dev/null +++ b/lib/users/session/helper.ts @@ -0,0 +1,62 @@ +import { authenticateWithSGips, verifyExternalCredentials } from "../auth/verifyCredentails"; +import { SessionRepository } from "./repository"; + +// lib/session/helpers.ts - NextAuth 헬퍼 함수들 개선 +export const authHelpers = { + // 1차 인증 검증 및 임시 키 생성 (DB 버전) + async performFirstAuth(username: string, password: string, provider: 'email' | 'sgips') { + console.log('performFirstAuth started:', { username, provider }) + + try { + let authResult; + + if (provider === 'sgips') { + authResult = await authenticateWithSGips(username, password) + } else { + authResult = await verifyExternalCredentials(username, password) + } + + if (!authResult.success || !authResult.user) { + return { success: false, error: 'Invalid credentials' } + } + + // DB에 임시 인증 세션 생성 + const expiresAt = new Date(Date.now() + (10 * 60 * 1000)) // 10분 후 만료 + const tempAuthKey = await SessionRepository.createTempAuthSession({ + userId: authResult.user.id, + email: authResult.user.email, + authMethod: provider, + expiresAt + }) + + console.log('Temp auth stored in DB:', { + tempAuthKey, + userId: authResult.user.id, + email: authResult.user.email, + authMethod: provider, + expiresAt + }) + + return { + success: true, + tempAuthKey, + userId: authResult.user.id, + email: authResult.user.email + } + } catch (error) { + console.error('First auth error:', error) + return { success: false, error: 'Authentication failed' } + } + }, + + // 임시 인증 정보 조회 (DB 버전) + async getTempAuth(tempAuthKey: string) { + return await SessionRepository.getTempAuthSession(tempAuthKey) + }, + + // 임시 인증 정보 삭제 (DB 버전) + async clearTempAuth(tempAuthKey: string) { + await SessionRepository.markTempAuthSessionAsUsed(tempAuthKey) + } + } +
\ No newline at end of file diff --git a/lib/users/session/repository.ts b/lib/users/session/repository.ts new file mode 100644 index 00000000..a3b44fbf --- /dev/null +++ b/lib/users/session/repository.ts @@ -0,0 +1,460 @@ +// lib/session/repository.ts +import db from '@/db/db' +import { + loginSessions, + tempAuthSessions, + pageVisits, + type NewLoginSession, + type NewTempAuthSession, + type NewPageVisit, + type LoginSession +} from '@/db/schema' +import { eq, and, desc, lt } from 'drizzle-orm' +import { v4 as uuidv4 } from 'uuid' + + +// 성능 최적화를 위한 캐시 +const sessionCache = new Map<string, { data: any; timestamp: number }>() +const CACHE_TTL = 5 * 60 * 1000 // 5분 캐시 + +export class SessionRepository { + // 임시 인증 세션 관리 (기존 메모리 저장소 대체) + static async createTempAuthSession(data: { + userId: number + email: string + authMethod: string + expiresAt: Date + }): Promise<string> { + const tempAuthKey = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + + try { + await db.insert(tempAuthSessions).values({ + tempAuthKey, + userId: data.userId, + email: data.email, + authMethod: data.authMethod, + expiresAt: data.expiresAt, + }) + + return tempAuthKey + } catch (error) { + console.error('Failed to create temp auth session:', error) + throw error + } + } + + static async getTempAuthSession(tempAuthKey: string) { + try { + const result = await db + .select() + .from(tempAuthSessions) + .where( + and( + eq(tempAuthSessions.tempAuthKey, tempAuthKey), + eq(tempAuthSessions.isUsed, false) + ) + ) + .limit(1) + + const session = result[0] + if (!session || new Date() > session.expiresAt) { + return null + } + + return session + } catch (error) { + console.error('Failed to get temp auth session:', error) + return null + } + } + + static async markTempAuthSessionAsUsed(tempAuthKey: string) { + try { + await db + .update(tempAuthSessions) + .set({ isUsed: true }) + .where(eq(tempAuthSessions.tempAuthKey, tempAuthKey)) + } catch (error) { + console.error('Failed to mark temp auth session as used:', error) + } + } + + static async cleanupExpiredTempSessions() { + try { + await db + .delete(tempAuthSessions) + .where(lt(tempAuthSessions.expiresAt, new Date())) + } catch (error) { + console.error('Failed to cleanup expired temp sessions:', error) + } + } + + // 로그인 세션 관리 + static async createLoginSession(data: { + userId: number + ipAddress: string + userAgent?: string + authMethod: string + sessionExpiredAt?: Date + nextAuthSessionId?: string + }): Promise<LoginSession> { + try { + const sessionData: NewLoginSession = { + userId: data.userId, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + authMethod: data.authMethod, + sessionExpiredAt: data.sessionExpiredAt, + nextAuthSessionId: data.nextAuthSessionId, + } + + const result = await db.insert(loginSessions).values(sessionData).returning() + + // 캐시에서 해당 사용자의 활성 세션 정보 제거 + sessionCache.delete(`active_session_${data.userId}`) + + return result[0] + } catch (error) { + console.error('Failed to create login session:', error) + throw error + } + } + + static async updateLoginSession(sessionId: string, updates: { + lastActivityAt?: Date + sessionExpiredAt?: Date + logoutAt?: Date + isActive?: boolean + }) { + try { + await db + .update(loginSessions) + .set({ + ...updates, + updatedAt: new Date() + }) + .where(eq(loginSessions.id, sessionId)) + + // 캐시 무효화 (세션이 업데이트되었으므로) + for (const [key] of sessionCache) { + if (key.includes(sessionId)) { + sessionCache.delete(key) + } + } + } catch (error) { + console.error('Failed to update login session:', error) + } + } + + // 캐시를 활용한 활성 세션 조회 + static async getActiveSessionByUserId(userId: number) { + const cacheKey = `active_session_${userId}` + const cached = sessionCache.get(cacheKey) + + // 캐시가 유효한 경우 반환 + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.data + } + + try { + const result = await db + .select() + .from(loginSessions) + .where( + and( + eq(loginSessions.userId, userId), + eq(loginSessions.isActive, true) + ) + ) + .orderBy(desc(loginSessions.loginAt)) + .limit(1) + + const session = result[0] || null + + // 캐시에 저장 + sessionCache.set(cacheKey, { + data: session, + timestamp: Date.now() + }) + + return session + } catch (error) { + console.error('Failed to get active session:', error) + return null + } + } + + static async logoutSession(sessionId: string) { + try { + await db + .update(loginSessions) + .set({ + logoutAt: new Date(), + isActive: false, + updatedAt: new Date() + }) + .where(eq(loginSessions.id, sessionId)) + + // 캐시에서 관련된 세션 정보 제거 + for (const [key] of sessionCache) { + if (key.includes(sessionId)) { + sessionCache.delete(key) + } + } + } catch (error) { + console.error('Failed to logout session:', error) + } + } + + static async logoutAllUserSessions(userId: string) { + try { + await db + .update(loginSessions) + .set({ + logoutAt: new Date(), + isActive: false, + updatedAt: new Date() + }) + .where( + and( + eq(loginSessions.userId, userId), + eq(loginSessions.isActive, true) + ) + ) + + // 해당 사용자의 캐시 제거 + sessionCache.delete(`active_session_${userId}`) + } catch (error) { + console.error('Failed to logout all user sessions:', error) + } + } + + // 배치 처리를 위한 페이지 방문 기록 (성능 최적화) + private static visitQueue: NewPageVisit[] = [] + private static isProcessingQueue = false + + static async recordPageVisit(data: { + userId?: number + sessionId?: string + route: string + pageTitle?: string + referrer?: string + ipAddress: string + userAgent?: string + queryParams?: string + deviceType?: string + browserName?: string + osName?: string + }) { + const visitData: NewPageVisit = { + userId: data.userId, + sessionId: data.sessionId, + route: data.route, + pageTitle: data.pageTitle, + referrer: data.referrer, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + queryParams: data.queryParams, + deviceType: data.deviceType, + browserName: data.browserName, + osName: data.osName, + } + + // 큐에 추가 + this.visitQueue.push(visitData) + + // 큐가 20개 이상이거나 3초마다 배치 처리 + if (this.visitQueue.length >= 20 || !this.isProcessingQueue) { + this.processVisitQueue() + } + } + + // 배치 처리로 성능 최적화 + private static async processVisitQueue() { + if (this.isProcessingQueue || this.visitQueue.length === 0) { + return + } + + this.isProcessingQueue = true + + try { + const batch = this.visitQueue.splice(0, 100) // 최대 100개씩 처리 + + if (batch.length > 0) { + await db.insert(pageVisits).values(batch) + } + } catch (error) { + console.error('Failed to process visit queue:', error) + } finally { + this.isProcessingQueue = false + + // 더 처리할 데이터가 있다면 재귀 호출 + if (this.visitQueue.length > 0) { + setTimeout(() => this.processVisitQueue(), 100) + } + } + } + + // 3초마다 큐 처리 (백그라운드) + static { + if (typeof setInterval !== 'undefined') { + setInterval(() => { + this.processVisitQueue() + }, 3000) + } + } + + // 세션 활동 업데이트 (논블로킹, 에러 무시) + static updateSessionActivity(sessionId: string): Promise<void> { + return new Promise((resolve) => { + // 비동기로 실행하되 메인 플로우를 블로킹하지 않음 + setImmediate(async () => { + try { + await this.updateLoginSession(sessionId, { + lastActivityAt: new Date() + }) + } catch (error) { + // 에러를 로그만 남기고 무시 + console.error('Failed to update session activity (non-blocking):', error) + } + resolve() + }) + }) + } + + static async updatePageVisitDuration(visitId: string, duration: number) { + try { + await db + .update(pageVisits) + .set({ duration }) + .where(eq(pageVisits.id, visitId)) + } catch (error) { + console.error('Failed to update page visit duration:', error) + } + } + + // 캐시 정리 (메모리 관리) + static cleanupCache() { + const now = Date.now() + + for (const [key, value] of sessionCache) { + if (now - value.timestamp > CACHE_TTL) { + sessionCache.delete(key) + } + } + } + + // 모니터링을 위한 통계 정보 제공 + static getRepositoryStats() { + return { + cacheSize: sessionCache.size, + queueSize: this.visitQueue?.length || 0, + cacheTTL: CACHE_TTL, + isProcessingQueue: this.isProcessingQueue + } + } + + // 캐시 크기 조회 (모니터링용) + static getCacheSize(): number { + return sessionCache.size + } + + // 큐 크기 조회 (모니터링용) + static getQueueSize(): number { + return this.visitQueue?.length || 0 + } + + // 정기적인 캐시 정리 (10분마다) + static { + if (typeof setInterval !== 'undefined') { + setInterval(() => { + this.cleanupCache() + }, 10 * 60 * 1000) + } + } +} + +// 에러 처리를 위한 래퍼 함수들 +export const safeSessionOperations = { + async recordPageVisit(data: Parameters<typeof SessionRepository.recordPageVisit>[0]) { + try { + await SessionRepository.recordPageVisit(data) + } catch (error) { + console.error('Safe page visit recording failed:', error) + } + }, + + async updateSessionActivity(sessionId: string) { + try { + await SessionRepository.updateSessionActivity(sessionId) + } catch (error) { + console.error('Safe session activity update failed:', error) + } + }, + + async getActiveSession(userId: number) { + try { + return await SessionRepository.getActiveSessionByUserId(userId) + } catch (error) { + console.error('Safe get active session failed:', error) + return null + } + } +} + +// lib/session/monitoring.ts - 성능 모니터링 (수정된 버전) +export class SessionMonitoring { + private static metrics = { + pageVisitRecords: 0, + sessionUpdates: 0, + cacheHits: 0, + cacheMisses: 0, + errors: 0 + } + + static incrementMetric(metric: keyof typeof this.metrics) { + this.metrics[metric]++ + } + + static getMetrics() { + return { ...this.metrics } + } + + static resetMetrics() { + Object.keys(this.metrics).forEach(key => { + this.metrics[key as keyof typeof this.metrics] = 0 + }) + } + + // 성능 통계 로깅 (수정된 버전) + static logPerformanceStats() { + const repoStats = SessionRepository.getRepositoryStats() + + console.log('Session Repository Performance:', { + ...this.metrics, + cacheHitRate: this.metrics.cacheHits / (this.metrics.cacheHits + this.metrics.cacheMisses) * 100 || 0, + ...repoStats // cacheSize, queueSize 등 포함 + }) + } + + // 상세 성능 리포트 생성 + static getDetailedPerformanceReport() { + const repoStats = SessionRepository.getRepositoryStats() + const totalRequests = this.metrics.cacheHits + this.metrics.cacheMisses + + return { + metrics: this.getMetrics(), + repository: repoStats, + performance: { + cacheHitRate: totalRequests > 0 ? (this.metrics.cacheHits / totalRequests) * 100 : 0, + errorRate: this.metrics.pageVisitRecords > 0 ? (this.metrics.errors / this.metrics.pageVisitRecords) * 100 : 0, + queueUtilization: repoStats.queueSize / 100 * 100, // 100이 최대 큐 크기라고 가정 + }, + status: { + healthy: this.metrics.errors / Math.max(this.metrics.pageVisitRecords, 1) < 0.01, // 1% 미만 에러율 + cacheEfficient: totalRequests > 0 ? (this.metrics.cacheHits / totalRequests) > 0.8 : true, // 80% 이상 캐시 히트율 + queueManageable: repoStats.queueSize < 50 // 큐 크기가 50 미만 + } + } + } +}
\ No newline at end of file |
