// 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; export type SmsToken = z.infer; export type PhoneRegistration = z.infer; export type PasswordResetRequest = z.infer; export type PasswordReset = z.infer; export type UserRegistration = z.infer; export type ChangePassword = z.infer; export type SecuritySettings = z.infer; export type LoginHistoryFilter = z.infer; export type CreatePassword = z.infer; // 패스워드 강도 결과 타입 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; // 인증 결과 타입 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;