summaryrefslogtreecommitdiff
path: root/lib/users/auth/verifyCredentails.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-27 01:16:20 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-27 01:16:20 +0000
commite9897d416b3e7327bbd4d4aef887eee37751ae82 (patch)
treebd20ce6eadf9b21755bd7425492d2d31c7700a0e /lib/users/auth/verifyCredentails.ts
parent3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff)
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'lib/users/auth/verifyCredentails.ts')
-rw-r--r--lib/users/auth/verifyCredentails.ts620
1 files changed, 620 insertions, 0 deletions
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