diff options
| -rw-r--r-- | components/login/login-form.tsx | 290 | ||||
| -rw-r--r-- | lib/users/auth/email-auth.ts | 229 | ||||
| -rw-r--r-- | lib/users/auth/passwordUtil.ts | 8 |
3 files changed, 446 insertions, 81 deletions
diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx index d249b6b1..0e047cc7 100644 --- a/components/login/login-form.tsx +++ b/components/login/login-form.tsx @@ -20,6 +20,7 @@ import { InputOTPSlot, } from "@/components/ui/input-otp" import { requestPasswordResetAction } from "@/lib/users/auth/partners-auth"; +import { checkEmailAndStartAuth, resendEmailOtp } from "@/lib/users/auth/email-auth"; import Loading from "../common/loading/loading"; type LoginMethod = 'username' | 'sgips'; @@ -59,6 +60,11 @@ export function LoginForm() { const [mfaCountdown, setMfaCountdown] = useState(0); const [mfaType, setMfaType] = useState<'sms' | 'email'>('sms'); // MFA 타입 + // 이메일 기반 인증 상태 + const [emailInput, setEmailInput] = useState(''); + const [showPasswordInput, setShowPasswordInput] = useState(false); + const [isWhitelistedEmail, setIsWhitelistedEmail] = useState(false); + // 일반 로그인 폼 데이터 const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); @@ -259,7 +265,7 @@ export function LoginForm() { } }; - // Email OTP 전송 + // Email OTP 전송/재전송 const handleSendEmail = async (userIdParam?: number, emailParam?: string, userNameParam?: string) => { const targetUserId = userIdParam || mfaUserId; const targetEmail = emailParam || mfaUserEmail; @@ -269,27 +275,19 @@ export function LoginForm() { setIsSmsLoading(true); try { - const response = await fetch('/api/auth/send-email-otp', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - userId: targetUserId, - email: targetEmail, - userName: targetUserName, - }), - }); + // 서버 액션 사용 + const result = await resendEmailOtp(targetUserId, targetEmail, targetUserName); - if (response.ok) { + if (result.success) { setMfaCountdown(60); toast({ title: '이메일 인증번호 전송', description: `${targetEmail}로 인증번호가 전송되었습니다.`, }); } else { - const errorData = await response.json(); toast({ title: t('errorTitle'), - description: errorData.error || '이메일 전송에 실패했습니다.', + description: result.error || '이메일 전송에 실패했습니다.', variant: 'destructive', }); } @@ -393,14 +391,85 @@ export function LoginForm() { } }; - // 일반 사용자명/패스워드 1차 인증 처리 - const handleUsernameLogin = async (e: React.FormEvent) => { + // 이메일 제출 핸들러 (1단계) + const handleEmailSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!username || !password) { + if (!emailInput) { toast({ title: t('errorTitle'), - description: t('credentialsRequired'), + description: '이메일을 입력해주세요.', + variant: 'destructive', + }); + return; + } + + setIsFirstAuthLoading(true); + + try { + // 이메일 화이트리스트 확인 및 인증 시작 + const result = await checkEmailAndStartAuth(emailInput); + + if (!result.success) { + const errorMessage = getErrorMessage( + { errorCode: result.errorCode, message: result.error }, + 'email' + ); + + toast({ + title: t('errorTitle'), + description: errorMessage, + variant: 'destructive', + }); + return; + } + + if (result.isWhitelisted) { + // 화이트리스트 이메일: 바로 Email MFA로 이동 + setIsWhitelistedEmail(true); + setMfaUserId(result.userId!); + setMfaUserEmail(emailInput); + setMfaUserName(result.userName || ''); + setTempAuthKey(result.tempAuthKey!); + setMfaType('email'); + setShowMfaForm(true); + + toast({ + title: '이메일 인증', + description: `${emailInput}로 인증번호가 전송되었습니다.`, + }); + } else { + // 일반 이메일: 패스워드 입력 필요 + setIsWhitelistedEmail(false); + setUsername(emailInput); + setShowPasswordInput(true); + + toast({ + title: '패스워드 입력', + description: '패스워드를 입력해주세요.', + }); + } + } catch (error: unknown) { + console.error('Email submit error:', error); + + toast({ + title: t('errorTitle'), + description: '이메일 확인 중 오류가 발생했습니다.', + variant: 'destructive', + }); + } finally { + setIsFirstAuthLoading(false); + } + }; + + // 패스워드 제출 핸들러 (2단계 - 화이트리스트 아닌 경우) + const handlePasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!password) { + toast({ + title: t('errorTitle'), + description: '패스워드를 입력해주세요.', variant: 'destructive', }); return; @@ -409,15 +478,14 @@ export function LoginForm() { setIsFirstAuthLoading(true); try { - // 1차 인증만 수행 (세션 생성 안함) + // 1차 인증 수행 (패스워드 검증) const authResult = await performFirstAuth(username, password, 'email'); if (authResult.success) { - const userMfaType = authResult.mfaType || 'sms'; - + // 패스워드 인증 성공 -> SMS MFA로 진행 toast({ title: t('firstAuthComplete'), - description: userMfaType === 'email' ? '이메일 인증을 진행합니다.' : t('proceedingSmsAuth'), + description: t('proceedingSmsAuth'), }); // MFA 화면으로 전환 @@ -425,27 +493,26 @@ export function LoginForm() { setMfaUserId(authResult.userId); setMfaUserEmail(authResult.email); setMfaUserName(authResult.userName || ''); - setMfaType(userMfaType); + setMfaType('sms'); setShowMfaForm(true); - // MFA 타입에 따라 자동으로 OTP 전송 + // SMS 자동 전송 setTimeout(() => { - if (userMfaType === 'email') { - handleSendEmail(authResult.userId, authResult.email, authResult.userName); - } else { - handleSendSms(authResult.userId); - } + handleSendSms(authResult.userId); }, 500); toast({ - title: userMfaType === 'email' ? '이메일 인증 필요' : t('smsAuthRequired'), - description: userMfaType === 'email' ? '이메일로 인증번호를 전송하고 있습니다.' : t('sendingCodeToPhone'), + title: t('smsAuthRequired'), + description: t('sendingCodeToPhone'), }); } } catch (error: unknown) { - console.error('Username login error:', error); - - const errorMessage = getErrorMessage(error as { errorCode?: string; message?: string }, 'email'); + console.error('Password submit error:', error); + + const errorMessage = getErrorMessage( + error as { errorCode?: string; message?: string }, + 'email' + ); toast({ title: t('errorTitle'), @@ -576,16 +643,41 @@ export function LoginForm() { } }; - // MFA 화면에서 뒤로 가기 + // MFA 화면 또는 패스워드 입력에서 뒤로 가기 const handleBackToLogin = () => { - setShowMfaForm(false); - setMfaToken(''); - setTempAuthKey(''); - setMfaUserId(null); - setMfaUserEmail(''); - setMfaUserName(''); - setMfaType('sms'); // 기본값으로 초기화 - setMfaCountdown(0); + // MFA 화면인 경우 + if (showMfaForm) { + // 화이트리스트 이메일인 경우 이메일 입력으로 돌아감 + if (isWhitelistedEmail) { + setShowMfaForm(false); + setMfaToken(''); + setTempAuthKey(''); + setMfaUserId(null); + setMfaUserEmail(''); + setMfaUserName(''); + setMfaType('email'); + setMfaCountdown(0); + setIsWhitelistedEmail(false); + } else { + // 일반 이메일인 경우 패스워드 입력으로 돌아감 + setShowMfaForm(false); + setMfaToken(''); + setTempAuthKey(''); + setMfaUserId(null); + setMfaUserEmail(''); + setMfaUserName(''); + setMfaType('sms'); + setMfaCountdown(0); + setShowPasswordInput(true); + } + } else if (showPasswordInput) { + // 패스워드 입력 화면인 경우 이메일 입력으로 돌아감 + setShowPasswordInput(false); + setPassword(''); + setUsername(''); + } + + // S-Gips 관련 초기화 setSelectedOtpUser(null); setShowUserSelectionDialog(false); setOtpUsers([]); @@ -699,40 +791,82 @@ export function LoginForm() { {/* Username Login Form */} {loginMethod === 'username' && ( - <form onSubmit={handleUsernameLogin} className="grid gap-4"> - <div className="grid gap-2"> - <Input - id="username" - type="text" - placeholder={t('emailPlaceholder')} - required - className="h-10" - value={username} - onChange={(e) => setUsername(e.target.value)} - disabled={isFirstAuthLoading} - /> - </div> - <div className="grid gap-2"> - <Input - id="password" - type="password" - placeholder={t('password')} - required - className="h-10" - value={password} - onChange={(e) => setPassword(e.target.value)} - disabled={isFirstAuthLoading} - /> - </div> - <Button - type="submit" - className="w-full" - variant="samsung" - disabled={isFirstAuthLoading || !username || !password} - > - {isFirstAuthLoading ? t('authenticating') : t('login')} - </Button> - </form> + <> + {!showPasswordInput ? ( + // 1단계: 이메일만 입력 + <form onSubmit={handleEmailSubmit} className="grid gap-4"> + <div className="grid gap-2"> + <Input + id="email" + type="email" + placeholder={t('emailPlaceholder')} + required + className="h-10" + value={emailInput} + onChange={(e) => setEmailInput(e.target.value)} + disabled={isFirstAuthLoading} + autoFocus + /> + </div> + <Button + type="submit" + className="w-full" + variant="samsung" + disabled={isFirstAuthLoading || !emailInput} + > + {isFirstAuthLoading ? t('authenticating') : '다음'} + </Button> + </form> + ) : ( + // 2단계: 패스워드 입력 + <div className="space-y-4"> + {/* 뒤로 가기 버튼 */} + <div className="flex items-center justify-start"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={handleBackToLogin} + className="text-blue-600 hover:text-blue-800" + > + <ArrowLeft className="w-4 h-4 mr-1" /> + {t('backToLogin')} + </Button> + </div> + + {/* 이메일 표시 */} + <div className="bg-gray-50 p-3 rounded-lg"> + <p className="text-sm text-gray-600 mb-1">로그인 이메일</p> + <p className="text-sm font-medium text-gray-900">{emailInput}</p> + </div> + + {/* 패스워드 입력 폼 */} + <form onSubmit={handlePasswordSubmit} className="grid gap-4"> + <div className="grid gap-2"> + <Input + id="password" + type="password" + placeholder={t('password')} + required + className="h-10" + value={password} + onChange={(e) => setPassword(e.target.value)} + disabled={isFirstAuthLoading} + autoFocus + /> + </div> + <Button + type="submit" + className="w-full" + variant="samsung" + disabled={isFirstAuthLoading || !password} + > + {isFirstAuthLoading ? t('authenticating') : t('login')} + </Button> + </form> + </div> + )} + </> )} {/* S-Gips Login Form - 영문 페이지에서 비활성화 0925 구매 요청사항*/} @@ -778,6 +912,7 @@ export function LoginForm() { {/* Additional Links */} <div className="flex flex-col gap-2 text-center"> + {/* 신규 업체 등록은 항상 표시 */} <Button type="button" variant="link" @@ -787,7 +922,8 @@ export function LoginForm() { {t('newVendor')} </Button> - {loginMethod === 'username' && ( + {/* 비밀번호 찾기는 패스워드 입력 단계에서만 표시 */} + {loginMethod === 'username' && showPasswordInput && ( <Button type="button" variant="link" diff --git a/lib/users/auth/email-auth.ts b/lib/users/auth/email-auth.ts new file mode 100644 index 00000000..edd4a634 --- /dev/null +++ b/lib/users/auth/email-auth.ts @@ -0,0 +1,229 @@ +'use server'; + +import { eq, and } from 'drizzle-orm'; +import db from '@/db/db'; +import { users, mfaTokens } from '@/db/schema'; +import { isEmailWhitelisted } from '@/lib/email-whitelist/service'; +import { SessionRepository } from '@/lib/users/session/repository'; +import { sendEmail } from '@/lib/mail/sendEmail'; + +/** + * 이메일 화이트리스트 확인 및 인증 시작 + * 화이트리스트면 바로 Email MFA 시작, 아니면 패스워드 필요 + */ +export async function checkEmailAndStartAuth(email: string): Promise<{ + success: boolean; + isWhitelisted: boolean; + requiresPassword: boolean; + userId?: number; + userName?: string; + tempAuthKey?: string; + error?: string; + errorCode?: string; +}> { + try { + if (!email) { + return { + success: false, + isWhitelisted: false, + requiresPassword: false, + error: '이메일을 입력해주세요.', + errorCode: 'INVALID_INPUT' + }; + } + + const normalizedEmail = email.toLowerCase().trim(); + + // 1. 사용자 존재 확인 + const [user] = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + isActive: users.isActive, + isLocked: users.isLocked, + lockoutUntil: users.lockoutUntil, + }) + .from(users) + .where(eq(users.email, normalizedEmail)) + .limit(1); + + if (!user) { + return { + success: false, + isWhitelisted: false, + requiresPassword: false, + error: '등록되지 않은 이메일입니다.', + errorCode: 'USER_NOT_FOUND' + }; + } + + // 2. 계정 상태 확인 + if (!user.isActive) { + return { + success: false, + isWhitelisted: false, + requiresPassword: false, + error: '비활성화된 계정입니다.', + errorCode: 'ACCOUNT_DEACTIVATED' + }; + } + + if (user.isLocked) { + const now = new Date(); + if (user.lockoutUntil && user.lockoutUntil > now) { + const remainingMinutes = Math.ceil((user.lockoutUntil.getTime() - now.getTime()) / 60000); + return { + success: false, + isWhitelisted: false, + requiresPassword: false, + error: `계정이 잠겼습니다. ${remainingMinutes}분 후 다시 시도해주세요.`, + errorCode: 'ACCOUNT_LOCKED' + }; + } + } + + // 3. 이메일 화이트리스트 확인 + const whitelisted = await isEmailWhitelisted(normalizedEmail); + + if (whitelisted) { + // 화이트리스트 이메일: 바로 Email MFA 시작 + const expiresAt = new Date(); + expiresAt.setMinutes(expiresAt.getMinutes() + 10); // 10분 유효 + + // 임시 인증 세션 생성 (tempAuthSessions 테이블 사용) + const tempAuthKey = await SessionRepository.createTempAuthSession({ + userId: user.id, + email: user.email, + authMethod: 'email', + expiresAt, + }); + + // Email OTP 생성 및 전송 + const otpCode = Math.floor(100000 + Math.random() * 900000).toString(); + const otpExpiresAt = new Date(); + otpExpiresAt.setMinutes(otpExpiresAt.getMinutes() + 5); // 5분 유효 + + // 기존 OTP 비활성화 + await db + .update(mfaTokens) + .set({ isActive: false }) + .where( + and( + eq(mfaTokens.userId, user.id), + eq(mfaTokens.type, 'email_otp'), + eq(mfaTokens.isActive, true) + ) + ); + + // 새 OTP 생성 + await db.insert(mfaTokens).values({ + userId: user.id, + token: otpCode, + type: 'email_otp', + expiresAt: otpExpiresAt, + isActive: true, + }); + + // OTP 이메일 전송 + await sendEmail({ + to: user.email, + subject: '로그인 인증번호', + template: 'otp', + context: { + language: 'ko', + name: user.name, + otp: otpCode, + verificationUrl: '', + location: '', + }, + }); + + return { + success: true, + isWhitelisted: true, + requiresPassword: false, + userId: user.id, + userName: user.name, + tempAuthKey, + }; + } else { + // 일반 이메일: 패스워드 입력 필요 + return { + success: true, + isWhitelisted: false, + requiresPassword: true, + userId: user.id, + userName: user.name, + }; + } + } catch (error) { + console.error('Email auth check error:', error); + return { + success: false, + isWhitelisted: false, + requiresPassword: false, + error: '인증 처리 중 오류가 발생했습니다.', + errorCode: 'SYSTEM_ERROR' + }; + } +} + +/** + * Email OTP 재전송 + */ +export async function resendEmailOtp(userId: number, email: string, userName: string): Promise<{ + success: boolean; + error?: string; +}> { + try { + // OTP 생성 + const otpCode = Math.floor(100000 + Math.random() * 900000).toString(); + const otpExpiresAt = new Date(); + otpExpiresAt.setMinutes(otpExpiresAt.getMinutes() + 5); + + // 기존 OTP 비활성화 + await db + .update(mfaTokens) + .set({ isActive: false }) + .where( + and( + eq(mfaTokens.userId, userId), + eq(mfaTokens.type, 'email_otp'), + eq(mfaTokens.isActive, true) + ) + ); + + // 새 OTP 생성 + await db.insert(mfaTokens).values({ + userId, + token: otpCode, + type: 'email_otp', + expiresAt: otpExpiresAt, + isActive: true, + }); + + // OTP 이메일 전송 + await sendEmail({ + to: email, + subject: '로그인 인증번호', + template: 'otp', + context: { + language: 'ko', + name: userName, + otp: otpCode, + verificationUrl: '', + location: '', + }, + }); + + return { success: true }; + } catch (error) { + console.error('Email OTP resend error:', error); + return { + success: false, + error: '이메일 전송에 실패했습니다.', + }; + } +} + diff --git a/lib/users/auth/passwordUtil.ts b/lib/users/auth/passwordUtil.ts index 046e7d90..4d5d69f6 100644 --- a/lib/users/auth/passwordUtil.ts +++ b/lib/users/auth/passwordUtil.ts @@ -707,7 +707,7 @@ export async function generateAndSendEmailToken( .where( and( eq(mfaTokens.userId, userId), - eq(mfaTokens.type, 'email'), + eq(mfaTokens.type, 'email_otp'), gte(mfaTokens.createdAt, today) ) ); @@ -723,7 +723,7 @@ export async function generateAndSendEmailToken( .where( and( eq(mfaTokens.userId, userId), - eq(mfaTokens.type, 'email'), + eq(mfaTokens.type, 'email_otp'), eq(mfaTokens.isActive, true) ) ); @@ -737,7 +737,7 @@ export async function generateAndSendEmailToken( await db.insert(mfaTokens).values({ userId, token, - type: 'email', + type: 'email_otp', expiresAt, isActive: true, attempts: 0, @@ -781,7 +781,7 @@ export async function verifyEmailToken( and( eq(mfaTokens.userId, userId), eq(mfaTokens.token, token), - eq(mfaTokens.type, 'email'), + eq(mfaTokens.type, 'email_otp'), eq(mfaTokens.isActive, true) ) ) |
