diff options
Diffstat (limited to 'lib/users')
| -rw-r--r-- | lib/users/auth/partners-auth.ts | 374 | ||||
| -rw-r--r-- | lib/users/auth/passwordUtil.ts | 608 | ||||
| -rw-r--r-- | lib/users/auth/validataions-password.ts | 230 | ||||
| -rw-r--r-- | lib/users/auth/verifyCredentails.ts | 620 | ||||
| -rw-r--r-- | lib/users/service.ts | 70 | ||||
| -rw-r--r-- | lib/users/verifyOtp.ts | 26 |
6 files changed, 1901 insertions, 27 deletions
diff --git a/lib/users/auth/partners-auth.ts b/lib/users/auth/partners-auth.ts new file mode 100644 index 00000000..5418e2a8 --- /dev/null +++ b/lib/users/auth/partners-auth.ts @@ -0,0 +1,374 @@ +'use server'; + +import { z } from 'zod'; +import { eq ,and} from 'drizzle-orm'; +import db from '@/db/db'; +import { users, mfaTokens } from '@/db/schema'; +import crypto from 'crypto'; +import { PasswordStrength, passwordResetRequestSchema, passwordResetSchema } from './validataions-password'; +import { sendEmail } from '@/lib/mail/sendEmail'; +import { analyzePasswordStrength, checkPasswordHistory, validatePasswordPolicy } from '@/lib/users/auth/passwordUtil'; + + +export interface PasswordValidationResult { + strength: PasswordStrength; + policyValid: boolean; + policyErrors: string[]; + historyValid?: boolean; +} + +// 비밀번호 재설정 요청 (서버 액션) +export async function requestPasswordResetAction( + prevState: any, + formData: FormData +): Promise<{ success: boolean; error?: string; message?: string }> { + try { + const rawData = { + email: formData.get('email') as string, + }; + + const validatedData = passwordResetRequestSchema.parse(rawData); + + // 사용자 존재 확인 + const user = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + language: users.language + }) + .from(users) + .where(eq(users.email, validatedData.email)) + .limit(1); + + if (!user[0]) { + // 보안상 사용자가 없어도 성공한 것처럼 응답 + return { + success: true, + message: '해당 이메일로 재설정 링크를 전송했습니다. (가입된 이메일인 경우)' + }; + } + + // 기존 재설정 토큰 비활성화 + await db + .update(mfaTokens) + .set({ isActive: false }) + .where(and( + eq(mfaTokens.userId, user[0].id) , + eq(mfaTokens.type, 'password_reset') , + eq(mfaTokens.isActive, true)) + ); + + // 새 토큰 생성 (32바이트 랜덤) + const resetToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 1); // 1시간 후 만료 + + await db.insert(mfaTokens).values({ + userId: user[0].id, + token: resetToken, + type: 'password_reset', + expiresAt, + isActive: true, + }); + + // 재설정 링크 생성 + const resetLink = `${process.env.NEXTAUTH_URL}/auth/reset-password?token=${resetToken}`; + + // 이메일 전송 + await sendEmail({ + to: user[0].email, + subject: user[0].language === 'ko' ? '비밀번호 재설정 요청' : 'Password Reset Request', + template: 'password-reset', + context: { + language: user[0].language || 'ko', + userName: user[0].name, + resetLink: resetLink, + expiryTime: '1시간', + supportEmail: process.env.SUPPORT_EMAIL || 'support@evcp.com', + }, + }); + + return { + success: true, + message: '비밀번호 재설정 링크를 이메일로 전송했습니다.' + }; + + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.errors[0]?.message || '입력값이 올바르지 않습니다' + }; + } + + console.error('Password reset request error:', error); + return { + success: false, + error: '재설정 요청 처리 중 오류가 발생했습니다' + }; + } +} + +// 비밀번호 재설정 실행 (서버 액션) +export async function resetPasswordAction( + prevState: any, + formData: FormData +): Promise<{ success: boolean; error?: string; message?: string }> { + try { + const rawData = { + token: formData.get('token') as string, + newPassword: formData.get('newPassword') as string, + confirmPassword: formData.get('confirmPassword') as string, + }; + + const validatedData = passwordResetSchema.parse(rawData); + + // 토큰 검증 + const resetToken = await db + .select({ + id: mfaTokens.id, + userId: mfaTokens.userId, + expiresAt: mfaTokens.expiresAt, + }) + .from(mfaTokens) + .where(and( + eq(mfaTokens.token, validatedData.token) , + eq(mfaTokens.type, 'password_reset') , + eq(mfaTokens.isActive, true) + ) + ) + .limit(1); + + if (!resetToken[0]) { + return { + success: false, + error: '유효하지 않은 재설정 링크입니다' + }; + } + + if (resetToken[0].expiresAt < new Date()) { + // 만료된 토큰 비활성화 + await db + .update(mfaTokens) + .set({ isActive: false }) + .where(eq(mfaTokens.id, resetToken[0].id)); + + return { + success: false, + error: '재설정 링크가 만료되었습니다. 다시 요청해주세요' + }; + } + + // 패스워드 변경 (기존 setPassword 함수 사용) + const { setPassword } = await import('@/lib/users/auth/passwordUtil'); + const result = await setPassword(resetToken[0].userId, validatedData.newPassword); + + if (result.success) { + // 토큰 비활성화 + await db + .update(mfaTokens) + .set({ + isActive: false, + usedAt: new Date(), + }) + .where(eq(mfaTokens.id, resetToken[0].id)); + + // 계정 잠금 해제 (패스워드 재설정 시) + await db + .update(users) + .set({ + isLocked: false, + lockoutUntil: null, + failedLoginAttempts: 0, + }) + .where(eq(users.id, resetToken[0].userId)); + + return { + success: true, + message: '비밀번호가 성공적으로 변경되었습니다. 새 비밀번호로 로그인해주세요.' + }; + } + + return { + success: false, + error: result.errors.join(', ') + }; + + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.errors[0]?.message || '입력값이 올바르지 않습니다' + }; + } + + console.error('Password reset error:', error); + return { + success: false, + error: '비밀번호 재설정 중 오류가 발생했습니다' + }; + } +} + +// 토큰 유효성 검증 (페이지 접근 시) +export async function validateResetTokenAction( + token: string +): Promise<{ valid: boolean; error?: string; expired?: boolean, userId?: number }> { + try { + if (!token) { + return { valid: false, error: '토큰이 제공되지 않았습니다' }; + } + + const resetToken = await db + .select({ + id: mfaTokens.id, + expiresAt: mfaTokens.expiresAt, + token: mfaTokens.token, + isActive: mfaTokens.isActive, + userId: mfaTokens.userId, + }) + .from(mfaTokens) + .where( + and(eq(mfaTokens.token, token), + eq(mfaTokens.type, 'password_reset') + ) + ) + .limit(1); + + + console.log(token) + console.log(resetToken[0], "resetToken") + + if (!resetToken[0]) { + return { valid: false, error: '유효하지 않은 토큰입니다' }; + } + + if (!resetToken[0].isActive) { + return { valid: false, error: '이미 사용된 토큰입니다' }; + } + + if (resetToken[0].expiresAt < new Date()) { + return { valid: false, expired: true, error: '토큰이 만료되었습니다' }; + } + + return { valid: true, userId: resetToken[0].userId }; + + } catch (error) { + console.error('Token validation error:', error); + return { valid: false, error: '토큰 검증 중 오류가 발생했습니다' }; + } +} + + +export async function validatePasswordAction( + password: string, + userId?: number +): Promise<PasswordValidationResult> { + try { + // 패스워드 강도 분석 + const strength = analyzePasswordStrength(password); + + // 정책 검증 + const policyResult = await validatePasswordPolicy(password); + + // 히스토리 검증 (userId가 있는 경우에만) + let historyValid: boolean | undefined = undefined; + if (userId) { + historyValid = await checkPasswordHistory(userId, password); + } + + return { + strength, + policyValid: policyResult.valid, + policyErrors: policyResult.errors, + historyValid, + }; + } catch (error) { + console.error('Password validation error:', error); + + // 에러 발생 시 기본값 반환 + return { + strength: { + score: 1, + hasUppercase: false, + hasLowercase: false, + hasNumbers: false, + hasSymbols: false, + length: 0, + feedback: ['검증 중 오류가 발생했습니다'], + }, + policyValid: false, + policyErrors: ['검증 중 오류가 발생했습니다'], + }; + } +} + + +// 비활성 유저 탐지 +export async function findInactiveUsers(inactiveDays: number = 90) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - inactiveDays); + + return await db + .select({ + id: users.id, + email: users.email, + name: users.name, + lastLoginAt: users.lastLoginAt, + }) + .from(users) + .where( + and( + eq(users.isActive, true), + or( + lt(users.lastLoginAt, cutoffDate), + isNull(users.lastLoginAt) // 한번도 로그인하지 않은 유저 + ) + ) + ); +} + +// 유저 비활성화 (Soft Delete) +export async function deactivateUser( + userId: number, + reason: 'INACTIVE' | 'ADMIN' | 'GDPR' = 'INACTIVE' +) { + return await db + .update(users) + .set({ + isActive: false, + deactivatedAt: new Date(), + deactivationReason: reason, + }) + .where(eq(users.id, userId)); +} + +// 배치 비활성화 +export async function deactivateInactiveUsers(inactiveDays: number = 90) { + const inactiveUsers = await findInactiveUsers(inactiveDays); + + if (inactiveUsers.length === 0) { + return { deactivatedCount: 0, users: [] }; + } + + // 로그 기록 + console.log(`Deactivating ${inactiveUsers.length} inactive users`); + + await db + .update(users) + .set({ + isActive: false, + deactivatedAt: new Date(), + deactivationReason: 'INACTIVE', + }) + .where( + inArray(users.id, inactiveUsers.map(u => u.id)) + ); + + return { + deactivatedCount: inactiveUsers.length, + users: inactiveUsers + }; +} + 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 diff --git a/lib/users/auth/validataions-password.ts b/lib/users/auth/validataions-password.ts new file mode 100644 index 00000000..ab73751c --- /dev/null +++ b/lib/users/auth/validataions-password.ts @@ -0,0 +1,230 @@ +// lib/validations/password.ts + +import { z } from 'zod'; +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; +import { passwords, passwordHistory, loginAttempts, mfaTokens, users } from '@/db/schema'; + +// Drizzle 테이블에서 자동 생성된 Zod 스키마 +export const insertPasswordSchema = createInsertSchema(passwords); +export const selectPasswordSchema = createSelectSchema(passwords); + +export const insertPasswordHistorySchema = createInsertSchema(passwordHistory); +export const selectPasswordHistorySchema = createSelectSchema(passwordHistory); + +export const insertLoginAttemptSchema = createInsertSchema(loginAttempts); +export const selectLoginAttemptSchema = createSelectSchema(loginAttempts); + +export const insertMfaTokenSchema = createInsertSchema(mfaTokens); +export const selectMfaTokenSchema = createSelectSchema(mfaTokens); + +// 커스텀 검증 스키마들 + +// 패스워드 생성 시 검증 +export const createPasswordSchema = z.object({ + userId: z.number().int().positive(), + password: z.string() + .min(8, "패스워드는 최소 8자 이상이어야 합니다") + .max(128, "패스워드는 최대 128자까지 가능합니다") + .regex(/[A-Z]/, "대문자를 포함해야 합니다") + .regex(/[a-z]/, "소문자를 포함해야 합니다") + .regex(/\d/, "숫자를 포함해야 합니다") + .regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, "특수문자를 포함해야 합니다") + .refine( + (password) => !/(.)\\1{2,}/.test(password), + "같은 문자가 3번 이상 연속될 수 없습니다" + ) + .refine( + (password) => !/123|abc|qwe|password|admin|login/i.test(password), + "일반적인 패턴이나 단어는 사용할 수 없습니다" + ), + expiresAt: z.date().optional(), +}); + +// 로그인 검증 +export const loginCredentialsSchema = z.object({ + email: z.string() + .email("올바른 이메일 형식이 아닙니다") + .max(255, "이메일이 너무 깁니다"), + password: z.string() + .min(1, "패스워드를 입력해주세요") + .max(128, "패스워드가 너무 깁니다"), +}); + +// MFA SMS 토큰 검증 +export const smsTokenSchema = z.object({ + userId: z.string().or(z.number()).transform(val => Number(val)), + token: z.string() + .length(6, "인증번호는 6자리여야 합니다") + .regex(/^\d{6}$/, "인증번호는 숫자 6자리여야 합니다"), +}); + +// 전화번호 등록 +export const phoneRegistrationSchema = z.object({ + phoneNumber: z.string() + .regex(/^\+?[1-9]\d{1,14}$/, "올바른 전화번호 형식이 아닙니다") + .transform(phone => phone.replace(/[\s-]/g, '')), // 공백, 하이픈 제거 +}); + +// 패스워드 재설정 요청 +export const passwordResetRequestSchema = z.object({ + email: z.string() + .email("올바른 이메일 형식이 아닙니다") + .max(255, "이메일이 너무 깁니다"), +}); + +// 패스워드 재설정 실행 +export const passwordResetSchema = z.object({ + token: z.string() + .min(1, "토큰이 필요합니다") + .max(255, "토큰이 너무 깁니다"), + newPassword: z.string() + .min(8, "패스워드는 최소 8자 이상이어야 합니다") + .max(128, "패스워드는 최대 128자까지 가능합니다") + .regex(/[A-Z]/, "대문자를 포함해야 합니다") + .regex(/[a-z]/, "소문자를 포함해야 합니다") + .regex(/\d/, "숫자를 포함해야 합니다") + .regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, "특수문자를 포함해야 합니다"), + confirmPassword: z.string(), +}).refine( + (data) => data.newPassword === data.confirmPassword, + { + message: "패스워드가 일치하지 않습니다", + path: ["confirmPassword"], + } +); + +// 보안 설정 업데이트 +export const securitySettingsSchema = z.object({ + minPasswordLength: z.number().int().min(6).max(32).default(8), + requireUppercase: z.boolean().default(true), + requireLowercase: z.boolean().default(true), + requireNumbers: z.boolean().default(true), + requireSymbols: z.boolean().default(true), + passwordExpiryDays: z.number().int().min(0).max(365).nullable().default(90), + passwordHistoryCount: z.number().int().min(1).max(24).default(5), + maxFailedAttempts: z.number().int().min(3).max(20).default(5), + lockoutDurationMinutes: z.number().int().min(5).max(1440).default(30), // 최대 24시간 + requireMfaForPartners: z.boolean().default(true), + smsTokenExpiryMinutes: z.number().int().min(1).max(60).default(5), + maxSmsAttemptsPerDay: z.number().int().min(5).max(50).default(10), + sessionTimeoutMinutes: z.number().int().min(30).max(1440).default(480), // 30분 ~ 24시간 +}); + +// 사용자 등록 (partners용 - 패스워드 포함) +export const userRegistrationSchema = z.object({ + name: z.string() + .min(1, "이름을 입력해주세요") + .max(255, "이름이 너무 깁니다"), + email: z.string() + .email("올바른 이메일 형식이 아닙니다") + .max(255, "이메일이 너무 깁니다"), + password: z.string() + .min(8, "패스워드는 최소 8자 이상이어야 합니다") + .max(128, "패스워드는 최대 128자까지 가능합니다") + .regex(/[A-Z]/, "대문자를 포함해야 합니다") + .regex(/[a-z]/, "소문자를 포함해야 합니다") + .regex(/\d/, "숫자를 포함해야 합니다") + .regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, "특수문자를 포함해야 합니다"), + confirmPassword: z.string(), + phone: z.string() + .regex(/^\+?[1-9]\d{1,14}$/, "올바른 전화번호 형식이 아닙니다") + .optional(), + domain: z.enum(['partners', 'clients', 'internal']).default('partners'), + companyId: z.number().int().positive().optional(), + techCompanyId: z.number().int().positive().optional(), +}).refine( + (data) => data.password === data.confirmPassword, + { + message: "패스워드가 일치하지 않습니다", + path: ["confirmPassword"], + } +); + +// 패스워드 변경 (기존 패스워드 필요) +export const changePasswordSchema = z.object({ + currentPassword: z.string() + .min(1, "현재 패스워드를 입력해주세요"), + newPassword: z.string() + .min(8, "패스워드는 최소 8자 이상이어야 합니다") + .max(128, "패스워드는 최대 128자까지 가능합니다") + .regex(/[A-Z]/, "대문자를 포함해야 합니다") + .regex(/[a-z]/, "소문자를 포함해야 합니다") + .regex(/\d/, "숫자를 포함해야 합니다") + .regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, "특수문자를 포함해야 합니다"), + confirmPassword: z.string(), +}).refine( + (data) => data.newPassword === data.confirmPassword, + { + message: "새 패스워드가 일치하지 않습니다", + path: ["confirmPassword"], + } +).refine( + (data) => data.currentPassword !== data.newPassword, + { + message: "새 패스워드는 현재 패스워드와 달라야 합니다", + path: ["newPassword"], + } +); + +// 로그인 이력 조회 필터 +export const loginHistoryFilterSchema = z.object({ + userId: z.string().or(z.number()).transform(val => Number(val)), + limit: z.number().int().min(1).max(100).default(10), + offset: z.number().int().min(0).default(0), + success: z.boolean().optional(), // 성공/실패 필터 + dateFrom: z.date().optional(), + dateTo: z.date().optional(), + ipAddress: z.string().optional(), +}); + +// API 응답 타입들 +export type LoginCredentials = z.infer<typeof loginCredentialsSchema>; +export type SmsToken = z.infer<typeof smsTokenSchema>; +export type PhoneRegistration = z.infer<typeof phoneRegistrationSchema>; +export type PasswordResetRequest = z.infer<typeof passwordResetRequestSchema>; +export type PasswordReset = z.infer<typeof passwordResetSchema>; +export type UserRegistration = z.infer<typeof userRegistrationSchema>; +export type ChangePassword = z.infer<typeof changePasswordSchema>; +export type SecuritySettings = z.infer<typeof securitySettingsSchema>; +export type LoginHistoryFilter = z.infer<typeof loginHistoryFilterSchema>; +export type CreatePassword = z.infer<typeof createPasswordSchema>; + +// 패스워드 강도 결과 타입 +export const passwordStrengthSchema = z.object({ + score: z.number().int().min(1).max(5), + hasUppercase: z.boolean(), + hasLowercase: z.boolean(), + hasNumbers: z.boolean(), + hasSymbols: z.boolean(), + length: z.number().int(), + feedback: z.array(z.string()), +}); + +export type PasswordStrength = z.infer<typeof passwordStrengthSchema>; + +// 인증 결과 타입 +export const authResultSchema = z.object({ + success: z.boolean(), + user: z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + imageUrl: z.string().nullable().optional(), + companyId: z.number().nullable().optional(), + techCompanyId: z.number().nullable().optional(), + domain: z.string().optional(), + }).optional(), + error: z.enum([ + 'INVALID_CREDENTIALS', + 'ACCOUNT_LOCKED', + 'PASSWORD_EXPIRED', + 'ACCOUNT_DISABLED', + 'RATE_LIMITED', + 'MFA_REQUIRED', + 'SYSTEM_ERROR' + ]).optional(), + requiresMfa: z.boolean().optional(), + mfaToken: z.string().optional(), +}); + +export type AuthResult = z.infer<typeof authResultSchema>;
\ No newline at end of file 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 diff --git a/lib/users/service.ts b/lib/users/service.ts index 0d0121b3..ad01c22a 100644 --- a/lib/users/service.ts +++ b/lib/users/service.ts @@ -9,10 +9,12 @@ import { saveDocument } from '../storage'; import { GetUsersSchema } from '../admin-users/validations'; import { revalidateTag, unstable_cache, unstable_noStore } from 'next/cache'; import { filterColumns } from '../filter-columns'; -import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; import { countUsers, selectUsersWithCompanyAndRoles } from '../admin-users/repository'; import db from "@/db/db"; import { getErrorMessage } from "@/lib/handle-error"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray } from "drizzle-orm"; interface AssignUsersArgs { roleId: number @@ -415,6 +417,72 @@ export async function getUsersAll(input: GetUsersSchema, domain: string) { } +export async function getUsersAllbyVendor(input: GetUsersSchema, domain: string) { + + try { + + const session = await getServerSession(authOptions) + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const companyId = session?.user.companyId + + const offset = (input.page - 1) * input.perPage; + + // (1) advancedWhere + const advancedWhere = filterColumns({ + table: userView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // (2) globalWhere + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(userView.user_name, s), + ilike(userView.user_email, s), + ); + } + + // (3) domainWhere - 무조건 들어가야 하는 domain 조건 + const domainWhere = eq(userView.user_domain, domain); + + // (4) 최종 where + // domainWhere과 advancedWhere, globalWhere를 모두 and로 묶는다. + // (globalWhere가 존재하지 않을 수 있으니, and() 호출 시 undefined를 자동 무시할 수도 있음) + const finalWhere = and(domainWhere, advancedWhere, globalWhere, eq(userView.company_id, companyId)); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(userView[item.id]) : asc(userView[item.id]) + ) + : [desc(users.createdAt)]; + + const { data, total } = await db.transaction(async (tx) => { + const data = await selectUsersWithCompanyAndRoles(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countUsers(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + return { data: [], pageCount: 0 }; + } + +} + export async function assignUsersToRole(roleId: number, userIds: number[]) { unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용) try{ diff --git a/lib/users/verifyOtp.ts b/lib/users/verifyOtp.ts index 7b25ed49..694665bf 100644 --- a/lib/users/verifyOtp.ts +++ b/lib/users/verifyOtp.ts @@ -51,31 +51,5 @@ export async function verifyOtpTemp(email: string) { } -export async function verifyExternalCredentials(username: string, password: string) { - // DB에서 email과 code가 맞는지, 만료 안됐는지 검증 - const otpRecord = await findEmailandOtp(username, password) - if (!otpRecord) { - return null - } - - // 만료 체크 - if (otpRecord.otpExpires && otpRecord.otpExpires < new Date()) { - return null - } - - // 여기서 otpRecord에 유저 정보가 있다고 가정 - // 예: otpRecord.userId, otpRecord.userName, otpRecord.email 등 - // 실제 DB 설계에 맞춰 필드명을 조정하세요. - return { - email: otpRecord.email, - name: otpRecord.name, - id: otpRecord.id, - imageUrl: otpRecord.imageUrl, - companyId: otpRecord.companyId, - techCompanyId: otpRecord.techCompanyId, - domain: otpRecord.domain, - } -} - |
