diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-27 01:16:20 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-27 01:16:20 +0000 |
| commit | e9897d416b3e7327bbd4d4aef887eee37751ae82 (patch) | |
| tree | bd20ce6eadf9b21755bd7425492d2d31c7700a0e /lib/users/auth/verifyCredentails.ts | |
| parent | 3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff) | |
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'lib/users/auth/verifyCredentails.ts')
| -rw-r--r-- | lib/users/auth/verifyCredentails.ts | 620 |
1 files changed, 620 insertions, 0 deletions
diff --git a/lib/users/auth/verifyCredentails.ts b/lib/users/auth/verifyCredentails.ts new file mode 100644 index 00000000..ec3159a8 --- /dev/null +++ b/lib/users/auth/verifyCredentails.ts @@ -0,0 +1,620 @@ +// lib/auth/verifyCredentials.ts + +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 { generateAndSendSmsToken, 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: string; + 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<string> { + 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<string> { + 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<boolean> { + 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<boolean> { + 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<boolean> { + 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<AuthResult> { + 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.toString(), + 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 { + // 1. S-Gips API 호출로 인증 확인 + const response = await fetch(process.env.S_GIPS_URL, { + 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: string; + 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]; + + // 3. MFA 토큰 생성 (S-Gips 사용자는 항상 MFA 필요) + // const mfaToken = await generateMfaToken(user.id); + + // 4. SMS 전송 + if (user.phone) { + await generateAndSendSmsToken(user.id, user.phone); + } + + return { + success: true, + user: { + id: user.id.toString(), + 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', + }; + } +}
\ No newline at end of file |
