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/passwordUtil.ts | |
| parent | 3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff) | |
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'lib/users/auth/passwordUtil.ts')
| -rw-r--r-- | lib/users/auth/passwordUtil.ts | 608 |
1 files changed, 608 insertions, 0 deletions
diff --git a/lib/users/auth/passwordUtil.ts b/lib/users/auth/passwordUtil.ts new file mode 100644 index 00000000..ee4e13c2 --- /dev/null +++ b/lib/users/auth/passwordUtil.ts @@ -0,0 +1,608 @@ +// 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<PasswordPolicy> { + 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<boolean> { + 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<string> { + 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<string> { + 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<boolean> { + 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<boolean> { + const user = await db + .select({ passwordChangeRequired: users.passwordChangeRequired }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return user[0]?.passwordChangeRequired || false; +}
\ No newline at end of file |
