summaryrefslogtreecommitdiff
path: root/lib/users/auth/passwordUtil.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/users/auth/passwordUtil.ts')
-rw-r--r--lib/users/auth/passwordUtil.ts608
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