diff options
| -rw-r--r-- | .env.development | 30 | ||||
| -rw-r--r-- | .env.production | 28 | ||||
| -rw-r--r-- | README.md | 27 | ||||
| -rw-r--r-- | app/api/auth/[...nextauth]/route.ts | 63 | ||||
| -rw-r--r-- | app/api/auth/[...nextauth]/saml/provider.ts | 259 | ||||
| -rw-r--r-- | app/api/auth/[...nextauth]/saml/utils.ts | 485 | ||||
| -rw-r--r-- | app/api/auth/saml/authn-request/route.ts | 98 | ||||
| -rw-r--r-- | app/api/auth/saml/mock-idp/route.ts | 141 | ||||
| -rw-r--r-- | app/api/saml/callback/route.ts | 209 | ||||
| -rw-r--r-- | components/login/login-form copy 2.tsx | 470 | ||||
| -rw-r--r-- | components/login/login-form copy.tsx | 468 | ||||
| -rw-r--r-- | components/login/login-form-shi.tsx | 18 | ||||
| -rw-r--r-- | components/login/saml-login-button.tsx | 121 | ||||
| -rw-r--r-- | config/saml/idp_metadata.xml | 26 | ||||
| -rw-r--r-- | config/saml/sp_metadata.xml | 24 | ||||
| -rw-r--r-- | lib/debug-utils.ts | 72 | ||||
| -rw-r--r-- | lib/saml/idp-metadata.ts | 86 | ||||
| -rw-r--r-- | lib/saml/sp-metadata.ts | 82 | ||||
| -rw-r--r-- | lib/users/repository.ts | 15 | ||||
| -rw-r--r-- | lib/users/saml-service.ts | 134 | ||||
| -rw-r--r-- | package-lock.json | 178 |
21 files changed, 1975 insertions, 1059 deletions
diff --git a/.env.development b/.env.development index 526cf9cc..d44520fe 100644 --- a/.env.development +++ b/.env.development @@ -61,4 +61,32 @@ DOLCE_API_URL=http://60.100.99.217:1111 DOLCE_UPLOAD_URL=http://60.100.99.217:1111/PWPUploadService.ashx -OCR_SECRET_KEY=QVZzbkFtVFV1UWl2THNCY01lYVVGUUxpWmdyUkxHYVA=
\ No newline at end of file +OCR_SECRET_KEY=QVZzbkFtVFV1UWl2THNCY01lYVVGUUxpWmdyUkxHYVA= + +# === [시작] SSO 설정 === + +# ! IdP와 통신 불가능한 상황에서 테스트를 위한 모킹 처리 지원하기 +SAML_MOCKING_IDP=true + +# ! SSO Redirect 주소로 활용되며, 상단에서 적절한 URL을 쓴다면 이 변수는 주석처리할 것 +# NEXTAUTH_URL="http://localhost:3000" + +# SAML 2.0 SP로서 신청할 때 기입하는 사항 +# 메타데이터 XML에서 추출 가능하나, 개발 편의성을 위해 추출로직 제거하고 환경변수에 하드코딩함 + +### sp_metadata.xml ### +SAML_SP_ENTITY_ID="http://60.101.108.100" +SAML_SP_CALLBACK_URL="http://60.101.108.100/api/saml/callback" +# POST +SAML_SP_ACS_BINDING_PRIMARY="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" +# Redirect +SAML_SP_ACS_BINDING_SECONDARY="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" +SAML_SP_AUTHN_REQUESTS_SIGNED=false +SAML_SP_WANT_ASSERTIONS_SIGNED=false + +### idp_metadata.xml ### +SAML_IDP_ENTITY_ID="www.stage1.samsung.net" +SAML_IDP_SSO_URL="https://epsso.stage.samsung.net/sso/saml/SingleSignOnService" +SAML_IDP_CERT="MIID2zCCAsOgAwIBAgIJAKUgkCmmclHOMA0GCSqGSIb3DQEBCwUAMIGDMQswCQYDVQQGEwJLUjEPMA0GA1UECAwGSmFtc2lsMQ4wDAYDVQQHDAVTZW91bDETMBEGA1UECgwKU2Ftc3VuZ1NEUzEdMBsGA1UECwwUSW50cmFuZXRCdXNpbmVzc1RlYW0xHzAdBgNVBAMMFm5ldC5zYW1zdW5nLmtub3hwb3J0YWwwHhcNMTcwOTA2MDQxNDAzWhcNMjcwOTA0MDQxNDAzWjCBgzELMAkGA1UEBhMCS1IxDzANBgNVBAgMBkphbXNpbDEOMAwGA1UEBwwFU2VvdWwxEzARBgNVBAoMClNhbXN1bmdTRFMxHTAbBgNVBAsMFEludHJhbmV0QnVzaW5lc3NUZWFtMR8wHQYDVQQDDBZuZXQuc2Ftc3VuZy5rbm94cG9ydGFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1nT5VRgS/PGj7iL8l4kpyEqs04BocOrIPf9mn+Ky/pA3BkgfxItkAfxqKjrzZ2J/0yB1jkjpHYxQQSpah5f/FrxK/G3lCMlpQzFgT9qfX/VJqhJLU3JF4hhxTVp77rF5Sqz2CWdTzrKgEhVhQupfANL67uw1GrR2AoPWsmGqr/ybdEcjr0w3lYrnCb9LYvvT+KOmZg1nVEbMAJ66xFuiuc4IGAot+IIHY86ZjSXRfMBkJaisEpStXXja0PD8SHDu31DdLomaRYrv9eyoh3q/LONejfgd8IrAJO3Om8zNmfF2Q665Ab4oPFoRznjvR74/pszIxqQTYoVgKkDKRmTOjQIDAQABo1AwTjAdBgNVHQ4EFgQUiolG//FttT/5g3IBaoRvjNWNCt0wHwYDVR0jBBgwFoAUiolG//FttT/5g3IBaoRvjNWNCt0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAQVBxmGnZHo3dMLKFgAf8oLevA1TuA03p6jj2MVLwFMjw0S74bFpgS4ZXEzsliGAQprVwTzo06XtTxQENxddbFMRfKroKvpyM20uBt2JI5nBmE/kzrb4AOguRRTNKfb9o4zk2yO7Ra31dWHrvZ3usV8A0KLIHef6iUPv4mBMXY5e7gEUjoZxbZQucyHOrYvuj/TISd7n6r37cotf5ldUD5B+ADP05AgTTP1vKzyfOsb+zRqTTi8WFOc2SlbTktXPvfiQmHs6OoCbNNYXfQT+YO0x3y8M4TevvoeKvTjQp1E+Q+J8hAh7xTIemb6wP460ObUD9w+wyqUk44XJGdibtgQ==" + +# === [끝] SSO 설정 ===
\ No newline at end of file diff --git a/.env.production b/.env.production index f10df9a0..a48ffb63 100644 --- a/.env.production +++ b/.env.production @@ -58,4 +58,30 @@ DOLCE_DOC_LIST_API_URL=http://60.100.99.217:1111/Services/VDCSWebService.svc/Dwg DOLCE_API_URL=http://60.100.99.217:1111 DOLCE_UPLOAD_URL=http://60.100.99.217:1111/PWPUploadService.ashx -OCR_SECRET_KEY=QVZzbkFtVFV1UWl2THNCY01lYVVGUUxpWmdyUkxHYVA=
\ No newline at end of file +OCR_SECRET_KEY=QVZzbkFtVFV1UWl2THNCY01lYVVGUUxpWmdyUkxHYVA= + + +# === [시작] SSO 설정 === + +# ! SSO Redirect 주소로 활용되며, 상단에서 적절한 URL을 쓴다면 이 변수는 주석처리할 것 +# NEXTAUTH_URL="http://60.101.108.100" + +# SAML 2.0 SP로서 신청할 때 기입하는 사항 +# 메타데이터 XML에서 추출 가능하나, 개발 편의성을 위해 추출로직 제거하고 환경변수에 하드코딩함 + +### sp_metadata.xml ### +SAML_SP_ENTITY_ID="http://60.101.108.100" +SAML_SP_CALLBACK_URL="http://60.101.108.100/api/saml/callback" +# POST +SAML_SP_ACS_BINDING_PRIMARY="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" +# Redirect +SAML_SP_ACS_BINDING_SECONDARY="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" +SAML_SP_AUTHN_REQUESTS_SIGNED=false +SAML_SP_WANT_ASSERTIONS_SIGNED=false + +### idp_metadata.xml ### +SAML_IDP_ENTITY_ID="www.stage1.samsung.net" +SAML_IDP_SSO_URL="https://epsso.stage.samsung.net/sso/saml/SingleSignOnService" +SAML_IDP_CERT="MIID2zCCAsOgAwIBAgIJAKUgkCmmclHOMA0GCSqGSIb3DQEBCwUAMIGDMQswCQYDVQQGEwJLUjEPMA0GA1UECAwGSmFtc2lsMQ4wDAYDVQQHDAVTZW91bDETMBEGA1UECgwKU2Ftc3VuZ1NEUzEdMBsGA1UECwwUSW50cmFuZXRCdXNpbmVzc1RlYW0xHzAdBgNVBAMMFm5ldC5zYW1zdW5nLmtub3hwb3J0YWwwHhcNMTcwOTA2MDQxNDAzWhcNMjcwOTA0MDQxNDAzWjCBgzELMAkGA1UEBhMCS1IxDzANBgNVBAgMBkphbXNpbDEOMAwGA1UEBwwFU2VvdWwxEzARBgNVBAoMClNhbXN1bmdTRFMxHTAbBgNVBAsMFEludHJhbmV0QnVzaW5lc3NUZWFtMR8wHQYDVQQDDBZuZXQuc2Ftc3VuZy5rbm94cG9ydGFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1nT5VRgS/PGj7iL8l4kpyEqs04BocOrIPf9mn+Ky/pA3BkgfxItkAfxqKjrzZ2J/0yB1jkjpHYxQQSpah5f/FrxK/G3lCMlpQzFgT9qfX/VJqhJLU3JF4hhxTVp77rF5Sqz2CWdTzrKgEhVhQupfANL67uw1GrR2AoPWsmGqr/ybdEcjr0w3lYrnCb9LYvvT+KOmZg1nVEbMAJ66xFuiuc4IGAot+IIHY86ZjSXRfMBkJaisEpStXXja0PD8SHDu31DdLomaRYrv9eyoh3q/LONejfgd8IrAJO3Om8zNmfF2Q665Ab4oPFoRznjvR74/pszIxqQTYoVgKkDKRmTOjQIDAQABo1AwTjAdBgNVHQ4EFgQUiolG//FttT/5g3IBaoRvjNWNCt0wHwYDVR0jBBgwFoAUiolG//FttT/5g3IBaoRvjNWNCt0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAQVBxmGnZHo3dMLKFgAf8oLevA1TuA03p6jj2MVLwFMjw0S74bFpgS4ZXEzsliGAQprVwTzo06XtTxQENxddbFMRfKroKvpyM20uBt2JI5nBmE/kzrb4AOguRRTNKfb9o4zk2yO7Ra31dWHrvZ3usV8A0KLIHef6iUPv4mBMXY5e7gEUjoZxbZQucyHOrYvuj/TISd7n6r37cotf5ldUD5B+ADP05AgTTP1vKzyfOsb+zRqTTi8WFOc2SlbTktXPvfiQmHs6OoCbNNYXfQT+YO0x3y8M4TevvoeKvTjQp1E+Q+J8hAh7xTIemb6wP460ObUD9w+wyqUk44XJGdibtgQ==" + +# === [끝] SSO 설정 ===
\ No newline at end of file @@ -117,3 +117,30 @@ npx dotenv -e .env.development -- npx drizzle-kit push > ``` [gh cli에 대해 알아보기](https://github.com/cli/cli) + +## SAML 2.0 SSO (For Knox Portal) +# === [시작] SSO 설정 === + +# ! SSO Redirect 주소로 활용되며, 상단에서 적절한 URL을 쓴다면 이 변수는 주석처리할 것 +# NEXTAUTH_URL="http://60.101.108.100" + +# SAML 2.0 SP로서 신청할 때 기입하는 사항 +# 메타데이터 XML에서 추출 가능하나, 개발 편의성을 위해 추출로직 제거하고 환경변수에 하드코딩함 + +### sp_metadata.xml ### +```bash +SAML_SP_ENTITY_ID="SP 측 ID(사전 정의됨)" +SAML_SP_CALLBACK_URL="SP 측이 콜백을 받을 주소(사전 정의됨)" +# POST +SAML_SP_ACS_BINDING_PRIMARY="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" +# Redirect +SAML_SP_ACS_BINDING_SECONDARY="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" +SAML_SP_AUTHN_REQUESTS_SIGNED=false #(사전 정의됨) +SAML_SP_WANT_ASSERTIONS_SIGNED=false #(사전 정의됨) + +### idp_metadata.xml ### +SAML_IDP_ENTITY_ID="(수신한 메타데이터 기준으로 추출)" +SAML_IDP_SSO_URL="(수신한 메타데이터 기준으로 추출. 이 위치로 브라우저는 사용자를 리다이렉트함)" +SAML_IDP_CERT="BASE64로 인코딩된 IDP의 CERT" +SAML_ENCRYPTION_METHOD="CERT 암호화 기법" +```
\ No newline at end of file diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 4673d8ae..969263ea 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -9,6 +9,7 @@ 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' // 1) 모듈 보강 선언 declare module "next-auth" { @@ -44,6 +45,18 @@ declare module "next-auth" { } } +// JWT 타입 확장 +declare module "next-auth/jwt" { + interface JWT { + id?: string + imageUrl?: string | null + companyId?: number | null + techCompanyId?: number | null + domain?: string | null + } +} + + // (2) authOptions에 NextAuthOptions 타입 지정 export const authOptions: NextAuthOptions = { providers: [ @@ -68,36 +81,11 @@ export const authOptions: NextAuthOptions = { imageUrl: user.imageUrl ?? null, name: user.name, // DB에서 가져온 실제 이름 companyId: user.companyId, // DB에서 가져온 실제 이름 - techCompanyId: (user as any).techCompanyId, // techVendor ID + techCompanyId: user.techCompanyId as number | undefined, // techVendor ID domain: user.domain, // DB에서 가져온 실제 이름 } }, }), - // CredentialsProvider({ - // name: 'Credentials', - // credentials: { - // email: { label: 'Email', type: 'text' }, - // code: { label: 'OTP code', type: 'text' }, - // }, - // async authorize(credentials, req) { - // const { email, code } = credentials ?? {} - - // // OTP 검증 - // const user = await verifyOtp(email ?? '', code ?? '') - // if (!user) { - // return null - // } - - // return { - // id: String(user.id ?? email ?? "dts"), - // email: user.email, - // imageUrl: user.imageUrl ?? null, - // name: user.name, // DB에서 가져온 실제 이름 - // companyId: user.companyId, // DB에서 가져온 실제 이름 - // domain: user.domain, // DB에서 가져온 실제 이름 - // } - // }, - // }), // 새로 추가할 ID/비밀번호 provider CredentialsProvider({ id: 'credentials-password', @@ -136,6 +124,22 @@ export const authOptions: NextAuthOptions = { return null; } } + }), + // SAML Provider 추가 (CredentialsProvider 기반) + 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 || '', // 선택적 + certificates: [process.env.SAML_IDP_CERT!] + }, + sp: { + entity_id: process.env.SAML_SP_ENTITY_ID!, + private_key: process.env.SAML_SP_PRIVATE_KEY || '', + certificate: process.env.SAML_SP_CERT || '', + assert_endpoint: process.env.SAML_SP_CALLBACK_URL || `${process.env.NEXTAUTH_URL}/api/saml/callback` + } }) ], // (3) session.strategy는 'jwt'가 되도록 선언 @@ -155,7 +159,7 @@ export const authOptions: NextAuthOptions = { token.companyId = user.companyId token.techCompanyId = user.techCompanyId token.domain = user.domain - ; (token as any).imageUrl = (user as any).imageUrl + token.imageUrl = user.imageUrl } return token }, @@ -168,7 +172,7 @@ export const authOptions: NextAuthOptions = { domain: token.domain as string, companyId: token.companyId as number, techCompanyId: token.techCompanyId as number, - image: (token as any).imageUrl ?? null + image: token.imageUrl ?? null } } return session @@ -185,8 +189,7 @@ export const authOptions: NextAuthOptions = { } // 그 외에는 baseUrl로 리다이렉트 return baseUrl; - } - + }, }, } diff --git a/app/api/auth/[...nextauth]/saml/provider.ts b/app/api/auth/[...nextauth]/saml/provider.ts new file mode 100644 index 00000000..8486a690 --- /dev/null +++ b/app/api/auth/[...nextauth]/saml/provider.ts @@ -0,0 +1,259 @@ +import CredentialsProvider from "next-auth/providers/credentials" +import { getOrCreateSAMLUser, validateSAMLUserData } from '@/lib/users/saml-service' +import { encode } from 'next-auth/jwt' +import type { User } from 'next-auth' +import type { SAMLUser } from './utils' +import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils' + +interface SAMLProviderOptions { + id: string + name: string + idp: { + sso_login_url: string + sso_logout_url: string + certificates: string[] + } + sp: { + entity_id: string + private_key: string + certificate: string + assert_endpoint: string + } +} + +export function SAMLProvider(options: SAMLProviderOptions) { + return CredentialsProvider({ + id: options.id, + name: options.name, + credentials: { + user: { + label: "User Data", + type: "text" + } + }, + async authorize(credentials) { + debugLog('🔍 SAMLProvider.authorize called with credentials:', credentials); + + try { + debugLog('🔍 Checking credentials.user:', { + hasCredentials: !!credentials, + hasUser: !!credentials?.user, + userType: typeof credentials?.user, + userValue: credentials?.user?.substring?.(0, 100) + '...' + }); + + if (!credentials?.user) { + debugError('No user data provided in credentials') + return null + } + + debugProcess('SAML Provider: Processing user data') + + // 사용자 데이터 파싱 (UTF-8 처리 개선) + const userDataString = credentials.user + debugLog('🔤 Raw user data string:', userDataString.substring(0, 200) + '...') + + let userData; + try { + userData = JSON.parse(userDataString); + debugSuccess('JSON parsing successful:', userData); + } catch (parseError) { + debugError('JSON parsing failed:', parseError); + debugError('Raw string that failed to parse:', userDataString); + return null; + } + + // 파싱된 데이터의 UTF-8 확인 + debugLog('🔤 Parsed user data UTF-8 check:', { + name: userData.name, + nameLength: userData.name?.length, + charCodes: userData.name ? [...userData.name].map(c => c.charCodeAt(0)) : [] + }) + + if (!userData.id || !userData.email) { + debugError('Invalid SAML user data:', userData) + return null + } + + debugSuccess('SAML Provider: User authenticated successfully', { + id: userData.id, + email: userData.email, + name: userData.name + }) + + // 🔥 SAML 사용자 데이터 검증 + debugProcess('Validating SAML user data structure...'); + const isValidData = await validateSAMLUserData(userData) + debugLog('Validation result:', isValidData); + if (!isValidData) { + debugError('Invalid SAML user data structure:', userData) + return null + } + + // 🔥 JIT (Just-In-Time) 사용자 생성 또는 조회 + debugProcess('Creating/getting SAML user from database...'); + const userCreateData = { + email: userData.email, + name: userData.name, + companyId: undefined, + techCompanyId: undefined, + domain: userData.domain + }; + debugLog('User create data:', userCreateData); + + const dbUser = await getOrCreateSAMLUser(userCreateData) + debugLog('Database user result:', dbUser); + + if (!dbUser) { + debugError('Failed to get or create SAML user') + return null + } + + // DB에서 가져온 실제 사용자 정보 반환 + const userResult = { + id: String(dbUser.id), // DB의 실제 ID + name: dbUser.name, // DB의 실제 이름 + email: dbUser.email, // DB의 실제 이메일 + companyId: dbUser.companyId, // DB의 실제 회사 ID + techCompanyId: dbUser.techCompanyId, // DB의 실제 기술회사 ID + domain: dbUser.domain, // DB의 실제 도메인 + imageUrl: dbUser.imageUrl, // DB의 실제 이미지 URL + } + + debugSuccess('SAML Provider: Returning user data to NextAuth:', userResult) + return userResult + } catch (error) { + debugError('SAML Provider: Authentication failed', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + errorType: typeof error, + credentials: credentials + }); + return null + } + } + }) +} + +// SAML 로그인 URL 생성 헬퍼 함수 +export function getSAMLLoginUrl(options: SAMLProviderOptions): string { + const params = new URLSearchParams({ + SAMLRequest: 'placeholder', // 실제로는 createAuthnRequest()로 생성 + RelayState: options.sp.assert_endpoint, + }) + + return `${options.idp.sso_login_url}?${params.toString()}` +} + +// SAML 설정 검증 +export function validateSAMLOptions(options: SAMLProviderOptions): boolean { + const required = [ + options.idp.sso_login_url, + options.sp.entity_id, + options.sp.assert_endpoint + ] + + return required.every(field => field && field.length > 0) +} + +// SAMLProvider의 authorize 함수를 직접 호출하기 위한 헬퍼 +export async function authenticateSAMLUser(userData: SAMLUser) { + debugLog('authenticateSAMLUser called with:', userData); + + try { + // SAMLProvider 대신 직접 로직 실행 (Provider 래퍼 없이) + debugProcess('SAML User Authentication: Processing user data') + + // 사용자 데이터 검증 + if (!userData.id || !userData.email) { + debugError('Invalid SAML user data:', userData) + return null + } + + debugSuccess('SAML User data validated successfully', { + id: userData.id, + email: userData.email, + name: userData.name + }) + + // 🔥 SAML 사용자 데이터 검증 + debugLog('Validating SAML user data structure...'); + const isValidData = await validateSAMLUserData(userData) + debugLog('Validation result:', isValidData); + if (!isValidData) { + debugError('Invalid SAML user data structure:', userData) + return null + } + + // 🔥 JIT (Just-In-Time) 사용자 생성 또는 조회 + debugLog('Creating/getting SAML user from database...'); + const userCreateData = { + email: userData.email, + name: userData.name, + companyId: undefined, + techCompanyId: undefined, + domain: userData.domain + }; + debugLog('User create data:', userCreateData); + + const dbUser = await getOrCreateSAMLUser(userCreateData) + debugLog('Database user result:', dbUser); + + if (!dbUser) { + debugError('Failed to get or create SAML user') + return null + } + + // DB에서 가져온 실제 사용자 정보 반환 + const userResult = { + id: String(dbUser.id), // DB의 실제 ID + name: dbUser.name, // DB의 실제 이름 + email: dbUser.email, // DB의 실제 이메일 + companyId: dbUser.companyId, // DB의 실제 회사 ID + techCompanyId: dbUser.techCompanyId, // DB의 실제 기술회사 ID + domain: dbUser.domain, // DB의 실제 도메인 + imageUrl: dbUser.imageUrl, // DB의 실제 이미지 URL + } + + debugSuccess('SAML User Authentication completed:', userResult) + return userResult; + + } catch (error) { + debugError('authenticateSAMLUser error:', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + userData + }); + return null; + } +} + +// NextAuth JWT 토큰 생성 헬퍼 +export async function createNextAuthToken(user: User): Promise<string> { + const token = { + id: user.id, + email: user.email, + name: user.name, + companyId: user.companyId, + techCompanyId: user.techCompanyId, + domain: user.domain, + imageUrl: user.imageUrl, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60) // 30일 + }; + + const secret = process.env.NEXTAUTH_SECRET!; + return await encode({ token, secret }); +} + +// NextAuth 세션 쿠키 이름 가져오기 +export function getSessionCookieName(): string { + // NEXTAUTH_URL이 HTTPS인 경우에만 __Secure- 접두사 사용 + const nextAuthUrl = process.env.NEXTAUTH_URL || ''; + const isHttps = nextAuthUrl.startsWith('https://'); + + return isHttps + ? '__Secure-next-auth.session-token' + : 'next-auth.session-token'; +} +
\ No newline at end of file diff --git a/app/api/auth/[...nextauth]/saml/utils.ts b/app/api/auth/[...nextauth]/saml/utils.ts new file mode 100644 index 00000000..a5bcfe7a --- /dev/null +++ b/app/api/auth/[...nextauth]/saml/utils.ts @@ -0,0 +1,485 @@ +import { SAML, ValidateInResponseTo } from "@node-saml/node-saml"; +import { + getIDPMetadata, + normalizeCertificate, +} from "@/lib/saml/idp-metadata"; +import { + getSPMetadata, +} from "@/lib/saml/sp-metadata"; +import { debugLog, debugError, debugSuccess, debugProcess, debugMock } from '@/lib/debug-utils'; + +export interface SAMLProfile { + nameID?: string; + nameIDFormat?: string; + attributes?: Record<string, string | string[]>; // 문자열 또는 배열 모두 지원 + [key: string]: unknown; +} + +export interface SAMLUser { + id: string; + email: string; + name: string; + companyId?: number; + techCompanyId?: number; + domain?: string; +} + +// SAML 설정 생성 (sync 함수) - 환경변수 기반으로 변경했음 +export function createSAMLConfig() { + console.log("⚙️ Creating SAML configuration..."); + + try { + const idpMetadata = getIDPMetadata(); + const spMetadata = getSPMetadata(); + + console.log("📋 IdP Metadata loaded:", { + entityId: idpMetadata.entityId, + ssoUrl: idpMetadata.ssoUrl, + organization: idpMetadata.organization, + wantAuthnRequestsSigned: idpMetadata.wantAuthnRequestsSigned, + }); + + console.log("📋 SP Metadata loaded:", { + entityId: spMetadata.entityId, + callbackUrl: spMetadata.callbackUrl, + authnRequestsSigned: spMetadata.authnRequestsSigned, + }); + + const config = { + callbackUrl: spMetadata.callbackUrl, + // IDP 메타데이터 기반 설정 + entryPoint: idpMetadata.ssoUrl, + // SP Entity ID + issuer: spMetadata.entityId, + // IDP 인증서 (정규화된 PEM 형식) + idpCert: normalizeCertificate(idpMetadata.certificate), + privateKey: process.env.SAML_SP_PRIVATE_KEY, + // IdP에서 요구하는 설정 + identifierFormat: idpMetadata.nameIdFormat, + signatureAlgorithm: "sha256" as const, + digestAlgorithm: "sha256", + // SP 메타데이터 설정 + decryptionPvk: process.env.SAML_SP_PRIVATE_KEY, + publicCert: process.env.SAML_SP_CERT, + // IdP 메타데이터 기반 설정 + wantAuthnResponseSigned: idpMetadata.wantAuthnRequestsSigned, + wantAssertionsSigned: spMetadata.wantAssertionsSigned, + validateInResponseTo: ValidateInResponseTo.never, + disableRequestedAuthnContext: true, + // HTTP-Redirect 바인딩 설정 + authnRequestBinding: undefined, // HTTP-Redirect (GET) 사용 (기본값) + skipRequestCompression: false, // Deflate 압축 사용 + // 추가 보안 설정 + acceptedClockSkewMs: 5000, // 5초 클럭 차이 허용 + forceAuthn: false, + // IDP Entity ID 설정 + idpIssuer: idpMetadata.entityId, + }; + + console.log("✅ SAML Config created:", { + callbackUrl: config.callbackUrl, + entryPoint: config.entryPoint, + issuer: config.issuer, + idpIssuer: config.idpIssuer, + identifierFormat: config.identifierFormat, + hasIdpCert: !!config.idpCert, + hasPrivateKey: !!config.privateKey, + hasPublicCert: !!config.publicCert, + wantAuthnResponseSigned: config.wantAuthnResponseSigned, + wantAssertionsSigned: config.wantAssertionsSigned, + }); + + return config; + } catch (error) { + console.error("💥 Failed to create SAML Config:", error); + throw error; + } +} + +// SAML AuthnRequest 생성 (서버 액션) +export async function createAuthnRequest(relayState?: string): Promise<string> { + "use server"; + + console.log("SSO STEP 2: Create AuthnRequest", { relayState }); + + // Mock IdP 모드 체크 + if (process.env.SAML_MOCKING_IDP === 'true') { + debugMock("Mock IdP mode enabled - simulating SAML response"); + return createMockSAMLFlow(relayState); + } + + try { + const config = createSAMLConfig(); + console.log("SAML Config ready for AuthnRequest generation"); + + const saml = new SAML(config); + console.log("SAML instance created, generating authorize URL..."); + + const startTime = Date.now(); + const authorizeUrl = await saml.getAuthorizeUrlAsync( + relayState || "", // RelayState - 원래 가려던 페이지 + undefined, // host + { + additionalParams: {}, + // additionalAuthorizeParams: {}, + } + ); + const endTime = Date.now(); + + // 🔍 SAML AuthnRequest 디코딩 및 분석 + try { + const urlObj = new URL(authorizeUrl); + const samlRequest = urlObj.searchParams.get("SAMLRequest"); + + if (samlRequest) { + console.log("SAML AuthnRequest 분석:"); + console.log("1️⃣ 원본 URL:", authorizeUrl); + console.log( + "2️⃣ URL 디코딩된 SAMLRequest:", + decodeURIComponent(samlRequest) + ); + + try { + // Base64 디코딩 + const base64DecodedBuffer = Buffer.from( + decodeURIComponent(samlRequest), + "base64" + ); + const base64DecodedString = base64DecodedBuffer.toString("utf-8"); + + // XML인지 확인 (XML은 '<'로 시작함) + if (base64DecodedString.trim().startsWith("<")) { + console.log("Base64 디코딩된 XML (압축 없음):"); + console.log("───────────────────────────────────"); + console.log(base64DecodedString); + console.log("───────────────────────────────────"); + + // XML 구조 분석 + const xmlLines = base64DecodedString + .split("\n") + .filter((line) => line.trim()); + console.log("XML 구조 요약:"); + xmlLines.forEach((line, index) => { + const trimmed = line.trim(); + if ( + trimmed.includes("<saml") || + trimmed.includes("<samlp") || + trimmed.includes("ID=") || + trimmed.includes("Destination=") + ) { + console.log(` ${index + 1}: ${trimmed}`); + } + }); + } else { + // XML이 아니면 Deflate 압축된 것으로 간주 + console.log( + "3️⃣ 압축된 바이너리 데이터 감지, Deflate 압축 해제 시도..." + ); + + try { + const zlib = await import("zlib"); + const decompressed = zlib + .inflateRawSync(base64DecodedBuffer) + .toString("utf-8"); + console.log("Deflate 압축 해제된 XML:"); + console.log("───────────────────────────────────"); + console.log(decompressed); + console.log("───────────────────────────────────"); + + // XML 구조 분석 + const xmlLines = decompressed + .split("\n") + .filter((line: string) => line.trim()); + console.log("XML 구조 요약:"); + xmlLines.forEach((line: string, index: number) => { + const trimmed = line.trim(); + if ( + trimmed.includes("<saml") || + trimmed.includes("<samlp") || + trimmed.includes("ID=") || + trimmed.includes("Destination=") || + trimmed.includes("Issuer>") || + trimmed.includes("AssertionConsumerServiceURL=") + ) { + console.log(` ${index + 1}: ${trimmed}`); + } + }); + + // 중요한 정보 추출 + const idMatch = decompressed.match(/ID="([^"]+)"/); + const destinationMatch = decompressed.match( + /Destination="([^"]+)"/ + ); + const issuerMatch = decompressed.match( + /<saml:Issuer[^>]*>([^<]+)<\/saml:Issuer>/ + ); + const acsMatch = decompressed.match( + /AssertionConsumerServiceURL="([^"]+)"/ + ); + + console.log("추출된 핵심 정보:"); + console.log(` Request ID: ${idMatch ? idMatch[1] : "없음"}`); + console.log( + ` Destination: ${ + destinationMatch ? destinationMatch[1] : "없음" + }` + ); + console.log( + ` Issuer: ${issuerMatch ? issuerMatch[1] : "없음"}` + ); + console.log( + ` Callback URL: ${acsMatch ? acsMatch[1] : "없음"}` + ); + } catch (inflateError) { + console.log("❌ Deflate 압축 해제 실패:", (inflateError as Error).message); + console.log( + " 원본 바이너리 데이터 (hex):", + base64DecodedBuffer.toString("hex").substring(0, 100) + "..." + ); + } + } + } catch (decodeError) { + console.log("❌ Base64 디코딩 실패:", (decodeError as Error).message); + } + } + } catch (analysisError) { + console.log("⚠️ SAML AuthnRequest 분석 중 오류:", (analysisError as Error).message); + } + + console.log("✅ SAML AuthnRequest URL generated:", { + url: authorizeUrl.substring(0, 100) + "...", + fullUrlLength: authorizeUrl.length, + processingTime: `${endTime - startTime}ms`, + timestamp: new Date().toISOString(), + }); + + return authorizeUrl; + } catch (error) { + console.error("💥 Failed to create SAML AuthnRequest:", { + error: error instanceof Error ? error.message : "Unknown error", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + throw error; + } +} + +// SAML Response 검증 및 파싱 (서버 액션) +export async function validateSAMLResponse( + samlResponse: string +): Promise<SAMLProfile> { + "use server"; + + console.log("🔍 Starting SAML Response validation..."); + console.log("📊 SAML Response info:", { + responseLength: samlResponse.length, + firstChars: samlResponse.substring(0, 50) + "...", + isBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(samlResponse), + timestamp: new Date().toISOString(), + }); + + // Mock IdP 모드 체크 + if (process.env.SAML_MOCKING_IDP === 'true') { + debugMock("Mock IdP mode - returning mock SAML profile"); + return createMockSAMLProfile(samlResponse); + } + + // 실제 SAML 검증 수행 (기본값) + console.log( + "🔐 Using Real SAML validation (SAML_MOCKING_IDP=false or not set)" + ); + + try { + console.log("⚙️ Creating SAML instance for validation..."); + const saml = new SAML(createSAMLConfig()); + console.log("✅ SAML instance created, starting validation..."); + + const startTime = Date.now(); + const result = await saml.validatePostResponseAsync({ + SAMLResponse: samlResponse, + }); + const endTime = Date.now(); + + // node-saml 라이브러리는 { profile, loggedOut } 형태로 반환 + const profile = result.profile; + if (!profile) { + throw new Error("No profile returned from SAML validation"); + } + + // SAMLProfile 형태로 변환 (타입 안전성 확보) + const samlProfile: SAMLProfile = { + nameID: profile.nameID as string | undefined, + nameIDFormat: profile.nameIDFormat as string | undefined, + attributes: profile.attributes as Record<string, string | string[]> | undefined, + }; + + console.log("✅ Real SAML Profile validated successfully:", { + nameID: samlProfile.nameID, + nameIDFormat: samlProfile.nameIDFormat, + attributeCount: Object.keys(samlProfile.attributes || {}).length, + attributes: Object.keys(samlProfile.attributes || {}), + processingTime: `${endTime - startTime}ms`, + timestamp: new Date().toISOString(), + }); + + return samlProfile; + } catch (error) { + console.error("❌ Real SAML validation error:", { + error: error instanceof Error ? error.message : "Unknown error", + stack: error instanceof Error ? error.stack : undefined, + samlResponseLength: samlResponse.length, + timestamp: new Date().toISOString(), + }); + throw new Error( + `SAML validation failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } +} + +// SAML Profile을 User 객체로 변환 (sync 함수) +export function mapSAMLProfileToUser(profile: SAMLProfile): SAMLUser { + console.log("🔄 Mapping SAML profile to user:", { + nameID: profile.nameID, + attributes: profile.attributes, + }); + + // SAML attributes는 문자열 또는 배열 형태일 수 있음 + const extractAttributeValue = (key: string): string | undefined => { + const value = profile.attributes?.[key]; + if (Array.isArray(value)) { + return value.length > 0 ? value[0] : undefined; + } + return typeof value === 'string' ? value : undefined; + }; + + // 기본적으로 nameID를 사용하거나 attributes에서 추출 + const id = profile.nameID || extractAttributeValue('id') || extractAttributeValue('sub'); + const email = extractAttributeValue('email') || extractAttributeValue('emailAddress'); + const name = extractAttributeValue('name') || extractAttributeValue('displayName') || extractAttributeValue('cn'); + + // 필수 필드 검증 + if (!id) { + throw new Error('SAML profile missing required field: id (nameID)'); + } + if (!email) { + throw new Error('SAML profile missing required field: email'); + } + if (!name) { + throw new Error('SAML profile missing required field: name'); + } + + // UTF-8 문자열 정규화 및 검증 + const normalizedName = name.normalize("NFC").trim(); + + // 한글이 깨진 경우 감지 및 로그 + const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(normalizedName); + if (hasInvalidChars) { + console.warn("⚠️ Invalid UTF-8 characters detected in name:", { + originalName: name, + normalizedName, + charCodes: [...normalizedName].map((c) => c.charCodeAt(0)), + hexDump: [...normalizedName] + .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0")) + .join(""), + }); + } + + // 회사 정보는 SSO 로그인 시 없음 (evcp 도메인) + const companyId = undefined; + const techCompanyId = undefined; + const domain = 'evcp'; + + const user: SAMLUser = { + id, + email, + name: normalizedName, + companyId, + techCompanyId, + domain, + }; + + console.log("👤 Mapped user object:", JSON.stringify(user)); + + return user; +} + +// Mock SAML 플로우 생성 (테스트용) +function createMockSAMLFlow(relayState?: string): string { + debugMock("Creating mock SAML flow...", { relayState }); + + // Mock 모드에서는 Mock IdP 엔드포인트로 리다이렉션 + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + let mockIdpUrl = `${baseUrl}/api/auth/saml/mock-idp`; + + // RelayState가 있으면 URL 파라미터로 전달 + if (relayState) { + mockIdpUrl += `?RelayState=${encodeURIComponent(relayState)}`; + } + + debugMock("Mock SAML Flow - redirecting to Mock IdP:", mockIdpUrl); + + return mockIdpUrl; +} + +// Mock SAML Profile 생성 (테스트용) +function createMockSAMLProfile(samlResponse: string): SAMLProfile { + console.log("🎭 Creating mock SAML profile from response..."); + + try { + // SAML Response가 우리가 생성한 Mock인지 확인 + const decodedXML = Buffer.from(samlResponse, 'base64').toString('utf-8'); + const isMockResponse = decodedXML.includes('MockIdP'); + + if (!isMockResponse) { + console.warn("⚠️ Mock mode enabled but received non-mock SAML Response"); + } + + console.log("🎭 Mock SAML XML preview:", decodedXML.substring(0, 200) + "..."); + } catch (error) { + console.warn("⚠️ Could not decode SAML Response for mock analysis:", (error as Error).message); + } + + // Mock SAML Profile 반환 (실제 SAML Response와 일치하도록 문자열 형태) + const mockProfile: SAMLProfile = { + nameID: "testuser@samsung.com", + nameIDFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress", + attributes: { + email: "testuser@samsung.com", + name: "테스트 사용자", + displayName: "Test User Samsung", + // 추가 테스트 속성들 + department: "개발팀", + employeeId: "TEST001", + mobile: "010-1234-5678" + } + }; + + console.log("🎭 Mock SAML Profile created:", { + nameID: mockProfile.nameID, + nameIDFormat: mockProfile.nameIDFormat, + attributeCount: Object.keys(mockProfile.attributes || {}).length, + attributes: Object.keys(mockProfile.attributes || {}), + timestamp: new Date().toISOString(), + }); + + return mockProfile; +} + +// SAML 로그아웃 URL 생성 (서버 액션) +// 로그아웃 지원 안함. 일단 구조만 유사하게 작성해둠. +export async function createLogoutRequest(nameID: string): Promise<string> { + "use server"; + + const saml = new SAML(createSAMLConfig()); + // Profile 객체 형태로 전달 + const profile = { nameID }; + return await saml.getLogoutUrlAsync( + profile, + "", // RelayState + { + nameIDFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + } + ); +} diff --git a/app/api/auth/saml/authn-request/route.ts b/app/api/auth/saml/authn-request/route.ts new file mode 100644 index 00000000..6544a765 --- /dev/null +++ b/app/api/auth/saml/authn-request/route.ts @@ -0,0 +1,98 @@ +/** + * SAML 2.0 SSO AuthnRequest 생성 API + * + * 역할: + * - 프론트엔드에서 SAML 로그인 URL을 요청할 때 사용 + * - SAML AuthnRequest를 생성하고 IdP 로그인 URL 반환 + * - Mock 모드 지원으로 개발/테스트 환경에서 시뮬레이션 가능 + * + * 플로우: + * 1. 사용자가 "Knox SSO로 로그인" 버튼 클릭 + * 2. 프론트엔드에서 이 API 호출 + * 3. SAML AuthnRequest URL 생성 후 반환 + * 4. 프론트엔드에서 해당 URL로 리다이렉트 + * 5. IdP에서 인증 후 /api/saml/callback으로 SAML Response 전송 + */ + +import { NextResponse } from 'next/server' +import { createAuthnRequest } from '../../[...nextauth]/saml/utils' +import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils' + +// SAML 환경변수 상태 체크 +function validateSAMLEnvironment() { + const samlEnvironment = { + NODE_ENV: process.env.NODE_ENV, + SAML_MOCKING_IDP: process.env.SAML_MOCKING_IDP, + NEXTAUTH_URL: process.env.NEXTAUTH_URL, + SAML_SP_PRIVATE_KEY: process.env.SAML_SP_PRIVATE_KEY ? '✅ Set' : '❌ Missing', + SAML_SP_CERT: process.env.SAML_SP_CERT ? '✅ Set' : '❌ Missing', + } + + debugLog('📊 SAML Environment check:', JSON.stringify(samlEnvironment, null, 2)) + + // 필수 환경변수 검증 + const missingVars = [] + if (!process.env.NEXTAUTH_URL) missingVars.push('NEXTAUTH_URL') + + // 키 없어도 구현 가능해서 주석 처리함. + // if (!process.env.SAML_SP_PRIVATE_KEY) missingVars.push('SAML_SP_PRIVATE_KEY') + // if (!process.env.SAML_SP_CERT) missingVars.push('SAML_SP_CERT') + + if (missingVars.length > 0) { + throw new Error(`Missing required SAML environment variables: ${missingVars.join(', ')}`) + } + + return samlEnvironment +} + +/** + * SAML AuthnRequest URL 생성 엔드포인트 + * + * @returns {JSON} { loginUrl: string, success: boolean, isThisMocking?: boolean } + */ +export async function GET(request: Request) { + debugProcess('🚀 SAML AuthnRequest API started') + + try { + // URL에서 RelayState 매개변수 추출 + const url = new URL(request.url) + const relayState = url.searchParams.get('relayState') + + debugLog('RelayState parameter:', relayState) + + // 환경변수 검증 + const environment = validateSAMLEnvironment() + + debugProcess('SSO STEP 1: Create AuthnRequest') + + const startTime = Date.now() + const loginUrl = await createAuthnRequest(relayState || undefined) + const endTime = Date.now() + + debugSuccess('SAML AuthnRequest created successfully:', { + url: loginUrl.substring(0, 100) + '...', + urlLength: loginUrl.length, + processingTime: `${endTime - startTime}ms`, + mockMode: environment.SAML_MOCKING_IDP === 'true', + timestamp: new Date().toISOString() + }) + + return NextResponse.json({ + loginUrl, + success: true, + isThisMocking: environment.SAML_MOCKING_IDP === 'true' + }) + } catch (error) { + debugError('Failed to create SAML AuthnRequest:', { + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString() + }) + + return NextResponse.json({ + error: 'Failed to create SAML AuthnRequest', + details: error instanceof Error ? error.message : 'Unknown error', + success: false + }, { status: 500 }) + } +} diff --git a/app/api/auth/saml/mock-idp/route.ts b/app/api/auth/saml/mock-idp/route.ts new file mode 100644 index 00000000..eccb6035 --- /dev/null +++ b/app/api/auth/saml/mock-idp/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from 'next/server' + +// Mock IdP 엔드포인트 - SAML Response HTML 폼 반환 +export async function GET(request: NextRequest) { + try { + // RelayState 파라미터 추출 + const url = new URL(request.url) + const relayState = url.searchParams.get('RelayState') || 'mock_test' + + console.log('🎭 Mock IdP endpoint accessed', { relayState }); + + // Mock SAML Response 데이터 (실제 형태와 일치하도록 문자열 형태) + const mockSAMLResponseData = { + nameID: "testuser@samsung.com", + nameIDFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress", + attributes: { + email: "testuser@samsung.com", + name: "홍길동", + } + }; + + // Mock XML SAML Response 생성 + const mockXML = ` + <samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" + ID="_mock_response_${Date.now()}" + Version="2.0" + IssueInstant="${new Date().toISOString()}"> + <saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">MockIdP</saml:Issuer> + <samlp:Status> + <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/> + </samlp:Status> + <saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" + ID="_mock_assertion_${Date.now()}" + Version="2.0" + IssueInstant="${new Date().toISOString()}"> + <saml:Issuer>MockIdP</saml:Issuer> + <saml:Subject> + <saml:NameID Format="${mockSAMLResponseData.nameIDFormat}">${mockSAMLResponseData.nameID}</saml:NameID> + </saml:Subject> + <saml:AttributeStatement> + <saml:Attribute Name="email"> + <saml:AttributeValue>${mockSAMLResponseData.attributes.email}</saml:AttributeValue> + </saml:Attribute> + <saml:Attribute Name="name"> + <saml:AttributeValue>${mockSAMLResponseData.attributes.name}</saml:AttributeValue> + </saml:Attribute> + </saml:AttributeStatement> + </saml:Assertion> + </samlp:Response> + `.trim(); + + // Base64 인코딩 + const encodedSAMLResponse = Buffer.from(mockXML, 'utf-8').toString('base64'); + + console.log("🎭 Mock SAML Response created:", { + nameID: mockSAMLResponseData.nameID, + email: mockSAMLResponseData.attributes.email, + name: mockSAMLResponseData.attributes.name, + encodedLength: encodedSAMLResponse.length + }); + + // 콜백 URL로 POST 요청을 시뮬레이션하는 HTML 폼 반환 + const callbackUrl = `${process.env.NEXTAUTH_URL}/api/saml/callback`; // process.env.SAML_SP_CALLBACK_URL + + const mockFormHTML = ` + <!DOCTYPE html> + <html> + <head> + <title>Mock SAML IdP</title> + <style> + body { font-family: Arial, sans-serif; padding: 20px; background-color: #f5f5f5; } + .container { max-width: 600px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } + .header { color: #e94560; text-align: center; margin-bottom: 20px; } + .info { background: #e3f2fd; padding: 15px; border-radius: 4px; margin-bottom: 20px; } + .button { background: #1976d2; color: white; padding: 12px 24px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; width: 100%; } + .button:hover { background: #1565c0; } + .details { font-size: 14px; color: #666; margin-top: 15px; } + .countdown { font-weight: bold; color: #1976d2; } + </style> + </head> + <body> + <div class="container"> + <h1 class="header">🎭 Mock SAML IdP</h1> + <div class="info"> + <strong>테스트 모드:</strong> 실제 IdP 대신 Mock 응답을 사용합니다.<br> + <em>실제 데이터 형태와 일치하도록 attributes를 문자열로 전송합니다.</em> + </div> + <form id="mockForm" method="POST" action="${callbackUrl}"> + <input type="hidden" name="SAMLResponse" value="${encodedSAMLResponse}" /> + <input type="hidden" name="RelayState" value="${relayState}" /> + <button type="submit" class="button">Continue with Mock Login</button> + </form> + <div class="details"> + <p><strong>테스트 사용자 정보:</strong></p> + <ul> + <li>이메일: ${mockSAMLResponseData.attributes.email}</li> + <li>이름: ${mockSAMLResponseData.attributes.name}</li> + </ul> + <p><span class="countdown" id="countdown">5</span>초 후 자동으로 로그인을 진행합니다...</p> + <p><em>프로덕션 환경에서는 SAML_MOCKING_IDP=false로 설정하세요.</em></p> + </div> + </div> + <script> + let countdown = 5; + const countdownEl = document.getElementById('countdown'); + + const timer = setInterval(() => { + countdown--; + countdownEl.textContent = countdown; + + if (countdown <= 0) { + clearInterval(timer); + console.log('🎭 Auto-submitting mock SAML form...'); + document.getElementById('mockForm').submit(); + } + }, 1000); + + // 사용자가 버튼을 클릭하면 타이머 취소 + document.getElementById('mockForm').addEventListener('submit', () => { + clearInterval(timer); + }); + </script> + </body> + </html> + `; + + return new NextResponse(mockFormHTML, { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + } + }); + + } catch (error) { + console.error('💥 Mock IdP error:', error); + return NextResponse.json({ + error: 'Mock IdP failed', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }); + } +}
\ No newline at end of file diff --git a/app/api/saml/callback/route.ts b/app/api/saml/callback/route.ts new file mode 100644 index 00000000..c0290e71 --- /dev/null +++ b/app/api/saml/callback/route.ts @@ -0,0 +1,209 @@ +import { NextRequest, NextResponse } from 'next/server' +import { validateSAMLResponse, mapSAMLProfileToUser } from '../../auth/[...nextauth]/saml/utils' +import { + authenticateSAMLUser, + createNextAuthToken, + getSessionCookieName +} from '../../auth/[...nextauth]/saml/provider' +import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils' + +// IdP가 인증처리 후 POST 요청을 콜백 URL로 날려준다. +// 이 라우트가 그 요청을 받아준다. +// GET 요청시 SP 메타데이터를 반환해주는데, 이건 필요 없으면 지우면 된다. + +export async function POST(request: NextRequest) { + // 안전한 baseUrl - 모든 리다이렉트에서 사용 + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + + try { + const isMockMode = process.env.SAML_MOCKING_IDP === 'true'; + debugProcess(`SAML Callback received at /api/saml/callback ${isMockMode ? '(🎭 Mock Mode)' : ''}`) + debugLog('Request info:', { + nextUrl: request.nextUrl?.toString(), + mockMode: isMockMode, + baseUrl: baseUrl + }) + + // FormData에서 SAML Response 추출 + const formData = await request.formData() + const samlResponse = formData.get('SAMLResponse') as string + const relayState = formData.get('RelayState') as string + + debugLog('SAML Response received:', { + hasResponse: !!samlResponse, + relayState: relayState || 'none', + responseLength: samlResponse?.length || 0 + }) + + // 🔍 SAML Response 디코딩 및 분석 + if (samlResponse) { + try { + debugLog('🔍 SAML Response 분석:') + debugLog('원본 SAMLResponse (일부):', samlResponse.substring(0, 100) + '...') + + try { + // Base64 디코딩 시도 + const base64Decoded = Buffer.from(samlResponse, 'base64').toString('utf-8') + debugLog('Base64 디코딩된 XML:') + debugLog('───────────────────────────────────') + debugLog(base64Decoded) + debugLog('───────────────────────────────────') + + // XML 구조 분석 + const xmlLines = base64Decoded.split('\n').filter(line => line.trim()) + debugLog('XML 구조 요약:') + xmlLines.forEach((line, index) => { + const trimmed = line.trim() + if (trimmed.includes('<saml') || trimmed.includes('<samlp') || + trimmed.includes('ID=') || trimmed.includes('InResponseTo=') || + trimmed.includes('<saml:Assertion') || trimmed.includes('<saml:Subject') || + trimmed.includes('<saml:NameID') || trimmed.includes('<saml:Attribute')) { + debugLog(` ${index + 1}: ${trimmed}`) + } + }) + + } catch (decodeError) { + debugError('Base64 디코딩 실패:', (decodeError as Error).message) + debugLog('SAMLResponse가 다른 형식으로 인코딩되었을 수 있습니다.') + } + } catch (analysisError) { + debugError('SAML Response 분석 중 오류:', (analysisError as Error).message) + } + } + + if (!samlResponse) { + debugError('No SAML Response found in request') + return NextResponse.redirect(new URL('/ko/evcp', baseUrl), 303) + } + + // SAML Response 검증 및 파싱 + let samlProfile + try { + debugProcess('Starting SAML Response validation...') + samlProfile = await validateSAMLResponse(samlResponse) + debugSuccess('SAML Response validated successfully:', { + nameId: samlProfile.nameID, + attributes: Object.keys(samlProfile.attributes || {}) + }) + } catch (error) { + debugError('SAML Response validation failed:', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + samlResponseLength: samlResponse.length, + timestamp: new Date().toISOString() + }) + + // SAML 검증 실패 시 evcp 페이지로 리다이렉트 + return NextResponse.redirect(new URL('/ko/evcp', baseUrl), 303) + } + + // SAML 프로필을 사용자 객체로 매핑 + const mappedUser = mapSAMLProfileToUser(samlProfile) + debugLog('Mapped user:', { + id: mappedUser.id, + email: mappedUser.email, + name: mappedUser.name, + }) + + // SAMLProvider를 통한 사용자 인증 (JIT 포함) + debugProcess('Authenticating user through SAMLProvider...') + const authenticatedUser = await authenticateSAMLUser(mappedUser) + + debugLog('Authenticated user:', authenticatedUser) + + if (!authenticatedUser) { + debugError('SAML user authentication failed') + return NextResponse.redirect(new URL('/ko/evcp', baseUrl), 303) + } + + debugSuccess('User authenticated successfully:', { + id: authenticatedUser.id, + email: authenticatedUser.email, + name: authenticatedUser.name + }) + + // NextAuth JWT 토큰 생성 + const encodedToken = await createNextAuthToken(authenticatedUser) + + // NextAuth 세션 쿠키 설정 + const cookieName = getSessionCookieName() + + // RelayState를 활용한 스마트 리다이렉트 + let redirectPath = '/ko/evcp' // 기본값 + + // RelayState 안전 처리 - null, 'null', undefined, 빈 문자열 모두 처리 + const isValidRelayState = relayState && + relayState !== 'null' && + relayState !== 'undefined' && + relayState.trim() !== '' && + typeof relayState === 'string'; + + if (isValidRelayState) { + debugLog('Using RelayState for redirect:', relayState) + // RelayState가 유효한 경로인지 확인 + if (relayState.startsWith('/') && !relayState.includes('//')) { + redirectPath = relayState + } else { + debugLog('Invalid RelayState format, using default:', relayState) + } + } else { + debugLog('No valid RelayState, using default path. RelayState value:', relayState) + } + + // URL 생성 전 최종 안전성 검사 + if (!redirectPath || typeof redirectPath !== 'string' || redirectPath.trim() === '') { + redirectPath = '/ko/evcp' // 안전한 기본값으로 재설정 + debugLog('redirectPath was invalid, reset to default:', redirectPath) + } + + debugLog('Final redirect path:', redirectPath) + + // POST 요청에 대한 응답으로는 303 See Other를 사용하여 GET으로 강제 변환 + const response = NextResponse.redirect(new URL(redirectPath, baseUrl), 303) + + // NEXTAUTH_URL이 HTTPS인 경우에만 secure 쿠키 사용 + const isHttps = baseUrl.startsWith('https://'); + + response.cookies.set(cookieName, encodedToken, { + httpOnly: true, + secure: isHttps, + sameSite: 'lax', + path: '/', + maxAge: 30 * 24 * 60 * 60 // 30일 + }) + + debugSuccess('SAML login successful, session created for:', authenticatedUser.email) + debugProcess('Authentication flow completed through NextAuth standard pipeline') + return response + + } catch (error) { + debugError('SAML Callback processing failed:', error) + return NextResponse.redirect(new URL('/ko/evcp', baseUrl), 303) + } +} + +// GET 요청에 대한 메타데이터 반환 +// 필요 없으면 굳이 노출할 필요 없음. 다만 SOAP WDSL과 유사하게 이렇게 스펙을 제공하는 건 SAML 2.0 구현 표준임. 필요하다면 IdP에게만 제공되도록 추가 처리하자. +export async function GET() { + try { + // GET 요청은 SP 메타데이터 반환 (정적 파일) + const fs = await import('fs/promises') + const path = await import('path') + + const metadataPath = path.join(process.cwd(), 'config/saml/sp_metadata.xml') + const metadata = await fs.readFile(metadataPath, 'utf-8') + + return new NextResponse(metadata, { + headers: { + 'Content-Type': 'application/xml', + 'Cache-Control': 'public, max-age=36000' // 10시간 캐시 + } + }) + } catch (error) { + debugError('Error reading SP metadata file:', error) + return NextResponse.json({ + error: 'Failed to read SP metadata file', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }) + } +}
\ No newline at end of file diff --git a/components/login/login-form copy 2.tsx b/components/login/login-form copy 2.tsx deleted file mode 100644 index d5ac01b9..00000000 --- a/components/login/login-form copy 2.tsx +++ /dev/null @@ -1,470 +0,0 @@ -'use client'; - -import { useState, useEffect } from "react"; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" -import { Input } from "@/components/ui/input" -import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship, InfoIcon, ArrowRight } from "lucide-react"; -import { useToast } from "@/hooks/use-toast"; -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu" -import { useTranslation } from '@/i18n/client' -import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation'; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from "@/components/ui/input-otp" -import { signIn } from 'next-auth/react'; -import { sendOtpAction } from "@/lib/users/send-otp"; -import { verifyTokenAction } from "@/lib/users/verifyToken"; -import { buttonVariants } from "@/components/ui/button" -import Link from "next/link" -import Image from 'next/image'; -import { - Alert, - AlertDescription, - AlertTitle, -} from "@/components/ui/alert"; - -export function LoginForm({ - className, - ...props -}: React.ComponentProps<"div">) { - - const params = useParams() || {}; - const pathname = usePathname() || ''; - const router = useRouter(); - const searchParams = useSearchParams(); - const token = searchParams?.get('token') || null; - const [showCredentialsForm, setShowCredentialsForm] = useState(false); - // 새로운 상태: 업체 등록 안내 표시 여부 - const [showVendorRegistrationInfo, setShowVendorRegistrationInfo] = useState(false); - - const lng = params.lng as string; - const { t, i18n } = useTranslation(lng, 'login'); - - const { toast } = useToast(); - - const handleChangeLanguage = (lang: string) => { - const segments = pathname.split('/'); - segments[1] = lang; - router.push(segments.join('/')); - }; - - const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english'); - - const [email, setEmail] = useState(''); - const [otpSent, setOtpSent] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [otp, setOtp] = useState(''); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - - // 업체 등록 페이지로 이동하는 함수 - const goToVendorRegistration = () => { - router.push(`/${lng}/partners/repository`); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - try { - const result = await sendOtpAction(email, lng); - - if (result.success) { - setOtpSent(true); - toast({ - title: t('otpSentTitle'), - description: t('otpSentMessage'), - }); - } else { - // Handle specific error types - let errorMessage = t('defaultErrorMessage'); - - // 업체 미등록 사용자 에러 처리 - if (result.error === 'userNotFound' || result.error === 'vendorNotRegistered') { - setShowVendorRegistrationInfo(true); - errorMessage = t('vendorNotRegistered') || '등록된 업체가 아닙니다. 먼저 업체 등록 신청을 해주세요.'; - } - - toast({ - title: t('errorTitle'), - description: result.message || errorMessage, - variant: 'destructive', - }); - } - } catch (error) { - // This will catch network errors or other unexpected issues - console.error(error); - toast({ - title: t('errorTitle'), - description: t('networkErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - - async function handleOtpSubmit(e: React.FormEvent) { - e.preventDefault(); - setIsLoading(true); - - try { - // next-auth의 Credentials Provider로 로그인 시도 - const result = await signIn('credentials', { - email, - code: otp, - redirect: false, // 커스텀 처리 위해 redirect: false - }); - - if (result?.ok) { - // 토스트 메시지 표시 - toast({ - title: t('loginSuccess'), - description: t('youAreLoggedIn'), - }); - - router.push(`/${lng}/partners/dashboard`); - } else { - // 로그인 실패 시 에러 메시지에 업체 등록 관련 정보 포함 - if (result?.error === 'vendorNotRegistered') { - setShowVendorRegistrationInfo(true); - toast({ - title: t('errorTitle'), - description: t('vendorNotRegistered') || '등록된 업체가 아닙니다. 먼저 업체 등록 신청을 해주세요.', - variant: 'destructive', - }); - } else { - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } - } - } catch (error) { - console.error('Login error:', error); - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - } - - // 새로운 로그인 처리 함수 추가 - const handleCredentialsLogin = async () => { - if (!username || !password) { - toast({ - title: t('errorTitle'), - description: t('credentialsRequired'), - variant: 'destructive', - }); - return; - } - - setIsLoading(true); - - try { - // next-auth의 다른 credentials provider로 로그인 시도 - const result = await signIn('credentials-password', { - username, - password, - redirect: false, - }); - - if (result?.ok) { - toast({ - title: t('loginSuccess'), - description: t('youAreLoggedIn'), - }); - - router.push(`/${lng}/partners/dashboard`); - } else { - // 로그인 실패 시 업체 등록 관련 정보 표시 여부 결정 - if (result?.error === 'vendorNotRegistered') { - setShowVendorRegistrationInfo(true); - toast({ - title: t('errorTitle'), - description: t('vendorNotRegistered') || '등록된 업체가 아닙니다. 먼저 업체 등록 신청을 해주세요.', - variant: 'destructive', - }); - } else { - toast({ - title: t('errorTitle'), - description: t('invalidCredentials'), - variant: 'destructive', - }); - } - } - } catch (error) { - console.error('Login error:', error); - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - const verifyToken = async () => { - if (!token) return; - setIsLoading(true); - - try { - const data = await verifyTokenAction(token); - - if (data.valid) { - setOtpSent(true); - setEmail(data.email ?? ''); - } else { - toast({ - title: t('errorTitle'), - description: t('invalidToken'), - variant: 'destructive', - }); - } - } catch (error) { - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - verifyToken(); - }, [token, toast, t]); - - return ( - <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> - {/* Left Content */} - <div className="flex flex-col w-full h-screen lg:p-2"> - {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */} - <div className="flex items-center justify-between p-4"> - <div className="flex items-center space-x-2"> - <Ship className="w-4 h-4" /> - <span className="text-md font-bold">eVCP</span> - </div> - - {/* 업체 등록 신청 버튼 - 가시성 향상을 위해 variant 변경 */} - <Link - href={`/${lng}/partners/repository`} - className={cn(buttonVariants({ variant: "outline" }), "border-blue-500 text-blue-600 hover:bg-blue-50")} - > - <InfoIcon className="w-4 h-4 mr-2" /> - {t('registerVendor') || '업체 등록 신청'} - </Link> - </div> - - {/* Content section that occupies remaining space, centered vertically */} - <div className="flex-1 flex items-center justify-center"> - {/* Your form container */} - <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> - {/* 업체 등록 안내 알림 - 특정 상황에서만 표시 */} - {showVendorRegistrationInfo && ( - <Alert className="border-blue-500 bg-blue-50"> - <InfoIcon className="h-4 w-4 text-blue-600" /> - <AlertTitle className="text-blue-700"> - {t('vendorRegistrationRequired') || '업체 등록이 필요합니다'} - </AlertTitle> - <AlertDescription className="text-blue-600"> - {t('vendorRegistrationMessage') || '로그인하시려면 먼저 업체 등록이 필요합니다. 아래 버튼을 클릭하여 등록을 진행해주세요.'} - </AlertDescription> - <Button - onClick={goToVendorRegistration} - className="mt-2 w-full bg-blue-600 hover:bg-blue-700 text-white" - > - {t('goToVendorRegistration') || '업체 등록 신청하기'} - <ArrowRight className="ml-2 h-4 w-4" /> - </Button> - </Alert> - )} - - <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> - <div className="flex flex-col gap-6"> - <div className="flex flex-col items-center text-center"> - <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> - - {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */} - <p className="text-sm text-muted-foreground mt-2"> - {t('loginDescription') || '등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} - </p> - </div> - - {/* S-chips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} - {!showCredentialsForm && ( - <> - <div className="grid gap-2"> - <Input - id="email" - type="email" - placeholder={t('email')} - required - className="h-10" - value={email} - onChange={(e) => setEmail(e.target.value)} - /> - </div> - <Button - type="submit" - className="w-full" - variant="samsung" - disabled={isLoading} - onClick={handleSubmit} - > - {isLoading ? t('sending') : t('ContinueWithEmail')} - </Button> - - {/* 구분선과 "Or continue with" 섹션 추가 */} - <div className="relative"> - <div className="absolute inset-0 flex items-center"> - <span className="w-full border-t"></span> - </div> - <div className="relative flex justify-center text-xs uppercase"> - <span className="bg-background px-2 text-muted-foreground"> - {t('orContinueWith')} - </span> - </div> - </div> - - {/* S-chips 로그인 버튼 */} - <Button - type="button" - className="w-full" - onClick={() => setShowCredentialsForm(true)} - > - S-Gips로 로그인하기 - </Button> - - {/* 업체 등록 안내 링크 추가 */} - <Button - type="button" - variant="link" - className="text-blue-600 hover:text-blue-800" - onClick={goToVendorRegistration} - > - {t('newVendor') || '신규 업체이신가요? 여기서 등록하세요'} - </Button> - </> - )} - - {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */} - {showCredentialsForm && ( - <> - <div className="grid gap-4"> - <Input - id="username" - type="text" - placeholder="S-chips ID" - className="h-10" - value={username} - onChange={(e) => setUsername(e.target.value)} - /> - <Input - id="password" - type="password" - placeholder="비밀번호" - className="h-10" - value={password} - onChange={(e) => setPassword(e.target.value)} - /> - <Button - type="button" - className="w-full" - variant="samsung" - onClick={handleCredentialsLogin} - disabled={isLoading} - > - {isLoading ? "로그인 중..." : "로그인"} - </Button> - - {/* 뒤로 가기 버튼 */} - <Button - type="button" - variant="ghost" - className="w-full text-sm" - onClick={() => setShowCredentialsForm(false)} - > - 이메일로 로그인하기 - </Button> - - {/* 업체 등록 안내 링크 추가 */} - <Button - type="button" - variant="link" - className="text-blue-600 hover:text-blue-800" - onClick={goToVendorRegistration} - > - {t('newVendor') || '신규 업체이신가요? 여기서 등록하세요'} - </Button> - </div> - </> - )} - - <div className="text-center text-sm mx-auto"> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="flex items-center gap-2"> - <GlobeIcon className="h-4 w-4" /> - <span>{currentLanguageText}</span> - <ChevronDownIcon className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuRadioGroup - value={i18n.language} - onValueChange={(value) => handleChangeLanguage(value)} - > - <DropdownMenuRadioItem value="en"> - {t('languages.english')} - </DropdownMenuRadioItem> - <DropdownMenuRadioItem value="ko"> - {t('languages.korean')} - </DropdownMenuRadioItem> - </DropdownMenuRadioGroup> - </DropdownMenuContent> - </DropdownMenu> - </div> - </div> - </form> - - <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> - {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')} - <a href="#">{t('privacyPolicy')}</a>. - </div> - </div> - </div> - </div> - - {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} - <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex"> - {/* Image 컴포넌트로 대체 */} - <div className="absolute inset-0"> - <Image - src="/images/02.jpg" - alt="Background image" - fill - priority - sizes="(max-width: 1024px) 100vw, 50vw" - className="object-cover" - /> - </div> - <div className="relative z-10 mt-auto"> - <blockquote className="space-y-2"> - <p className="text-sm">“{t("blockquote")}”</p> - {/* <footer className="text-sm">SHI</footer> */} - </blockquote> - </div> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/components/login/login-form copy.tsx b/components/login/login-form copy.tsx deleted file mode 100644 index ef9eba10..00000000 --- a/components/login/login-form copy.tsx +++ /dev/null @@ -1,468 +0,0 @@ -'use client'; - -import { useState, useEffect } from "react"; -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" -import { Input } from "@/components/ui/input" -import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship, InfoIcon } from "lucide-react"; -import { useToast } from "@/hooks/use-toast"; -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu" -import { useTranslation } from '@/i18n/client' -import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation'; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from "@/components/ui/input-otp" -import { signIn } from 'next-auth/react'; -import { sendOtpAction } from "@/lib/users/send-otp"; -import { verifyTokenAction } from "@/lib/users/verifyToken"; -import { buttonVariants } from "@/components/ui/button" -import Link from "next/link" -import Image from 'next/image'; // 추가: Image 컴포넌트 import - -export function LoginForm({ - className, - ...props -}: React.ComponentProps<"div">) { - - const params = useParams() || {}; - const pathname = usePathname() || ''; - const router = useRouter(); - const searchParams = useSearchParams(); - const token = searchParams?.get('token') || null; - const [showCredentialsForm, setShowCredentialsForm] = useState(false); - - - const lng = params.lng as string; - const { t, i18n } = useTranslation(lng, 'login'); - - const { toast } = useToast(); - - const handleChangeLanguage = (lang: string) => { - const segments = pathname.split('/'); - segments[1] = lang; - router.push(segments.join('/')); - }; - - const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english'); - - const [email, setEmail] = useState(''); - const [otpSent, setOtpSent] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [otp, setOtp] = useState(''); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - - const goToVendorRegistration = () => { - router.push(`/${lng}/partners/repository`); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - try { - const result = await sendOtpAction(email, lng); - - if (result.success) { - setOtpSent(true); - toast({ - title: t('otpSentTitle'), - description: t('otpSentMessage'), - }); - } else { - // Handle specific error types - let errorMessage = t('defaultErrorMessage'); - - // You can handle different error types differently - if (result.error === 'userNotFound') { - errorMessage = t('userNotFoundMessage'); - } - - toast({ - title: t('errorTitle'), - description: result.message || errorMessage, - variant: 'destructive', - }); - } - } catch (error) { - // This will catch network errors or other unexpected issues - console.error(error); - toast({ - title: t('errorTitle'), - description: t('networkErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - - async function handleOtpSubmit(e: React.FormEvent) { - e.preventDefault(); - setIsLoading(true); - - try { - // next-auth의 Credentials Provider로 로그인 시도 - const result = await signIn('credentials', { - email, - code: otp, - redirect: false, // 커스텀 처리 위해 redirect: false - }); - - if (result?.ok) { - // 토스트 메시지 표시 - toast({ - title: t('loginSuccess'), - description: t('youAreLoggedIn'), - }); - - router.push(`/${lng}/partners/dashboard`); - - } else { - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } - } catch (error) { - console.error('Login error:', error); - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - } - - // 새로운 로그인 처리 함수 추가 - const handleCredentialsLogin = async () => { - if (!username || !password) { - toast({ - title: t('errorTitle'), - description: t('credentialsRequired'), - variant: 'destructive', - }); - return; - } - - setIsLoading(true); - - try { - // next-auth의 다른 credentials provider로 로그인 시도 - const result = await signIn('credentials-password', { - username, - password, - redirect: false, - }); - - if (result?.ok) { - toast({ - title: t('loginSuccess'), - description: t('youAreLoggedIn'), - }); - - router.push(`/${lng}/partners/dashboard`); - } else { - toast({ - title: t('errorTitle'), - description: t('invalidCredentials'), - variant: 'destructive', - }); - } - } catch (error) { - console.error('Login error:', error); - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - const verifyToken = async () => { - if (!token) return; - setIsLoading(true); - - try { - const data = await verifyTokenAction(token); - - if (data.valid) { - setOtpSent(true); - setEmail(data.email ?? ''); - } else { - toast({ - title: t('errorTitle'), - description: t('invalidToken'), - variant: 'destructive', - }); - } - } catch (error) { - toast({ - title: t('errorTitle'), - description: t('defaultErrorMessage'), - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } - }; - verifyToken(); - }, [token, toast, t]); - - return ( - <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> - {/* Left Content */} - <div className="flex flex-col w-full h-screen lg:p-2"> - {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */} - <div className="flex items-center justify-between"> - <div className="flex items-center space-x-2"> - {/* <img - src="/images/logo.png" - alt="logo" - className="h-8 w-auto" - /> */} - <Ship className="w-4 h-4" /> - <span className="text-md font-bold">eVCP</span> - </div> - <Link - href="/partners/repository" - className={cn(buttonVariants({ variant: "ghost" }))} - > - <InfoIcon className="w-4 h-4 mr-1" /> - {'업체 등록 신청'} - </Link> - </div> - - {/* Content section that occupies remaining space, centered vertically */} - <div className="flex-1 flex items-center justify-center"> - {/* Your form container */} - <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> - - {/* Here's your existing login/OTP forms: */} - {/* {!otpSent ? ( */} - - {/* <form onSubmit={handleSubmit} className="p-6 md:p-8"> */} - <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> - <div className="flex flex-col gap-6"> - <div className="flex flex-col items-center text-center"> - <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> - - {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */} - <p className="text-xs text-muted-foreground mt-2"> - {'등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} - </p> - </div> - - {/* S-Gips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} - {!showCredentialsForm && ( - <> - <div className="grid gap-2"> - <Input - id="email" - type="email" - placeholder={t('email')} - required - className="h-10" - value={email} - onChange={(e) => setEmail(e.target.value)} - /> - </div> - <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}> - {isLoading ? t('sending') : t('ContinueWithEmail')} - </Button> - - {/* 구분선과 "Or continue with" 섹션 추가 */} - <div className="relative"> - <div className="absolute inset-0 flex items-center"> - <span className="w-full border-t"></span> - </div> - <div className="relative flex justify-center text-xs uppercase"> - <span className="bg-background px-2 text-muted-foreground"> - {t('orContinueWith')} - </span> - </div> - </div> - - {/* S-Gips 로그인 버튼 */} - <Button - type="button" - className="w-full" - // variant="" - onClick={() => setShowCredentialsForm(true)} - > - S-Gips로 로그인하기 - </Button> - - {/* 업체 등록 안내 링크 추가 */} - <Button - type="button" - variant="link" - className="text-blue-600 hover:text-blue-800" - onClick={goToVendorRegistration} - > - {'신규 업체이신가요? 여기서 등록하세요'} - </Button> - </> - )} - - {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */} - {showCredentialsForm && ( - <> - <div className="grid gap-4"> - <Input - id="username" - type="text" - placeholder="S-Gips ID" - className="h-10" - value={username} - onChange={(e) => setUsername(e.target.value)} - /> - <Input - id="password" - type="password" - placeholder="비밀번호" - className="h-10" - value={password} - onChange={(e) => setPassword(e.target.value)} - /> - <Button - type="button" - className="w-full" - variant="samsung" - onClick={handleCredentialsLogin} - disabled={isLoading} - > - {isLoading ? "로그인 중..." : "로그인"} - </Button> - - {/* 뒤로 가기 버튼 */} - <Button - type="button" - variant="ghost" - className="w-full text-sm" - onClick={() => setShowCredentialsForm(false)} - > - 이메일로 로그인하기 - </Button> - </div> - </> - )} - - <div className="text-center text-sm mx-auto"> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="flex items-center gap-2"> - <GlobeIcon className="h-4 w-4" /> - <span>{currentLanguageText}</span> - <ChevronDownIcon className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuRadioGroup - value={i18n.language} - onValueChange={(value) => handleChangeLanguage(value)} - > - <DropdownMenuRadioItem value="en"> - {t('languages.english')} - </DropdownMenuRadioItem> - <DropdownMenuRadioItem value="ko"> - {t('languages.korean')} - </DropdownMenuRadioItem> - </DropdownMenuRadioGroup> - </DropdownMenuContent> - </DropdownMenu> - </div> - </div> - </form> - {/* ) : ( - <form onSubmit={handleOtpSubmit} className="flex flex-col gap-4 p-6 md:p-8"> - <div className="flex flex-col gap-6"> - <div className="flex flex-col items-center text-center"> - <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> - </div> - <div className="grid gap-2 justify-center"> - <InputOTP - maxLength={6} - value={otp} - onChange={(value) => setOtp(value)} - > - <InputOTPGroup> - <InputOTPSlot index={0} /> - <InputOTPSlot index={1} /> - <InputOTPSlot index={2} /> - <InputOTPSlot index={3} /> - <InputOTPSlot index={4} /> - <InputOTPSlot index={5} /> - </InputOTPGroup> - </InputOTP> - </div> - <Button type="submit" className="w-full" disabled={isLoading}> - {isLoading ? t('verifying') : t('verifyOtp')} - </Button> - <div className="mx-auto"> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="flex items-center gap-2"> - <GlobeIcon className="h-4 w-4" /> - <span>{currentLanguageText}</span> - <ChevronDownIcon className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuRadioGroup - value={i18n.language} - onValueChange={(value) => handleChangeLanguage(value)} - > - <DropdownMenuRadioItem value="en"> - {t('languages.english')} - </DropdownMenuRadioItem> - <DropdownMenuRadioItem value="ko"> - {t('languages.korean')} - </DropdownMenuRadioItem> - </DropdownMenuRadioGroup> - </DropdownMenuContent> - </DropdownMenu> - </div> - </div> - </form> - )} */} - - <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> - {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')} - <a href="#">{t('privacyPolicy')}</a>. - </div> - </div> - </div> - </div> - - {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} - <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex"> - {/* Image 컴포넌트로 대체 */} - <div className="absolute inset-0"> - <Image - src="/images/02.jpg" - alt="Background image" - fill - priority - sizes="(max-width: 1024px) 100vw, 50vw" - className="object-cover" - /> - </div> - <div className="relative z-10 mt-auto"> - <blockquote className="space-y-2"> - <p className="text-sm">“{t("blockquote")}”</p> - {/* <footer className="text-sm">SHI</footer> */} - </blockquote> - </div> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/components/login/login-form-shi.tsx b/components/login/login-form-shi.tsx index d22e15a8..6be8d5c8 100644 --- a/components/login/login-form-shi.tsx +++ b/components/login/login-form-shi.tsx @@ -21,6 +21,7 @@ import { verifyTokenAction } from "@/lib/users/verifyToken"; import { buttonVariants } from "@/components/ui/button" import Link from "next/link" import Image from 'next/image'; // 추가: Image 컴포넌트 import +import { KnoxSSOButton } from './saml-login-button'; // SAML 로그인 버튼 import export function LoginFormSHI({ className, @@ -225,6 +226,23 @@ export function LoginFormSHI({ <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}> {isLoading ? t('sending') : t('ContinueWithEmail')} </Button> + + {/* 구분선과 "Or continue with" 섹션 추가 */} + <div className="relative"> + <div className="absolute inset-0 flex items-center"> + <span className="w-full border-t"></span> + </div> + <div className="relative flex justify-center text-xs uppercase"> + <span className="bg-background px-2 text-muted-foreground"> + {t('orContinueWith')} + </span> + </div> + </div> + + {/* SAML 로그인 버튼 - 로직 분리 */} + <KnoxSSOButton /> + + {/* 언어 선택 드롭다운 */} <div className="text-center text-sm mx-auto"> <DropdownMenu> <DropdownMenuTrigger asChild> diff --git a/components/login/saml-login-button.tsx b/components/login/saml-login-button.tsx new file mode 100644 index 00000000..02825e2f --- /dev/null +++ b/components/login/saml-login-button.tsx @@ -0,0 +1,121 @@ +'use client' + +/** + * + * SAML 2.0 기반 SSO 로그인 요청을 시작하는 버튼 컴포넌트 + * + * + */ + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { toast } from '@/hooks/use-toast' +import { Loader2, Shield } from 'lucide-react' +import React from 'react' + +interface SAMLLoginButtonProps { + className?: string + children?: React.ReactNode + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' | 'samsung' + size?: 'default' | 'sm' | 'lg' | 'icon' +} + +export function SAMLLoginButton({ + className, + children = "Knox SSO로 로그인하기", + variant = "outline", + size = "default" +}: SAMLLoginButtonProps) { + const [isLoading, setIsLoading] = useState(false) + + const handleSAMLLogin = async () => { + try { + setIsLoading(true) + + // 현재 페이지 경로를 RelayState로 설정 (로그인 후 이 페이지로 돌아옴) + const currentPath = window.location.pathname + window.location.search + const relayState = encodeURIComponent(currentPath) + + console.log('Setting RelayState to:', currentPath) + + // API 엔드포인트를 통해 SAML AuthnRequest URL 생성 + const response = await fetch(`/api/auth/saml/authn-request?relayState=${relayState}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + throw new Error('Failed to create SAML AuthnRequest') + } + + const data = await response.json() + + if (!data.success || !data.loginUrl) { + throw new Error(data.error || 'Failed to get SAML login URL') + } + + console.log('SAML Login URL:', data.loginUrl) + + // data URL인지 확인 (브라우저 보안 정책으로 차단될 수 있음) + if (data.loginUrl.startsWith('data:')) { + console.warn('⚠️ Data URL detected - this may be blocked by browser security policies') + toast({ + title: '테스트 모드 감지', + description: 'Mock SAML IdP 모드가 활성화되어 있습니다. 프로덕션에서는 SAML_MOCKING_IDP=false로 설정하세요.', + variant: 'default', + }) + + // data URL 대신 Mock IdP 페이지로 직접 리다이렉트 + const baseUrl = window.location.origin + const mockIdpUrl = `${baseUrl}/api/auth/saml/mock-idp` + console.log('🎭 Redirecting to Mock IdP instead:', mockIdpUrl) + window.location.href = mockIdpUrl + return + } + + // 일반적인 URL로 리다이렉트 + window.location.href = data.loginUrl + + } catch (error) { + console.error('SAML Login Error:', error) + toast({ + title: '로그인 오류', + description: 'SAML 로그인을 시작할 수 없습니다.', + variant: 'destructive', + }) + setIsLoading(false) + } + } + + return ( + <Button + type="button" + variant={variant} + size={size} + className={className} + onClick={handleSAMLLogin} + disabled={isLoading} + > + {isLoading ? ( + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + ) : ( + <Shield className="h-4 w-4 mr-2" /> + )} + {children} + </Button> + ) +} + +// 간단한 Knox SSO 버튼 (props 없이) +export function KnoxSSOButton() { + return ( + <SAMLLoginButton + className="w-full" + variant="outline" + > + Knox SSO (STAGE 단계) + </SAMLLoginButton> + ) +} diff --git a/config/saml/idp_metadata.xml b/config/saml/idp_metadata.xml new file mode 100644 index 00000000..bc448907 --- /dev/null +++ b/config/saml/idp_metadata.xml @@ -0,0 +1,26 @@ +<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="www.stage1.samsung.net">
+ <md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
+ <md:KeyDescriptor use="signing">
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+ <ds:X509Data>
+ <ds:X509Certificate>MIID2zCCAsOgAwIBAgIJAKUgkCmmclHOMA0GCSqGSIb3DQEBCwUAMIGDMQswCQYDVQQGEwJLUjEPMA0GA1UECAwGSmFtc2lsMQ4wDAYDVQQHDAVTZW91bDETMBEGA1UECgwKU2Ftc3VuZ1NEUzEdMBsGA1UECwwUSW50cmFuZXRCdXNpbmVzc1RlYW0xHzAdBgNVBAMMFm5ldC5zYW1zdW5nLmtub3hwb3J0YWwwHhcNMTcwOTA2MDQxNDAzWhcNMjcwOTA0MDQxNDAzWjCBgzELMAkGA1UEBhMCS1IxDzANBgNVBAgMBkphbXNpbDEOMAwGA1UEBwwFU2VvdWwxEzARBgNVBAoMClNhbXN1bmdTRFMxHTAbBgNVBAsMFEludHJhbmV0QnVzaW5lc3NUZWFtMR8wHQYDVQQDDBZuZXQuc2Ftc3VuZy5rbm94cG9ydGFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1nT5VRgS/PGj7iL8l4kpyEqs04BocOrIPf9mn+Ky/pA3BkgfxItkAfxqKjrzZ2J/0yB1jkjpHYxQQSpah5f/FrxK/G3lCMlpQzFgT9qfX/VJqhJLU3JF4hhxTVp77rF5Sqz2CWdTzrKgEhVhQupfANL67uw1GrR2AoPWsmGqr/ybdEcjr0w3lYrnCb9LYvvT+KOmZg1nVEbMAJ66xFuiuc4IGAot+IIHY86ZjSXRfMBkJaisEpStXXja0PD8SHDu31DdLomaRYrv9eyoh3q/LONejfgd8IrAJO3Om8zNmfF2Q665Ab4oPFoRznjvR74/pszIxqQTYoVgKkDKRmTOjQIDAQABo1AwTjAdBgNVHQ4EFgQUiolG//FttT/5g3IBaoRvjNWNCt0wHwYDVR0jBBgwFoAUiolG//FttT/5g3IBaoRvjNWNCt0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAQVBxmGnZHo3dMLKFgAf8oLevA1TuA03p6jj2MVLwFMjw0S74bFpgS4ZXEzsliGAQprVwTzo06XtTxQENxddbFMRfKroKvpyM20uBt2JI5nBmE/kzrb4AOguRRTNKfb9o4zk2yO7Ra31dWHrvZ3usV8A0KLIHef6iUPv4mBMXY5e7gEUjoZxbZQucyHOrYvuj/TISd7n6r37cotf5ldUD5B+ADP05AgTTP1vKzyfOsb+zRqTTi8WFOc2SlbTktXPvfiQmHs6OoCbNNYXfQT+YO0x3y8M4TevvoeKvTjQp1E+Q+J8hAh7xTIemb6wP460ObUD9w+wyqUk44XJGdibtgQ==</ds:X509Certificate>
+ </ds:X509Data>
+ </ds:KeyInfo>
+ </md:KeyDescriptor>
+ <md:KeyDescriptor use="encryption">
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+ <ds:X509Data>
+ <ds:X509Certificate>MIID2zCCAsOgAwIBAgIJAKUgkCmmclHOMA0GCSqGSIb3DQEBCwUAMIGDMQswCQYDVQQGEwJLUjEPMA0GA1UECAwGSmFtc2lsMQ4wDAYDVQQHDAVTZW91bDETMBEGA1UECgwKU2Ftc3VuZ1NEUzEdMBsGA1UECwwUSW50cmFuZXRCdXNpbmVzc1RlYW0xHzAdBgNVBAMMFm5ldC5zYW1zdW5nLmtub3hwb3J0YWwwHhcNMTcwOTA2MDQxNDAzWhcNMjcwOTA0MDQxNDAzWjCBgzELMAkGA1UEBhMCS1IxDzANBgNVBAgMBkphbXNpbDEOMAwGA1UEBwwFU2VvdWwxEzARBgNVBAoMClNhbXN1bmdTRFMxHTAbBgNVBAsMFEludHJhbmV0QnVzaW5lc3NUZWFtMR8wHQYDVQQDDBZuZXQuc2Ftc3VuZy5rbm94cG9ydGFsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1nT5VRgS/PGj7iL8l4kpyEqs04BocOrIPf9mn+Ky/pA3BkgfxItkAfxqKjrzZ2J/0yB1jkjpHYxQQSpah5f/FrxK/G3lCMlpQzFgT9qfX/VJqhJLU3JF4hhxTVp77rF5Sqz2CWdTzrKgEhVhQupfANL67uw1GrR2AoPWsmGqr/ybdEcjr0w3lYrnCb9LYvvT+KOmZg1nVEbMAJ66xFuiuc4IGAot+IIHY86ZjSXRfMBkJaisEpStXXja0PD8SHDu31DdLomaRYrv9eyoh3q/LONejfgd8IrAJO3Om8zNmfF2Q665Ab4oPFoRznjvR74/pszIxqQTYoVgKkDKRmTOjQIDAQABo1AwTjAdBgNVHQ4EFgQUiolG//FttT/5g3IBaoRvjNWNCt0wHwYDVR0jBBgwFoAUiolG//FttT/5g3IBaoRvjNWNCt0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAQVBxmGnZHo3dMLKFgAf8oLevA1TuA03p6jj2MVLwFMjw0S74bFpgS4ZXEzsliGAQprVwTzo06XtTxQENxddbFMRfKroKvpyM20uBt2JI5nBmE/kzrb4AOguRRTNKfb9o4zk2yO7Ra31dWHrvZ3usV8A0KLIHef6iUPv4mBMXY5e7gEUjoZxbZQucyHOrYvuj/TISd7n6r37cotf5ldUD5B+ADP05AgTTP1vKzyfOsb+zRqTTi8WFOc2SlbTktXPvfiQmHs6OoCbNNYXfQT+YO0x3y8M4TevvoeKvTjQp1E+Q+J8hAh7xTIemb6wP460ObUD9w+wyqUk44XJGdibtgQ==</ds:X509Certificate>
+ </ds:X509Data>
+ </ds:KeyInfo>
+ <md:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc">
+ <xenc:KeySize xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">256</xenc:KeySize>
+ </md:EncryptionMethod>
+ </md:KeyDescriptor>
+ <md:Organization>
+ <md:OrganizationName xml:lang="en">Knox Portal</md:OrganizationName>
+ </md:Organization>
+ <md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:entity</md:NameIDFormat>
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://epsso.stage.samsung.net/sso/saml/SingleSignOnService"/>
+ </md:IDPSSODescriptor>
+</md:EntityDescriptor>
\ No newline at end of file diff --git a/config/saml/sp_metadata.xml b/config/saml/sp_metadata.xml new file mode 100644 index 00000000..9d0ff626 --- /dev/null +++ b/config/saml/sp_metadata.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="UTF-8"?>
+<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
+ entityID="http://60.101.108.100">
+
+ <md:SPSSODescriptor
+ protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"
+ AuthnRequestsSigned="false"
+ WantAssertionsSigned="false">
+
+ <!-- HTTP-POST 및 HTTP-Redirect 바인딩 둘 다 지원 -->
+ <md:AssertionConsumerService
+ Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+ Location="http://60.101.108.100/api/saml/callback"
+ index="0"
+ isDefault="true"/>
+
+ <md:AssertionConsumerService
+ Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
+ Location="http://60.101.108.100/api/saml/callback"
+ index="1"/>
+
+ </md:SPSSODescriptor>
+
+</md:EntityDescriptor>
\ No newline at end of file diff --git a/lib/debug-utils.ts b/lib/debug-utils.ts new file mode 100644 index 00000000..1d417ba5 --- /dev/null +++ b/lib/debug-utils.ts @@ -0,0 +1,72 @@ +// 개발 환경 디버그 유틸리티 + +const isDev = process.env.NODE_ENV === 'development'; +const isDebugEnabled = process.env.DEBUG === 'true' || isDev; + +/** + * 개발 환경에서만 console.log 출력 + */ +export function debugLog(message: string, ...args: any[]) { + if (isDebugEnabled) { + console.log(`🔍 ${message}`, ...args); + } +} + +/** + * 개발 환경에서만 console.error 출력 + */ +export function debugError(message: string, ...args: any[]) { + if (isDebugEnabled) { + console.error(`❌ ${message}`, ...args); + } +} + +/** + * 개발 환경에서만 console.warn 출력 + */ +export function debugWarn(message: string, ...args: any[]) { + if (isDebugEnabled) { + console.warn(`⚠️ ${message}`, ...args); + } +} + +/** + * 개발 환경에서만 성공 로그 출력 + */ +export function debugSuccess(message: string, ...args: any[]) { + if (isDebugEnabled) { + console.log(`✅ ${message}`, ...args); + } +} + +/** + * 개발 환경에서만 프로세스 로그 출력 + */ +export function debugProcess(message: string, ...args: any[]) { + if (isDebugEnabled) { + console.log(`🔐 ${message}`, ...args); + } +} + +/** + * 개발 환경에서만 Mock 모드 로그 출력 + */ +export function debugMock(message: string, ...args: any[]) { + if (isDebugEnabled) { + console.log(`🎭 ${message}`, ...args); + } +} + +/** + * 개발 환경 여부 확인 + */ +export function isDevMode(): boolean { + return isDev; +} + +/** + * 디버그 모드 여부 확인 (DEBUG=true 또는 NODE_ENV=development) + */ +export function isDebugMode(): boolean { + return isDebugEnabled; +}
\ No newline at end of file diff --git a/lib/saml/idp-metadata.ts b/lib/saml/idp-metadata.ts new file mode 100644 index 00000000..a33ecad6 --- /dev/null +++ b/lib/saml/idp-metadata.ts @@ -0,0 +1,86 @@ +// SAML Identity Provider (IdP) 메타데이터 유틸리티 + +export interface IDPMetadata { + entityId: string + ssoUrl: string + sloUrl?: string + certificate: string + nameIdFormat: string + organization: string + wantAuthnRequestsSigned: boolean +} + +// IdP 메타데이터 가져오기 (환경변수 기반) +export function getIDPMetadata(): IDPMetadata { + console.log('🔍 Loading IdP metadata from environment variables...') + + // 필수 환경변수 검증 + const entityId = process.env.SAML_IDP_ENTITY_ID + const ssoUrl = process.env.SAML_IDP_SSO_URL + const certificate = process.env.SAML_IDP_CERT + + if (!entityId || !ssoUrl || !certificate) { + throw new Error('Required SAML IdP environment variables are missing: SAML_IDP_ENTITY_ID, SAML_IDP_SSO_URL, SAML_IDP_CERT') + } + + const metadata: IDPMetadata = { + entityId, + ssoUrl, + sloUrl: process.env.SAML_IDP_SLO_URL, + certificate, + nameIdFormat: process.env.SAML_IDP_NAME_ID_FORMAT || 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + organization: process.env.SAML_IDP_ORGANIZATION || 'Unknown Organization', + wantAuthnRequestsSigned: process.env.SAML_IDP_WANT_AUTHN_REQUESTS_SIGNED === 'true' + } + + console.log('✅ IdP metadata loaded from environment variables:', { + entityId, + ssoUrl, + sloUrl: metadata.sloUrl, + organization: metadata.organization, + wantAuthnRequestsSigned: metadata.wantAuthnRequestsSigned + }) + + return metadata +} + +// 인증서 형식 정규화 (PEM 형식으로 변환) +export function normalizeCertificate(cert: string): string { + // 이미 PEM 형식인 경우 그대로 반환 + if (cert.includes('-----BEGIN CERTIFICATE-----')) { + return cert + } + + // Base64 인증서를 PEM 형식으로 변환 (64자마다 줄바꿈) + const cleanCert = cert.replace(/\s+/g, '') + const formattedCert = cleanCert.match(/.{1,64}/g)?.join('\n') || cleanCert + return `-----BEGIN CERTIFICATE-----\n${formattedCert}\n-----END CERTIFICATE-----` +} + +// 특정 용도의 인증서 가져오기 +export function getCertificateByUse(metadata: IDPMetadata, use: 'signing' | 'encryption'): string { + const cert = metadata.certificates.find(c => c.use === use) + return cert ? normalizeCertificate(cert.certificate) : '' +} + +// 모든 인증서를 PEM 형식으로 변환 +export function getAllCertificatesAsPEM(metadata: IDPMetadata): { use: string; pem: string }[] { + return metadata.certificates.map(cert => ({ + use: cert.use, + pem: normalizeCertificate(cert.certificate) + })) +} + +// 레거시 호환성을 위한 함수 - 첫 번째 인증서를 문자열로 반환 +export function getFirstCertificateAsString(metadata: IDPMetadata): string { + return metadata.certificates[0]?.certificate || '' +} + +// SP 메타데이터 생성을 위한 헬퍼 +export function getSPEntityId(): string { + return process.env.SAML_SP_ENTITY_ID || `${process.env.NEXTAUTH_URL}/saml/metadata` +} + +export function getSPCallbackUrl(): string { + return `${process.env.NEXTAUTH_URL}/api/saml/callback` +}
\ No newline at end of file diff --git a/lib/saml/sp-metadata.ts b/lib/saml/sp-metadata.ts new file mode 100644 index 00000000..7f25f602 --- /dev/null +++ b/lib/saml/sp-metadata.ts @@ -0,0 +1,82 @@ +// SAML Service Provider (SP) 메타데이터 유틸리티 + +export interface SPAssertionConsumerService { + binding: string + location: string + index: number + isDefault?: boolean +} + +export interface SPMetadata { + entityId: string + // SPSSODescriptor 속성들 + protocolSupportEnumeration: string[] + authnRequestsSigned: boolean + wantAssertionsSigned: boolean + // 서비스 엔드포인트들 + assertionConsumerServices: SPAssertionConsumerService[] + // 편의를 위한 기본값들 + defaultACS: SPAssertionConsumerService + callbackUrl: string +} + +// SP 메타데이터 가져오기 (환경변수 기반) +export function getSPMetadata(): SPMetadata { + console.log('🔍 Loading SP metadata from environment variables...') + + // 필수 환경변수 검증 + const entityId = process.env.SAML_SP_ENTITY_ID + const callbackUrl = process.env.SAML_SP_CALLBACK_URL || `${process.env.NEXTAUTH_URL}/api/saml/callback` + + if (!entityId) { + throw new Error('Required SAML environment variable is missing: SAML_SP_ENTITY_ID') + } + + if (!callbackUrl) { + throw new Error('SAML_SP_CALLBACK_URL or NEXTAUTH_URL environment variable is required') + } + + // AssertionConsumerService 구성 + const assertionConsumerServices: SPAssertionConsumerService[] = [ + // 기본 HTTP-POST 바인딩 + { + binding: process.env.SAML_SP_ACS_BINDING_PRIMARY || 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + location: callbackUrl, + index: 0, + isDefault: true + } + ] + + // 보조 HTTP-Redirect 바인딩 (선택사항) + const secondaryBinding = process.env.SAML_SP_ACS_BINDING_SECONDARY + if (secondaryBinding) { + assertionConsumerServices.push({ + binding: secondaryBinding, + location: callbackUrl, + index: 1, + isDefault: false + }) + } + + const metadata: SPMetadata = { + entityId, + protocolSupportEnumeration: [ + process.env.SAML_SP_PROTOCOL_SUPPORT || 'urn:oasis:names:tc:SAML:2.0:protocol' + ], + authnRequestsSigned: process.env.SAML_SP_AUTHN_REQUESTS_SIGNED === 'true', + wantAssertionsSigned: process.env.SAML_SP_WANT_ASSERTIONS_SIGNED === 'true', + assertionConsumerServices, + defaultACS: assertionConsumerServices[0], + callbackUrl + } + + console.log('✅ SP metadata loaded from environment variables:', { + entityId, + callbackUrl, + authnRequestsSigned: metadata.authnRequestsSigned, + wantAssertionsSigned: metadata.wantAssertionsSigned, + acsCount: assertionConsumerServices.length + }) + + return metadata +}
\ No newline at end of file diff --git a/lib/users/repository.ts b/lib/users/repository.ts index 3a404bde..75981b03 100644 --- a/lib/users/repository.ts +++ b/lib/users/repository.ts @@ -44,6 +44,21 @@ export const createUser = async (name: string, email: string): Promise<User> => return user }; +// SAML 사용자 생성 (domain과 추가 정보 포함) +export const createSAMLUser = async ( + name: string, + email: string, + domain: 'evcp' | 'partners' = 'evcp', +): Promise<User> => { + const usersRes = await db.insert(users).values({ + name, + email, + domain, + }).returning(); + const user = usersRes[0]; + return user +}; + // 사용자 업데이트 export const updateUser = async (id: number, data: Partial<User>): Promise<User | null> => { const usersRes = await db.update(users).set(data).where(eq(users.id, id)).returning(); diff --git a/lib/users/saml-service.ts b/lib/users/saml-service.ts new file mode 100644 index 00000000..cd3c604f --- /dev/null +++ b/lib/users/saml-service.ts @@ -0,0 +1,134 @@ +"use server"; + +import { getUserByEmail, createSAMLUser } from './repository'; +import { User } from '@/db/schema/users'; +import logger from '@/lib/logger'; + +export interface SAMLUserData { + email: string; + name: string; + companyId?: number; + techCompanyId?: number; + domain?: string; +} + +/** + * SAML JIT (Just-In-Time) 사용자 생성 또는 조회 + * + * @param samlData SAML에서 받은 사용자 데이터 + * @returns DB에 저장된 사용자 정보 + */ +export async function getOrCreateSAMLUser(samlData: SAMLUserData): Promise<User | null> { + try { + logger.info({ email: samlData.email }, 'SAML JIT: Processing user'); + + // 1. 기존 사용자 조회 + const existingUser = await getUserByEmail(samlData.email); + + if (existingUser) { + logger.info({ + userId: existingUser.id, + email: existingUser.email + }, 'SAML JIT: Existing user found'); + + // 기존 사용자가 있으면 그대로 반환 + return existingUser; + } + + // 2. 새 사용자 생성 (JIT) + logger.info({ + email: samlData.email, + name: samlData.name + }, 'SAML JIT: Creating new user'); + + const newUser = await createSAMLUserFromData(samlData); + + logger.info({ + userId: newUser.id, + email: newUser.email + }, 'SAML JIT: New user created successfully'); + + return newUser; + + } catch (error) { + logger.error({ + error, + email: samlData.email + }, 'SAML JIT: Failed to get or create user'); + + return null; + } +} + +/** + * SAML 데이터로 새 사용자 생성 + * + * @param samlData SAML 사용자 데이터 + * @returns 생성된 사용자 + */ +async function createSAMLUserFromData(samlData: SAMLUserData): Promise<User> { + // 1. UTF-8 name 처리 + let userName = samlData.name || samlData.email.split('@')[0]; + + // 길이 제한 확인 (DB varchar(255) 제한) + if (userName.length > 255) { + userName = userName.substring(0, 252) + '...'; + } + + // 빈 문자열 방지 + if (!userName.trim()) { + userName = samlData.email.split('@')[0] || 'SAML User'; + } + + // 2. domain을 evcp로 고정 + const domain = 'evcp'; + + // 3. SAML 사용자 생성 (Repository 함수 재사용하고 싶었으나 domain 정보 입력 불가로 새로운 함수 정의함) + const newUser = await createSAMLUser( + userName, + samlData.email, + domain, + ); + + // 4. 생성된 사용자 검증 + if (newUser.domain !== 'evcp') { + logger.error({ + userId: newUser.id, + expectedDomain: 'evcp', + actualDomain: newUser.domain + }, 'SAML JIT: Domain mismatch in created user'); + throw new Error('Failed to create SAML user with correct domain'); + } + + logger.info({ + userId: newUser.id, + email: newUser.email, + name: newUser.name, + nameLength: newUser.name.length, + domain: newUser.domain, + companyId: newUser.companyId, + techCompanyId: newUser.techCompanyId + }, 'SAML JIT: New user created with UTF-8 name and evcp domain'); + + return newUser; +} + +/** + * SAML 사용자 데이터 검증 + * + * @param samlData 검증할 SAML 데이터 + * @returns 유효성 여부 + */ +export async function validateSAMLUserData(samlData: unknown): Promise<boolean> { + return ( + samlData !== null && + samlData !== undefined && + typeof samlData === 'object' && + 'email' in samlData && + 'name' in samlData && + typeof (samlData as Record<string, unknown>).email === 'string' && + typeof (samlData as Record<string, unknown>).name === 'string' && + (samlData as Record<string, unknown>).email.includes('@') && + (samlData as Record<string, unknown>).name.length > 0 + ); +}
\ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3051c42b..e1ac4f40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3015,6 +3015,95 @@ "node": ">= 18" } }, + "node_modules/@node-saml/node-saml/node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@node-saml/node-saml/node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/@node-saml/node-saml/node_modules/xml-crypto": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz", + "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", + "license": "MIT", + "dependencies": { + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "xpath": "^0.0.33" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@node-saml/node-saml/node_modules/xml-crypto/node_modules/xpath": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz", + "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/@node-saml/node-saml/node_modules/xml-encryption": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz", + "integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.5", + "escape-html": "^1.0.3", + "xpath": "0.0.32" + } + }, + "node_modules/@node-saml/node-saml/node_modules/xml-encryption/node_modules/xpath": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", + "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/@node-saml/node-saml/node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@node-saml/node-saml/node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@node-saml/node-saml/node_modules/xpath": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", + "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5114,15 +5203,6 @@ "node": ">= 16" } }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -15547,77 +15627,6 @@ "node": ">= 6" } }, - "node_modules/xml-crypto": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz", - "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", - "license": "MIT", - "dependencies": { - "@xmldom/is-dom-node": "^1.0.1", - "@xmldom/xmldom": "^0.8.10", - "xpath": "^0.0.33" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/xml-crypto/node_modules/xpath": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz", - "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==", - "license": "MIT", - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/xml-encryption": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz", - "integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==", - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.5", - "escape-html": "^1.0.3", - "xpath": "0.0.32" - } - }, - "node_modules/xml-encryption/node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/xml-encryption/node_modules/xpath": { - "version": "0.0.32", - "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", - "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", - "license": "MIT", - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xml2js/node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -15641,15 +15650,6 @@ "node": ">=0.4.0" } }, - "node_modules/xpath": { - "version": "0.0.34", - "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", - "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", - "license": "MIT", - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", |
