summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-10-15 21:38:21 +0900
committerjoonhoekim <26rote@gmail.com>2025-10-15 21:38:21 +0900
commita070f833d132e6370311c0bbdad03beb51d595df (patch)
tree9184292e4c2631ee0c7a7247f9728fc26de790f1 /app
parent280a2628df810dc157357e0e4d2ed8076d020a2c (diff)
(김준회) 이메일 화이트리스트 (SMS 우회) 기능 추가 및 기존 로그인 과정 통합
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/(evcp)/(system)/email-whitelist/page.tsx82
-rw-r--r--app/api/auth/[...nextauth]/route.ts25
-rw-r--r--app/api/auth/first-auth/route.ts8
-rw-r--r--app/api/auth/send-email-otp/route.ts74
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 }
+ );
+ }
+}
+