diff options
Diffstat (limited to 'app/api')
| -rw-r--r-- | app/api/auth/[...nextauth]/route.ts | 85 | ||||
| -rw-r--r-- | app/api/auth/first-auth/route.ts | 4 | ||||
| -rw-r--r-- | app/api/auth/send-sms/route.ts | 8 | ||||
| -rw-r--r-- | app/api/tracking/page-duration/route.ts | 56 | ||||
| -rw-r--r-- | app/api/tracking/page-visit/route.ts | 58 |
5 files changed, 171 insertions, 40 deletions
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 2b168746..e059377c 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,4 +1,3 @@ -// auth/config.ts - 업데이트된 NextAuth 설정 import NextAuth, { NextAuthOptions, Session, @@ -14,16 +13,15 @@ import { verifyOtpTemp } from '@/lib/users/verifyOtp' import { getSecuritySettings } from '@/lib/password-policy/service' import { verifySmsToken } from '@/lib/users/auth/passwordUtil' import { SessionRepository } from '@/lib/users/session/repository' -import { loginSessions } from '@/db/schema' // 인증 방식 타입 정의 type AuthMethod = 'otp' | 'email' | 'sgips' | 'saml' -// 모듈 보강 선언 (기존과 동일) +// 모듈 보강 선언 - ID를 string으로 통일 declare module "next-auth" { interface Session { user: { - id: string + id: string // number → string으로 변경 name?: string | null email?: string | null image?: string | null @@ -33,12 +31,12 @@ declare module "next-auth" { reAuthTime?: number | null authMethod?: AuthMethod sessionExpiredAt?: number | null - dbSessionId?: string | null // DB 세션 ID 추가 + dbSessionId?: string | null } } interface User { - id: string + id: string // number → string으로 변경 imageUrl?: string | null companyId?: number | null techCompanyId?: number | null @@ -51,7 +49,7 @@ declare module "next-auth" { declare module "next-auth/jwt" { interface JWT { - id?: string + id?: string // 이미 string이므로 그대로 imageUrl?: string | null companyId?: number | null techCompanyId?: number | null @@ -63,6 +61,15 @@ declare module "next-auth/jwt" { } } +// 타입 변환 헬퍼 함수들 +function ensureString(value: string | number): string { + return String(value) +} + +function ensureNumber(value: string | number): number { + return typeof value === 'string' ? parseInt(value, 10) : value +} + // 보안 설정 캐시 (기존과 동일) let securitySettingsCache: { data: any | null @@ -71,7 +78,7 @@ let securitySettingsCache: { } = { data: null, lastFetch: 0, - ttl: 5 * 60 * 1000 // 5분 캐시 + ttl: 5 * 60 * 1000 } async function getCachedSecuritySettings() { @@ -85,7 +92,7 @@ async function getCachedSecuritySettings() { } catch (error) { console.error('Failed to fetch security settings:', error) securitySettingsCache.data = { - sessionTimeoutMinutes: 480 // 8시간 기본값 + sessionTimeoutMinutes: 480 } } } @@ -111,7 +118,7 @@ function getClientIP(req: any): string { export const authOptions: NextAuthOptions = { providers: [ - // OTP 로그인 (기존 유지) + // OTP 로그인 - 타입 에러 수정 CredentialsProvider({ id: 'credentials-otp', name: 'OTP', @@ -130,8 +137,9 @@ export const authOptions: NextAuthOptions = { const securitySettings = await getCachedSecuritySettings() const reAuthTime = Date.now() + // 반환 객체의 id를 string으로 변환 return { - id: String(user.id ?? email ?? "dts"), + id: ensureString(user.id), // ✅ string으로 변환 email: user.email, imageUrl: user.imageUrl ?? null, name: user.name, @@ -144,12 +152,12 @@ export const authOptions: NextAuthOptions = { }, }), - // MFA 완료 후 최종 인증 (DB 연동 버전) + // MFA 완료 후 최종 인증 - 타입 에러 수정 CredentialsProvider({ id: 'credentials-mfa', name: 'MFA Verification', credentials: { - userId: { label: 'User ID', type: 'text' }, + userId: { label: 'User ID', type: 'text' }, // number → text로 변경 smsToken: { label: 'SMS Token', type: 'text' }, tempAuthKey: { label: 'Temp Auth Key', type: 'text' }, }, @@ -159,28 +167,29 @@ export const authOptions: NextAuthOptions = { return null } + // userId를 number로 변환하여 DB 조회 + const numericUserId = ensureNumber(credentials.userId) + const user = await getUserById(numericUserId) + if (!user) { + console.error('User not found after MFA verification') + return null + } + try { // DB에서 임시 인증 정보 확인 const tempAuth = await SessionRepository.getTempAuthSession(credentials.tempAuthKey) - if (!tempAuth || tempAuth.userId !== credentials.userId) { + if (!tempAuth || ensureNumber(tempAuth.userId) !== user.id) { console.error('Temp auth expired or not found') return null } // SMS 토큰 검증 - const smsVerificationResult = await verifySmsToken(Number(credentials.userId), credentials.smsToken) + const smsVerificationResult = await verifySmsToken(user.id, credentials.smsToken) if (!smsVerificationResult || !smsVerificationResult.success) { console.error('SMS token verification failed') return null } - // 사용자 정보 조회 - const user = await getUserById(Number(credentials.userId)) - if (!user) { - console.error('User not found after MFA verification') - return null - } - // 임시 인증 정보를 사용됨으로 표시 await SessionRepository.markTempAuthSessionAsUsed(credentials.tempAuthKey) @@ -194,7 +203,7 @@ export const authOptions: NextAuthOptions = { const userAgent = req.headers?.['user-agent'] const dbSession = await SessionRepository.createLoginSession({ - userId: String(user.id), + userId: user.id, // number로 전달 ipAddress, userAgent, authMethod: tempAuth.authMethod, @@ -203,8 +212,9 @@ export const authOptions: NextAuthOptions = { console.log(`MFA completed for user ${user.email} (${tempAuth.authMethod})`) + // 반환 객체의 id를 string으로 변환 return { - id: String(user.id), + id: ensureString(user.id), // ✅ string으로 변환 email: user.email, imageUrl: user.imageUrl ?? null, name: user.name, @@ -257,7 +267,7 @@ export const authOptions: NextAuthOptions = { session: { strategy: 'jwt', - maxAge: 30 * 24 * 60 * 60, // 30일 + maxAge: 30 * 24 * 60 * 60, }, callbacks: { @@ -268,7 +278,7 @@ export const authOptions: NextAuthOptions = { // 최초 로그인 시 (MFA 완료 후) if (user) { const reAuthTime = Date.now() - token.id = user.id + token.id = user.id // ✅ 이제 둘 다 string 타입 token.email = user.email token.name = user.name token.companyId = user.companyId @@ -288,8 +298,8 @@ export const authOptions: NextAuthOptions = { try { const dbSession = await SessionRepository.createLoginSession({ - userId: token.id, - ipAddress: '0.0.0.0', // SAML의 경우 IP 추적 제한적 + userId: ensureNumber(token.id), // string을 number로 변환하여 DB에 저장 + ipAddress: '0.0.0.0', authMethod: 'saml', sessionExpiredAt, }) @@ -346,7 +356,7 @@ export const authOptions: NextAuthOptions = { if (token) { session.user = { - id: token.id as string, + id: token.id as string, // ✅ string으로 일관성 유지 email: token.email as string, name: token.name as string, domain: token.domain as string, @@ -386,14 +396,16 @@ export const authOptions: NextAuthOptions = { // 이미 MFA에서 DB 세션이 생성된 경우가 아니라면 여기서 생성 if (account?.provider !== 'credentials-mfa' && user.id) { try { + const numericUserId = ensureNumber(user.id) // string을 number로 변환 + // 기존 활성 세션 확인 - const existingSession = await SessionRepository.getActiveSessionByUserId(user.id) + const existingSession = await SessionRepository.getActiveSessionByUserId(numericUserId) if (!existingSession) { const sessionExpiredAt = new Date(Date.now() + (securitySettings.sessionTimeoutMinutes * 60 * 1000)) await SessionRepository.createLoginSession({ - userId: user.id, - ipAddress: '0.0.0.0', // signIn 이벤트에서는 IP 접근 제한적 + userId: numericUserId, + ipAddress: '0.0.0.0', authMethod: user.authMethod || 'unknown', sessionExpiredAt, }) @@ -415,8 +427,15 @@ export const authOptions: NextAuthOptions = { await SessionRepository.logoutSession(dbSessionId) } else if (userId) { // dbSessionId가 없는 경우 사용자의 모든 활성 세션 로그아웃 - await SessionRepository.logoutAllUserSessions(userId) + const numericUserId = ensureNumber(userId) // string을 number로 변환 + await SessionRepository.logoutAllUserSessions(numericUserId) } } } } + + +const handler = NextAuth(authOptions) + +// ✅ 핵심: 반드시 GET, POST를 named export로 내보내야 함 +export { handler as GET, handler as POST }
\ No newline at end of file diff --git a/app/api/auth/first-auth/route.ts b/app/api/auth/first-auth/route.ts index 18f44904..ff92e71c 100644 --- a/app/api/auth/first-auth/route.ts +++ b/app/api/auth/first-auth/route.ts @@ -1,8 +1,8 @@ // /api/auth/first-auth/route.ts // 1차 인증 처리 API 엔드포인트 +import { authHelpers } from '@/lib/users/session/helper' import { NextRequest, NextResponse } from 'next/server' -import { authHelpers } from '../[...nextauth]/route' // 요청 데이터 타입 interface FirstAuthRequest { @@ -15,7 +15,7 @@ interface FirstAuthRequest { interface FirstAuthResponse { success: boolean tempAuthKey?: string - userId?: string + userId?: number email?: string error?: string } diff --git a/app/api/auth/send-sms/route.ts b/app/api/auth/send-sms/route.ts index 6b9eb114..805ff7f7 100644 --- a/app/api/auth/send-sms/route.ts +++ b/app/api/auth/send-sms/route.ts @@ -2,13 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { getUserByEmail, getUserById } from '@/lib/users/repository'; +import { getUserById } from '@/lib/users/repository'; import { generateAndSendSmsToken } from '@/lib/users/auth/passwordUtil'; const sendSmsSchema = z.object({ - userId: z.string(), + userId: z.number(), }); export async function POST(request: NextRequest) { @@ -28,7 +26,7 @@ export async function POST(request: NextRequest) { } // 사용자 정보 조회 - const user = await getUserById(Number(userId)); + const user = await getUserById(userId); if (!user || !user.phone) { return NextResponse.json( { error: '전화번호가 등록되지 않았습니다' }, diff --git a/app/api/tracking/page-duration/route.ts b/app/api/tracking/page-duration/route.ts new file mode 100644 index 00000000..861a52cc --- /dev/null +++ b/app/api/tracking/page-duration/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import db from '@/db/db' +import { pageVisits } from '@/db/schema' +import { and, eq, desc, gte } from 'drizzle-orm' +import { authOptions } from '../../auth/[...nextauth]/route' + +// 타입 변환 헬퍼 +function ensureNumber(value: string | number): number { + return typeof value === 'string' ? parseInt(value, 10) : value + } + + export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + const { route, duration, timestamp } = await request.json() + + // 세션이 있는 경우에만 체류 시간 업데이트 + if (session?.user?.id) { + // string ID를 number로 변환 + const numericUserId = ensureNumber(session.user.id) + + // 최근 5분 내의 해당 라우트 방문 기록을 찾아서 체류 시간 업데이트 + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000) + + // 방법 1: 서브쿼리를 사용해서 가장 최근 레코드의 ID를 찾아서 업데이트 + const latestVisitSubquery = db + .select({ id: pageVisits.id }) + .from(pageVisits) + .where( + and( + eq(pageVisits.userId, numericUserId), // ✅ 이제 타입 매칭 + eq(pageVisits.route, route), + gte(pageVisits.visitedAt, fiveMinutesAgo) + ) + ) + .orderBy(desc(pageVisits.visitedAt)) + .limit(1) + + // 서브쿼리 결과를 사용해서 업데이트 + const latestVisit = await latestVisitSubquery + + if (latestVisit.length > 0) { + await db + .update(pageVisits) + .set({ duration }) + .where(eq(pageVisits.id, latestVisit[0].id)) + } + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Page duration tracking API error:', error) + return NextResponse.json({ success: false }, { status: 200 }) + } + }
\ No newline at end of file diff --git a/app/api/tracking/page-visit/route.ts b/app/api/tracking/page-visit/route.ts new file mode 100644 index 00000000..26263b04 --- /dev/null +++ b/app/api/tracking/page-visit/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { SessionRepository } from '@/lib/users/session/repository' +import { authOptions } from '../../auth/[...nextauth]/route' + +function ensureNumber(value: string | number): number { + return typeof value === 'string' ? parseInt(value, 10) : value +} +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + const trackingData = await request.json() + + // IP 주소 추출 + const getClientIP = (req: NextRequest): string => { + const forwarded = req.headers.get('x-forwarded-for') + const realIP = req.headers.get('x-real-ip') + const cfConnectingIP = req.headers.get('cf-connecting-ip') + + if (cfConnectingIP) return cfConnectingIP + if (forwarded) return forwarded.split(',')[0].trim() + if (realIP) return realIP + return '127.0.0.1' + } + + // 활성 세션 조회 및 업데이트 + let sessionId = null + if (session?.user?.id && session?.user?.dbSessionId) { + sessionId = session.user.dbSessionId + + // 세션 활동 시간 업데이트 (백그라운드) + SessionRepository.updateSessionActivity(sessionId).catch(error => { + console.error('Failed to update session activity:', error) + }) + } + + // 페이지 방문 기록 + await SessionRepository.recordPageVisit({ + userId: session?.user?.id ? ensureNumber(session.user.id) : undefined, // ✅ 타입 변환 + sessionId, + route: trackingData.route, + pageTitle: trackingData.pageTitle || undefined, + referrer: trackingData.referrer || undefined, + ipAddress: getClientIP(request), + userAgent: trackingData.userAgent, + queryParams: new URL(request.url).search || undefined, + deviceType: trackingData.deviceType || undefined, + browserName: trackingData.browserName || undefined, + osName: trackingData.osName || undefined, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Page visit tracking API error:', error) + // 추적 실패가 클라이언트에 영향을 주지 않도록 성공 응답 + return NextResponse.json({ success: false }, { status: 200 }) + } +}
\ No newline at end of file |
