summaryrefslogtreecommitdiff
path: root/lib/users
diff options
context:
space:
mode:
Diffstat (limited to 'lib/users')
-rw-r--r--lib/users/auth/passwordUtil.ts2
-rw-r--r--lib/users/auth/verifyCredentails.ts11
-rw-r--r--lib/users/middleware/page-tracking.ts98
-rw-r--r--lib/users/session/helper.ts62
-rw-r--r--lib/users/session/repository.ts460
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