summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/login/login-form.tsx290
-rw-r--r--lib/users/auth/email-auth.ts229
-rw-r--r--lib/users/auth/passwordUtil.ts8
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)
)
)