diff options
Diffstat (limited to 'app/api/auth')
| -rw-r--r-- | app/api/auth/[...nextauth]/route.ts | 258 | ||||
| -rw-r--r-- | app/api/auth/first-auth/route.ts | 112 | ||||
| -rw-r--r-- | app/api/auth/send-sms/route.ts | 20 | ||||
| -rw-r--r-- | app/api/auth/verify-mfa/route.ts | 21 |
4 files changed, 317 insertions, 94 deletions
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index f5d49f77..2b168746 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,5 +1,4 @@ -// Updated NextAuth configuration with dynamic session timeout from database - +// auth/config.ts - 업데이트된 NextAuth 설정 import NextAuth, { NextAuthOptions, Session, @@ -9,15 +8,18 @@ import NextAuth, { import { JWT } from "next-auth/jwt" import CredentialsProvider from 'next-auth/providers/credentials' import { SAMLProvider } from './saml/provider' -import { getUserById } from '@/lib/users/repository' +import { getUserByEmail, getUserById } from '@/lib/users/repository' import { authenticateWithSGips, verifyExternalCredentials } from '@/lib/users/auth/verifyCredentails' 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' -// 모듈 보강 선언 (인증 방식 추가) +// 모듈 보강 선언 (기존과 동일) declare module "next-auth" { interface Session { user: { @@ -30,7 +32,8 @@ declare module "next-auth" { domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod - sessionExpiredAt?: number | null // 세션 만료 시간 추가 + sessionExpiredAt?: number | null + dbSessionId?: string | null // DB 세션 ID 추가 } } @@ -42,6 +45,7 @@ declare module "next-auth" { domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod + dbSessionId?: string | null } } @@ -54,11 +58,12 @@ declare module "next-auth/jwt" { domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod - sessionExpiredAt?: number | null // 세션 만료 시간 추가 + sessionExpiredAt?: number | null + dbSessionId?: string | null } } -// 보안 설정 캐시 (성능 최적화) +// 보안 설정 캐시 (기존과 동일) let securitySettingsCache: { data: any | null lastFetch: number @@ -69,7 +74,6 @@ let securitySettingsCache: { ttl: 5 * 60 * 1000 // 5분 캐시 } -// 보안 설정을 가져오는 함수 (캐시 적용) async function getCachedSecuritySettings() { const now = Date.now() @@ -80,7 +84,6 @@ async function getCachedSecuritySettings() { securitySettingsCache.lastFetch = now } catch (error) { console.error('Failed to fetch security settings:', error) - // 기본값 사용 securitySettingsCache.data = { sessionTimeoutMinutes: 480 // 8시간 기본값 } @@ -90,11 +93,28 @@ async function getCachedSecuritySettings() { return securitySettingsCache.data } +// 클라이언트 IP 추출 헬퍼 +function getClientIP(req: any): string { + const forwarded = req.headers['x-forwarded-for'] + const realIP = req.headers['x-real-ip'] + + if (forwarded) { + return forwarded.split(',')[0].trim() + } + + if (realIP) { + return realIP + } + + return req.ip || req.connection?.remoteAddress || '127.0.0.1' +} + export const authOptions: NextAuthOptions = { providers: [ - // OTP provider + // OTP 로그인 (기존 유지) CredentialsProvider({ - name: 'Credentials', + id: 'credentials-otp', + name: 'OTP', credentials: { email: { label: 'Email', type: 'text' }, code: { label: 'OTP code', type: 'text' }, @@ -107,9 +127,7 @@ export const authOptions: NextAuthOptions = { return null } - // 보안 설정에서 세션 타임아웃 가져오기 const securitySettings = await getCachedSecuritySettings() - const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000 const reAuthTime = Date.now() return { @@ -125,61 +143,101 @@ export const authOptions: NextAuthOptions = { } }, }), - - // ID/패스워드 provider (S-Gips와 일반 이메일 구분) + + // MFA 완료 후 최종 인증 (DB 연동 버전) CredentialsProvider({ - id: 'credentials-password', - name: 'Username Password', + id: 'credentials-mfa', + name: 'MFA Verification', credentials: { - username: { label: "Username", type: "text" }, - password: { label: "Password", type: "password" }, - provider: { label: "Provider", type: "text" }, + userId: { label: 'User ID', type: 'text' }, + smsToken: { label: 'SMS Token', type: 'text' }, + tempAuthKey: { label: 'Temp Auth Key', type: 'text' }, }, async authorize(credentials, req) { - if (!credentials?.username || !credentials?.password) { - return null; + if (!credentials?.userId || !credentials?.smsToken || !credentials?.tempAuthKey) { + console.error('MFA credentials missing') + return null } - + try { - let authResult; - const isSSgips = credentials.provider === 'sgips'; - - if (isSSgips) { - authResult = await authenticateWithSGips( - credentials.username, - credentials.password - ); - } else { - authResult = await verifyExternalCredentials( - credentials.username, - credentials.password - ); + // DB에서 임시 인증 정보 확인 + const tempAuth = await SessionRepository.getTempAuthSession(credentials.tempAuthKey) + if (!tempAuth || tempAuth.userId !== credentials.userId) { + console.error('Temp auth expired or not found') + return null } - - if (authResult.success && authResult.user) { - return { - id: authResult.user.id, - name: authResult.user.name, - email: authResult.user.email, - imageUrl: authResult.user.imageUrl ?? null, - companyId: authResult.user.companyId, - techCompanyId: authResult.user.techCompanyId, - domain: authResult.user.domain, - reAuthTime: Date.now(), - authMethod: isSSgips ? 'sgips' as AuthMethod : 'email' as AuthMethod, - }; + + // SMS 토큰 검증 + const smsVerificationResult = await verifySmsToken(Number(credentials.userId), credentials.smsToken) + if (!smsVerificationResult || !smsVerificationResult.success) { + console.error('SMS token verification failed') + return null } - 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) + + // 보안 설정 및 세션 정보 설정 + const securitySettings = await getCachedSecuritySettings() + const reAuthTime = Date.now() + const sessionExpiredAt = new Date(reAuthTime + (securitySettings.sessionTimeoutMinutes * 60 * 1000)) + + // DB에 로그인 세션 생성 + const ipAddress = getClientIP(req) + const userAgent = req.headers?.['user-agent'] + const dbSession = await SessionRepository.createLoginSession({ + userId: String(user.id), + ipAddress, + userAgent, + authMethod: tempAuth.authMethod, + sessionExpiredAt, + }) + + console.log(`MFA completed for user ${user.email} (${tempAuth.authMethod})`) + + return { + id: String(user.id), + email: user.email, + imageUrl: user.imageUrl ?? null, + name: user.name, + companyId: user.companyId, + techCompanyId: user.techCompanyId as number | undefined, + domain: user.domain, + reAuthTime, + authMethod: tempAuth.authMethod as AuthMethod, + dbSessionId: dbSession.id, + } + } catch (error) { - console.error("Authentication error:", error); - return null; + console.error('MFA authorization error:', error) + return null } + }, + }), + + // 1차 인증용 프로바이더 (기존 유지) + CredentialsProvider({ + id: 'credentials-first-auth', + name: 'First Factor Authentication', + credentials: { + username: { label: "Username", type: "text" }, + password: { label: "Password", type: "password" }, + provider: { label: "Provider", type: "text" }, + }, + async authorize(credentials, req) { + return null } }), - // SAML Provider + // SAML Provider (기존 유지) SAMLProvider({ id: "credentials-saml", name: "SAML SSO", @@ -199,18 +257,15 @@ export const authOptions: NextAuthOptions = { session: { strategy: 'jwt', - // JWT 기본 maxAge는 30일로 설정하되, 실제 세션 만료는 콜백에서 처리 maxAge: 30 * 24 * 60 * 60, // 30일 }, callbacks: { - // JWT 콜백 - 세션 타임아웃 설정 (만료 체크는 session 콜백에서) async jwt({ token, user, account, trigger, session }) { - // 보안 설정 가져오기 const securitySettings = await getCachedSecuritySettings() const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000 - // 최초 로그인 시 + // 최초 로그인 시 (MFA 완료 후) if (user) { const reAuthTime = Date.now() token.id = user.id @@ -223,34 +278,44 @@ export const authOptions: NextAuthOptions = { token.reAuthTime = reAuthTime token.authMethod = user.authMethod token.sessionExpiredAt = reAuthTime + sessionTimeoutMs + token.dbSessionId = user.dbSessionId } - // 인증 방식 결정 (account 정보 기반) - if (account && !token.authMethod) { + // SAML 인증 시 DB 세션 생성 + if (account && account.provider === 'credentials-saml' && token.id) { const reAuthTime = Date.now() - if (account.provider === 'credentials-saml') { + const sessionExpiredAt = new Date(reAuthTime + sessionTimeoutMs) + + try { + const dbSession = await SessionRepository.createLoginSession({ + userId: token.id, + ipAddress: '0.0.0.0', // SAML의 경우 IP 추적 제한적 + authMethod: 'saml', + sessionExpiredAt, + }) + token.authMethod = 'saml' token.reAuthTime = reAuthTime token.sessionExpiredAt = reAuthTime + sessionTimeoutMs - } else if (account.provider === 'credentials') { - // OTP는 이미 user.authMethod에서 설정됨 - if (!token.sessionExpiredAt) { - token.sessionExpiredAt = (token.reAuthTime || Date.now()) + sessionTimeoutMs - } - } else if (account.provider === 'credentials-password') { - // credentials-password는 이미 user.authMethod에서 설정됨 - if (!token.sessionExpiredAt) { - token.sessionExpiredAt = (token.reAuthTime || Date.now()) + sessionTimeoutMs - } + token.dbSessionId = dbSession.id + } catch (error) { + console.error('Failed to create SAML session:', error) } } - // 세션 업데이트 시 (재인증 시간 업데이트) + // 세션 업데이트 시 if (trigger === "update" && session) { if (session.reAuthTime !== undefined) { token.reAuthTime = session.reAuthTime - // 재인증 시간 업데이트 시 세션 만료 시간도 연장 token.sessionExpiredAt = session.reAuthTime + sessionTimeoutMs + + // DB 세션 업데이트 + if (token.dbSessionId) { + await SessionRepository.updateLoginSession(token.dbSessionId, { + lastActivityAt: new Date(), + sessionExpiredAt: new Date(session.reAuthTime + sessionTimeoutMs) + }) + } } if (session.user) { @@ -263,14 +328,18 @@ export const authOptions: NextAuthOptions = { return token }, - // Session 콜백 - 세션 만료 체크 및 정보 포함 async session({ session, token }: { session: Session; token: JWT }) { // 세션 만료 체크 if (token.sessionExpiredAt && Date.now() > token.sessionExpiredAt) { console.log(`Session expired for user ${token.email}. Expired at: ${new Date(token.sessionExpiredAt)}`) - // 만료된 세션 처리 - 빈 세션 반환하여 로그아웃 유도 + + // DB 세션 만료 처리 + if (token.dbSessionId) { + await SessionRepository.logoutSession(token.dbSessionId) + } + return { - expires: new Date(0).toISOString(), // 즉시 만료 + expires: new Date(0).toISOString(), user: null as any } } @@ -287,12 +356,12 @@ export const authOptions: NextAuthOptions = { reAuthTime: token.reAuthTime as number | null, authMethod: token.authMethod as AuthMethod, sessionExpiredAt: token.sessionExpiredAt as number | null, + dbSessionId: token.dbSessionId as string | null, } } return session }, - // Redirect 콜백 async redirect({ url, baseUrl }) { if (url.startsWith("/")) { return `${baseUrl}${url}`; @@ -309,18 +378,45 @@ export const authOptions: NextAuthOptions = { error: '/auth/error', }, - // 디버깅을 위한 이벤트 로깅 events: { async signIn({ user, account, profile }) { const securitySettings = await getCachedSecuritySettings() console.log(`User ${user.email} signed in via ${account?.provider} (authMethod: ${user.authMethod}), session timeout: ${securitySettings.sessionTimeoutMinutes} minutes`); + + // 이미 MFA에서 DB 세션이 생성된 경우가 아니라면 여기서 생성 + if (account?.provider !== 'credentials-mfa' && user.id) { + try { + // 기존 활성 세션 확인 + const existingSession = await SessionRepository.getActiveSessionByUserId(user.id) + 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 접근 제한적 + authMethod: user.authMethod || 'unknown', + sessionExpiredAt, + }) + } + } catch (error) { + console.error('Failed to create session in signIn event:', error) + } + } }, + async signOut({ session, token }) { console.log(`User ${session?.user?.email || token?.email} signed out`); + + // DB에서 세션 로그아웃 처리 + const userId = session?.user?.id || token?.id + const dbSessionId = session?.user?.dbSessionId || token?.dbSessionId + + if (dbSessionId) { + await SessionRepository.logoutSession(dbSessionId) + } else if (userId) { + // dbSessionId가 없는 경우 사용자의 모든 활성 세션 로그아웃 + await SessionRepository.logoutAllUserSessions(userId) + } } } } - -const handler = NextAuth(authOptions) -export { handler as GET, handler as POST } - diff --git a/app/api/auth/first-auth/route.ts b/app/api/auth/first-auth/route.ts new file mode 100644 index 00000000..18f44904 --- /dev/null +++ b/app/api/auth/first-auth/route.ts @@ -0,0 +1,112 @@ +// /api/auth/first-auth/route.ts +// 1차 인증 처리 API 엔드포인트 + +import { NextRequest, NextResponse } from 'next/server' +import { authHelpers } from '../[...nextauth]/route' + +// 요청 데이터 타입 +interface FirstAuthRequest { + username: string + password: string + provider: 'email' | 'sgips' +} + +// 응답 데이터 타입 +interface FirstAuthResponse { + success: boolean + tempAuthKey?: string + userId?: string + email?: string + error?: string +} + +export async function POST(request: NextRequest): Promise<NextResponse<FirstAuthResponse>> { + try { + // 요청 데이터 파싱 + const body: FirstAuthRequest = await request.json() + const { username, password, provider } = body + + // 입력 검증 + if (!username || !password || !provider) { + return NextResponse.json( + { + success: false, + error: '필수 입력값이 누락되었습니다.' + }, + { status: 400 } + ) + } + + if (!['email', 'sgips'].includes(provider)) { + return NextResponse.json( + { + success: false, + error: '지원하지 않는 인증 방식입니다.' + }, + { status: 400 } + ) + } + + // 레이트 리미팅 (옵셔널) + // const rateLimitResult = await rateLimit.check(request, `first-auth:${username}`) + // if (!rateLimitResult.success) { + // return NextResponse.json( + // { + // success: false, + // error: '너무 많은 시도입니다. 잠시 후 다시 시도해주세요.' + // }, + // { status: 429 } + // ) + // } + + // 1차 인증 수행 + const authResult = await authHelpers.performFirstAuth(username, password, provider) + + if (!authResult.success) { + // 인증 실패 응답 + let errorMessage = '인증에 실패했습니다.' + + if (provider === 'sgips') { + errorMessage = 'S-Gips 계정 정보가 올바르지 않습니다.' + } else { + errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.' + } + + return NextResponse.json( + { + success: false, + error: authResult.error || errorMessage + }, + { status: 401 } + ) + } + + // 1차 인증 성공 응답 + return NextResponse.json({ + success: true, + tempAuthKey: authResult.tempAuthKey, + userId: authResult.userId, + email: authResult.email + }) + + } catch (error) { + console.error('First auth API error:', error) + + // 에러 응답 + return NextResponse.json( + { + success: false, + error: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + }, + { status: 500 } + ) + } +} + +// GET 요청은 지원하지 않음 +export async function GET() { + return NextResponse.json( + { error: 'Method not allowed' }, + { status: 405 } + ) +}
\ No newline at end of file diff --git a/app/api/auth/send-sms/route.ts b/app/api/auth/send-sms/route.ts index 3d51d445..6b9eb114 100644 --- a/app/api/auth/send-sms/route.ts +++ b/app/api/auth/send-sms/route.ts @@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { getUserById } from '@/lib/users/repository'; +import { getUserByEmail, getUserById } from '@/lib/users/repository'; import { generateAndSendSmsToken } from '@/lib/users/auth/passwordUtil'; const sendSmsSchema = z.object({ @@ -13,20 +13,14 @@ const sendSmsSchema = z.object({ export async function POST(request: NextRequest) { try { - // 세션 확인 - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json( - { error: '인증이 필요합니다' }, - { status: 401 } - ); - } const body = await request.json(); const { userId } = sendSmsSchema.parse(body); + console.log(userId, "userId") + // 본인 확인 - if (session.user.id !== userId) { + if (!userId) { return NextResponse.json( { error: '권한이 없습니다' }, { status: 403 } @@ -42,8 +36,12 @@ export async function POST(request: NextRequest) { ); } + console.log(user, "user") + + + // SMS 전송 - const result = await generateAndSendSmsToken(parseInt(userId), user.phone); + const result = await generateAndSendSmsToken(Number(userId), user.phone); if (result.success) { return NextResponse.json({ diff --git a/app/api/auth/verify-mfa/route.ts b/app/api/auth/verify-mfa/route.ts index f9d1b51e..dea06164 100644 --- a/app/api/auth/verify-mfa/route.ts +++ b/app/api/auth/verify-mfa/route.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { verifySmsToken } from '@/lib/users/auth/passwordUtil'; +import { getUserByEmail } from '@/lib/users/repository'; const verifyMfaSchema = z.object({ userId: z.string(), @@ -25,16 +26,32 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { userId, token } = verifyMfaSchema.parse(body); + + console.log(userId) + + + // 본인 확인 - if (session.user.id !== userId) { + if (session.user.email !== userId) { return NextResponse.json( { error: '권한이 없습니다' }, { status: 403 } ); } + const user = await getUserByEmail(userId); + if (!user || !user.phone) { + return NextResponse.json( + { error: '전화번호가 등록되지 않았습니다' }, + { status: 400 } + ); + } + + const userIdfromUsers = user.id + + // MFA 토큰 검증 - const result = await verifySmsToken(parseInt(userId), token); + const result = await verifySmsToken(userIdfromUsers, token); if (result.success) { return NextResponse.json({ |
