summaryrefslogtreecommitdiff
path: root/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'app/api')
-rw-r--r--app/api/auth/[...nextauth]/route.ts238
-rw-r--r--app/api/auth/send-sms/route.ts75
-rw-r--r--app/api/auth/verify-mfa/route.ts65
-rw-r--r--app/api/cron/cleanup-users/route.ts40
-rw-r--r--app/api/revision-upload-ship/route.ts2
5 files changed, 364 insertions, 56 deletions
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
index 969263ea..f5d49f77 100644
--- a/app/api/auth/[...nextauth]/route.ts
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -1,51 +1,50 @@
-// (1) next-auth에서 필요한 타입들을 import
+// Updated NextAuth configuration with dynamic session timeout from database
+
import NextAuth, {
- NextAuthOptions, // authOptions에 쓸 타입
+ NextAuthOptions,
Session,
- User
+ User,
+ Account
} from 'next-auth'
import { JWT } from "next-auth/jwt"
-
import CredentialsProvider from 'next-auth/providers/credentials'
-
-import { verifyExternalCredentials, verifyOtp, verifyOtpTemp } from '@/lib/users/verifyOtp'
import { SAMLProvider } from './saml/provider'
+import { getUserById } from '@/lib/users/repository'
+import { authenticateWithSGips, verifyExternalCredentials } from '@/lib/users/auth/verifyCredentails'
+import { verifyOtpTemp } from '@/lib/users/verifyOtp'
+import { getSecuritySettings } from '@/lib/password-policy/service'
-// 1) 모듈 보강 선언
+// 인증 방식 타입 정의
+type AuthMethod = 'otp' | 'email' | 'sgips' | 'saml'
+
+// 모듈 보강 선언 (인증 방식 추가)
declare module "next-auth" {
- /**
- * Session 객체를 확장
- */
interface Session {
user: {
- /** 우리가 필요로 하는 user id */
id: string
-
- // 기본적으로 NextAuth가 제공하는 name/email/image 필드
name?: string | null
email?: string | null
image?: string | null
companyId?: number | null
techCompanyId?: number | null
domain?: string | null
-
+ reAuthTime?: number | null
+ authMethod?: AuthMethod
+ sessionExpiredAt?: number | null // 세션 만료 시간 추가
}
}
- /**
- * User 객체를 확장
- */
interface User {
id: string
imageUrl?: string | null
companyId?: number | null
techCompanyId?: number | null
domain?: string | null
- // 필요한 필드를 추가로 선언 가능
+ reAuthTime?: number | null
+ authMethod?: AuthMethod
}
}
-// JWT 타입 확장
declare module "next-auth/jwt" {
interface JWT {
id?: string
@@ -53,13 +52,47 @@ declare module "next-auth/jwt" {
companyId?: number | null
techCompanyId?: number | null
domain?: string | null
+ reAuthTime?: number | null
+ authMethod?: AuthMethod
+ sessionExpiredAt?: number | null // 세션 만료 시간 추가
}
}
+// 보안 설정 캐시 (성능 최적화)
+let securitySettingsCache: {
+ data: any | null
+ lastFetch: number
+ ttl: number
+} = {
+ data: null,
+ lastFetch: 0,
+ ttl: 5 * 60 * 1000 // 5분 캐시
+}
+
+// 보안 설정을 가져오는 함수 (캐시 적용)
+async function getCachedSecuritySettings() {
+ const now = Date.now()
+
+ if (!securitySettingsCache.data ||
+ (now - securitySettingsCache.lastFetch) > securitySettingsCache.ttl) {
+ try {
+ securitySettingsCache.data = await getSecuritySettings()
+ securitySettingsCache.lastFetch = now
+ } catch (error) {
+ console.error('Failed to fetch security settings:', error)
+ // 기본값 사용
+ securitySettingsCache.data = {
+ sessionTimeoutMinutes: 480 // 8시간 기본값
+ }
+ }
+ }
+
+ return securitySettingsCache.data
+}
-// (2) authOptions에 NextAuthOptions 타입 지정
export const authOptions: NextAuthOptions = {
providers: [
+ // OTP provider
CredentialsProvider({
name: 'Credentials',
credentials: {
@@ -69,69 +102,90 @@ export const authOptions: NextAuthOptions = {
async authorize(credentials, req) {
const { email, code } = credentials ?? {}
- // OTP 검증
const user = await verifyOtpTemp(email ?? '')
if (!user) {
return null
}
+ // 보안 설정에서 세션 타임아웃 가져오기
+ const securitySettings = await getCachedSecuritySettings()
+ const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000
+ const reAuthTime = Date.now()
+
return {
id: String(user.id ?? email ?? "dts"),
email: user.email,
imageUrl: user.imageUrl ?? null,
- name: user.name, // DB에서 가져온 실제 이름
- companyId: user.companyId, // DB에서 가져온 실제 이름
- techCompanyId: user.techCompanyId as number | undefined, // techVendor ID
- domain: user.domain, // DB에서 가져온 실제 이름
+ name: user.name,
+ companyId: user.companyId,
+ techCompanyId: user.techCompanyId as number | undefined,
+ domain: user.domain,
+ reAuthTime,
+ authMethod: 'otp' as AuthMethod,
}
},
}),
- // 새로 추가할 ID/비밀번호 provider
+
+ // ID/패스워드 provider (S-Gips와 일반 이메일 구분)
CredentialsProvider({
id: 'credentials-password',
name: 'Username Password',
credentials: {
username: { label: "Username", type: "text" },
- password: { label: "Password", type: "password" }
+ password: { label: "Password", type: "password" },
+ provider: { label: "Provider", type: "text" },
},
- async authorize(credentials, req) { // req 매개변수 추가
+ async authorize(credentials, req) {
if (!credentials?.username || !credentials?.password) {
return null;
}
try {
- // 여기서 외부 서비스 API를 호출하여 사용자 인증
- const user = await verifyExternalCredentials(
- credentials.username,
- credentials.password
- );
+ let authResult;
+ const isSSgips = credentials.provider === 'sgips';
- if (user) {
+ if (isSSgips) {
+ authResult = await authenticateWithSGips(
+ credentials.username,
+ credentials.password
+ );
+ } else {
+ authResult = await verifyExternalCredentials(
+ credentials.username,
+ credentials.password
+ );
+ }
+
+ if (authResult.success && authResult.user) {
return {
- id: String(user.id), // id를 string으로 변환
- name: user.name,
- email: user.email,
- // 첫 번째 provider와 동일한 필드 구조 유지
- imageUrl: user.imageUrl ?? null,
- companyId: user.companyId,
- techCompanyId: user.techCompanyId,
- domain: user.domain
+ id: authResult.user.id,
+ name: authResult.user.name,
+ email: authResult.user.email,
+ imageUrl: authResult.user.imageUrl ?? null,
+ companyId: authResult.user.companyId,
+ techCompanyId: authResult.user.techCompanyId,
+ domain: authResult.user.domain,
+ reAuthTime: Date.now(),
+ authMethod: isSSgips ? 'sgips' as AuthMethod : 'email' as AuthMethod,
};
}
+
return null;
+
} catch (error) {
console.error("Authentication error:", error);
return null;
}
}
}),
- // SAML Provider 추가 (CredentialsProvider 기반)
+
+ // SAML Provider
SAMLProvider({
id: "credentials-saml",
name: "SAML SSO",
idp: {
sso_login_url: process.env.SAML_IDP_SSO_URL!,
- sso_logout_url: process.env.SAML_IDP_SLO_URL || '', // 선택적
+ sso_logout_url: process.env.SAML_IDP_SLO_URL || '',
certificates: [process.env.SAML_IDP_CERT!]
},
sp: {
@@ -142,17 +196,23 @@ export const authOptions: NextAuthOptions = {
}
})
],
- // (3) session.strategy는 'jwt'가 되도록 선언
- // 필요하다면 as SessionStrategy 라고 명시해줄 수도 있음
- // 예) strategy: 'jwt' as SessionStrategy
+
session: {
strategy: 'jwt',
+ // JWT 기본 maxAge는 30일로 설정하되, 실제 세션 만료는 콜백에서 처리
+ maxAge: 30 * 24 * 60 * 60, // 30일
},
callbacks: {
- // (4) 콜백에서 token, user, session 등의 타입을 좀 더 명시해주고 싶다면 아래처럼 destructuring에 제네릭/타입 지정
- async jwt({ token, user }: { token: JWT; user?: User }) {
+ // JWT 콜백 - 세션 타임아웃 설정 (만료 체크는 session 콜백에서)
+ async jwt({ token, user, account, trigger, session }) {
+ // 보안 설정 가져오기
+ const securitySettings = await getCachedSecuritySettings()
+ const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000
+
+ // 최초 로그인 시
if (user) {
+ const reAuthTime = Date.now()
token.id = user.id
token.email = user.email
token.name = user.name
@@ -160,10 +220,61 @@ export const authOptions: NextAuthOptions = {
token.techCompanyId = user.techCompanyId
token.domain = user.domain
token.imageUrl = user.imageUrl
+ token.reAuthTime = reAuthTime
+ token.authMethod = user.authMethod
+ token.sessionExpiredAt = reAuthTime + sessionTimeoutMs
+ }
+
+ // 인증 방식 결정 (account 정보 기반)
+ if (account && !token.authMethod) {
+ const reAuthTime = Date.now()
+ if (account.provider === 'credentials-saml') {
+ token.authMethod = 'saml'
+ token.reAuthTime = reAuthTime
+ token.sessionExpiredAt = reAuthTime + sessionTimeoutMs
+ } else if (account.provider === 'credentials') {
+ // OTP는 이미 user.authMethod에서 설정됨
+ if (!token.sessionExpiredAt) {
+ token.sessionExpiredAt = (token.reAuthTime || Date.now()) + sessionTimeoutMs
+ }
+ } else if (account.provider === 'credentials-password') {
+ // credentials-password는 이미 user.authMethod에서 설정됨
+ if (!token.sessionExpiredAt) {
+ token.sessionExpiredAt = (token.reAuthTime || Date.now()) + sessionTimeoutMs
+ }
+ }
}
+
+ // 세션 업데이트 시 (재인증 시간 업데이트)
+ if (trigger === "update" && session) {
+ if (session.reAuthTime !== undefined) {
+ token.reAuthTime = session.reAuthTime
+ // 재인증 시간 업데이트 시 세션 만료 시간도 연장
+ token.sessionExpiredAt = session.reAuthTime + sessionTimeoutMs
+ }
+
+ if (session.user) {
+ if (session.user.name !== undefined) token.name = session.user.name
+ if (session.user.email !== undefined) token.email = session.user.email
+ if (session.user.image !== undefined) token.imageUrl = session.user.image
+ }
+ }
+
return token
},
+
+ // Session 콜백 - 세션 만료 체크 및 정보 포함
async session({ session, token }: { session: Session; token: JWT }) {
+ // 세션 만료 체크
+ if (token.sessionExpiredAt && Date.now() > token.sessionExpiredAt) {
+ console.log(`Session expired for user ${token.email}. Expired at: ${new Date(token.sessionExpiredAt)}`)
+ // 만료된 세션 처리 - 빈 세션 반환하여 로그아웃 유도
+ return {
+ expires: new Date(0).toISOString(), // 즉시 만료
+ user: null as any
+ }
+ }
+
if (token) {
session.user = {
id: token.id as string,
@@ -172,27 +283,44 @@ export const authOptions: NextAuthOptions = {
domain: token.domain as string,
companyId: token.companyId as number,
techCompanyId: token.techCompanyId as number,
- image: token.imageUrl ?? null
+ image: token.imageUrl ?? null,
+ reAuthTime: token.reAuthTime as number | null,
+ authMethod: token.authMethod as AuthMethod,
+ sessionExpiredAt: token.sessionExpiredAt as number | null,
}
}
return session
},
- // redirect 콜백 추가
+
+ // Redirect 콜백
async redirect({ url, baseUrl }) {
- // 상대 경로인 경우 baseUrl을 기준으로 함
if (url.startsWith("/")) {
return `${baseUrl}${url}`;
}
- // 같은 도메인인 경우 그대로 사용
else if (new URL(url).origin === baseUrl) {
return url;
}
- // 그 외에는 baseUrl로 리다이렉트
return baseUrl;
},
},
+
+ pages: {
+ signIn: '/auth/login',
+ error: '/auth/error',
+ },
+
+ // 디버깅을 위한 이벤트 로깅
+ events: {
+ async signIn({ user, account, profile }) {
+ const securitySettings = await getCachedSecuritySettings()
+ console.log(`User ${user.email} signed in via ${account?.provider} (authMethod: ${user.authMethod}), session timeout: ${securitySettings.sessionTimeoutMinutes} minutes`);
+ },
+ async signOut({ session, token }) {
+ console.log(`User ${session?.user?.email || token?.email} signed out`);
+ }
+ }
}
const handler = NextAuth(authOptions)
+export { handler as GET, handler as POST }
-export { handler as GET, handler as POST } \ No newline at end of file
diff --git a/app/api/auth/send-sms/route.ts b/app/api/auth/send-sms/route.ts
new file mode 100644
index 00000000..3d51d445
--- /dev/null
+++ b/app/api/auth/send-sms/route.ts
@@ -0,0 +1,75 @@
+// app/api/auth/send-sms/route.ts
+
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import { getUserById } from '@/lib/users/repository';
+import { generateAndSendSmsToken } from '@/lib/users/auth/passwordUtil';
+
+const sendSmsSchema = z.object({
+ userId: z.string(),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { error: '인증이 필요합니다' },
+ { status: 401 }
+ );
+ }
+
+ const body = await request.json();
+ const { userId } = sendSmsSchema.parse(body);
+
+ // 본인 확인
+ if (session.user.id !== userId) {
+ return NextResponse.json(
+ { error: '권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ // 사용자 정보 조회
+ const user = await getUserById(Number(userId));
+ if (!user || !user.phone) {
+ return NextResponse.json(
+ { error: '전화번호가 등록되지 않았습니다' },
+ { status: 400 }
+ );
+ }
+
+ // SMS 전송
+ const result = await generateAndSendSmsToken(parseInt(userId), user.phone);
+
+ if (result.success) {
+ return NextResponse.json({
+ success: true,
+ message: 'SMS가 전송되었습니다'
+ });
+ } else {
+ return NextResponse.json(
+ { error: result.error },
+ { status: 400 }
+ );
+ }
+
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: '잘못된 요청입니다' },
+ { status: 400 }
+ );
+ }
+
+ console.error('SMS send API error:', error);
+ return NextResponse.json(
+ { error: '서버 오류가 발생했습니다' },
+ { status: 500 }
+ );
+ }
+}
+
diff --git a/app/api/auth/verify-mfa/route.ts b/app/api/auth/verify-mfa/route.ts
new file mode 100644
index 00000000..f9d1b51e
--- /dev/null
+++ b/app/api/auth/verify-mfa/route.ts
@@ -0,0 +1,65 @@
+// app/api/auth/verify-mfa/route.ts
+
+import { NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+import { verifySmsToken } from '@/lib/users/auth/passwordUtil';
+
+const verifyMfaSchema = z.object({
+ userId: z.string(),
+ token: z.string().length(6, '6자리 인증번호를 입력해주세요'),
+});
+
+export async function POST(request: NextRequest) {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { error: '인증이 필요합니다' },
+ { status: 401 }
+ );
+ }
+
+ const body = await request.json();
+ const { userId, token } = verifyMfaSchema.parse(body);
+
+ // 본인 확인
+ if (session.user.id !== userId) {
+ return NextResponse.json(
+ { error: '권한이 없습니다' },
+ { status: 403 }
+ );
+ }
+
+ // MFA 토큰 검증
+ const result = await verifySmsToken(parseInt(userId), token);
+
+ if (result.success) {
+ return NextResponse.json({
+ success: true,
+ message: 'MFA 인증이 완료되었습니다'
+ });
+ } else {
+ return NextResponse.json(
+ { error: result.error },
+ { status: 400 }
+ );
+ }
+
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: error.errors[0]?.message || '잘못된 요청입니다' },
+ { status: 400 }
+ );
+ }
+
+ console.error('MFA verify API error:', error);
+ return NextResponse.json(
+ { error: '서버 오류가 발생했습니다' },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/cron/cleanup-users/route.ts b/app/api/cron/cleanup-users/route.ts
new file mode 100644
index 00000000..d3b7a203
--- /dev/null
+++ b/app/api/cron/cleanup-users/route.ts
@@ -0,0 +1,40 @@
+// app/api/cron/cleanup-users/route.ts
+import { deactivateInactiveUsers } from '@/lib/users/auth/partners-auth';
+import { NextRequest } from 'next/server';
+
+export async function GET(request: NextRequest) {
+ try {
+ // 보안: 헤더 또는 쿼리 파라미터로 인증
+ const authToken = request.headers.get('x-cron-secret') ||
+ request.nextUrl.searchParams.get('secret');
+
+ if (authToken !== process.env.CRON_SECRET) {
+ console.log('Unauthorized cron request');
+ return new Response('Unauthorized', { status: 401 });
+ }
+
+ console.log(`[${new Date().toISOString()}] Starting user cleanup job...`);
+
+ const result = await deactivateInactiveUsers(90); // 90일
+
+ const message = `User cleanup completed: ${result.deactivatedCount} users deactivated`;
+ console.log(`[${new Date().toISOString()}] ${message}`);
+
+ return Response.json({
+ success: true,
+ message,
+ deactivatedCount: result.deactivatedCount,
+ timestamp: new Date().toISOString()
+ });
+
+ } catch (error) {
+ const errorMessage = `User cleanup job failed: ${error.message}`;
+ console.error(`[${new Date().toISOString()}] ${errorMessage}`);
+
+ return Response.json({
+ success: false,
+ error: errorMessage,
+ timestamp: new Date().toISOString()
+ }, { status: 500 });
+ }
+} \ No newline at end of file
diff --git a/app/api/revision-upload-ship/route.ts b/app/api/revision-upload-ship/route.ts
index c68d405e..549d15bd 100644
--- a/app/api/revision-upload-ship/route.ts
+++ b/app/api/revision-upload-ship/route.ts
@@ -31,7 +31,7 @@ export async function POST(request: NextRequest) {
const docId = Number(formData.get("documentId"))
const uploaderName = formData.get("uploaderName") as string | null
const comment = formData.get("comment") as string | null
- const targetSystem = (formData.get("targetSystem") as string | null) ?? "DOLCE"
+ const targetSystem = "DOLCE"
const attachmentFiles = formData.getAll("attachments") as File[]
/* ------- 검증 ------- */