// lib/auth/verifyCredentials.ts 'use server' import bcrypt from 'bcryptjs'; import crypto from 'crypto'; // (처리 불필요) 키 암호화를 위한 fs 모듈 사용, 형제 경로 사용하며 public 경로 아니므로 파일이 노출되지 않음. import fs from 'fs'; import path from 'path'; import { eq, and, desc, gte, count ,sql } 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'; import { debugSuccess } from '@/lib/debug-utils'; // 에러 타입 정의 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( sql`LOWER(${users.email}) = LOWER(${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: '인증 처리 중 오류가 발생했습니다' }; } } // RSA 암호화 함수 function encryptPasswordWithRSA(password: string): string { try { // 파일에서 RSA 공개키 읽기 const keyPath = path.join(process.cwd(), 'lib/users/auth/public_key.pem'); // 파일 존재 여부 확인 if (!fs.existsSync(keyPath)) { throw new Error(`RSA 키 파일을 찾을 수 없습니다: ${keyPath}`); } const publicKey = fs.readFileSync(keyPath, 'utf8'); // RSA 공개키로 암호화 (Java와 동일한 OAEP 패딩 사용) const encryptOptions = { key: publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256', // Java: SHA-256 (MGF1 해시도 동일 값 사용됨) }; const encryptedBuffer = crypto.publicEncrypt(encryptOptions, Buffer.from(password, 'utf8')); // Base64로 인코딩하여 반환 const base64Result = encryptedBuffer.toString('base64'); return base64Result; } catch (error) { console.error('RSA 암호화 오류:', error); throw new 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; otpUsers?: Array<{ id: string; name: string; vndrcd: string; phone: string; email: string; nation_cd: string; userId?: number; // 로컬 DB 사용자 ID (없으면 생성) vendorInfo?: any; // 벤더 추가 정보 }>; error?: string; }> { try { const sgipsUrl = process.env.S_GIPS_URL || "http://qa.shi-api.com/evcp/Common/verifySgipsUser" // password를 RSA로 암호화 const encryptedPassword = encryptPasswordWithRSA(password); // URLSearchParams를 사용하여 특수문자를 안전하게 인코딩 const params = new URLSearchParams({ Id: username, Passwd: encryptedPassword, }); const requestUrl = `${sgipsUrl}?${params.toString()}`; const response = await fetch(requestUrl, { method: 'GET', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.SHI_API_JWT_TOKEN}`, }, }); // 응답 본문을 한 번만 읽어서 재사용 const responseText = await response.text(); if (!response.ok && response.status == 401) { console.error('유효하지 않은 S-GIPS JWT 응답코드 발생 :', response.status); console.error('오류 응답 본문:', responseText); return { success: false, error: 'INVALID_CREDENTIALS' }; } // 텍스트를 JSON으로 파싱 let data; try { data = JSON.parse(responseText); } catch (e) { console.error('S-Gips Invalid JSON response:', e); throw new Error('S-Gips Invalid JSON response'); } // 2. S-Gips API 응답 확인 if (data.message === "success" && data.code === "0") { const otpUsers = data.OtpUsers || data.otpUsers || []; if (otpUsers.length === 0) { return { success: false, error: 'NO_USERS_FOUND' }; } // 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; debugSuccess('S-GIPS 사용자 정보 업데이트', { email: 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, otpUsers: processedOtpUsers.filter(user => user.userId !== undefined), // userId가 있는 사용자만 반환 }; } 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; otpUsers?: Array<{ id: string; name: string; vndrcd: string; phone: string; email: string; nation_cd: string; userId: number; vendorInfo?: any; }>; requiresMfa: boolean; mfaToken?: string; error?: string; }> { try { // 1. S-Gips API로 인증 const sgipsResult = await verifySGipsCredentials(username, password); if (!sgipsResult.success || !sgipsResult.otpUsers || sgipsResult.otpUsers.length === 0) { return { success: false, requiresMfa: false, error: sgipsResult.error || 'INVALID_CREDENTIALS', }; } // 2. verifySGipsCredentials에서 이미 사용자 생성/매핑이 완료되었으므로 // otpUsers 배열을 그대로 반환 (임시 인증 세션은 개별 사용자 선택 시 생성) return { success: true, otpUsers: sgipsResult.otpUsers, requiresMfa: true, }; } catch (error) { console.error('S-Gips authentication error:', error); return { success: false, requiresMfa: false, error: 'SYSTEM_ERROR', }; } }