// 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'; // libphonenumber-js import 추가 import { parsePhoneNumber, parsePhoneNumberFromString, isValidPhoneNumber } from 'libphonenumber-js'; 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))) } } // ========== SMS 관련 함수들 ========== // 전화번호에서 국가 정보 추출 (libphonenumber-js 사용) function extractCountryInfo(phoneNumber: string): { countryCode: string; nationalNumber: string; country?: string; } | null { try { // 앞뒤 공백 및 중간 공백 제거 const cleanedPhone = phoneNumber.trim().replace(/\s+/g, ''); let parsed; // E.164 형식인지 확인 if (cleanedPhone.startsWith('+')) { parsed = parsePhoneNumber(cleanedPhone); } else { // 국가 코드가 없으면 한국으로 가정 (기본값) parsed = parsePhoneNumberFromString(cleanedPhone, 'KR'); } if (!parsed || !isValidPhoneNumber(parsed.number)) { return null; } const countryCallingCode = parsed.countryCallingCode; let nationalNumber = parsed.nationalNumber; // 국가별 특별 처리 switch (countryCallingCode) { case '82': // 한국 // 한국 번호는 0으로 시작해야 함 if (!nationalNumber.startsWith('0')) { nationalNumber = '0' + nationalNumber; } break; case '1': // 미국/캐나다 // 이미 올바른 형식 break; case '81': // 일본 // 이미 올바른 형식 break; case '86': // 중국 // 이미 올바른 형식 break; } return { countryCode: countryCallingCode, nationalNumber: nationalNumber.replace(/[-\s]/g, ''), // 하이픈과 공백 제거 country: parsed.country }; } catch (error) { console.error('Country info extraction error:', error); return null; } } // 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 메시지 전송 (libphonenumber-js 사용) 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'); } // libphonenumber-js를 사용하여 전화번호 파싱 const countryInfo = extractCountryInfo(phoneNumber); if (!countryInfo) { throw new Error(`Invalid phone number format: ${phoneNumber}`); } console.log(`Sending SMS to ${phoneNumber}:`); console.log(` Country Code: ${countryInfo.countryCode}`); console.log(` National Number: ${countryInfo.nationalNumber}`); if (countryInfo.country) { console.log(` Country: ${countryInfo.country}`); } const requestBody = { account: account, type: 'sms', from: fromNumber, to: countryInfo.nationalNumber, country: countryInfo.countryCode, 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 to ${phoneNumber}. 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; } } // SMS 템플릿 (기존 유지) const SMS_TEMPLATES = { '82': '[인증번호] {token}', // 한국 '1': '[Verification Code] {token}', // 미국 '81': '[認証コード] {token}', // 일본 '86': '[验证码] {token}', // 중국 'default': '[Verification Code] {token}' // 기본값 (영어) } as const; // SMS 메시지 생성 (libphonenumber-js 사용) function getSmsMessage(phoneNumber: string, token: string): string { try { const countryInfo = extractCountryInfo(phoneNumber); if (!countryInfo) { return SMS_TEMPLATES.default.replace('{token}', token); } const template = SMS_TEMPLATES[countryInfo.countryCode as keyof typeof SMS_TEMPLATES] || SMS_TEMPLATES.default; return template.replace('{token}', token); } catch (error) { return SMS_TEMPLATES.default.replace('{token}', token); // 에러 시 기본값 } } // 전화번호 정규화 (저장용) export function normalizePhoneNumber(phoneNumber: string, countryCode?: string): string | null { try { // 앞뒤 공백 및 중간 공백 제거 const cleanedPhone = phoneNumber.trim().replace(/\s+/g, ''); let parsed; if (countryCode) { // 국가 코드가 제공된 경우 parsed = parsePhoneNumberFromString(cleanedPhone, countryCode); } else { // 국가 코드가 없는 경우 국제 형식으로 파싱 시도 parsed = parsePhoneNumber(cleanedPhone); } if (!parsed || !isValidPhoneNumber(parsed.number)) { return null; } // 항상 국제 형식으로 반환 (예: +821012345678) return parsed.format('E.164'); } catch (error) { console.error('Phone number normalization error:', error); return null; } } // SMS 토큰 생성 및 전송 (업데이트됨) export async function generateAndSendSmsToken( userId: number, phoneNumber: string ): Promise<{ success: boolean; error?: string }> { try { // 앞뒤 공백 및 중간 공백 제거 const cleanedPhone = phoneNumber.trim().replace(/\s+/g, ''); // 전화번호 유효성 검사 if (!isValidPhoneNumber(cleanedPhone)) { return { success: false, error: '유효하지 않은 전화번호입니다' }; } // 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 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); // 전화번호 정규화 (저장용) const normalizedPhone = normalizePhoneNumber(cleanedPhone); if (!normalizedPhone) { return { success: false, error: '전화번호 형식이 올바르지 않습니다' }; } await db.insert(mfaTokens).values({ userId, token, type: 'sms', phoneNumber: normalizedPhone, // 정규화된 번호로 저장 expiresAt, isActive: true, }); // 4. SMS 전송 const message = getSmsMessage(normalizedPhone, token); const smsResult = await sendSmsMessage(normalizedPhone, 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 토큰을 ${normalizedPhone}로 전송했습니다`); 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 { // 개발용: 000000 입력 시 무조건 통과 if (token === '000000') { console.log(`${new Date().toISOString()} 개발용 우회 코드 '000000' 사용됨 - 사용자 ${userId} 인증 성공`); return { success: true }; } 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; }