// lib/auth/passwordUtils.ts import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import { eq, and, desc, count, sql, gte, inArray } from 'drizzle-orm'; import db from '@/db/db'; import { users, passwords, passwordHistory, securitySettings, mfaTokens } from '@/db/schema'; export interface PasswordStrength { score: number; // 1-5 hasUppercase: boolean; hasLowercase: boolean; hasNumbers: boolean; hasSymbols: boolean; length: number; feedback: string[]; } export interface PasswordPolicy { minLength: number; requireUppercase: boolean; requireLowercase: boolean; requireNumbers: boolean; requireSymbols: boolean; historyCount: number; } // 패스워드 강도 분석 export function analyzePasswordStrength(password: string): PasswordStrength { const hasUppercase = /[A-Z]/.test(password); const hasLowercase = /[a-z]/.test(password); const hasNumbers = /\d/.test(password); const hasSymbols = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); const length = password.length; const feedback: string[] = []; let score = 1; // 길이 체크 if (length >= 12) { score += 1; } else if (length < 8) { feedback.push('최소 8자 이상 사용하세요'); } // 문자 종류 체크 const typeCount = [hasUppercase, hasLowercase, hasNumbers, hasSymbols] .filter(Boolean).length; if (typeCount >= 4) { score += 2; } else if (typeCount >= 3) { score += 1; } else { if (!hasUppercase) feedback.push('대문자를 포함하세요'); if (!hasLowercase) feedback.push('소문자를 포함하세요'); if (!hasNumbers) feedback.push('숫자를 포함하세요'); if (!hasSymbols) feedback.push('특수문자를 포함하세요'); } // 일반적인 패턴 체크 if (/(.)\1{2,}/.test(password)) { feedback.push('같은 문자가 3번 이상 반복되지 않도록 하세요'); score = Math.max(1, score - 1); } if (/123|abc|qwe|password|admin/i.test(password)) { feedback.push('일반적인 패턴이나 단어는 피하세요'); score = Math.max(1, score - 1); } return { score: Math.min(5, score), hasUppercase, hasLowercase, hasNumbers, hasSymbols, length, feedback, }; } // 패스워드 정책 가져오기 export async function getPasswordPolicy(): Promise { const settings = await db.select().from(securitySettings).limit(1); const setting = settings[0]; return { minLength: setting?.minPasswordLength || 8, requireUppercase: setting?.requireUppercase || true, requireLowercase: setting?.requireLowercase || true, requireNumbers: setting?.requireNumbers || true, requireSymbols: setting?.requireSymbols || true, historyCount: setting?.passwordHistoryCount || 5, }; } // 패스워드 정책 검증 export async function validatePasswordPolicy( password: string, policy?: PasswordPolicy ): Promise<{ valid: boolean; errors: string[] }> { const passwordPolicy = policy || await getPasswordPolicy(); const strength = analyzePasswordStrength(password); const errors: string[] = []; if (strength.length < passwordPolicy.minLength) { errors.push(`최소 ${passwordPolicy.minLength}자 이상이어야 합니다`); } if (passwordPolicy.requireUppercase && !strength.hasUppercase) { errors.push('대문자를 포함해야 합니다'); } if (passwordPolicy.requireLowercase && !strength.hasLowercase) { errors.push('소문자를 포함해야 합니다'); } if (passwordPolicy.requireNumbers && !strength.hasNumbers) { errors.push('숫자를 포함해야 합니다'); } if (passwordPolicy.requireSymbols && !strength.hasSymbols) { errors.push('특수문자를 포함해야 합니다'); } return { valid: errors.length === 0, errors, }; } // 이전 패스워드와 중복 체크 export async function checkPasswordHistory( userId: number, newPassword: string, historyCount: number = 5 ): Promise { const histories = await db .select({ passwordHash: passwordHistory.passwordHash, salt: passwordHistory.salt, }) .from(passwordHistory) .where(eq(passwordHistory.userId, userId)) .orderBy(desc(passwordHistory.createdAt)) .limit(historyCount); // 현재 활성 패스워드도 체크 const currentPassword = await db .select({ passwordHash: passwords.passwordHash, salt: passwords.salt, }) .from(passwords) .where( and( eq(passwords.userId, userId), eq(passwords.isActive, true) ) ) .limit(1); const allPasswords = [...histories]; if (currentPassword[0]) { allPasswords.unshift(currentPassword[0]); } // 각 이전 패스워드와 비교 for (const pwd of allPasswords) { const isMatch = await bcrypt.compare( newPassword + pwd.salt, pwd.passwordHash ); if (isMatch) { return false; // 중복됨 } } return true; // 중복 없음 } // Salt 생성 function generateSalt(): string { return crypto.randomBytes(16).toString('hex'); } // 패스워드 해싱 async function hashPassword(password: string, salt: string): Promise { const saltRounds = 12; return bcrypt.hash(password + salt, saltRounds); } // 새 패스워드 설정 export async function setPassword( userId: number, newPassword: string ): Promise<{ success: boolean; errors: string[] }> { try { // 1. 정책 검증 const policyResult = await validatePasswordPolicy(newPassword); if (!policyResult.valid) { return { success: false, errors: policyResult.errors }; } // 2. 히스토리 검증 const policy = await getPasswordPolicy(); const isUnique = await checkPasswordHistory(userId, newPassword, policy.historyCount); if (!isUnique) { return { success: false, errors: [`최근 ${policy.historyCount}개 패스워드와 달라야 합니다`] }; } // 3. 현재 패스워드를 히스토리로 이동 const currentPassword = await db .select() .from(passwords) .where( and( eq(passwords.userId, userId), eq(passwords.isActive, true) ) ) .limit(1); if (currentPassword[0]) { await db.insert(passwordHistory).values({ userId, passwordHash: currentPassword[0].passwordHash, salt: currentPassword[0].salt, createdAt: currentPassword[0].createdAt, replacedAt: new Date(), }); // 현재 패스워드 비활성화 await db .update(passwords) .set({ isActive: false }) .where(eq(passwords.id, currentPassword[0].id)); } // 4. 새 패스워드 생성 const salt = generateSalt(); const hashedPassword = await hashPassword(newPassword, salt); const strength = analyzePasswordStrength(newPassword); // 패스워드 만료일 계산 let expiresAt: Date | null = null; const settings = await db.select().from(securitySettings).limit(1); const expiryDays = settings[0]?.passwordExpiryDays; if (expiryDays) { expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + expiryDays); } await db.insert(passwords).values({ userId, passwordHash: hashedPassword, salt, strength: strength.score, hasUppercase: strength.hasUppercase, hasLowercase: strength.hasLowercase, hasNumbers: strength.hasNumbers, hasSymbols: strength.hasSymbols, length: strength.length, expiresAt, isActive: true, }); // 5. 패스워드 변경 필수 플래그 해제 await db .update(users) .set({ passwordChangeRequired: false }) .where(eq(users.id, userId)); // 6. 오래된 히스토리 정리 await cleanupPasswordHistory(userId, policy.historyCount); return { success: true, errors: [] }; } catch (error) { console.error('Password setting error:', error); return { success: false, errors: ['패스워드 설정 중 오류가 발생했습니다'] }; } } // 패스워드 히스토리 정리 async function cleanupPasswordHistory(userId: number, keepCount: number) { const histories = await db .select({ id: passwordHistory.id }) .from(passwordHistory) .where(eq(passwordHistory.userId, userId)) .orderBy(desc(passwordHistory.createdAt)) .offset(keepCount); if (histories.length > 0) { const idsToDelete = histories.map(h => h.id); await db .delete(passwordHistory) .where(and(eq(passwordHistory.userId, userId), inArray(passwordHistory.id, idsToDelete))) } } // MFA SMS 토큰 생성 및 전송 // Bizppurio API 토큰 발급 async function getBizppurioToken(): Promise { const account = process.env.BIZPPURIO_ACCOUNT; const password = process.env.BIZPPURIO_PASSWORD; if (!account || !password) { throw new Error('Bizppurio credentials not configured'); } const credentials = Buffer.from(`${account}:${password}`).toString('base64'); const response = await fetch('https://api.bizppurio.com/v1/token', { method: 'POST', headers: { 'Authorization': `Basic ${credentials}`, 'Content-Type': 'application/json; charset=utf-8' } }); if (!response.ok) { throw new Error(`Token request failed: ${response.status}`); } const data = await response.json(); return data.accesstoken; } // SMS 메시지 전송 async function sendSmsMessage(phoneNumber: string, message: string): Promise { try { const accessToken = await getBizppurioToken(); const account = process.env.BIZPPURIO_ACCOUNT; const fromNumber = process.env.BIZPPURIO_FROM_NUMBER; if (!account || !fromNumber) { throw new Error('Bizppurio configuration missing'); } // phoneNumber에서 국가코드와 번호 분리 let country = ''; let to = phoneNumber; if (phoneNumber.startsWith('+82')) { country = '82'; to = phoneNumber.substring(3); // 한국 번호는 0으로 시작하지 않는 경우 0 추가 if (!to.startsWith('0')) { to = '0' + to; } } else if (phoneNumber.startsWith('+1')) { country = '1'; to = phoneNumber.substring(2); } else if (phoneNumber.startsWith('+81')) { country = '81'; to = phoneNumber.substring(3); } else if (phoneNumber.startsWith('+86')) { country = '86'; to = phoneNumber.substring(3); } // 국가코드가 없는 경우 한국으로 가정 else if (!phoneNumber.startsWith('+')) { country = '82'; to = phoneNumber.replace(/-/g, ''); // 하이픈 제거 if (!to.startsWith('0')) { to = '0' + to; } } const requestBody = { account: account, type: 'SMS', from: fromNumber, to: to, country: country, content: { sms: { message: message } }, refkey: `sms_${Date.now()}_${Math.random().toString(36).substring(7)}` // 고객사에서 부여한 키 }; const response = await fetch('https://api.bizppurio.com/v3/message', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(requestBody) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`SMS send failed: ${response.status} - ${errorText}`); } const result = await response.json(); if (result.code === 1000) { console.log(`SMS sent successfully. MessageKey: ${result.messagekey}`); return true; } else { throw new Error(`SMS send failed: ${result.description} (Code: ${result.code})`); } } catch (error) { console.error('SMS send error:', error); return false; } } const SMS_TEMPLATES = { '82': '[인증번호] {token}', // 한국 '1': '[Verification Code] {token}', // 미국 '81': '[認証コード] {token}', // 일본 '86': '[验证码] {token}', // 중국 'default': '[Verification Code] {token}' // 기본값 (영어) } as const; function getSmsMessage(country: string, token: string): string { const template = SMS_TEMPLATES[country as keyof typeof SMS_TEMPLATES] || SMS_TEMPLATES.default; return template.replace('{token}', token); } // 업데이트된 메인 함수 export async function generateAndSendSmsToken( userId: number, phoneNumber: string ): Promise<{ success: boolean; error?: string }> { try { // 1. 일일 SMS 한도 체크 const settings = await db.select().from(securitySettings).limit(1); const maxSmsPerDay = settings[0]?.maxSmsAttemptsPerDay || 10; const today = new Date(); today.setHours(0, 0, 0, 0); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); const todayCount = await db .select({ count: count() }) .from(mfaTokens) .where( and( eq(mfaTokens.userId, userId), eq(mfaTokens.type, 'sms'), gte(mfaTokens.createdAt, today) ) ); if (todayCount[0]?.count >= maxSmsPerDay) { return { success: false, error: '일일 SMS 한도를 초과했습니다' }; } // 2. 이전 SMS 토큰 비활성화 await db .update(mfaTokens) .set({ isActive: false }) .where( and( eq(mfaTokens.userId, userId), eq(mfaTokens.type, 'sms'), eq(mfaTokens.isActive, true) ) ); // 3. 새 토큰 생성 const token = Math.random().toString().slice(2, 8).padStart(6, '0'); const expiryMinutes = settings[0]?.smsTokenExpiryMinutes || 5; const expiresAt = new Date(); expiresAt.setMinutes(expiresAt.getMinutes() + expiryMinutes); await db.insert(mfaTokens).values({ userId, token, type: 'sms', phoneNumber, expiresAt, isActive: true, }); let country = ''; if (phoneNumber.startsWith('+82')) { country = '82'; } else if (phoneNumber.startsWith('+1')) { country = '1'; } else if (phoneNumber.startsWith('+81')) { country = '81'; } else if (phoneNumber.startsWith('+86')) { country = '86'; } // 국가코드가 없는 경우 한국으로 가정 else if (!phoneNumber.startsWith('+')) { country = '82'; } // 4. SMS 전송 (Bizppurio API 사용) const message = getSmsMessage(country, token); const smsResult = await sendSmsMessage(phoneNumber, message); if (!smsResult) { // SMS 전송 실패 시 토큰 비활성화 await db .update(mfaTokens) .set({ isActive: false }) .where( and( eq(mfaTokens.userId, userId), eq(mfaTokens.token, token) ) ); return { success: false, error: 'SMS 전송에 실패했습니다' }; } console.log(`SMS 토큰 ${token}을 ${phoneNumber}로 전송했습니다`); return { success: true }; } catch (error) { console.error('SMS token generation error:', error); return { success: false, error: 'SMS 전송 중 오류가 발생했습니다' }; } } // SMS 토큰 검증 export async function verifySmsToken( userId: number, token: string ): Promise<{ success: boolean; error?: string }> { try { const mfaToken = await db .select() .from(mfaTokens) .where( and( eq(mfaTokens.userId, userId), eq(mfaTokens.token, token), eq(mfaTokens.type, 'sms'), eq(mfaTokens.isActive, true) ) ) .limit(1); if (!mfaToken[0]) { return { success: false, error: '잘못된 인증번호입니다' }; } // 만료 체크 if (mfaToken[0].expiresAt < new Date()) { await db .update(mfaTokens) .set({ isActive: false }) .where(eq(mfaTokens.id, mfaToken[0].id)); return { success: false, error: '인증번호가 만료되었습니다' }; } // 시도 횟수 증가 const newAttempts = mfaToken[0].attempts + 1; if (newAttempts > 3) { await db .update(mfaTokens) .set({ isActive: false }) .where(eq(mfaTokens.id, mfaToken[0].id)); return { success: false, error: '시도 횟수를 초과했습니다' }; } // 토큰 사용 처리 await db .update(mfaTokens) .set({ usedAt: new Date(), isActive: false, attempts: newAttempts, }) .where(eq(mfaTokens.id, mfaToken[0].id)); return { success: true }; } catch (error) { console.error('SMS token verification error:', error); return { success: false, error: '인증 중 오류가 발생했습니다' }; } } // 패스워드 강제 변경 필요 체크 export async function checkPasswordChangeRequired(userId: number): Promise { const user = await db .select({ passwordChangeRequired: users.passwordChangeRequired }) .from(users) .where(eq(users.id, userId)) .limit(1); return user[0]?.passwordChangeRequired || false; }