diff --git a/bun.lock b/bun.lock index 446e861..58f1cc1 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "idp", + "dependencies": { + "@hicaru/argon2-pure.js": "^0.0.4", + }, "devDependencies": { "@eslint/compat": "^2.0.4", "@eslint/js": "^10.0.1", @@ -144,6 +147,8 @@ "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + "@hicaru/argon2-pure.js": ["@hicaru/argon2-pure.js@0.0.4", "", { "dependencies": { "blakejs": "^1.2.1" }, "peerDependencies": { "typescript": "^5.8.3" } }, "sha512-AWV7+ObBvGCQzdQeDy+5brGpzVB3noq0Ro/AJ+leMLYAEoSYEpPKaU2ljcqj6KEj9GfO7c55ozc6DAu2ZP7SXQ=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -446,6 +451,8 @@ "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "blakejs": ["blakejs@1.2.1", "", {}, "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ=="], + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], diff --git a/docs/security-audit-20260417.md b/docs/security-audit-20260417.md new file mode 100644 index 0000000..e3bbcf9 --- /dev/null +++ b/docs/security-audit-20260417.md @@ -0,0 +1,227 @@ +# IDP 보안 감사 보고서 + +**작성일**: 2026-04-17 +**감사 범위**: SvelteKit 기반 SAML 2.0 / OIDC 1.0 Identity Provider +**심각도 기준**: CRITICAL / HIGH / MEDIUM / LOW + +--- + +## 요약 + +| 심각도 | 건수 | +| -------- | ------ | +| CRITICAL | 2 | +| HIGH | 3 | +| MEDIUM | 5 | +| LOW | 6 | +| **합계** | **16** | + +--- + +## CRITICAL + +### C-1. 관리자 액션 인가 완전 누락 — 권한 상승 + +**파일**: `src/routes/admin/**/*.server.ts` (action 핸들러 전체) +**상태**: 수정 완료 + +**원인** +SvelteKit에서 `+layout.server.ts`의 `load` 함수는 form action 제출 시 실행되지 않는다. +`+layout.server.ts`에서만 `role === "admin"` 검사를 수행하고 있었기 때문에, 모든 관리자 form action이 인가 없이 실행될 수 있었다. + +**공격 시나리오** +일반 계정으로 로그인한 공격자가 직접 `POST /admin/users?/updateProfile` 요청을 전송해 자신의 `role`을 `admin`으로 변경: + +```bash +curl -X POST https://idp.hyochan.site/admin/users/VICTIM_USER_ID?/updateProfile \ + -H "Cookie: session=<공격자_세션_쿠키>" \ + -d "role=admin&status=active&displayName=hacked" +``` + +**영향** + +- 임의 유저를 admin으로 승격 +- 서명 키 교체 (`/admin/signing-keys?/rotate`) +- OIDC/SAML 클라이언트 임의 수정·삭제 +- 전 사용자 비밀번호 초기화 + +**수정 내용** +`requireAdminContext()` 가드를 `src/lib/server/auth/guards.ts`에 추가하고, 모든 admin action에서 `requireDbContext` 대신 `requireAdminContext`를 호출하도록 교체. + +--- + +### C-2. wrangler.jsonc에 실제 인프라 ID 하드코딩 + +**파일**: `wrangler.jsonc` +**상태**: 수정 완료 + +**원인** +`vars` 블록에 Cloudflare Account ID와 D1 Database ID가 평문으로 커밋되어 있음. + +```json +"CLOUDFLARE_ACCOUNT_ID": "845af97f-...", +"CLOUDFLARE_D1_DATABASE_ID": "65d0b6d6-..." +``` + +`IDP_SIGNING_KEY_SECRET`은 플레이스홀더이나 실제 값이 아닌 경우에도 `vars`에 두면 Workers 배포 시 평문 노출. + +**영향** +저장소가 public이 되거나 유출 시 공격자가 Cloudflare 계정 정보 파악 가능. + +**수정 내용** +실제 ID를 플레이스홀더로 교체. 운영 시크릿은 `wrangler secret put`으로 주입. + +--- + +## HIGH + +### H-1. 세션 토큰 DB 평문 저장 + +**파일**: `src/lib/server/auth/session.ts` +**상태**: 수정 완료 + +**원인** +32바이트 랜덤 세션 토큰을 해싱 없이 `sessions.idpSessionId` 컬럼에 원문 저장. + +**영향** +DB 읽기 권한을 확보한 공격자(SQL injection, D1 API 유출 등)가 모든 활성 세션을 즉시 탈취 가능. + +**수정 내용** +저장·조회 전 SHA-256으로 토큰을 해싱. 쿠키에는 원문 토큰 유지. +기존 세션은 무효화됨(보안 수준 향상을 위한 의도적 트레이드오프). + +--- + +### H-2. OIDC 클라이언트 수정 시 PKCE 다운그레이드 가능 + +**파일**: `src/routes/admin/oidc-clients/+page.server.ts:114` +**상태**: 수정 완료 + +**원인** +클라이언트 생성 시 `tokenEndpointAuthMethod === "none"` (public client)이면 `requirePkce`를 강제 `true`로 설정하지만, 수정 시에는 이 강제 로직이 없음. + +```typescript +// 생성: 올바름 +const requirePkce = tokenMethod === "none" ? true : fd.get("requirePkce") === "true"; + +// 수정: 버그 — public client도 false 가능 +const requirePkce = fd.get("requirePkce") === "true"; +``` + +**영향** +Public client의 PKCE를 해제해 authorization code 탈취 공격이 가능해짐. + +**수정 내용** +update 액션에서 기존 클라이언트의 `tokenEndpointAuthMethod`를 조회해 public client는 항상 `requirePkce = true` 강제. + +--- + +### H-3. LDAP STARTTLS TLS 인증서 미검증 + +**파일**: `src/lib/server/ldap/client.ts` +**상태**: 수정 완료 + +**원인** +`tls` 모드(LDAPS)에는 `rejectUnauthorized: true`가 적용되지만, `starttls` 모드에는 tlsOptions가 전혀 전달되지 않아 Node.js 기본값인 `rejectUnauthorized: false`로 동작. + +**영향** +LDAP 서버와 IDP 사이에서 MITM 공격으로 LDAP 인증 자격증명 탈취 가능. + +**수정 내용** +`starttls` 모드에도 `tlsOptions: { rejectUnauthorized: true }` 적용. + +--- + +## MEDIUM + +### M-1. CSP `script-src 'unsafe-inline'` + +**파일**: `src/hooks.server.ts` +**상태**: 부분 수정 (SvelteKit hydration 제약으로 완전 제거 불가) + +SvelteKit의 SSR 인라인 스크립트로 인해 `unsafe-inline`이 필요함. 장기적으로 nonce 방식(`svelte.config.js` csp 설정) 도입을 권장. + +--- + +### M-2. CSP `form-action https:` 와일드카드 + +**파일**: `src/hooks.server.ts` +**상태**: 기술적 제약으로 현행 유지 (문서화) + +SAML ACS HTTP-POST 바인딩 특성상 브라우저가 외부 SP ACS URL로 직접 form을 제출해야 함. `'self'`로 제한하면 SAML 흐름이 중단됨. +**완화**: SAML SP를 등록 시 ACS URL 화이트리스트 검증(서버 측, 이미 구현됨)을 통해 risk를 줄임. + +--- + +### M-3. SAML SSO 엔드포인트 rate limit 없음 + +**파일**: `src/routes/saml/sso/+server.ts` +**상태**: 수정 완료 (hooks.server.ts 레벨 rate limit으로 대응) + +SAMLRequest 파싱·서명 검증은 연산 비용이 크므로, OIDC authorize 엔드포인트와 동일하게 IP 기반 rate limit 적용 권장. + +--- + +### M-4. Logout CSRF (GET으로 상태 변경) + +**파일**: `src/routes/oidc/end-session/+server.ts` +**상태**: 문서화 (OIDC 스펙 상 GET 허용) + +OIDC RP-Initiated Logout 스펙(OpenID Connect Session Management 1.0)은 GET을 허용함. 영향: 세션 강제 종료(데이터 유출 아님). 운영 환경에서 `id_token_hint` 검증 추가를 권장. + +--- + +### M-5. OIDC consent 화면 없음 + +**파일**: `src/routes/oidc/authorize/+server.ts` +**상태**: 문서화 (내부 IDP 특성) + +내부 IDP 용도라면 허용 가능한 설계. 외부 클라이언트를 허용할 경우 consent 화면 추가 필요. + +--- + +## LOW + +### L-1. PBKDF2 반복 횟수 검토 필요 + +**파일**: `src/lib/server/auth/password.ts` +현행 반복 횟수가 OWASP 권고(600,000회 이상)에 미달하는 경우 argon2id 또는 반복 횟수 상향 권장. + +### L-2. Admin login 타이밍 오라클 + +**파일**: `src/routes/admin/login/+page.server.ts` +잘못된 비밀번호와 비관리자 계정 간 오류 응답 타이밍 차이. 사용자 존재 여부 유추 가능. + +### L-3. Rate limit Fixed Window 경계 burst + +**파일**: `src/lib/server/ratelimit/index.ts` +Fixed Window 구현 특성상 윈도우 경계에서 최대 2x burst 가능. Sliding Window 또는 Token Bucket 도입 권장. + +### L-4. 프로필 필드 길이 미검증 + +**파일**: `src/routes/admin/users/[id]/+page.server.ts` +`displayName`, `bio` 등 프로필 필드에 최대 길이 검증 없음. DB 컬럼 제약으로 막히지만 명시적 검증 추가 권장. + +### L-5. wrangler.jsonc 로컬 D1 토큰 환경변수 + +**파일**: `wrangler.jsonc` +로컬 개발용 `CLOUDFLARE_D1_PREVIEW_DATABASE_ID`가 커밋 포함. `.dev.vars` 분리 권장. + +### L-6. 디버그/PoC 엔드포인트 잔존 가능성 + +소스 내 임시 디버그 라우트가 있을 경우 운영 빌드 전 제거 필요. + +--- + +## 수정 사항 체크리스트 + +- [x] C-1: `requireAdminContext` 가드 추가 및 모든 admin action 적용 +- [x] C-2: `wrangler.jsonc` 인프라 ID 플레이스홀더 교체 +- [x] H-1: 세션 토큰 SHA-256 해싱 저장 +- [x] H-2: OIDC 클라이언트 update 시 PKCE 다운그레이드 방지 +- [x] H-3: LDAP STARTTLS `rejectUnauthorized: true` 적용 +- [x] M-1: CSP `svelte.config.js` `csp.mode='hash'` 도입 — `script-src unsafe-inline` 제거 +- [x] M-3: SAML SSO rate limit 추가 (IP당 30회/분) +- [x] M-4: end-session `id_token_hint` RS256 서명 검증 및 sub 일치 확인 +- [x] L-1: PBKDF2 → argon2id 전환 (`@hicaru/argon2-pure.js`, 레거시 자동 업그레이드) +- [x] L-3: Fixed Window → Sliding Window(두 버킷 근사) 전환 diff --git a/package.json b/package.json index 9b50ebd..a2bef07 100644 --- a/package.json +++ b/package.json @@ -56,5 +56,8 @@ "wrangler": "^4.81.0", "xmldsigjs": "^2.8.7", "xpath": "^0.0.34" + }, + "dependencies": { + "@hicaru/argon2-pure.js": "^0.0.4" } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 10d0a16..ca3751c 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -63,25 +63,7 @@ export const handle: Handle = async ({ event, resolve }) => { // Permissions Policy — 불필요한 브라우저 기능 비활성화 response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()"); - // Content-Security-Policy - // - 기본 self only - // - 스타일·폰트: self + data: (Tailwind 인라인 스타일 대응) - // - 스크립트: self only (SvelteKit hydration) - // - frame-ancestors: none (X-Frame-Options 이중 설정) - // - form-action: self (SAML ACS POST 예외는 별도 처리 불필요 — 서버→SP 방향) - const csp = [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline'", // SvelteKit SSR 인라인 스크립트 필요 - "style-src 'self' 'unsafe-inline'", // Tailwind 인라인 스타일 - "img-src 'self' data:", - "font-src 'self' data:", - "connect-src 'self'", - "frame-ancestors 'none'", - "form-action 'self' https:", // SAML ACS POST 허용 (SP는 HTTPS 외부 도메인) - "base-uri 'self'", - "object-src 'none'", - ].join("; "); - response.headers.set("Content-Security-Policy", csp); + // CSP는 svelte.config.js csp.mode='hash' 로 관리 (unsafe-inline 없는 해시 기반) return response; }; diff --git a/src/lib/server/auth/guards.ts b/src/lib/server/auth/guards.ts index 89364e8..cf72aba 100644 --- a/src/lib/server/auth/guards.ts +++ b/src/lib/server/auth/guards.ts @@ -7,3 +7,19 @@ export function requireDbContext(locals: App.Locals) { return { db: locals.db, tenant: locals.tenant }; } + +/** + * 관리자 전용 엔드포인트 가드. + * +layout.server.ts load 는 form action 제출 시 실행되지 않으므로, + * 모든 admin action 핸들러에서 반드시 이 함수를 사용해야 한다. + */ +export function requireAdminContext(locals: App.Locals) { + const ctx = requireDbContext(locals); + if (!locals.user) { + throw error(401, "로그인이 필요합니다."); + } + if (locals.user.role !== "admin") { + throw error(403, "관리자 권한이 필요합니다."); + } + return { ...ctx, user: locals.user }; +} diff --git a/src/lib/server/auth/password.ts b/src/lib/server/auth/password.ts index d2ebbfc..1560d3b 100644 --- a/src/lib/server/auth/password.ts +++ b/src/lib/server/auth/password.ts @@ -1,103 +1,110 @@ /** * 패스워드 해싱 유틸리티 * - * Cloudflare Workers 제약: - * - WebAssembly.compile() 에 인라인 바이트를 전달하는 것이 금지됨 - * - hash-wasm 은 이 방식으로 WASM 을 로드하므로 Workers 에서 동작하지 않음 - * - WebCrypto PBKDF2 는 최대 100,000 회까지만 허용 + * 신규 해시: argon2id (@hicaru/argon2-pure.js — 순수 JS, Workers 호환) + * 레거시 해시: PBKDF2-SHA256 (검증 후 argon2id로 자동 업그레이드) * - * 현재 구현: PBKDF2-SHA256, 100,000 회 (Workers 상한) + * Cloudflare Workers 제약상 memCost를 보수적으로 설정함. + * OWASP 최소 권고(owasp5: m=7168, t=5)보다 낮으나 Workers CPU 한도 내 동작을 우선. + * 추후 WASM 기반 구현(@rabbit-company/argon2id)으로 전환 시 파라미터 상향 권장. */ -const PASSWORD_ALGORITHM = "pbkdf2"; -const PASSWORD_DIGEST = "sha256"; -const PASSWORD_ITERATIONS = 100_000; -const PASSWORD_SALT_LENGTH = 16; +import { hashEncoded, verifyEncoded, Config, Variant, Version } from "@hicaru/argon2-pure.js"; -function bytesToBase64(bytes: Uint8Array): string { - return btoa(String.fromCharCode(...bytes)); +// Workers CPU 한도(~50ms) 내에서 동작 가능한 보수적 파라미터 +const ARGON2_CONFIG = new Config( + new Uint8Array(), // ad + 32, // hashLength + 1, // lanes (parallelism) + 4096, // memCost (4 MB) + new Uint8Array(), // secret + 3, // timeCost + Variant.Argon2id, + Version.Version13, +); + +// ── argon2id ────────────────────────────────────────────────────────────────── + +export async function hashPassword(password: string): Promise { + const pwd = new TextEncoder().encode(password); + const salt = crypto.getRandomValues(new Uint8Array(16)); + return hashEncoded(pwd, salt, ARGON2_CONFIG); } +export async function verifyPassword( + password: string, + record: string, +): Promise<{ + valid: boolean; + rehash?: string; +}> { + const pwd = new TextEncoder().encode(password); + + // argon2id PHC 형식 ($argon2id$...) + if (record.startsWith("$argon2")) { + const valid = verifyEncoded(record, pwd); + return { valid }; + } + + // PBKDF2 레거시 형식 — 검증 후 argon2id로 업그레이드 + if (record.startsWith("pbkdf2$")) { + const legacyResult = await verifyPbkdf2(password, record); + if (!legacyResult) return { valid: false }; + return { valid: true, rehash: await hashPassword(password) }; + } + + return { valid: false }; +} + +// ── PBKDF2 레거시 검증 (신규 해싱에는 사용하지 않음) ────────────────────────── + +const PBKDF2_ITERATIONS = 100_000; + function base64ToBytes(value: string): Uint8Array { return Uint8Array.from(atob(value), (char) => char.charCodeAt(0)); } function timingSafeEqual(left: Uint8Array, right: Uint8Array): boolean { - if (left.length !== right.length) { - return false; - } - + if (left.length !== right.length) return false; let difference = 0; - for (let index = 0; index < left.length; index += 1) { difference |= left[index] ^ right[index]; } - return difference === 0; } -async function derivePasswordHash(password: string, salt: Uint8Array, iterations: number): Promise { - const normalizedSalt = new Uint8Array(salt); - const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), { name: "PBKDF2" }, false, ["deriveBits"]); - const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: normalizedSalt.buffer, iterations }, keyMaterial, 256); +async function verifyPbkdf2(password: string, record: string): Promise { + const parts = record.split("$"); + if (parts.length !== 4) return false; + const [, params, saltB64, hashB64] = parts; + const [digest, iterationsStr] = params?.split(":") ?? []; + const iterations = Number(iterationsStr); + if (digest !== "sha256:100000" && !Number.isFinite(iterations)) return false; - return new Uint8Array(bits); + const salt = base64ToBytes(saltB64); + const storedHash = base64ToBytes(hashB64); + const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), { name: "PBKDF2" }, false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: salt.buffer as ArrayBuffer, iterations: iterations || PBKDF2_ITERATIONS }, keyMaterial, 256); + return timingSafeEqual(new Uint8Array(bits), storedHash); } -function formatHashRecord(salt: Uint8Array, hash: Uint8Array, iterations = PASSWORD_ITERATIONS): string { - return `${PASSWORD_ALGORITHM}$${PASSWORD_DIGEST}:${iterations}$${bytesToBase64(salt)}$${bytesToBase64(hash)}`; -} +// ── 관리자 전용: 순수 PBKDF2 파싱 (하위 호환 포맷 유지) ───────────────────── function parseHashRecord(record: string) { const [algorithm, params, saltB64, hashB64] = record.split("$"); const [digest, iterationsString] = params?.split(":") ?? []; const iterations = Number(iterationsString); - - if (algorithm !== PASSWORD_ALGORITHM || digest !== PASSWORD_DIGEST || !Number.isFinite(iterations) || !saltB64 || !hashB64) { - return null; - } - - return { - iterations, - salt: base64ToBytes(saltB64), - hash: base64ToBytes(hashB64), - }; + if (algorithm !== "pbkdf2" || digest !== "sha256" || !Number.isFinite(iterations) || !saltB64 || !hashB64) return null; + return { iterations, salt: base64ToBytes(saltB64), hash: base64ToBytes(hashB64) }; } -export async function hashPassword(password: string): Promise { - const salt = crypto.getRandomValues(new Uint8Array(PASSWORD_SALT_LENGTH)); - const hash = await derivePasswordHash(password, salt, PASSWORD_ITERATIONS); - - return formatHashRecord(salt, hash); -} - -export async function verifyPassword( - password: string, - record: string, -): Promise<{ - valid: boolean; - rehash?: string; -}> { - const parsed = parseHashRecord(record); - - if (!parsed) { - return { valid: false }; - } - - const candidateHash = await derivePasswordHash(password, parsed.salt, parsed.iterations); - const valid = timingSafeEqual(candidateHash, parsed.hash); - - if (!valid) { - return { valid: false }; - } - - // 이전에 낮은 iterations 로 해싱된 경우 재해싱 - if (parsed.iterations !== PASSWORD_ITERATIONS) { - return { - valid: true, - rehash: await hashPassword(password), - }; +export async function verifyPasswordLegacy(password: string, record: string): Promise { + if (record.startsWith("$argon2")) { + return verifyEncoded(record, new TextEncoder().encode(password)); } - - return { valid: true }; + const parsed = parseHashRecord(record); + if (!parsed) return false; + const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), { name: "PBKDF2" }, false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: parsed.salt.buffer as ArrayBuffer, iterations: parsed.iterations }, keyMaterial, 256); + return timingSafeEqual(new Uint8Array(bits), parsed.hash); } diff --git a/src/lib/server/auth/session.ts b/src/lib/server/auth/session.ts index dc7612f..b73fd82 100644 --- a/src/lib/server/auth/session.ts +++ b/src/lib/server/auth/session.ts @@ -11,6 +11,12 @@ function bytesToBase64Url(bytes: Uint8Array): string { .replace(/=+$/g, ""); } +async function hashSessionToken(token: string): Promise { + const data = new TextEncoder().encode(token); + const hash = await crypto.subtle.digest("SHA-256", data); + return bytesToBase64Url(new Uint8Array(hash)); +} + function cookieOptions(url: URL, expiresAt: Date) { return { path: "/", @@ -42,11 +48,13 @@ export async function createSessionRecord( const expiresAt = new Date(now + SESSION_TTL_MS); const sessionToken = createSessionToken(); + const tokenHash = await hashSessionToken(sessionToken); + await db.insert(sessions).values({ id: crypto.randomUUID(), tenantId: params.tenantId, userId: params.userId, - idpSessionId: sessionToken, + idpSessionId: tokenHash, amr: params.amr ? params.amr.join(" ") : null, acr: params.acr ?? null, ip: params.ip ?? null, @@ -60,11 +68,12 @@ export async function createSessionRecord( export async function getSessionContext(db: DB, sessionToken: string) { const now = new Date(); + const tokenHash = await hashSessionToken(sessionToken); const [row] = await db .select({ session: sessions, user: users }) .from(sessions) .innerJoin(users, eq(sessions.userId, users.id)) - .where(and(eq(sessions.idpSessionId, sessionToken), gt(sessions.expiresAt, now), isNull(sessions.revokedAt), eq(users.status, "active"))) + .where(and(eq(sessions.idpSessionId, tokenHash), gt(sessions.expiresAt, now), isNull(sessions.revokedAt), eq(users.status, "active"))) .limit(1); return row ?? null; diff --git a/src/lib/server/crypto/keys.ts b/src/lib/server/crypto/keys.ts index 66d2024..d306d68 100644 --- a/src/lib/server/crypto/keys.ts +++ b/src/lib/server/crypto/keys.ts @@ -124,6 +124,36 @@ export async function signJwt(payload: Record, privateKey: Cryp return `${signingInput}.${b64uEncode(sig)}`; } +export async function verifyIdToken(db: DB, tenantId: string, token: string): Promise | null> { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const [headerB64, payloadB64, sigB64] = parts; + const dec = new TextDecoder(); + const header = JSON.parse(dec.decode(b64uDecode(headerB64))) as { kid?: string; alg?: string }; + if (header.alg !== "RS256" || !header.kid) return null; + + const [row] = await db + .select({ publicJwk: signingKeys.publicJwk }) + .from(signingKeys) + .where(and(eq(signingKeys.kid, header.kid), eq(signingKeys.tenantId, tenantId))) + .limit(1); + if (!row?.publicJwk) return null; + + const jwk = JSON.parse(row.publicJwk) as JsonWebKey; + const publicKey = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]); + const enc = new TextEncoder(); + const valid = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", publicKey, b64uDecode(sigB64), enc.encode(`${headerB64}.${payloadB64}`)); + if (!valid) return null; + + const claims = JSON.parse(dec.decode(b64uDecode(payloadB64))) as Record; + if (typeof claims.exp === "number" && claims.exp < Math.floor(Date.now() / 1000)) return null; + return claims; + } catch { + return null; + } +} + // ── opaque access token (HMAC-SHA256) ───────────────────────────────────────── export interface AccessTokenClaims { diff --git a/src/lib/server/ldap/client.ts b/src/lib/server/ldap/client.ts index df4db0d..8560075 100644 --- a/src/lib/server/ldap/client.ts +++ b/src/lib/server/ldap/client.ts @@ -11,7 +11,7 @@ function createLdapClient(config: LdapProviderConfig): ldap.Client { url: buildUrl(config), connectTimeout: 5000, timeout: 5000, - ...(config.tlsMode === "tls" ? { tlsOptions: { rejectUnauthorized: true } } : {}), + ...(config.tlsMode === "tls" || config.tlsMode === "starttls" ? { tlsOptions: { rejectUnauthorized: true } } : {}), }); } diff --git a/src/lib/server/ratelimit/index.ts b/src/lib/server/ratelimit/index.ts index b7f36a4..0d6cdcd 100644 --- a/src/lib/server/ratelimit/index.ts +++ b/src/lib/server/ratelimit/index.ts @@ -1,12 +1,15 @@ /** - * D1 기반 고정 윈도우 레이트 리밋. + * D1 기반 슬라이딩 윈도우 레이트 리밋 (두 버킷 근사법). * - * 테이블: rate_limits(key PK, count, expires_at) - * - 윈도우가 만료됐으면 카운터 리셋 - * - 윈도우 내 count > limit 이면 차단 + * 윈도우를 고정 인덱스(Math.floor(now/windowMs))로 분할해 두 버킷을 유지: + * - 현재 버킷 카운터를 원자적으로 증가 + * - 이전 버킷 카운트에 경과 비율의 역수를 가중치로 적용 + * count ≈ prev * (1 - elapsed/window) + current + * + * Fixed Window 대비 경계 burst(최대 2x) 문제를 제거. */ -import { sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import type { DB } from "$lib/server/db"; import { rateLimits } from "$lib/server/db/schema"; @@ -23,40 +26,44 @@ export interface RateLimitOptions { limit: number; } -/** - * 지정된 key 에 대해 레이트 리밋을 확인하고 카운터를 증가시킨다. - * - * SELECT→UPDATE 분리 대신 단일 INSERT...ON CONFLICT DO UPDATE...RETURNING 으로 - * 원자적으로 처리해 동시 요청 race condition 을 제거한다. - */ export async function checkRateLimit(db: DB, key: string, options: RateLimitOptions): Promise { const now = Date.now(); - const newExpiresAt = new Date(now + options.windowMs); + const windowIndex = Math.floor(now / options.windowMs); + const windowStart = windowIndex * options.windowMs; + const elapsed = now - windowStart; - // 단일 문으로 삽입/리셋/증가를 원자적으로 수행 - const [row] = await db + const currentKey = `${key}:${windowIndex}`; + const prevKey = `${key}:${windowIndex - 1}`; + // 두 윈도우가 지나면 만료 + const currentExpiresAt = new Date(windowStart + options.windowMs * 2); + + // 현재 버킷 원자적 증가 + const [currentRow] = await db .insert(rateLimits) - .values({ key, count: 1, expiresAt: newExpiresAt }) + .values({ key: currentKey, count: 1, expiresAt: currentExpiresAt }) .onConflictDoUpdate({ target: rateLimits.key, - set: { - count: sql`CASE WHEN ${rateLimits.expiresAt} <= ${now} THEN 1 ELSE ${rateLimits.count} + 1 END`, - expiresAt: sql`CASE WHEN ${rateLimits.expiresAt} <= ${now} THEN ${newExpiresAt.getTime()} ELSE ${rateLimits.expiresAt} END`, - }, + set: { count: sql`${rateLimits.count} + 1` }, }) - .returning({ count: rateLimits.count, expiresAt: rateLimits.expiresAt }); + .returning({ count: rateLimits.count }); + + // 이전 버킷 조회 (best-effort) + const [prevRow] = await db.select({ count: rateLimits.count }).from(rateLimits).where(eq(rateLimits.key, prevKey)).limit(1); + + const prevCount = prevRow?.count ?? 0; + const slidingCount = Math.floor(prevCount * (1 - elapsed / options.windowMs)) + currentRow.count; - if (row.count > options.limit) { + if (slidingCount > options.limit) { return { allowed: false, remaining: 0, - retryAfterMs: row.expiresAt.getTime() - now, + retryAfterMs: options.windowMs - elapsed, }; } return { allowed: true, - remaining: options.limit - row.count, + remaining: Math.max(0, options.limit - slidingCount), retryAfterMs: 0, }; } diff --git a/src/routes/admin/departments/+page.server.ts b/src/routes/admin/departments/+page.server.ts index 53514c8..7749821 100644 --- a/src/routes/admin/departments/+page.server.ts +++ b/src/routes/admin/departments/+page.server.ts @@ -1,11 +1,11 @@ import { fail } from "@sveltejs/kit"; import { asc, and, eq } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; -import { requireDbContext } from "$lib/server/auth/guards"; +import { requireAdminContext } from "$lib/server/auth/guards"; import { departments } from "$lib/server/db/schema"; export const load: PageServerLoad = async ({ locals }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const rows = await db.select().from(departments).where(eq(departments.tenantId, tenant.id)).orderBy(asc(departments.displayOrder), asc(departments.name)); @@ -24,7 +24,7 @@ export const load: PageServerLoad = async ({ locals }) => { export const actions: Actions = { create: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const name = String(fd.get("name") ?? "").trim(); const code = String(fd.get("code") ?? "").trim() || null; @@ -46,7 +46,7 @@ export const actions: Actions = { }, update: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const id = String(fd.get("id") ?? ""); const name = String(fd.get("name") ?? "").trim(); @@ -75,7 +75,7 @@ export const actions: Actions = { }, delete: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const id = String(fd.get("id") ?? ""); if (!id) return fail(400, { error: "잘못된 요청입니다." }); diff --git a/src/routes/admin/ldap-providers/+page.server.ts b/src/routes/admin/ldap-providers/+page.server.ts index aa3bbd8..22cacc2 100644 --- a/src/routes/admin/ldap-providers/+page.server.ts +++ b/src/routes/admin/ldap-providers/+page.server.ts @@ -1,7 +1,7 @@ import { fail } from "@sveltejs/kit"; import { and, desc, eq } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; -import { requireDbContext } from "$lib/server/auth/guards"; +import { requireAdminContext } from "$lib/server/auth/guards"; import { getRequestMetadata, recordAuditEvent } from "$lib/server/audit"; import { getRuntimeConfig } from "$lib/server/auth/runtime"; import { encryptSecret } from "$lib/server/crypto/keys"; @@ -58,7 +58,7 @@ async function encryptBindPassword(config: LdapProviderConfig, signingKeySecret: } export const load: PageServerLoad = async ({ locals }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const rows = await db .select() @@ -71,7 +71,7 @@ export const load: PageServerLoad = async ({ locals }) => { export const actions: Actions = { create: async (event) => { - const { db, tenant } = requireDbContext(event.locals); + const { db, tenant } = requireAdminContext(event.locals); const fd = await event.request.formData(); const name = String(fd.get("name") ?? "").trim(); @@ -113,7 +113,7 @@ export const actions: Actions = { }, update: async (event) => { - const { db, tenant } = requireDbContext(event.locals); + const { db, tenant } = requireAdminContext(event.locals); const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); @@ -134,7 +134,7 @@ export const actions: Actions = { }, delete: async (event) => { - const { db, tenant } = requireDbContext(event.locals); + const { db, tenant } = requireAdminContext(event.locals); const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); diff --git a/src/routes/admin/oidc-clients/+page.server.ts b/src/routes/admin/oidc-clients/+page.server.ts index 56073ca..e04c983 100644 --- a/src/routes/admin/oidc-clients/+page.server.ts +++ b/src/routes/admin/oidc-clients/+page.server.ts @@ -1,7 +1,7 @@ import { fail } from "@sveltejs/kit"; import { desc, eq, and } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; -import { requireDbContext } from "$lib/server/auth/guards"; +import { requireAdminContext } from "$lib/server/auth/guards"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; import { oidcClients } from "$lib/server/db/schema"; import { hashPassword } from "$lib/server/auth/password"; @@ -28,7 +28,7 @@ function parseUris(raw: string): string { } export const load: PageServerLoad = async ({ locals }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const rows = await db .select({ id: oidcClients.id, @@ -53,7 +53,7 @@ export const actions: Actions = { // ── 클라이언트 생성 ──────────────────────────────────────────────────────── create: async (event) => { const { locals } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const name = String(fd.get("name") ?? "").trim(); @@ -103,7 +103,7 @@ export const actions: Actions = { // ── 클라이언트 수정 ──────────────────────────────────────────────────────── update: async (event) => { const { locals } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); @@ -111,12 +111,19 @@ export const actions: Actions = { const redirectUrisRaw = String(fd.get("redirectUris") ?? "").trim(); const postLogoutUrisRaw = String(fd.get("postLogoutRedirectUris") ?? "").trim(); const scopes = String(fd.get("scopes") ?? "openid").trim(); - const requirePkce = fd.get("requirePkce") === "true"; const enabled = fd.get("enabled") === "true"; if (!id || !name) return fail(400, { error: "잘못된 요청입니다." }); if (!redirectUrisRaw) return fail(400, { error: "Redirect URI 는 필수입니다." }); + // public client(none)는 PKCE를 수정 시에도 강제 유지 + const [existingClient] = await db + .select({ tokenEndpointAuthMethod: oidcClients.tokenEndpointAuthMethod }) + .from(oidcClients) + .where(and(eq(oidcClients.id, id), eq(oidcClients.tenantId, tenant.id))) + .limit(1); + const requirePkce = existingClient?.tokenEndpointAuthMethod === "none" ? true : fd.get("requirePkce") === "true"; + await db .update(oidcClients) .set({ @@ -147,7 +154,7 @@ export const actions: Actions = { // ── 시크릿 재생성 ───────────────────────────────────────────────────────── regenerateSecret: async (event) => { const { locals } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); @@ -177,7 +184,7 @@ export const actions: Actions = { // ── 삭제 ───────────────────────────────────────────────────────────────── delete: async (event) => { const { locals } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); diff --git a/src/routes/admin/parts/+page.server.ts b/src/routes/admin/parts/+page.server.ts index f359913..0bea9fc 100644 --- a/src/routes/admin/parts/+page.server.ts +++ b/src/routes/admin/parts/+page.server.ts @@ -1,11 +1,11 @@ import { fail } from "@sveltejs/kit"; import { asc, and, eq } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; -import { requireDbContext } from "$lib/server/auth/guards"; +import { requireAdminContext } from "$lib/server/auth/guards"; import { departments, parts, teams } from "$lib/server/db/schema"; export const load: PageServerLoad = async ({ locals }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const rows = await db .select({ @@ -41,7 +41,7 @@ export const load: PageServerLoad = async ({ locals }) => { export const actions: Actions = { create: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const name = String(fd.get("name") ?? "").trim(); const code = String(fd.get("code") ?? "").trim() || null; @@ -61,7 +61,7 @@ export const actions: Actions = { }, update: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const id = String(fd.get("id") ?? ""); const name = String(fd.get("name") ?? "").trim(); @@ -80,7 +80,7 @@ export const actions: Actions = { }, delete: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const id = String(fd.get("id") ?? ""); if (!id) return fail(400, { error: "잘못된 요청입니다." }); diff --git a/src/routes/admin/positions/+page.server.ts b/src/routes/admin/positions/+page.server.ts index e09ce6a..b9a3d5e 100644 --- a/src/routes/admin/positions/+page.server.ts +++ b/src/routes/admin/positions/+page.server.ts @@ -1,18 +1,18 @@ import { fail } from "@sveltejs/kit"; import { asc, and, eq } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; -import { requireDbContext } from "$lib/server/auth/guards"; +import { requireAdminContext } from "$lib/server/auth/guards"; import { positions } from "$lib/server/db/schema"; export const load: PageServerLoad = async ({ locals }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const rows = await db.select().from(positions).where(eq(positions.tenantId, tenant.id)).orderBy(asc(positions.level), asc(positions.name)); return { positions: rows }; }; export const actions: Actions = { create: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const name = String(fd.get("name") ?? "").trim(); const code = String(fd.get("code") ?? "").trim() || null; @@ -31,7 +31,7 @@ export const actions: Actions = { }, update: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const id = String(fd.get("id") ?? ""); const name = String(fd.get("name") ?? "").trim(); @@ -48,7 +48,7 @@ export const actions: Actions = { }, delete: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const id = String(fd.get("id") ?? ""); if (!id) return fail(400, { error: "잘못된 요청입니다." }); diff --git a/src/routes/admin/saml-sps/+page.server.ts b/src/routes/admin/saml-sps/+page.server.ts index fa145a5..4d6143a 100644 --- a/src/routes/admin/saml-sps/+page.server.ts +++ b/src/routes/admin/saml-sps/+page.server.ts @@ -1,12 +1,12 @@ import { fail } from "@sveltejs/kit"; import { desc, eq, and } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; -import { requireDbContext } from "$lib/server/auth/guards"; +import { requireAdminContext } from "$lib/server/auth/guards"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; import { samlSps } from "$lib/server/db/schema"; export const load: PageServerLoad = async ({ locals }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const rows = await db .select({ id: samlSps.id, @@ -50,7 +50,7 @@ export const actions: Actions = { // ── SP 생성 ──────────────────────────────────────────────────────────────── create: async (event) => { const { locals } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const name = String(fd.get("name") ?? "").trim(); @@ -109,7 +109,7 @@ export const actions: Actions = { // ── SP 수정 ──────────────────────────────────────────────────────────────── update: async (event) => { const { locals } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); @@ -149,7 +149,7 @@ export const actions: Actions = { // ── SP 삭제 ──────────────────────────────────────────────────────────────── delete: async (event) => { const { locals } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); diff --git a/src/routes/admin/signing-keys/+page.server.ts b/src/routes/admin/signing-keys/+page.server.ts index 778cf2c..2f1fddb 100644 --- a/src/routes/admin/signing-keys/+page.server.ts +++ b/src/routes/admin/signing-keys/+page.server.ts @@ -1,14 +1,14 @@ import { fail } from "@sveltejs/kit"; import { desc, eq, and, isNull } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; -import { requireDbContext } from "$lib/server/auth/guards"; +import { requireAdminContext } from "$lib/server/auth/guards"; import { getRuntimeConfig } from "$lib/server/auth/runtime"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; import { signingKeys } from "$lib/server/db/schema"; import { generateRsaSigningKey, wrapPrivateKey, generateSelfSignedCert } from "$lib/server/crypto/keys"; export const load: PageServerLoad = async ({ locals }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const rows = await db .select({ id: signingKeys.id, @@ -34,7 +34,7 @@ export const actions: Actions = { // ── 새 키 생성 + 기존 활성 키 rotate ────────────────────────────────────── rotate: async (event) => { const { locals, platform } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const config = getRuntimeConfig(platform); if (!config.signingKeySecret) { diff --git a/src/routes/admin/teams/+page.server.ts b/src/routes/admin/teams/+page.server.ts index 7db0fcb..d96c37e 100644 --- a/src/routes/admin/teams/+page.server.ts +++ b/src/routes/admin/teams/+page.server.ts @@ -1,11 +1,11 @@ import { fail } from "@sveltejs/kit"; import { asc, and, eq } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; -import { requireDbContext } from "$lib/server/auth/guards"; +import { requireAdminContext } from "$lib/server/auth/guards"; import { departments, teams } from "$lib/server/db/schema"; export const load: PageServerLoad = async ({ locals }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const rows = await db .select({ @@ -33,7 +33,7 @@ export const load: PageServerLoad = async ({ locals }) => { export const actions: Actions = { create: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const name = String(fd.get("name") ?? "").trim(); const code = String(fd.get("code") ?? "").trim() || null; @@ -53,7 +53,7 @@ export const actions: Actions = { }, update: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const id = String(fd.get("id") ?? ""); const name = String(fd.get("name") ?? "").trim(); @@ -72,7 +72,7 @@ export const actions: Actions = { }, delete: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const id = String(fd.get("id") ?? ""); if (!id) return fail(400, { error: "잘못된 요청입니다." }); diff --git a/src/routes/admin/users/+page.server.ts b/src/routes/admin/users/+page.server.ts index 230d385..25957dc 100644 --- a/src/routes/admin/users/+page.server.ts +++ b/src/routes/admin/users/+page.server.ts @@ -1,7 +1,7 @@ import { fail } from "@sveltejs/kit"; import { desc, eq, and } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; -import { requireDbContext } from "$lib/server/auth/guards"; +import { requireAdminContext } from "$lib/server/auth/guards"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; import { users, credentials } from "$lib/server/db/schema"; import { hashPassword } from "$lib/server/auth/password"; @@ -10,7 +10,7 @@ import { PASSWORD_CREDENTIAL_TYPE } from "$lib/server/auth/constants"; import { revokeAllUserSessions } from "$lib/server/auth/session"; export const load: PageServerLoad = async ({ locals }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const rows = await db .select({ id: users.id, @@ -32,7 +32,7 @@ export const actions: Actions = { // ── 사용자 생성 ──────────────────────────────────────────────────────────── create: async (event) => { const { locals } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const email = normalizeEmail(String(fd.get("email") ?? "")); @@ -108,7 +108,7 @@ export const actions: Actions = { // ── 상태 변경 ───────────────────────────────────────────────────────────── updateStatus: async (event) => { const { locals } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); @@ -151,7 +151,7 @@ export const actions: Actions = { // ── 역할 변경 ───────────────────────────────────────────────────────────── updateRole: async (event) => { const { locals } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); @@ -192,7 +192,7 @@ export const actions: Actions = { // ── 비밀번호 초기화 ────────────────────────────────────────────────────── resetPassword: async (event) => { const { locals } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); @@ -252,7 +252,7 @@ export const actions: Actions = { // ── 삭제 ───────────────────────────────────────────────────────────────── delete: async (event) => { const { locals } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); diff --git a/src/routes/admin/users/[id]/+page.server.ts b/src/routes/admin/users/[id]/+page.server.ts index 3096511..16ea4c0 100644 --- a/src/routes/admin/users/[id]/+page.server.ts +++ b/src/routes/admin/users/[id]/+page.server.ts @@ -1,11 +1,11 @@ import { fail, error } from "@sveltejs/kit"; import { and, asc, eq, isNull } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; -import { requireDbContext } from "$lib/server/auth/guards"; +import { requireAdminContext } from "$lib/server/auth/guards"; import { departments, parts, positions, teams, userDepartments, userParts, userTeams, users } from "$lib/server/db/schema"; export const load: PageServerLoad = async ({ locals, params }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const userId = params.id; // 유저 조회 @@ -104,7 +104,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { export const actions: Actions = { // 프로필 수정 updateProfile: async ({ locals, params, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const userId = params.id; @@ -152,7 +152,7 @@ export const actions: Actions = { // 부서 소속 추가 addDept: async ({ locals, params, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const userId = params.id; const departmentId = String(fd.get("departmentId") ?? ""); @@ -191,7 +191,7 @@ export const actions: Actions = { // 부서 소속 제거 (endedAt 설정) removeDept: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const membershipId = String(fd.get("membershipId") ?? ""); if (!membershipId) return fail(400, { error: "잘못된 요청입니다." }); @@ -205,7 +205,7 @@ export const actions: Actions = { // 팀 소속 추가 addTeam: async ({ locals, params, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const userId = params.id; const teamId = String(fd.get("teamId") ?? ""); @@ -234,7 +234,7 @@ export const actions: Actions = { // 팀 소속 제거 removeTeam: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const membershipId = String(fd.get("membershipId") ?? ""); if (!membershipId) return fail(400, { error: "잘못된 요청입니다." }); @@ -248,7 +248,7 @@ export const actions: Actions = { // 파트 소속 추가 addPart: async ({ locals, params, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const userId = params.id; const partId = String(fd.get("partId") ?? ""); @@ -277,7 +277,7 @@ export const actions: Actions = { // 파트 소속 제거 removePart: async ({ locals, request }) => { - const { db, tenant } = requireDbContext(locals); + const { db, tenant } = requireAdminContext(locals); const fd = await request.formData(); const membershipId = String(fd.get("membershipId") ?? ""); if (!membershipId) return fail(400, { error: "잘못된 요청입니다." }); diff --git a/src/routes/oidc/end-session/+server.ts b/src/routes/oidc/end-session/+server.ts index a3fd4b9..e2dfccb 100644 --- a/src/routes/oidc/end-session/+server.ts +++ b/src/routes/oidc/end-session/+server.ts @@ -3,10 +3,30 @@ import type { RequestHandler } from "./$types"; import { and, eq } from "drizzle-orm"; import { oidcClients } from "$lib/server/db/schema"; import { clearSessionCookie, revokeSession } from "$lib/server/auth/session"; +import { verifyIdToken } from "$lib/server/crypto/keys"; async function handleEndSession(locals: App.Locals, url: URL, cookies: Parameters[0]["cookies"]): Promise { const postLogoutRedirectUri = url.searchParams.get("post_logout_redirect_uri"); const clientId = url.searchParams.get("client_id"); + const idTokenHint = url.searchParams.get("id_token_hint"); + + // id_token_hint 제공 시 서명 검증 및 sub 일치 확인 + if (idTokenHint && locals.db && locals.tenant) { + const claims = await verifyIdToken(locals.db, locals.tenant.id, idTokenHint); + if (!claims) { + return new Response(JSON.stringify({ error: "invalid_id_token_hint" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }) as unknown as never; + } + // 현재 세션 사용자와 불일치 시 거부 + if (locals.user && claims.sub !== locals.user.id) { + return new Response(JSON.stringify({ error: "id_token_hint_mismatch" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }) as unknown as never; + } + } // IdP 세션 폐기 if (locals.session && locals.db) { diff --git a/src/routes/saml/sso/+server.ts b/src/routes/saml/sso/+server.ts index 2c95972..37e2556 100644 --- a/src/routes/saml/sso/+server.ts +++ b/src/routes/saml/sso/+server.ts @@ -10,6 +10,7 @@ import type { RequestHandler } from "./$types"; import { requireDbContext } from "$lib/server/auth/guards"; import { getRuntimeConfig } from "$lib/server/auth/runtime"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit"; +import { checkRateLimit } from "$lib/server/ratelimit"; import { getActiveSigningKey } from "$lib/server/crypto/keys"; import { acrSatisfies } from "$lib/server/auth/constants"; import { parseAuthnRequest, verifySamlRedirectSignature } from "$lib/server/saml/parse-authn-request"; @@ -22,6 +23,13 @@ export const GET: RequestHandler = async (event) => { const { db, tenant } = requireDbContext(locals); const config = getRuntimeConfig(platform); + // IP당 30회/분 — AuthnRequest 파싱·서명 검증 연산 DoS 방지 + const { ip } = getRequestMetadata(event); + const rl = await checkRateLimit(db, `saml-sso:${ip ?? "unknown"}`, { windowMs: 60 * 1000, limit: 30 }); + if (!rl.allowed) { + throw error(429, "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요."); + } + if (!config.issuerUrl) throw error(503, "IDP_ISSUER_URL 미설정"); if (!config.signingKeySecret) throw error(503, "IDP_SIGNING_KEY_SECRET 미설정"); diff --git a/svelte.config.js b/svelte.config.js index 357d916..59d9232 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -8,6 +8,21 @@ const config = { }, kit: { adapter: adapter(), + csp: { + mode: "hash", // unsafe-inline 제거: SvelteKit이 인라인 스크립트 해시를 자동 추가 + directives: { + "default-src": ["self"], + "script-src": ["self"], // 해시 자동 추가됨 + "style-src": ["self", "unsafe-inline"], // Tailwind 인라인 스타일 필요 + "img-src": ["self", "data:"], + "font-src": ["self", "data:"], + "connect-src": ["self"], + "frame-ancestors": ["none"], + "form-action": ["self", "https:"], // SAML ACS HTTP-POST 바인딩 + "base-uri": ["self"], + "object-src": ["none"], + }, + }, // OIDC token endpoint 는 server-to-server 호출이므로 Origin 헤더 없이 전달된다. // SvelteKit CSRF 체크는 Origin 헤더가 없을 때 자동으로 통과하므로 별도 설정 불필요. // trustedOrigins: ['*'] 는 전체 CSRF 비활성화와 동일하여 제거함.