diff options
| author | joonhoekim <26rote@gmail.com> | 2025-06-20 11:47:15 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-06-20 11:47:15 +0000 |
| commit | abd9f950bbd95b9ad713a26d3fd8a7e0282b7c51 (patch) | |
| tree | aafc71d5ff23962c2d6d5e902c66ee070b7ac068 /lib | |
| parent | 994defd6446ce20c4b4e0d6cc91688b0e64230a4 (diff) | |
(김준회) SAML 2.0 SSO (Knox Portal) 추가
Diffstat (limited to 'lib')
| -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 |
4 files changed, 317 insertions, 0 deletions
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 |
