// lib/auth/verifyCredentials.ts 'use server' import bcrypt from 'bcryptjs'; import { eq, and, desc, gte, count } from 'drizzle-orm'; import db from '@/db/db'; import { users, passwords, passwordHistory, loginAttempts, securitySettings, mfaTokens, vendors } from '@/db/schema'; import { headers } from 'next/headers'; import { verifySmsToken } from './passwordUtil'; // 에러 타입 정의 export type AuthError = | 'INVALID_CREDENTIALS' | 'ACCOUNT_LOCKED' | 'PASSWORD_EXPIRED' | 'ACCOUNT_DISABLED' | 'RATE_LIMITED' | 'MFA_REQUIRED' | 'SYSTEM_ERROR'; export interface AuthResult { success: boolean; user?: { id: number; name: string; email: string; imageUrl?: string | null; companyId?: number | null; techCompanyId?: number | null; domain?: string | null; }; error?: AuthError; requiresMfa?: boolean; mfaToken?: string; // MFA가 필요한 경우 임시 토큰 } // 클라이언트 IP 가져오기 export async function getClientIP(): Promise { const headersList = await headers(); // ✨ await! const forwarded = headersList.get('x-forwarded-for'); const realIP = headersList.get('x-real-ip'); if (forwarded) return forwarded.split(',')[0].trim(); if (realIP) return realIP; return 'unknown'; } // User-Agent 가져오기 export async function getUserAgent(): Promise { const headersList = await headers(); // ✨ await! return headersList.get('user-agent') ?? 'unknown'; } // 보안 설정 가져오기 (캐시 고려) async function getSecuritySettings() { const settings = await db.select().from(securitySettings).limit(1); return settings[0] || { maxFailedAttempts: 5, lockoutDurationMinutes: 30, requireMfaForPartners: true, smsTokenExpiryMinutes: 5, maxSmsAttemptsPerDay: 10, passwordExpiryDays: 90, }; } // Rate limiting 체크 async function checkRateLimit(email: string, ipAddress: string): Promise { const now = new Date(); const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); // 이메일별 시도 횟수 체크 (1시간 내 20회 제한) const emailAttempts = await db .select({ count: count() }) .from(loginAttempts) .where( and( eq(loginAttempts.email, email), gte(loginAttempts.attemptedAt, oneHourAgo) ) ); if (emailAttempts[0]?.count >= 20) { return false; } // IP별 시도 횟수 체크 (1시간 내 50회 제한) const ipAttempts = await db .select({ count: count() }) .from(loginAttempts) .where( and( eq(loginAttempts.ipAddress, ipAddress), gte(loginAttempts.attemptedAt, oneHourAgo) ) ); if (ipAttempts[0]?.count >= 50) { return false; } return true; } // 로그인 시도 기록 async function logLoginAttempt( username: string, userId: number | null, success: boolean, failureReason?: string ) { const ipAddress = getClientIP(); const userAgent = getUserAgent(); await db.insert(loginAttempts).values({ email: username, userId, success, ipAddress, userAgent, failureReason, attemptedAt: new Date(), }); } // 계정 잠금 체크 및 업데이트 async function checkAndUpdateLockout(userId: number, settings: any): Promise { const user = await db .select({ isLocked: users.isLocked, lockoutUntil: users.lockoutUntil, failedAttempts: users.failedLoginAttempts, }) .from(users) .where(eq(users.id, userId)) .limit(1); if (!user[0]) return true; // 사용자가 없으면 잠금 처리 const now = new Date(); // 잠금 해제 시간이 지났는지 확인 if (user[0].lockoutUntil && user[0].lockoutUntil < now) { await db .update(users) .set({ isLocked: false, lockoutUntil: null, failedLoginAttempts: 0, }) .where(eq(users.id, userId)); return false; } return user[0].isLocked; } // 실패한 로그인 시도 처리 async function handleFailedLogin(userId: number, settings: any) { const user = await db .select({ failedAttempts: users.failedLoginAttempts, }) .from(users) .where(eq(users.id, userId)) .limit(1); if (!user[0]) return; const newFailedAttempts = user[0].failedAttempts + 1; const shouldLock = newFailedAttempts >= settings.maxFailedAttempts; const updateData: any = { failedLoginAttempts: newFailedAttempts, }; if (shouldLock) { const lockoutUntil = new Date(); lockoutUntil.setMinutes(lockoutUntil.getMinutes() + settings.lockoutDurationMinutes); updateData.isLocked = true; updateData.lockoutUntil = lockoutUntil; } await db .update(users) .set(updateData) .where(eq(users.id, userId)); } // 성공한 로그인 처리 async function handleSuccessfulLogin(userId: number) { await db .update(users) .set({ failedLoginAttempts: 0, lastLoginAt: new Date(), isLocked: false, lockoutUntil: null, }) .where(eq(users.id, userId)); } // 패스워드 만료 체크 async function checkPasswordExpiry(userId: number, settings: any): Promise { if (!settings.passwordExpiryDays) return false; const password = await db .select({ createdAt: passwords.createdAt, expiresAt: passwords.expiresAt, }) .from(passwords) .where( and( eq(passwords.userId, userId), eq(passwords.isActive, true) ) ) .limit(1); if (!password[0]) return true; // 패스워드가 없으면 만료 처리 const now = new Date(); // 명시적 만료일이 있는 경우 if (password[0].expiresAt && password[0].expiresAt < now) { return true; } // 생성일 기준 만료 체크 const expiryDate = new Date(password[0].createdAt); expiryDate.setDate(expiryDate.getDate() + settings.passwordExpiryDays); return expiryDate < now; } // MFA 필요 여부 확인 function requiresMfa(domain: string, settings: any): boolean { return domain === 'partners' && settings.requireMfaForPartners; } // 메인 인증 함수 export async function verifyExternalCredentials( username: string, password: string ): Promise { const ipAddress = getClientIP(); try { // 1. Rate limiting 체크 const rateLimitOk = await checkRateLimit(username, ipAddress); if (!rateLimitOk) { await logLoginAttempt(username, null, false, 'RATE_LIMITED'); return { success: false, error: 'RATE_LIMITED' }; } // 2. 보안 설정 가져오기 const settings = await getSecuritySettings(); // 3. 사용자 조회 const userResult = await db .select({ id: users.id, name: users.name, email: users.email, imageUrl: users.imageUrl, companyId: users.companyId, techCompanyId: users.techCompanyId, domain: users.domain, mfaEnabled: users.mfaEnabled, isActive: users.isActive, // 추가 }) .from(users) .where( and( eq(users.email, username), eq(users.isActive, true) // 활성 유저만 ) ) .limit(1); if (!userResult[0]) { const deactivatedUser = await db .select({ id: users.id }) .from(users) .where(eq(users.email, username)) .limit(1); if (deactivatedUser[0]) { await logLoginAttempt(username, deactivatedUser[0].id, false, 'ACCOUNT_DEACTIVATED'); return { success: false, error: 'ACCOUNT_DEACTIVATED' }; } // 타이밍 공격 방지를 위해 가짜 해시 연산 await bcrypt.compare(password, '$2a$12$fake.hash.to.prevent.timing.attacks'); await logLoginAttempt(username, null, false, 'INVALID_CREDENTIALS'); return { success: false, error: 'INVALID_CREDENTIALS' }; } const user = userResult[0]; // 4. 계정 잠금 체크 const isLocked = await checkAndUpdateLockout(user.id, settings); if (isLocked) { await logLoginAttempt(username, user.id, false, 'ACCOUNT_LOCKED'); return { success: false, error: 'ACCOUNT_LOCKED' }; } // 5. 패스워드 조회 및 검증 const passwordResult = await db .select({ passwordHash: passwords.passwordHash, salt: passwords.salt, }) .from(passwords) .where( and( eq(passwords.userId, user.id), eq(passwords.isActive, true) ) ) .limit(1); if (!passwordResult[0]) { await logLoginAttempt(username, user.id, false, 'INVALID_CREDENTIALS'); await handleFailedLogin(user.id, settings); return { success: false, error: 'INVALID_CREDENTIALS' }; } // 6. 패스워드 검증 const isValidPassword = await bcrypt.compare( password + passwordResult[0].salt, passwordResult[0].passwordHash ); if (!isValidPassword) { await logLoginAttempt(username, user.id, false, 'INVALID_CREDENTIALS'); await handleFailedLogin(user.id, settings); return { success: false, error: 'INVALID_CREDENTIALS' }; } // 7. 패스워드 만료 체크 const isPasswordExpired = await checkPasswordExpiry(user.id, settings); if (isPasswordExpired) { await logLoginAttempt(username, user.id, false, 'PASSWORD_EXPIRED'); return { success: false, error: 'PASSWORD_EXPIRED' }; } // 9. 성공 처리 await handleSuccessfulLogin(user.id); await logLoginAttempt(username, user.id, true); 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, }, }; } catch (error) { console.error('Authentication error:', error); await logLoginAttempt(username, null, false, 'SYSTEM_ERROR'); return { success: false, error: 'SYSTEM_ERROR' }; } } export async function completeMfaAuthentication( userId: string, smsToken: string ): Promise<{ success: boolean; error?: string }> { try { // SMS 토큰 검증 const result = await verifySmsToken(parseInt(userId), smsToken); if (result.success) { // MFA 성공 시 사용자의 마지막 로그인 시간 업데이트 await db .update(users) .set({ lastLoginAt: new Date(), failedLoginAttempts: 0, }) .where(eq(users.id, parseInt(userId))); // 성공한 로그인 기록 await logLoginAttempt( '', // 이미 1차 인증에서 기록되었으므로 빈 문자열 parseInt(userId), true, 'MFA_COMPLETED' ); } return result; } catch (error) { console.error('MFA completion error:', error); return { success: false, error: '인증 처리 중 오류가 발생했습니다' }; } } // 서버 액션: 벤더 정보 조회 export async function getVendorByCode(vendorCode: string) { try { const vendor = await db .select() .from(vendors) .where(eq(vendors.vendorCode, vendorCode)) .limit(1); return vendor[0] || null; } catch (error) { console.error('Database error:', error); return null; } } // 수정된 S-Gips 인증 함수 export async function verifySGipsCredentials( username: string, password: string ): Promise<{ success: boolean; user?: { id: string; name: string; email: string; phone: string; companyId?: number; vendorInfo?: any; // 벤더 추가 정보 }; error?: string; }> { try { const sgipsUrl = process.env.S_GIPS_URL || "http://qa.shi-api.com/evcp/Common/verifySgipsUser" // 1. S-Gips API 호출로 인증 확인 const response = await fetch(sgipsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `${process.env.S_GIPS_TOKEN}`, }, body: JSON.stringify({ username, password, }), }); if (!response.ok) { if (response.status === 401) { return { success: false, error: 'INVALID_CREDENTIALS' }; } throw new Error(`API Error: ${response.status}`); } const data = await response.json(); // 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' }; } // 5. 사용자 정보 구성 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, } }, }; } return { success: false, error: 'INVALID_CREDENTIALS' }; } catch (error) { console.error('S-Gips API error:', error); return { success: false, error: 'SYSTEM_ERROR' }; } } // S-Gips 사용자를 위한 통합 인증 함수 export async function authenticateWithSGips( username: string, password: string ): Promise<{ success: boolean; user?: { id: number; name: string; email: string; imageUrl?: string | null; companyId?: number | null; techCompanyId?: number | null; domain?: string | null; }; requiresMfa: boolean; mfaToken?: string; error?: string; }> { try { // 1. S-Gips API로 인증 const sgipsResult = await verifySGipsCredentials(username, password); if (!sgipsResult.success || !sgipsResult.user) { return { success: false, requiresMfa: false, error: sgipsResult.error || 'INVALID_CREDENTIALS', }; } // 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]; 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, }, requiresMfa: true, // mfaToken, }; } catch (error) { console.error('S-Gips authentication error:', error); return { success: false, requiresMfa: false, error: 'SYSTEM_ERROR', }; } }