diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx | 82 | ||||
| -rw-r--r-- | app/api/auth/[...nextauth]/route.ts | 25 | ||||
| -rw-r--r-- | app/api/auth/first-auth/route.ts | 8 | ||||
| -rw-r--r-- | app/api/auth/send-email-otp/route.ts | 74 |
4 files changed, 180 insertions, 9 deletions
diff --git a/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx b/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx new file mode 100644 index 00000000..95abd556 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx @@ -0,0 +1,82 @@ +/** + * 이메일 화이트리스트 + * + * 이메일 도메인의 화이트리스트를 통해, SMS 인증을 우회할 도메인을 관리 + * + * db schema : db/schema/emailWhitelist.ts + * + * 구현방향: 이메일 화이트리스트 조회 + dialog 기반의 생성/삭제 + * + */ + +import * as React from "react" +import { type Metadata } from "next" +import { getEmailWhitelistList } from "@/lib/email-whitelist/service" +import { type SearchParams } from "@/types/table" +import { WhitelistTable } from "@/lib/email-whitelist/table/whitelist-table" +import { Shell } from "@/components/shell" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" + +// export const metadata: Metadata = { +// title: "이메일 화이트리스트 관리", +// description: "SMS 인증 대신 이메일로 인증번호를 받을 도메인 및 개별 이메일을 관리", +// } + +interface WhitelistPageProps { + searchParams: SearchParams +} + +export default async function WhitelistPage(props: WhitelistPageProps) { + + const searchParams = await props.searchParams + + // 기본 검색 파라미터 처리 + const search = { + page: searchParams.page ? parseInt(searchParams.page as string) : 1, + perPage: searchParams.perPage ? parseInt(searchParams.perPage as string) : 10, + search: searchParams.search as string || "", + sort: searchParams.sort as string || "createdAt.desc", + filters: searchParams.filters ? JSON.parse(searchParams.filters as string) : undefined, + } + + const promises = Promise.all([ + getEmailWhitelistList(search), + ]) + + return ( + + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 이메일 화이트리스트 관리 + </h2> + </div> + <p className="text-muted-foreground"> + SMS 인증 대신 이메일로 인증번호를 받을 도메인 및 개별 이메일을 관리합니다. + </p> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + </React.Suspense> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={5} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["4rem", "12rem", "20rem", "12rem", "12rem", "8rem"]} + shrinkZero + /> + } + > + <WhitelistTable promises={promises} /> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 5896fb90..3b0f8c61 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -11,7 +11,7 @@ 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 { verifySmsToken, verifyEmailToken } from '@/lib/users/auth/passwordUtil' import { SessionRepository } from '@/lib/users/session/repository' import { getUserRoles } from '@/lib/users/service' @@ -161,14 +161,15 @@ export const authOptions: NextAuthOptions = { }, }), - // ✅ MFA 완료 후 최종 인증 - roles 정보 추가 + // ✅ MFA 완료 후 최종 인증 - roles 정보 추가 (SMS/Email OTP 지원) CredentialsProvider({ id: 'credentials-mfa', name: 'MFA Verification', credentials: { userId: { label: 'User ID', type: 'text' }, - smsToken: { label: 'SMS Token', type: 'text' }, + smsToken: { label: 'SMS Token', type: 'text' }, // SMS 또는 Email OTP 토큰 tempAuthKey: { label: 'Temp Auth Key', type: 'text' }, + mfaType: { label: 'MFA Type', type: 'text' }, // 'sms' 또는 'email' }, async authorize(credentials, req) { if (!credentials?.userId || !credentials?.smsToken || !credentials?.tempAuthKey) { @@ -191,10 +192,20 @@ export const authOptions: NextAuthOptions = { return null } - // SMS 토큰 검증 - const smsVerificationResult = await verifySmsToken(user.id, credentials.smsToken) - if (!smsVerificationResult || !smsVerificationResult.success) { - console.error('SMS token verification failed') + // MFA 타입에 따라 SMS 또는 Email OTP 검증 + const mfaType = credentials.mfaType || 'sms'; // 기본값은 SMS + let verificationResult; + + if (mfaType === 'email') { + verificationResult = await verifyEmailToken(user.id, credentials.smsToken) + console.log(`Email OTP verification for user ${user.email}:`, verificationResult.success) + } else { + verificationResult = await verifySmsToken(user.id, credentials.smsToken) + console.log(`SMS OTP verification for user ${user.email}:`, verificationResult.success) + } + + if (!verificationResult || !verificationResult.success) { + console.error(`${mfaType.toUpperCase()} token verification failed`) return null } diff --git a/app/api/auth/first-auth/route.ts b/app/api/auth/first-auth/route.ts index 6952b472..93daf316 100644 --- a/app/api/auth/first-auth/route.ts +++ b/app/api/auth/first-auth/route.ts @@ -17,6 +17,8 @@ interface FirstAuthResponse { tempAuthKey?: string userId?: number email?: string + mfaType?: 'sms' | 'email' // MFA 타입 추가 + userName?: string // Email OTP 전송 시 필요 otpUsers?: Array<{ id: string name: string @@ -134,12 +136,14 @@ export async function POST(request: NextRequest): Promise<NextResponse<FirstAuth }) } - // 일반 사용자의 경우 기존 응답 + // 일반 사용자의 경우 mfaType 포함하여 응답 return NextResponse.json({ success: true, tempAuthKey: authResult.tempAuthKey, userId: authResult.userId, - email: authResult.email + email: authResult.email, + mfaType: (authResult.mfaType || 'sms') as 'sms' | 'email', // 기본값은 SMS + userName: authResult.userName, }) } catch (error) { diff --git a/app/api/auth/send-email-otp/route.ts b/app/api/auth/send-email-otp/route.ts new file mode 100644 index 00000000..92bdbe6d --- /dev/null +++ b/app/api/auth/send-email-otp/route.ts @@ -0,0 +1,74 @@ +// app/api/auth/send-email-otp/route.ts +// Email OTP 전송 API 엔드포인트 + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getUserById } from '@/lib/users/repository'; +import { generateAndSendEmailToken } from '@/lib/users/auth/passwordUtil'; + +const sendEmailOtpSchema = z.object({ + userId: z.number(), + email: z.string().email().optional(), + userName: z.string().optional(), +}); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { userId, email, userName } = sendEmailOtpSchema.parse(body); + + // 본인 확인 + if (!userId) { + return NextResponse.json( + { error: '권한이 없습니다' }, + { status: 403 } + ); + } + + // 사용자 정보 조회 + const user = await getUserById(userId); + if (!user || !user.email) { + return NextResponse.json( + { error: '이메일 주소가 등록되지 않았습니다' }, + { status: 400 } + ); + } + + // Email OTP 전송 + const userEmail = email || user.email; + const userDisplayName = userName || user.name; + + const result = await generateAndSendEmailToken( + Number(userId), + userEmail, + userDisplayName + ); + + if (result.success) { + return NextResponse.json({ + success: true, + message: '이메일 인증번호가 전송되었습니다' + }); + } else { + return NextResponse.json( + { error: result.error }, + { status: 400 } + ); + } + + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: '잘못된 요청입니다' }, + { status: 400 } + ); + } + + console.error('Email OTP send API error:', error); + return NextResponse.json( + { error: '서버 오류가 발생했습니다' }, + { status: 500 } + ); + } +} + |
