summaryrefslogtreecommitdiff
path: root/lib/users
diff options
context:
space:
mode:
Diffstat (limited to 'lib/users')
-rw-r--r--lib/users/auth/partners-auth.ts374
-rw-r--r--lib/users/auth/passwordUtil.ts608
-rw-r--r--lib/users/auth/validataions-password.ts230
-rw-r--r--lib/users/auth/verifyCredentails.ts620
-rw-r--r--lib/users/service.ts70
-rw-r--r--lib/users/verifyOtp.ts26
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,
- }
-}
-