diff options
| -rw-r--r-- | app/api/auth/first-auth/route.ts | 19 | ||||
| -rw-r--r-- | app/api/auth/select-sgips-user/route.ts | 85 | ||||
| -rw-r--r-- | components/login/login-form.tsx | 215 | ||||
| -rw-r--r-- | lib/users/auth/verifyCredentails.ts | 192 | ||||
| -rw-r--r-- | lib/users/session/helper.ts | 65 |
5 files changed, 460 insertions, 116 deletions
diff --git a/app/api/auth/first-auth/route.ts b/app/api/auth/first-auth/route.ts index e8d86a02..6952b472 100644 --- a/app/api/auth/first-auth/route.ts +++ b/app/api/auth/first-auth/route.ts @@ -17,6 +17,16 @@ interface FirstAuthResponse { tempAuthKey?: string userId?: number email?: string + otpUsers?: Array<{ + id: string + name: string + vndrcd: string + phone: string + email: string + nation_cd: string + userId: number + vendorInfo?: any + }> error?: string errorCode?: string } @@ -116,6 +126,15 @@ export async function POST(request: NextRequest): Promise<NextResponse<FirstAuth } // 1차 인증 성공 응답 + // S-GIPS의 경우 otpUsers 배열 반환 + if (provider === 'sgips' && authResult.otpUsers) { + return NextResponse.json({ + success: true, + otpUsers: authResult.otpUsers + }) + } + + // 일반 사용자의 경우 기존 응답 return NextResponse.json({ success: true, tempAuthKey: authResult.tempAuthKey, diff --git a/app/api/auth/select-sgips-user/route.ts b/app/api/auth/select-sgips-user/route.ts new file mode 100644 index 00000000..75c2012f --- /dev/null +++ b/app/api/auth/select-sgips-user/route.ts @@ -0,0 +1,85 @@ +// /api/auth/select-sgips-user/route.ts +// 선택된 S-GIPS 사용자에 대한 임시 인증 세션 생성 API 엔드포인트 + +import { authHelpers } from '@/lib/users/session/helper' +import { NextRequest, NextResponse } from 'next/server' + +// 요청 데이터 타입 +interface SelectUserRequest { + userId: number + email: string + name: string +} + +// 응답 데이터 타입 +interface SelectUserResponse { + success: boolean + tempAuthKey?: string + userId?: number + email?: string + error?: string +} + +export async function POST(request: NextRequest): Promise<NextResponse<SelectUserResponse>> { + try { + // 요청 데이터 파싱 + const body: SelectUserRequest = await request.json() + const { userId, email, name } = body + + // 입력 검증 + if (!userId || !email || !name) { + return NextResponse.json( + { + success: false, + error: '필수 입력값이 누락되었습니다.' + }, + { status: 400 } + ) + } + + // 선택된 사용자에 대한 임시 인증 세션 생성 + const result = await authHelpers.createTempAuthForSelectedUser({ + userId, + email, + name + }) + + if (!result.success) { + return NextResponse.json( + { + success: false, + error: '임시 인증 세션 생성에 실패했습니다.' + }, + { status: 500 } + ) + } + + // 성공 응답 + return NextResponse.json({ + success: true, + tempAuthKey: result.tempAuthKey, + userId: result.userId, + email: result.email + }) + + } catch (error) { + console.error('Select S-GIPS user API error:', error) + + // 에러 응답 + return NextResponse.json( + { + success: false, + error: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + }, + { status: 500 } + ) + } +} + +// GET 요청은 지원하지 않음 +export async function GET() { + return NextResponse.json( + { error: 'Method not allowed' }, + { status: 405 } + ) +} diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx index 8e9509c8..b0a0e574 100644 --- a/components/login/login-form.tsx +++ b/components/login/login-form.tsx @@ -24,6 +24,15 @@ import Loading from "../common/loading/loading"; type LoginMethod = 'username' | 'sgips'; +type OtpUser = { + name: string; + vndrcd: string; + phone: string; + email: string; + nation_cd: string; + userId: number; // 백엔드에서 생성된 로컬 DB 사용자 ID +}; + export function LoginForm() { const params = useParams() || {}; const pathname = usePathname() || ''; @@ -44,7 +53,7 @@ export function LoginForm() { const [showMfaForm, setShowMfaForm] = useState(false); const [mfaToken, setMfaToken] = useState(''); const [tempAuthKey, setTempAuthKey] = useState(''); - const [mfaUserId, setMfaUserId] = useState(null); + const [mfaUserId, setMfaUserId] = useState<number | null>(null); const [mfaUserEmail, setMfaUserEmail] = useState(''); const [mfaCountdown, setMfaCountdown] = useState(0); @@ -52,10 +61,15 @@ export function LoginForm() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - // S-Gips 로그인 폼 데이터 + // S-Gips 로그인 폼 데이터 const [sgipsUsername, setSgipsUsername] = useState(''); const [sgipsPassword, setSgipsPassword] = useState(''); + // OTP 사용자 선택 관련 상태 + const [otpUsers, setOtpUsers] = useState<OtpUser[]>([]); + const [showUserSelectionDialog, setShowUserSelectionDialog] = useState(false); + const [selectedOtpUser, setSelectedOtpUser] = useState<OtpUser | null>(null); + const [isMfaLoading, setIsMfaLoading] = useState(false); const [isSmsLoading, setIsSmsLoading] = useState(false); @@ -189,25 +203,40 @@ export function LoginForm() { } }; - // SMS 토큰 전송 (userId 파라미터 추가) + // SMS 토큰 전송 const handleSendSms = async (userIdParam?: number) => { const targetUserId = userIdParam || mfaUserId; if (!targetUserId || mfaCountdown > 0) return; setIsSmsLoading(true); try { + const requestBody: any = { userId: targetUserId }; + + // S-GIPS 사용자인 경우 추가 정보 포함 + if (selectedOtpUser) { + requestBody.phone = selectedOtpUser.phone; + requestBody.name = selectedOtpUser.name; + } + const response = await fetch('/api/auth/send-sms', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userId: targetUserId }), + body: JSON.stringify(requestBody), }); if (response.ok) { setMfaCountdown(60); - toast({ - title: t('smsSent'), - description: t('smsCodeSent'), - }); + if (selectedOtpUser) { + toast({ + title: t('smsSent'), + description: t('smsCodeSentTo', { name: selectedOtpUser.name, phone: selectedOtpUser.phone }), + }); + } else { + toast({ + title: t('smsSent'), + description: t('smsCodeSent'), + }); + } } else { const errorData = await response.json(); toast({ @@ -391,30 +420,24 @@ export function LoginForm() { const authResult = await performFirstAuth(sgipsUsername, sgipsPassword, 'sgips'); if (authResult.success) { - toast({ - title: t('sgipsAuthComplete'), - description: t('proceedingSmsAuth'), - }); - - // MFA 화면으로 전환 - setTempAuthKey(authResult.tempAuthKey); - setMfaUserId(authResult.userId); - setMfaUserEmail(authResult.email); - setShowMfaForm(true); - - // 자동으로 SMS 전송 (userId 직접 전달) - setTimeout(() => { - handleSendSms(authResult.userId); - }, 500); + const users = authResult.otpUsers || []; + + if (users.length === 0) { + toast({ + title: t('errorTitle'), + description: t('noUsersFound'), + variant: 'destructive', + }); + return; + } - toast({ - title: t('smsAuthStarted'), - description: t('sendingCodeToSgipsPhone'), - }); + // 사용자 선택 다이얼로그 표시 (항상) + setOtpUsers(users); + setShowUserSelectionDialog(true); } } catch (error: unknown) { console.error('S-Gips login error:', error); - + const errorMessage = getErrorMessage(error as { errorCode?: string; message?: string }, 'sgips'); toast({ @@ -427,6 +450,78 @@ export function LoginForm() { } }; + // 선택된 OTP 사용자와 함께 MFA 진행 + const proceedWithSelectedUser = async (user: OtpUser, tempAuthKey: string) => { + try { + // 사용자 정보를 기반으로 MFA 진행 + setTempAuthKey(tempAuthKey); + setSelectedOtpUser(user); + setMfaUserId(user.userId); // 선택된 사용자의 userId 설정 + setMfaUserEmail(user.email); + setShowMfaForm(true); + + // 선택된 사용자의 정보를 이용해 SMS 전송 준비 + // 실제로는 userId가 필요하므로 API에서 받아와야 함 + // 여기서는 임시로 user 객체를 저장하고 SMS 전송 시 사용 + setTimeout(() => { + // 실제 구현에서는 user 정보를 기반으로 SMS 전송 + // 현재는 기존 로직 유지하되, 선택된 사용자 정보 활용 + handleSendSms(); + }, 500); + + toast({ + title: t('sgipsAuthComplete'), + description: t('sendingCodeToSelectedUser', { name: user.name }), + }); + } catch (error) { + console.error('Proceeding with selected user error:', error); + toast({ + title: t('errorTitle'), + description: t('mfaSetupError'), + variant: 'destructive', + }); + } + }; + + // OTP 사용자 선택 처리 + const handleUserSelection = async (user: OtpUser) => { + setShowUserSelectionDialog(false); + + try { + // 선택된 사용자에 대한 임시 인증 세션 생성 요청 + const response = await fetch('/api/auth/select-sgips-user', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: user.userId, + email: user.email, + name: user.name + }), + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + toast({ + title: t('errorTitle'), + description: result.error || t('mfaSetupError'), + variant: 'destructive', + }); + return; + } + + // 임시 인증 세션 생성 성공, MFA 진행 + await proceedWithSelectedUser(user, result.tempAuthKey); + } catch (error) { + console.error('User selection error:', error); + toast({ + title: t('errorTitle'), + description: t('mfaSetupError'), + variant: 'destructive', + }); + } + }; + // MFA 화면에서 뒤로 가기 const handleBackToLogin = () => { setShowMfaForm(false); @@ -435,6 +530,9 @@ export function LoginForm() { setMfaUserId(null); setMfaUserEmail(''); setMfaCountdown(0); + setSelectedOtpUser(null); + setShowUserSelectionDialog(false); + setOtpUsers([]); }; // 세션 로딩 중이거나 이미 인증된 상태에서는 로딩 표시 @@ -491,7 +589,10 @@ export function LoginForm() { </div> <h1 className="text-2xl font-bold">{t('smsVerification')}</h1> <p className="text-sm text-muted-foreground mt-2"> - {t('firstAuthCompleteFor', { email: mfaUserEmail })} + {selectedOtpUser + ? t('firstAuthCompleteForSgips', { name: selectedOtpUser.name, email: mfaUserEmail }) + : t('firstAuthCompleteFor', { email: mfaUserEmail }) + } </p> <p className="text-xs text-muted-foreground mt-1"> {t('enterSixDigitCodeInstructions')} @@ -738,6 +839,62 @@ export function LoginForm() { </div> )} + {/* OTP 사용자 선택 다이얼로그 */} + {showUserSelectionDialog && !showMfaForm && ( + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> + <div className="bg-white rounded-lg p-6 w-full max-w-md mx-4 max-h-[80vh] overflow-y-auto"> + <div className="flex justify-between items-center mb-4"> + <h3 className="text-lg font-semibold">{t('selectUser')}</h3> + <button + onClick={() => setShowUserSelectionDialog(false)} + className="text-gray-400 hover:text-gray-600" + > + ✕ + </button> + </div> + <div className="space-y-3"> + <p className="text-sm text-gray-600 mb-4"> + {t('selectUserDescription')} + </p> + {otpUsers.map((user, index) => ( + <div + key={index} + className="border rounded-lg p-4 hover:bg-gray-50 cursor-pointer" + onClick={() => handleUserSelection(user)} + > + <div className="flex justify-between items-start"> + <div className="flex-1"> + <div className="font-medium text-gray-900">{user.name}</div> + <div className="text-sm text-gray-600">{user.email}</div> + <div className="text-sm text-gray-500">{user.phone}</div> + <div className="text-xs text-gray-400 mt-1">Vendor: {user.vndrcd}</div> + </div> + <Button + size="sm" + variant="outline" + onClick={(e) => { + e.stopPropagation(); + handleUserSelection(user); + }} + > + {t('select')} + </Button> + </div> + </div> + ))} + </div> + <div className="flex justify-end mt-6"> + <Button + variant="outline" + onClick={() => setShowUserSelectionDialog(false)} + > + {t('cancel')} + </Button> + </div> + </div> + </div> + )} + {/* 비밀번호 재설정 다이얼로그 */} {showForgotPassword && !showMfaForm && ( <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> diff --git a/lib/users/auth/verifyCredentails.ts b/lib/users/auth/verifyCredentails.ts index b3dcd270..b0cbd6c9 100644 --- a/lib/users/auth/verifyCredentails.ts +++ b/lib/users/auth/verifyCredentails.ts @@ -482,14 +482,16 @@ export async function verifySGipsCredentials( password: string ): Promise<{ success: boolean; - user?: { + otpUsers?: Array<{ id: string; name: string; - email: string; + vndrcd: string; phone: string; - companyId?: number; + email: string; + nation_cd: string; + userId?: number; // 로컬 DB 사용자 ID (없으면 생성) vendorInfo?: any; // 벤더 추가 정보 - }; + }>; error?: string; }> { try { @@ -535,42 +537,104 @@ export async function verifySGipsCredentials( // 2. S-Gips API 응답 확인 if (data.message === "success" && data.code === "0") { - // 3. username의 앞 8자리로 vendorCode 추출 - const vendorCode = username.substring(0, 8); - - // 4. 데이터베이스에서 벤더 정보 조회 - const vendorInfo = await getVendorByCode(vendorCode); - - if (!vendorInfo) { - return { - success: false, - error: 'VENDOR_NOT_FOUND' - }; + const otpUsers = data.otpUsers || []; + + if (otpUsers.length === 0) { + return { success: false, error: 'NO_USERS_FOUND' }; } - // 5. 사용자 정보 구성 + // 3. 각 OTP 사용자에 대해 로컬 DB 사용자 확인/생성 + const processedOtpUsers = await Promise.all( + otpUsers.map(async (otpUser: any) => { + try { + // email로 기존 사용자 검색 + const localUser = await db + .select() + .from(users) + .where(eq(users.email, otpUser.email)) + .limit(1); + + let userId: number; + + if (!localUser[0]) { + // 사용자가 없으면 벤더코드로 벤더 정보 조회 후 새 사용자 생성 + const vendorInfo = await getVendorByCode(otpUser.vndrcd); + + if (!vendorInfo) { + console.warn(`벤더를 찾을 수 없음: ${otpUser.vndrcd}`); + // 벤더가 없어도 사용자 생성은 시도 (기본 정보로) + const newUser = await db + .insert(users) + .values({ + name: otpUser.name, + email: otpUser.email, + phone: otpUser.phone, + domain: 'partners', + mfaEnabled: true, + }) + .returning(); + + userId = newUser[0].id; + } else { + // 벤더 정보를 바탕으로 사용자 생성 + const newUser = await db + .insert(users) + .values({ + name: otpUser.name, + email: otpUser.email, + phone: otpUser.phone, + companyId: vendorInfo.id, + domain: 'partners', + mfaEnabled: true, + }) + .returning(); + + userId = newUser[0].id; + } + } else { + // 기존 사용자가 있으면 S-GIPS 정보로 전화번호 업데이트 + await db + .update(users) + .set({ + phone: otpUser.phone, + name: otpUser.name, + }) + .where(eq(users.id, localUser[0].id)); + + userId = localUser[0].id; + console.log(`S-GIPS 사용자 정보 업데이트: ${otpUser.email} - phone: ${otpUser.phone}`); + } + + return { + id: otpUser.vndrcd || username, + name: otpUser.name, + vndrcd: otpUser.vndrcd, + phone: otpUser.phone, + email: otpUser.email, + nation_cd: otpUser.nation_cd, + userId: userId, + vendorInfo: otpUser.vndrcd ? await getVendorByCode(otpUser.vndrcd) : null, + }; + } catch (error) { + console.error(`OTP 사용자 처리 중 오류: ${otpUser.email}`, error); + // 오류가 발생해도 다른 사용자는 처리 계속 + return { + id: otpUser.vndrcd || username, + name: otpUser.name, + vndrcd: otpUser.vndrcd, + phone: otpUser.phone, + email: otpUser.email, + nation_cd: otpUser.nation_cd, + userId: undefined, // 생성 실패 + vendorInfo: null, + }; + } + }) + ); + return { success: true, - user: { - id: username, // 또는 vendorInfo.id를 사용 - name: vendorInfo.representativeName || vendorInfo.vendorName, - email: vendorInfo.representativeEmail || vendorInfo.email || '', - phone: vendorInfo.representativePhone || vendorInfo.phone || '', - companyId: vendorInfo.id, - vendorInfo: { - vendorName: vendorInfo.vendorName, - vendorCode: vendorInfo.vendorCode, - status: vendorInfo.status, - taxId: vendorInfo.taxId, - address: vendorInfo.address, - country: vendorInfo.country, - website: vendorInfo.website, - vendorTypeId: vendorInfo.vendorTypeId, - businessSize: vendorInfo.businessSize, - creditRating: vendorInfo.creditRating, - cashFlowRating: vendorInfo.cashFlowRating, - } - }, + otpUsers: processedOtpUsers.filter(user => user.userId !== undefined), // userId가 있는 사용자만 반환 }; } @@ -589,15 +653,16 @@ export async function authenticateWithSGips( password: string ): Promise<{ success: boolean; - user?: { - id: number; + otpUsers?: Array<{ + id: string; name: string; + vndrcd: string; + phone: string; email: string; - imageUrl?: string | null; - companyId?: number | null; - techCompanyId?: number | null; - domain?: string | null; - }; + nation_cd: string; + userId: number; + vendorInfo?: any; + }>; requiresMfa: boolean; mfaToken?: string; error?: string; @@ -606,7 +671,7 @@ export async function authenticateWithSGips( // 1. S-Gips API로 인증 const sgipsResult = await verifySGipsCredentials(username, password); - if (!sgipsResult.success || !sgipsResult.user) { + if (!sgipsResult.success || !sgipsResult.otpUsers || sgipsResult.otpUsers.length === 0) { return { success: false, requiresMfa: false, @@ -614,45 +679,12 @@ export async function authenticateWithSGips( }; } - // 2. 로컬 DB에서 사용자 확인 또는 생성 - let localUser = await db - .select() - .from(users) - .where(eq(users.email, sgipsResult.user.email)) - .limit(1); - - if (!localUser[0]) { - // 사용자가 없으면 새로 생성 (S-Gips 사용자는 자동 생성) - const newUser = await db - .insert(users) - .values({ - name: sgipsResult.user.name, - email: sgipsResult.user.email, - phone: sgipsResult.user.phone, - companyId: sgipsResult.user.companyId, - domain: 'partners', // S-Gips 사용자는 partners 도메인 - mfaEnabled: true, // S-Gips 사용자는 MFA 필수 - }) - .returning(); - - localUser = newUser; - } - - const user = localUser[0]; - + // 2. verifySGipsCredentials에서 이미 사용자 생성/매핑이 완료되었으므로 + // otpUsers 배열을 그대로 반환 (임시 인증 세션은 개별 사용자 선택 시 생성) return { success: true, - user: { - id: user.id, - name: user.name, - email: user.email, - imageUrl: user.imageUrl, - companyId: user.companyId, - techCompanyId: user.techCompanyId, - domain: user.domain, - }, + otpUsers: sgipsResult.otpUsers, requiresMfa: true, - // mfaToken, }; } catch (error) { console.error('S-Gips authentication error:', error); diff --git a/lib/users/session/helper.ts b/lib/users/session/helper.ts index f99ca80a..03bfd7bc 100644 --- a/lib/users/session/helper.ts +++ b/lib/users/session/helper.ts @@ -6,20 +6,35 @@ export const authHelpers = { // 1차 인증 검증 및 임시 키 생성 (DB 버전) async performFirstAuth(username: string, password: string, provider: 'email' | 'sgips') { console.log('performFirstAuth started:', { username, provider }) - + try { let authResult; - + if (provider === 'sgips') { authResult = await authenticateWithSGips(username, password) } else { authResult = await verifyExternalCredentials(username, password) } - - if (!authResult.success || !authResult.user) { + + if (!authResult.success) { return { success: false, error: authResult.error || 'INVALID_CREDENTIALS' } } - + + // S-GIPS의 경우 otpUsers 배열 반환 + if (provider === 'sgips' && authResult.otpUsers) { + console.log('S-GIPS auth successful with otpUsers:', authResult.otpUsers.length) + + return { + success: true, + otpUsers: authResult.otpUsers + } + } + + // 일반 사용자의 경우 기존 로직 + if (!authResult.user) { + return { success: false, error: 'INVALID_CREDENTIALS' } + } + // DB에 임시 인증 세션 생성 const expiresAt = new Date(Date.now() + (10 * 60 * 1000)) // 10분 후 만료 const tempAuthKey = await SessionRepository.createTempAuthSession({ @@ -28,7 +43,7 @@ export const authHelpers = { authMethod: provider, expiresAt }) - + console.log('Temp auth stored in DB:', { tempAuthKey, userId: authResult.user.id, @@ -36,7 +51,7 @@ export const authHelpers = { authMethod: provider, expiresAt }) - + return { success: true, tempAuthKey, @@ -57,6 +72,42 @@ export const authHelpers = { // 임시 인증 정보 삭제 (DB 버전) async clearTempAuth(tempAuthKey: string) { await SessionRepository.markTempAuthSessionAsUsed(tempAuthKey) + }, + + // 선택된 S-GIPS 사용자에 대한 임시 인증 세션 생성 + async createTempAuthForSelectedUser(selectedUser: { + userId: number; + email: string; + name: string; + }) { + console.log('Creating temp auth for selected S-GIPS user:', selectedUser) + + try { + const expiresAt = new Date(Date.now() + (10 * 60 * 1000)) // 10분 후 만료 + const tempAuthKey = await SessionRepository.createTempAuthSession({ + userId: selectedUser.userId, + email: selectedUser.email, + authMethod: 'sgips', + expiresAt + }) + + console.log('Temp auth created for selected user:', { + tempAuthKey, + userId: selectedUser.userId, + email: selectedUser.email, + expiresAt + }) + + return { + success: true, + tempAuthKey, + userId: selectedUser.userId, + email: selectedUser.email + } + } catch (error) { + console.error('Error creating temp auth for selected user:', error) + return { success: false, error: 'SYSTEM_ERROR' } + } } }
\ No newline at end of file |
