diff options
Diffstat (limited to 'app/api/auth')
| -rw-r--r-- | app/api/auth/[...nextauth]/route.ts | 238 | ||||
| -rw-r--r-- | app/api/auth/send-sms/route.ts | 75 | ||||
| -rw-r--r-- | app/api/auth/verify-mfa/route.ts | 65 |
3 files changed, 323 insertions, 55 deletions
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 969263ea..f5d49f77 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,51 +1,50 @@ -// (1) next-auth에서 필요한 타입들을 import +// Updated NextAuth configuration with dynamic session timeout from database + import NextAuth, { - NextAuthOptions, // authOptions에 쓸 타입 + NextAuthOptions, Session, - User + User, + Account } from 'next-auth' import { JWT } from "next-auth/jwt" - import CredentialsProvider from 'next-auth/providers/credentials' - -import { verifyExternalCredentials, verifyOtp, verifyOtpTemp } from '@/lib/users/verifyOtp' import { SAMLProvider } from './saml/provider' +import { 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' -// 1) 모듈 보강 선언 +// 인증 방식 타입 정의 +type AuthMethod = 'otp' | 'email' | 'sgips' | 'saml' + +// 모듈 보강 선언 (인증 방식 추가) declare module "next-auth" { - /** - * Session 객체를 확장 - */ interface Session { user: { - /** 우리가 필요로 하는 user id */ id: string - - // 기본적으로 NextAuth가 제공하는 name/email/image 필드 name?: string | null email?: string | null image?: string | null companyId?: number | null techCompanyId?: number | null domain?: string | null - + reAuthTime?: number | null + authMethod?: AuthMethod + sessionExpiredAt?: number | null // 세션 만료 시간 추가 } } - /** - * User 객체를 확장 - */ interface User { id: string imageUrl?: string | null companyId?: number | null techCompanyId?: number | null domain?: string | null - // 필요한 필드를 추가로 선언 가능 + reAuthTime?: number | null + authMethod?: AuthMethod } } -// JWT 타입 확장 declare module "next-auth/jwt" { interface JWT { id?: string @@ -53,13 +52,47 @@ declare module "next-auth/jwt" { companyId?: number | null techCompanyId?: number | null domain?: string | null + reAuthTime?: number | null + authMethod?: AuthMethod + sessionExpiredAt?: number | null // 세션 만료 시간 추가 } } +// 보안 설정 캐시 (성능 최적화) +let securitySettingsCache: { + data: any | null + lastFetch: number + ttl: number +} = { + data: null, + lastFetch: 0, + ttl: 5 * 60 * 1000 // 5분 캐시 +} + +// 보안 설정을 가져오는 함수 (캐시 적용) +async function getCachedSecuritySettings() { + const now = Date.now() + + if (!securitySettingsCache.data || + (now - securitySettingsCache.lastFetch) > securitySettingsCache.ttl) { + try { + securitySettingsCache.data = await getSecuritySettings() + securitySettingsCache.lastFetch = now + } catch (error) { + console.error('Failed to fetch security settings:', error) + // 기본값 사용 + securitySettingsCache.data = { + sessionTimeoutMinutes: 480 // 8시간 기본값 + } + } + } + + return securitySettingsCache.data +} -// (2) authOptions에 NextAuthOptions 타입 지정 export const authOptions: NextAuthOptions = { providers: [ + // OTP provider CredentialsProvider({ name: 'Credentials', credentials: { @@ -69,69 +102,90 @@ export const authOptions: NextAuthOptions = { async authorize(credentials, req) { const { email, code } = credentials ?? {} - // OTP 검증 const user = await verifyOtpTemp(email ?? '') if (!user) { return null } + // 보안 설정에서 세션 타임아웃 가져오기 + const securitySettings = await getCachedSecuritySettings() + const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000 + const reAuthTime = Date.now() + return { id: String(user.id ?? email ?? "dts"), email: user.email, imageUrl: user.imageUrl ?? null, - name: user.name, // DB에서 가져온 실제 이름 - companyId: user.companyId, // DB에서 가져온 실제 이름 - techCompanyId: user.techCompanyId as number | undefined, // techVendor ID - domain: user.domain, // DB에서 가져온 실제 이름 + name: user.name, + companyId: user.companyId, + techCompanyId: user.techCompanyId as number | undefined, + domain: user.domain, + reAuthTime, + authMethod: 'otp' as AuthMethod, } }, }), - // 새로 추가할 ID/비밀번호 provider + + // ID/패스워드 provider (S-Gips와 일반 이메일 구분) CredentialsProvider({ id: 'credentials-password', name: 'Username Password', credentials: { username: { label: "Username", type: "text" }, - password: { label: "Password", type: "password" } + password: { label: "Password", type: "password" }, + provider: { label: "Provider", type: "text" }, }, - async authorize(credentials, req) { // req 매개변수 추가 + async authorize(credentials, req) { if (!credentials?.username || !credentials?.password) { return null; } try { - // 여기서 외부 서비스 API를 호출하여 사용자 인증 - const user = await verifyExternalCredentials( - credentials.username, - credentials.password - ); + let authResult; + const isSSgips = credentials.provider === 'sgips'; - if (user) { + if (isSSgips) { + authResult = await authenticateWithSGips( + credentials.username, + credentials.password + ); + } else { + authResult = await verifyExternalCredentials( + credentials.username, + credentials.password + ); + } + + if (authResult.success && authResult.user) { return { - id: String(user.id), // id를 string으로 변환 - name: user.name, - email: user.email, - // 첫 번째 provider와 동일한 필드 구조 유지 - imageUrl: user.imageUrl ?? null, - companyId: user.companyId, - techCompanyId: user.techCompanyId, - domain: user.domain + 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, }; } + return null; + } catch (error) { console.error("Authentication error:", error); return null; } } }), - // SAML Provider 추가 (CredentialsProvider 기반) + + // SAML Provider SAMLProvider({ id: "credentials-saml", name: "SAML SSO", idp: { sso_login_url: process.env.SAML_IDP_SSO_URL!, - sso_logout_url: process.env.SAML_IDP_SLO_URL || '', // 선택적 + sso_logout_url: process.env.SAML_IDP_SLO_URL || '', certificates: [process.env.SAML_IDP_CERT!] }, sp: { @@ -142,17 +196,23 @@ export const authOptions: NextAuthOptions = { } }) ], - // (3) session.strategy는 'jwt'가 되도록 선언 - // 필요하다면 as SessionStrategy 라고 명시해줄 수도 있음 - // 예) strategy: 'jwt' as SessionStrategy + session: { strategy: 'jwt', + // JWT 기본 maxAge는 30일로 설정하되, 실제 세션 만료는 콜백에서 처리 + maxAge: 30 * 24 * 60 * 60, // 30일 }, callbacks: { - // (4) 콜백에서 token, user, session 등의 타입을 좀 더 명시해주고 싶다면 아래처럼 destructuring에 제네릭/타입 지정 - async jwt({ token, user }: { token: JWT; user?: User }) { + // JWT 콜백 - 세션 타임아웃 설정 (만료 체크는 session 콜백에서) + async jwt({ token, user, account, trigger, session }) { + // 보안 설정 가져오기 + const securitySettings = await getCachedSecuritySettings() + const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000 + + // 최초 로그인 시 if (user) { + const reAuthTime = Date.now() token.id = user.id token.email = user.email token.name = user.name @@ -160,10 +220,61 @@ export const authOptions: NextAuthOptions = { token.techCompanyId = user.techCompanyId token.domain = user.domain token.imageUrl = user.imageUrl + token.reAuthTime = reAuthTime + token.authMethod = user.authMethod + token.sessionExpiredAt = reAuthTime + sessionTimeoutMs + } + + // 인증 방식 결정 (account 정보 기반) + if (account && !token.authMethod) { + const reAuthTime = Date.now() + if (account.provider === 'credentials-saml') { + 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 + } + } } + + // 세션 업데이트 시 (재인증 시간 업데이트) + if (trigger === "update" && session) { + if (session.reAuthTime !== undefined) { + token.reAuthTime = session.reAuthTime + // 재인증 시간 업데이트 시 세션 만료 시간도 연장 + token.sessionExpiredAt = session.reAuthTime + sessionTimeoutMs + } + + if (session.user) { + if (session.user.name !== undefined) token.name = session.user.name + if (session.user.email !== undefined) token.email = session.user.email + if (session.user.image !== undefined) token.imageUrl = session.user.image + } + } + 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)}`) + // 만료된 세션 처리 - 빈 세션 반환하여 로그아웃 유도 + return { + expires: new Date(0).toISOString(), // 즉시 만료 + user: null as any + } + } + if (token) { session.user = { id: token.id as string, @@ -172,27 +283,44 @@ export const authOptions: NextAuthOptions = { domain: token.domain as string, companyId: token.companyId as number, techCompanyId: token.techCompanyId as number, - image: token.imageUrl ?? null + image: token.imageUrl ?? null, + reAuthTime: token.reAuthTime as number | null, + authMethod: token.authMethod as AuthMethod, + sessionExpiredAt: token.sessionExpiredAt as number | null, } } return session }, - // redirect 콜백 추가 + + // Redirect 콜백 async redirect({ url, baseUrl }) { - // 상대 경로인 경우 baseUrl을 기준으로 함 if (url.startsWith("/")) { return `${baseUrl}${url}`; } - // 같은 도메인인 경우 그대로 사용 else if (new URL(url).origin === baseUrl) { return url; } - // 그 외에는 baseUrl로 리다이렉트 return baseUrl; }, }, + + pages: { + signIn: '/auth/login', + 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`); + }, + async signOut({ session, token }) { + console.log(`User ${session?.user?.email || token?.email} signed out`); + } + } } const handler = NextAuth(authOptions) +export { handler as GET, handler as POST } -export { handler as GET, handler as POST }
\ No newline at end of file diff --git a/app/api/auth/send-sms/route.ts b/app/api/auth/send-sms/route.ts new file mode 100644 index 00000000..3d51d445 --- /dev/null +++ b/app/api/auth/send-sms/route.ts @@ -0,0 +1,75 @@ +// app/api/auth/send-sms/route.ts + +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 { generateAndSendSmsToken } from '@/lib/users/auth/passwordUtil'; + +const sendSmsSchema = z.object({ + userId: z.string(), +}); + +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); + + // 본인 확인 + if (session.user.id !== userId) { + return NextResponse.json( + { error: '권한이 없습니다' }, + { status: 403 } + ); + } + + // 사용자 정보 조회 + const user = await getUserById(Number(userId)); + if (!user || !user.phone) { + return NextResponse.json( + { error: '전화번호가 등록되지 않았습니다' }, + { status: 400 } + ); + } + + // SMS 전송 + const result = await generateAndSendSmsToken(parseInt(userId), user.phone); + + if (result.success) { + return NextResponse.json({ + success: true, + message: 'SMS가 전송되었습니다' + }); + } else { + return NextResponse.json( + { error: result.error }, + { status: 400 } + ); + } + + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: '잘못된 요청입니다' }, + { status: 400 } + ); + } + + console.error('SMS send API error:', error); + return NextResponse.json( + { error: '서버 오류가 발생했습니다' }, + { status: 500 } + ); + } +} + diff --git a/app/api/auth/verify-mfa/route.ts b/app/api/auth/verify-mfa/route.ts new file mode 100644 index 00000000..f9d1b51e --- /dev/null +++ b/app/api/auth/verify-mfa/route.ts @@ -0,0 +1,65 @@ +// app/api/auth/verify-mfa/route.ts + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { verifySmsToken } from '@/lib/users/auth/passwordUtil'; + +const verifyMfaSchema = z.object({ + userId: z.string(), + token: z.string().length(6, '6자리 인증번호를 입력해주세요'), +}); + +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, token } = verifyMfaSchema.parse(body); + + // 본인 확인 + if (session.user.id !== userId) { + return NextResponse.json( + { error: '권한이 없습니다' }, + { status: 403 } + ); + } + + // MFA 토큰 검증 + const result = await verifySmsToken(parseInt(userId), token); + + if (result.success) { + return NextResponse.json({ + success: true, + message: 'MFA 인증이 완료되었습니다' + }); + } else { + return NextResponse.json( + { error: result.error }, + { status: 400 } + ); + } + + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.errors[0]?.message || '잘못된 요청입니다' }, + { status: 400 } + ); + } + + console.error('MFA verify API error:', error); + return NextResponse.json( + { error: '서버 오류가 발생했습니다' }, + { status: 500 } + ); + } +}
\ No newline at end of file |
