diff --git a/.env.example b/.env.example index df41d0f..17b7b77 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,15 @@ # - node: 순수 Node 서버 (adapter-node). platform 바인딩 없음 → pg/mysql 은 # DATABASE_URL 로 직결, 설정값은 process.env 로 읽는다. (D1 은 Workers 전용) # 예) BUILD_TARGET=node DB_DIALECT=postgres bun run build && node build +# +# 레이트 리밋 저장소(RateLimitStore)는 배포 타깃에 따라 자동 선택된다: +# - cloudflare(Workers): 요청 간 상태를 공유할 수 없는 isolate 특성상 DB(rate_limits 테이블) +# 기반 공유 저장소를 사용한다. +# - node(adapter-node) : 프로세스 내 in-memory 저장소를 사용한다(핫패스 DB write 없음). +# ⚠️ in-memory 는 "단일 프로세스" 가정이다. 같은 앱을 여러 인스턴스로 수평 확장하면 +# 각 인스턴스가 자기 카운터만 보므로 실질 한도가 인스턴스 수만큼 완화된다. +# 다중 인스턴스 Node 배포에서 엄격한 전역 한도가 필요하면 공유 저장소(Redis 등)로 +# RateLimitStore 를 교체해야 한다(인터페이스는 열려 있으나 이번 버전엔 미포함). BUILD_TARGET="cloudflare" # DB_DIALECT: d1(기본) | sqlite | postgres | mysql — 배포 단위로 하나만 사용 @@ -71,11 +80,14 @@ IDP_DEFAULT_TENANT_NAME="My Organization" # 서명 키 암호화 KEK — 최소 32자 랜덤 문자열 # 생성: openssl rand -base64 32 +# ⚠️ 프로덕션 필수: 미설정 시 요청 초기에 오류로 차단(fail-fast). dev 에서는 생략 가능. # ⚠️ 프로덕션에서는 wrangler secret put IDP_SIGNING_KEY_SECRET 사용 IDP_SIGNING_KEY_SECRET="your-very-long-random-secret-at-least-32-chars" # OIDC/SAML issuer URL (배포 도메인과 일치시킬 것) -# 미설정 시 요청 origin으로 자동 대체 (로컬 개발 시 http://localhost:5173) +# ⚠️ 프로덕션 필수: 미설정 시 요청 초기에 503 으로 차단(fail-closed). Host 헤더 주입으로 +# iss 클레임/SAML Issuer 가 오염되는 것을 막는다. +# dev 에서만 미설정 시 요청 origin 으로 자동 대체(로컬 http://localhost:5173). IDP_ISSUER_URL="http://localhost:5173" SMTP_HOSTNAME="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87ebf77..9ce1403 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,14 @@ jobs: - name: Test run: bun run test + - name: Migration drift check + run: | + bun run db:generate:all + if ! git diff --exit-code -- drizzle; then + echo "::error::스키마가 변경되었으나 마이그레이션이 생성되지 않았습니다. 'bun run db:generate:all' 실행 후 drizzle*/ 변경분을 커밋하세요." >&2 + exit 1 + fi + - name: Build run: bun run build diff --git a/.gitignore b/.gitignore index f316eca..490df1d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,12 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Test coverage +/coverage + +# ESLint cache +.eslintcache # SQLite *.db diff --git a/.gitleaksignore b/.gitleaksignore index 1712e11..ba2ba26 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,2 +1,11 @@ # False positive: "pbkdf2/argon2id" 는 credentials.secret 컬럼이 어떤 해시 알고리즘 형식을 저장하는지 설명하는 문자열일 뿐, 실제 시크릿이 아님 02bc8b64f32a78d60cc39c46acfd6d49b384cbbd:src/lib/server/db/schema.ts:generic-api-key:62 + +# False positive: 테스트 시드용 고정 비밀번호("pw-strong-123") — 실제 시크릿 아님 +57e8e95ccc4038c7a631a7c3318efcb446c15c8e:test/integration/p12-logic.test.ts:generic-api-key:45 + +# False positive: AES-GCM 도메인 분리 라벨 문자열("idp-signing-key-wrap-v1") — 실제 시크릿 아님 +e83b290fba4a75c4c989434b024b589c1e256918:scripts/reencrypt-secrets.ts:generic-api-key:194 + +# False positive: 테스트 상수("wrap-secret-0123456789") — 실제 시크릿 아님 +d48992c3dd491706b9795d1e2ae3de3d234eadcf:test/unit/crypto-keys.test.ts:generic-api-key:204 diff --git a/README.md b/README.md index c52fd1a..9254c2a 100644 --- a/README.md +++ b/README.md @@ -197,16 +197,16 @@ wrangler secret put IDP_SIGNING_KEY_SECRET ## 환경변수 -| 변수 | 필수 | 설명 | -| ----------------------------------- | ---- | ---------------------------------------------------------------------------------------- | -| `IDP_ISSUER_URL` | ✅ | OIDC/SAML 발급자 URL (배포 도메인과 일치) | -| `IDP_SIGNING_KEY_SECRET` | ✅ | 서명 키 암호화 KEK (프로덕션은 반드시 Secret) | -| `DISPATCHER_SERVICE_TOKEN` | 선택 | stardust dispatcher 가 `/api/totp/*` 호출 시 사용할 Bearer 토큰. 미설정이면 해당 API 503 | -| `IDP_DEFAULT_TENANT_NAME` | 선택 | 기본 테넌트 이름 (기본: `My Organization`) | -| `CLOUDFLARE_ACCOUNT_ID` | 선택 | Cloudflare 계정 ID (마이그레이션 스크립트에서 사용) | -| `CLOUDFLARE_D1_DATABASE_ID` | 선택 | D1 데이터베이스 ID (마이그레이션 스크립트에서 사용) | -| `CLOUDFLARE_D1_PREVIEW_DATABASE_ID` | 선택 | 프리뷰용 D1 데이터베이스 ID | -| `CLOUDFLARE_D1_TOKEN` | 선택 | D1 API 토큰 (`db:migrate` 스크립트에서 사용) | +| 변수 | 필수 | 설명 | +| ----------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `IDP_ISSUER_URL` | ✅ | OIDC/SAML 발급자 URL (배포 도메인과 일치). **프로덕션 필수** — 미설정 시 요청 초기 503(fail-closed). dev 에서만 요청 origin 자동 대체 | +| `IDP_SIGNING_KEY_SECRET` | ✅ | 서명 키 암호화 KEK (프로덕션은 반드시 Secret). **프로덕션 필수** — 미설정 시 요청 초기에 오류로 차단(fail-fast) | +| `DISPATCHER_SERVICE_TOKEN` | 선택 | stardust dispatcher 가 `/api/totp/*` 호출 시 사용할 Bearer 토큰. 미설정이면 해당 API 503 | +| `IDP_DEFAULT_TENANT_NAME` | 선택 | 기본 테넌트 이름 (기본: `My Organization`) | +| `CLOUDFLARE_ACCOUNT_ID` | 선택 | Cloudflare 계정 ID (마이그레이션 스크립트에서 사용) | +| `CLOUDFLARE_D1_DATABASE_ID` | 선택 | D1 데이터베이스 ID (마이그레이션 스크립트에서 사용) | +| `CLOUDFLARE_D1_PREVIEW_DATABASE_ID` | 선택 | 프리뷰용 D1 데이터베이스 ID | +| `CLOUDFLARE_D1_TOKEN` | 선택 | D1 API 토큰 (`db:migrate` 스크립트에서 사용) | > **참고**: 초기 관리자 계정은 `bun run setup` 이 생성합니다. 수동/CI 시드가 필요하면 `IDP_BOOTSTRAP_ADMIN_USERNAME` / `IDP_BOOTSTRAP_ADMIN_EMAIL` / `IDP_BOOTSTRAP_ADMIN_PASSWORD` (+선택 `IDP_BOOTSTRAP_ADMIN_NAME`) 를 설정하고 `bun run db:seed`(방언별: `db:seed:pg` 등)를 실행하세요. 비대화 환경에서는 `SEED_RESET=0|1` 로 초기화 여부를 지정합니다. diff --git a/bun.lock b/bun.lock index 795d1ad..7e917f5 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@types/node": "^26.1.0", "@types/nodemailer": "^8.0.0", "@types/qrcode": "^1.5.6", + "@vitest/coverage-v8": "^4.1.9", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", "eslint": "^10.3.0", @@ -59,6 +60,16 @@ "ws": "^8.21.0", }, "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], @@ -503,6 +514,8 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.9", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.9", "vitest": "4.1.9" }, "optionalPeers": ["@vitest/browser"] }, "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g=="], + "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], @@ -543,6 +556,8 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], @@ -685,8 +700,12 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -709,10 +728,18 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -787,6 +814,10 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "miniflare": ["miniflare@4.20260630.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260630.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-lyRplDrSJJWVpzSSQPBSQtNmUuxScCZyOOkXFs37uSbdTfWRDDmw6DyFKVS2s1eYtA/i4u2xR/0FyPIsTl/HJw=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -925,7 +956,7 @@ "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1037,6 +1068,8 @@ "@ldapjs/controls/@ldapjs/asn1": ["@ldapjs/asn1@1.2.0", "", {}, "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw=="], + "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], "@simplewebauthn/server/@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], @@ -1079,6 +1112,8 @@ "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "ast-v8-to-istanbul/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], @@ -1157,6 +1192,8 @@ "@vitest/mocker/estree-walker/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "ast-v8-to-istanbul/estree-walker/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md new file mode 100644 index 0000000..4d53b4d --- /dev/null +++ b/docs/ADMIN_GUIDE.md @@ -0,0 +1,285 @@ +# KeyStone 관리자 운영 매뉴얼 + +KeyStone(멀티테넌트 IdP)의 관리 콘솔 운영 가이드입니다. 각 화면에서 관리자가 무엇을 클릭하고 어떤 일이 일어나는지를 실무 관점에서 정리했습니다. + +> 콘솔 UI 는 한국어/영어(ko/en)를 지원합니다. 모든 관리 작업은 현재 로그인한 테넌트 범위에서만 동작하며, 주요 변경은 감사 로그(`/admin/audit`)에 기록됩니다. + +--- + +## 1. 개요 / 접근 + +### 관리자 로그인 + +- 로그인 URL: **`/admin/login`** +- 인증 흐름(`/admin/login` → `/mfa`): + 1. 아이디/비밀번호 검증(로컬 계정). + 2. **`role === "admin"` 이 아니면 거부**(감사 로그에 `reason: not_admin`). + 3. **TOTP(MFA) 미등록 관리자는 로그인 불가**(`reason: mfa_not_configured`) — 관리자는 반드시 TOTP 를 등록해야 합니다. + 4. MFA pending 쿠키 발급 후 `/mfa` 로 이동해 TOTP 코드 확인 → 세션 생성. +- 레이트리밋: IP당 15분에 10회. +- `IDP_SIGNING_KEY_SECRET` 미설정 시 MFA 토큰 서명이 불가해 로그인이 503 으로 막힙니다. + +### 접근 제어 + +- `/admin/**` 전 구간은 레이아웃 가드(`+layout.server.ts`)가 보호합니다. + - 미로그인 → `/admin/login?redirectTo=...` 로 리다이렉트. + - **`role !== "admin"` → `/` 로 강제 이동**(일반 사용자는 콘솔 접근 불가). +- `/admin/login` 만 예외적으로 비인증 접근 허용. + +### 대시보드 + +- **`/admin`**: 테넌트 요약 카운트(사용자, OIDC 클라이언트, SAML SP, 서명키, 감사 이벤트, 부서/팀/직급) 표시. + +--- + +## 2. 조직 관리 (부서 / 팀 / 파트 / 직급) + +조직은 **부서(department) → 팀(team) → 파트(part)** 3단계 계층이며, **직급(position)** 은 별도 축입니다. + +| 화면 | 경로 | 상위 참조 | 계층 | +| ---- | -------------------- | ------------------------------------- | ------------- | +| 부서 | `/admin/departments` | 상위 부서(`parentId`, 자기 참조 트리) | 최상위 | +| 팀 | `/admin/teams` | 부서(`departmentId`) | 부서 하위 | +| 파트 | `/admin/parts` | 팀(`teamId`) | 팀 하위 | +| 직급 | `/admin/positions` | 없음 | 독립(레벨 축) | + +### 부서 (`/admin/departments`) + +- 필드: 이름(필수), 코드, 상위 부서, 설명, 표시순서(`displayOrder`, 빈값 0). 수정 시 상태(active/inactive 등) 지정. +- **부서 트리 검증**(등록·수정 공통): + - 최대 깊이 **8단계**. + - 자기 자신을 상위로 지정 불가. + - 상위 체인에 순환 참조가 생기면 차단(간접 순환 A→B→A 포함). + - 상위 부서 선택지는 **활성(active) 부서**만 노출. +- 부서 트리 변경은 권한 상속에 직결되므로 **모든 변경이 감사 로그**(`department_*`)에 기록됩니다. + +### 팀 (`/admin/teams`) + +- 필드: 이름(필수), 코드, 소속 부서, 설명. 수정 시 상태. +- 소속 부서 선택지는 **활성 부서**만. 지정한 부서가 같은 테넌트에 존재하는지 참조 무결성 검증. + +### 파트 (`/admin/parts`) + +- 필드: 이름(필수), 코드, 소속 팀, 설명. 수정 시 상태. +- 소속 팀 선택지는 **활성 팀**만(부서명 병기). 참조 무결성 검증. + +### 직급 (`/admin/positions`) + +- 필드: 이름(필수), 코드, **레벨(`level`, 정수)**. 레벨 오름차순으로 정렬 표시. + +### 사용자 소속 배정 & 주소속(primary) 의미 + +개별 사용자의 소속은 **`/admin/users/[id]`** 상세 화면에서 배정합니다(부서/팀/파트 각각 add/remove). + +- 배정 시 각 소속에 **직책(jobTitle)** 을 지정할 수 있고, 부서 배정에는 **직급(position)** 을 함께 지정합니다. +- **주소속(primary)**: 각 축(부서/팀/파트)마다 `isPrimary` 체크박스로 지정. 소속 해제는 하드 삭제가 아니라 **`endedAt` 설정(소프트 종료)** 으로 처리됩니다(이력 보존). +- **주소속 부서**의 직급/직책이 OIDC `organization` 클레임의 최상위 `position` / `job_title` 값이 됩니다(주소속이 없으면 현재 소속 목록의 첫 부서를 사용). + +--- + +## 3. OIDC 클라이언트 등록/관리 (`/admin/oidc-clients`) + +### 생성 + +- **client_id**: 자동 생성(무작위 20자). +- **client_secret**: `token_endpoint_auth_method` 가 `none`(public)이 아니면 자동 생성되어 **생성 직후 화면에 1회만 노출**됩니다. DB 에는 해시만 저장되므로 이때 반드시 복사해 두어야 합니다. +- **Redirect URIs**(필수): 줄바꿈/콤마로 여러 개. `https` 또는 loopback(`http://localhost` 등) 허용, 모바일용 **커스텀 스킴 허용**. `javascript:`/`data:`/`file:`/`blob:`/`vbscript:` 및 fragment(`#`) 포함 URI 는 거부. +- **Post-Logout Redirect URIs / Front-channel / Back-channel Logout URI**: 로그아웃 관련 URL(각 세션 요구 플래그 포함). 커스텀 스킴 불허(https/loopback 만). +- **token_endpoint_auth_method**: `client_secret_basic` / `client_secret_post` / `none` 중 선택. +- **PKCE(`requirePkce`)**: 체크로 강제. **public 클라이언트(`none`)는 PKCE 가 항상 강제**되며 수정 시에도 해제 불가. +- **Wildcard Redirect URI(`allowWildcardRedirectUri`)**: 보안상 기본 비활성. 와일드카드 매칭이 꼭 필요할 때만 **명시적 opt-in**(체크). +- **Scopes**(공백 구분): `openid`(필수) / `profile` / `email` / `address` / `phone` / `offline_access` / `organization` / `groups`. `openid` 누락 시 거부. + - `offline_access` 를 넣어야 refresh token(grant) 이 발급됩니다. + +### 관리 + +- **수정**: 이름/URI/scope/로그아웃 설정/PKCE/와일드카드/활성화(enabled) 변경. +- **시크릿 재발급(`regenerateSecret`)**: 새 시크릿 생성 후 **1회 노출**. 기존 시크릿은 즉시 무효화됩니다. +- **삭제**: 클라이언트 제거. +- 생성/수정/시크릿재발급/삭제 모두 감사 로그(`oidc_client_*`) 기록. 모든 폼은 CSRF 토큰으로 보호됩니다. + +--- + +## 4. 서비스 role/scope 설정 (`/admin/oidc-clients/[id]`) + +클라이언트 상세 화면에서 **서비스 role** 을 정의합니다(SAML SP 도 `/admin/saml-sps/[id]` 에서 동일 구조). + +- role 필드: + - **key**(필수): `^[A-Za-z0-9_.-]{1,64}$` 형식. 같은 서비스 내 중복 불가(중복 시 409). + - **label**(필수): 표시 이름. + - **description**: 설명(선택). + - **isDefault**: 기본 부여 role 표시. + - **displayOrder**: 정렬 순서(정수). +- role 추가/삭제는 감사 로그(`service_role_created` / `service_role_deleted`)에 기록됩니다. +- 정의한 role 은 `/admin/users/[id]` 에서 사용자에게 **서비스 권한(assignment)** 으로 부여합니다(만료/취소 관리 포함). + +--- + +## 5. organization 클레임 노출 설정 (`/admin/oidc-clients/[id]`) + +클라이언트 상세 화면 하단 **"조직 클레임 노출 설정"** 에서, `organization` scope 로 노출되는 조직 정보를 필드별로 on/off 합니다. + +### 노출되는 클레임 구조 + +`organization` scope 가 켜진 클라이언트의 **id_token 과 userinfo 응답에 동일하게** 아래 4개 최상위 키가 들어갑니다. + +| 클레임 키 | 내용 | +| ------------ | --------------------------------------------------------------------------------------------------------------------------- | +| `department` | 현재 소속 부서 배열. 각 원소: `id`, `name`, `code`, `is_primary`, `job_title`, `position`(`{id,name,code,level}` 또는 null) | +| `team` | 현재 소속 팀 배열. 각 원소: `id`, `name`, `code`, `department`(부서명), `is_primary`, `job_title` | +| `position` | 주소속 부서의 직급명(문자열) 또는 null | +| `job_title` | 주소속 부서의 직책(문자열) 또는 null | + +### 체크박스 4개와 저장 규칙 + +토글 필드는 **`department` / `team` / `position` / `jobTitle`** 4개입니다. + +- **모든 필드를 켜면 → `null`(미설정)로 저장**됩니다. 즉 "전량 노출"이며, DB 를 깨끗하게 유지하고 **기존 동작과 하위호환**을 보장합니다. +- **하나라도 끄면 → 명시적 JSON** 으로 저장됩니다. 예: + ```json + { "department": true, "team": true, "position": false, "jobTitle": true } + ``` + `false` 인 필드의 **최상위 클레임 키 자체가 응답에서 생략**됩니다. +- 저장 위치: `oidcClients.organizationClaimConfig`(JSON text). +- **id_token 과 userinfo 가 동일한 config 를 적용**하므로 두 응답의 조직 정보가 항상 일치합니다. + +### 하위호환 / 무회귀 + +- `organizationClaimConfig` 가 없는(=null) 기존 클라이언트는 **전량 노출**로 동작합니다. 이번 기능 도입으로 인한 기존 클라이언트 회귀는 없습니다. +- 저장값 파싱이 실패하거나 알 수 없는 값이면 안전하게 null(전량 노출)로 폴백합니다. +- 변경은 감사 로그(`oidc_client_updated`, `detail.organizationClaimConfig`)에 기록됩니다. + +--- + +## 6. SAML SP 등록/관리 (`/admin/saml-sps`) + +### 생성 / 수정 + +- 필드: **이름**(필수), **Entity ID**(필수, 테넌트 내 중복 불가 → 중복 시 409), **ACS URL**(필수), SLO URL, SP 인증서(`cert`), NameID Format. +- **ACS/SLO URL 검증**: `validateSamlUrl` 로 형식 검사. +- **NameID Format**: emailAddress / unspecified / persistent / transient(SAML 표준 URN) 중에서만 허용. +- 서명/암호화 옵션: + - **`signResponse` 는 항상 `true` 로 강제**됩니다(관리 UI 가 false 를 보내도 무시). XSW 계열 공격 방지를 위해 IdP 가 Response 자체를 항상 서명. + - `signAssertion`, `wantAuthnRequestsSigned` 는 토글. + - **`encryptAssertion` 을 켜려면 SP 공개키(cert)가 반드시 있어야** 합니다(없으면 400). +- **allowedAttributes**: 콤마 구분. 허용 키 화이트리스트(`email`, `username`, `displayName`, `givenName`, `familyName`, `surName`, `phoneNumber`, `department`, `team`, `jobTitle`, `position`, `Role`, `RoleLabel`)에 없는 값은 무시됩니다. +- 보안 설정 변경(특히 **cert / acsUrl / wantAuthnRequestsSigned**)은 ACS 하이재킹 포렌식을 위해 before/after diff 가 감사 로그(`saml_sp_updated`)에 상세 기록됩니다. + +### 상세 (`/admin/saml-sps/[id]`) + +- OIDC 클라이언트와 동일하게 **서비스 role** 을 정의(key/label/description/isDefault/displayOrder). 4장 참조. + +### 메타데이터 + +- IdP 측 SAML 메타데이터는 `/saml/metadata` 에서 제공됩니다(SP 설정 시 참조). + +--- + +## 7. 스킨(커스텀 로그인 UI) 등록 (`/admin/skins`) + +외부에 호스팅한 HTML 을 가져와 로그인/가입 등 인증 화면을 클라이언트별로 커스터마이즈합니다. 사용법 안내는 **`/admin/skins/guide`** 에서 확인할 수 있습니다. + +### 등록 필드 + +- **대상 클라이언트**: `clientType`(oidc/saml) + `clientRefId`. +- **스킨 타입(`skinType`)**: `login` / `signup` / `find_id` / `find_password` / `mfa` / `reset_password`. +- **Fetch URL**: 스킨 HTML 을 가져올 URL. **https 필수**, loopback/내부주소(127.x, link-local) 금지(SSRF 방지). +- **Fetch Secret**: 스킨 서버 인증용 시크릿. IdP 가 스킨 HTML 을 가져올 때 **`X-IDP-Token`** 헤더로 이 값을 전송하므로, 스킨 서버는 이 헤더를 검증해 접근을 통제할 수 있습니다(선택). +- **캐시 TTL(`cacheTtlSeconds`)**: 기본 3600초, 0 이상, **최대 86400초(1일)**. + +### 운영 + +- **수정 / 삭제 / 활성화 토글 / 캐시 무효화(`invalidateCache`)** 지원. URL·TTL 변경이나 삭제 시 캐시가 자동 무효화됩니다. +- 같은 (클라이언트, 스킨타입) 조합 중복 등록 시 409. + +### 치환자(placeholder) + +스킨 HTML 안에서 `{{...}}` 형태로 사용하며, IdP 가 렌더링 시 값을 채웁니다. **총 6개**이고, 스킨 타입별 적용 범위가 다릅니다. + +| 치환자 | 채워지는 값 | 적용 스킨 | +| ------------------------ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `{{IDP_FORM_ACTION}}` | 항상 빈 문자열 `""`(비어 있으면 폼이 **현재 URL로 POST**) | 모든 스킨 공통 | +| `{{IDP_REDIRECT_TO}}` | `escapeHtml(redirectTo ?? "")` — hidden input 용 | **login / signup / reset_password** 에서 채워짐. find_id·find_password·mfa 는 `""` | +| `{{IDP_SKIN_HINT}}` | `escapeHtml(skinHint)` — hidden input 용(어떤 스킨을 쓸지 서버에 되전달) | 모든 스킨 공통 | +| `{{IDP_REGISTERED}}` | 회원가입 완료 직후 `"1"`, 그 외 `""`(가입 완료 안내 노출용) | **login 전용** | +| `{{IDP_PASSWORD_RESET}}` | 비밀번호 재설정 완료 직후 `"1"`, 그 외 `""`(재설정 완료 안내 노출용) | **login 전용** | +| `{{IDP_FLASH_MSG}}` | `escapeHtml(flashMsg)` — 서버가 채우는 플래시/오류 메시지(이미 HTML 이스케이프됨). 없으면 `""` | 모든 스킨 공통(폼 재제출 오류 표시) | + +> **필수 hidden input**: `login` 스킨의 `
` 에는 최소한 `redirectTo`(값 `{{IDP_REDIRECT_TO}}`)와 `skinHint`(값 `{{IDP_SKIN_HINT}}`) hidden input 및 `username`/`password` 입력이 있어야 정상 동작합니다. 폼 `action` 은 `{{IDP_FORM_ACTION}}`(빈 값=현재 URL POST)으로 둡니다. + +### 캐시 동작 + +- 가져온 스킨 HTML 은 TTL 동안 캐시됩니다. 스킨을 갱신했는데 즉시 반영이 필요하면 콘솔의 **캐시 무효화** 버튼을 사용하세요. + +--- + +## 8. 서명키 회전 (`/admin/signing-keys`) + +OIDC/SAML 토큰 서명에 쓰이는 **RSA 서명키**를 생성·회전합니다. + +- 목록: `kid`, alg(RS256), 용도(use), **활성 여부(active)**, 인증서 보유 여부, 생성/회전/만료 시각. +- **회전(rotate)** 액션 한 번으로: + 1. 새 RSA 키 + 자체서명 인증서 생성(CN = issuer 호스트명, 없으면 `idp.local`). + 2. 기존 활성 키를 비활성화하고 새 키를 활성으로 **원자적(atomic)** 전환. + 3. partial unique index 로 **"동시에 활성 키는 항상 1개"** 불변식을 DB 레벨에서 보장(동시 회전 충돌 시 409 `rotate_conflict`). + 4. 로컬 캐시/JWKS 캐시 무효화(다른 isolate 는 캐시 TTL 만료로 수렴). +- 새 키의 private JWK 는 **`IDP_SIGNING_KEY_SECRET`** 로 래핑(AES-256-GCM)되어 저장됩니다. 따라서 이 시크릿이 없으면 회전이 503 으로 실패합니다. +- 공개키는 `/oidc/jwks` 로 노출됩니다. + +> **중요(혼동 주의)**: 이 화면의 "서명키 rotate" 는 **현재 `IDP_SIGNING_KEY_SECRET` 을 그대로 사용해 새 RSA 서명키를 만드는 것**입니다. 마스터 시크릿(`IDP_SIGNING_KEY_SECRET`) 자체의 회전과는 별개입니다. 마스터 시크릿 회전(무중단 절차, `IDP_SIGNING_KEY_SECRET_PREVIOUS` 병기, 재암호화 배치)은 **[docs/SECRET_ROTATION.md](./SECRET_ROTATION.md)** 를 따르세요. + +--- + +## 9. 감사 로그 조회 (`/admin/audit`) + +- 컬럼: 시각, **kind**(이벤트 종류), **outcome**(success/failure), IP, 사용자 이메일(연결된 경우), 상세(JSON). +- 필터: + - **kind**: 실제 존재하는 kind 목록에서 선택. + - **outcome**: `success` / `failure`. +- 페이징: 최신순 **50건**씩, 커서(마지막 행의 생성시각 기준) 기반 "더 보기". +- 감사 이벤트 행에는 무결성 MAC(`hash`)이 포함됩니다(위변조 탐지용, `IDP_SIGNING_KEY_SECRET` 기반). + +주요 kind 예: `login`, `user_created` / `user_invited` / `user_deleted`, `user_status_changed` / `user_role_changed`, `password_reset`, `oidc_client_*`, `saml_sp_*`, `service_role_*`, `signing_key_rotated`, `ldap_provider_*`, `user_deletion_requested` / `user_deletion_cancelled`. + +--- + +## 10. 사용자 운영 흐름 (`/admin/users`) + +목록은 최신순 50건 페이징 + 검색(이메일/아이디/표시이름 부분일치, 대소문자 무시). 유효한 미사용 초대 토큰을 가진 계정에는 **"초대중"** 배지가 붙습니다. + +### 관리자 작업 + +| 작업 | 동작 | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **생성(create)** | 이메일+비밀번호(8자 이상) 즉시 계정 생성. 이메일/아이디 중복 시 409. | +| **초대(invite)** | 비밀번호 없이 계정 선생성(status=active, 이메일 미인증) 후 **초대 메일** 발송. 수락 시 최초 비밀번호 설정. | +| **상태 변경(updateStatus)** | active/disabled/locked. 비활성/잠금 전환 시 **기존 세션 즉시 파기** + 보안 알림 메일. 자기 자신 비활성화 및 **마지막 관리자 보호** 차단. | +| **역할 변경(updateRole)** | admin ↔ user. **자기 역할 변경 불가**, admin→user 강등 시 마지막 관리자 보호. 변경 시 세션 파기. | +| **비밀번호 초기화(resetPassword)** | 관리자가 새 비밀번호(8자 이상) 설정. 대상 사용자 **전 세션 파기** + 알림 메일. | +| **삭제(delete)** | 계정 하드 삭제. 자기 삭제 불가, 마지막 관리자 보호. | + +- 개별 사용자 상세(`/admin/users/[id]`)에서 프로필/조직 소속/서비스 권한/강제 로그아웃까지 관리합니다(2·4장 참조). + +### 이메일 인증 (self-service) + +- 가입/재발송 시 인증 토큰 발급 + 메일 발송(`/verify-email?token=...`). 토큰 유효기간 **24시간**. +- `IDP_ISSUER_URL` 미설정이면 host header injection 방지를 위해 메일 발송을 스킵합니다. + +### 초대 수락 (self-service) + +- 초대 링크(`/accept-invite?token=...`) 유효기간 **72시간**. 수락 시 사용자가 최초 비밀번호를 설정하며 토큰이 소비(used)됩니다. + +### 계정 삭제 유예 (self-service, `/account/danger-zone`) + +- 사용자 본인이 탈퇴를 요청하면: + 1. **step-up 재인증**(비밀번호 또는 TOTP) 필수 — 세션 탈취자에 의한 삭제 방지. + 2. **마지막 관리자 자기삭제 차단**. + 3. 계정을 **`status=deletion_pending` + `deletionScheduledAt`(now+30일)** 로 소프트 삭제. 전 세션·refresh token 즉시 폐기 후 로그아웃. 접수 알림 메일 발송. +- **복구(유예 내)**: 유예 30일 안에 다시 로그인하면 복구 확인 프롬프트가 뜨고, 비밀번호 재입력으로 확정하면 계정이 `active` 로 환원됩니다(`user_deletion_cancelled`). +- **유예 경과**: `deletionScheduledAt` 이 지난 계정은 로그인 거부되고, **GC 가 하드 삭제**합니다(감사 로그·복구 불가). + +--- + +### 참고 문서 + +- 마스터 시크릿 회전 절차: [docs/SECRET_ROTATION.md](./SECRET_ROTATION.md) diff --git a/docs/SECRET_ROTATION.md b/docs/SECRET_ROTATION.md new file mode 100644 index 0000000..6442fd4 --- /dev/null +++ b/docs/SECRET_ROTATION.md @@ -0,0 +1,72 @@ +# `IDP_SIGNING_KEY_SECRET` 회전 절차 + +> 작성: 2026-07-06 / 갱신: 2026-07-06 (Phase 9 — 무중단 회전 지원 추가). +> 코드 기준: `src/lib/server/crypto/keys.ts`, `auth/runtime.ts`, `auth/totp.ts`, `auth/mfa.ts`, `auth/webauthn.ts`, `audit/index.ts`. +> +> **무중단(zero-downtime) 회전이 지원된다.** `IDP_SIGNING_KEY_SECRET_PREVIOUS` 에 old +> 시크릿을 병기하면, 모든 복호/검증 경로가 current→previous 순차로 시도(`tryWithSecrets`) +> 하므로 재암호화 창 동안에도 요청이 실패하지 않는다. **발급/암호화(토큰 서명, private key +> 래핑, 시크릿·TOTP 암호화, 쿠키·audit 서명)는 항상 current(`IDP_SIGNING_KEY_SECRET`)만 +> 사용**하므로, 회전 후 새로 쓰이는 데이터는 곧바로 new 시크릿으로 저장된다. + +## 1. 이 시크릿이 쓰이는 곳 (7용도) + +| # | 용도 | 방식 | 저장 데이터 | 근거 | +| --- | ------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------ | +| 1 | OIDC/SAML 서명용 RSA private JWK 래핑 | HKDF(`idp-signing-key-wrap-v1`) → AES-256-GCM | `signing_keys.private_jwk_encrypted` (활성 행만 런타임 사용) | `crypto/keys.ts:37-69` | +| 2 | Opaque access token 서명/검증 | **원문 그대로** HMAC-SHA256 키 | 저장 없음 (TTL 300초) | `crypto/keys.ts:213-250` | +| 3 | LDAP admin bind password 암호화 | HKDF(`idp-ldap-bind-password-v1`) → AES-256-GCM | `identity_providers.config_json` 내 `bindPasswordEnc` | `crypto/keys.ts:258-280` | +| 4 | TOTP seed 암호화 | HKDF(v2: `idp-totp-secret-wrap-v2:`, v1 레거시) → AES-256-GCM | `credentials.secret` (type='totp') | `auth/totp.ts:99-175` | +| 5 | MFA pending 쿠키 서명 | 원문 HMAC | 저장 없음 (쿠키, TTL 5분) | `auth/mfa.ts:44-92` | +| 6 | WebAuthn challenge 쿠키 서명 | 원문 HMAC | 저장 없음 (쿠키, TTL 5분) | `auth/webauthn.ts:28-58` | +| 7 | 감사 로그 행 무결성 MAC | 원문 HMAC | `audit_events.hash` (평문 필드에 대한 MAC — 암호화 아님) | `audit/index.ts:93-124` | + +## 2. 회전 시 영향 분류 + +**A. 자연 해소(조치 불필요)** — 발급된 access token(최대 5분 내 만료), MFA pending·WebAuthn challenge 쿠키(5분, 재시도로 해소). `oidc_refresh_tokens`는 이 시크릿과 무관(랜덤 토큰 + SHA-256 해시)이라 영향 없음. + +**B. 재암호화 필수(누락 시 장애)** — 아래 3종은 old secret으로 복호해 new secret으로 재암호화하지 않으면 **영구 복호 불가**: + +| 데이터 | 미조치 시 증상 | +| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `signing_keys` 활성 행(`active=true AND rotated_at IS NULL`)의 `private_jwk_encrypted` | unwrap 예외(try/catch 없음) → OIDC 토큰 발급·SAML SSO 전면 500 | +| `credentials.secret` (TOTP 전량) | `api/totp/verify` 500 → TOTP 사용자 전원 MFA 로그인 불가 | +| `identity_providers.config_json.bindPasswordEnc` (LDAP) | 복호 실패는 try/catch로 흡수되나 bind 자격증명 부재 → 해당 LDAP 로그인 전부 실패 | + +**C. 선택 후처리** — `audit_events.hash`: 원본 평문이 DB에 있으므로 new secret으로 전량 재계산 가능(전용 스크립트는 현재 없음). 재계산하지 않으면 향후 무결성 검증 시 old-secret 시절 행이 전부 오탐된다. + +## 3. 무중단 회전 절차 (순서 엄수) + +핵심: **new 를 current 로 먼저 배포하고 old 를 PREVIOUS 로 병기** → 이 상태에서는 old·new 로 +암호화된 데이터가 모두 복호되고(fallback), 새 데이터는 new 로만 저장된다. 재암호화 후 PREVIOUS +를 제거한다. 어느 시점에도 요청 실패 창이 없다. + +1. **준비**: 새 secret 생성(고엔트로피 256bit 이상). +2. **PREVIOUS 병기 + new 배포** (fallback 활성화): + - `IDP_SIGNING_KEY_SECRET` = **new**, `IDP_SIGNING_KEY_SECRET_PREVIOUS` = **old** 로 설정 후 재배포. + - Workers: `wrangler secret put IDP_SIGNING_KEY_SECRET` / `... IDP_SIGNING_KEY_SECRET_PREVIOUS`. + - Node: 두 환경변수를 설정 후 재시작. + - 이 시점부터 발급/암호화는 new 로, 복호/검증은 new→old 순차로 처리된다(무중단). +3. **재암호화 배치 실행** (`scripts/reencrypt-secrets.ts`): old(PREVIOUS)로 복호 → new 로 재암호화. + - 대상: `signing_keys` 활성 행 `private_jwk_encrypted`, `credentials` type='totp' `secret`, + `identity_providers` kind='ldap' `config_json.bindPasswordEnc`. + - 먼저 **dry-run**(기본): `IDP_SIGNING_KEY_SECRET_PREVIOUS= IDP_SIGNING_KEY_SECRET= DB_DIALECT=... DATABASE_URL=... bun scripts/reencrypt-secrets.ts` + → 대상 건수만 보고, DB 미변경. + - 확인 후 **적용**: 동일 명령에 `--apply` 추가 → DB 쓰기. + - 멱등: 이미 new 로 재암호화된 행은 "already"로 건너뛴다(반복 실행 안전). old/new 둘 다 복호 실패 + 행은 error 로 집계(종료코드 2)하되 배치는 계속 진행한다. + - (프로젝트 규칙상 이 스크립트는 원격 DB 를 변경하므로 자동 실행하지 않는다 — 운영자가 직접 실행.) +4. **스모크 테스트**: `/oidc/token` 발급, `/saml/sso` 서명, TOTP 로그인, LDAP 로그인, JWKS(`/oidc/jwks`) kid 노출 확인. +5. **PREVIOUS 제거로 회전 마무리**: `IDP_SIGNING_KEY_SECRET_PREVIOUS` 삭제 후 재배포. old 로만 복호되던 + 데이터는 3단계에서 모두 new 로 재암호화되었으므로 이제 old 는 불필요하다. +6. **old secret 파기**. + +> **`audit_events.hash` 재계산은 이번 범위 밖(수동 절차)**이다. audit hash 는 발급(생성) 전용이라 +> fallback 이 없고, current(new)로 계산된다. 회전 후 무결성 검증 시 old 시절 행이 오탐되지 않게 하려면 +> 원본 평문(모두 DB 에 존재)으로 new 시크릿으로 전량 재계산하는 별도 배치가 필요하다 — 필요 시 수동으로 수행한다. + +## 4. 알려진 한계 / 개선 여지 (별도 트랙) + +- access-token HMAC·쿠키 서명(#2,#5,#6,#7)이 파생 없이 원문을 공유 — HKDF 도메인 분리로 통일하면 용도별 노출 반경이 줄어든다. +- `audit_events.hash` 재계산 전용 배치가 아직 없다(위 3절 주석 참조). +- admin 콘솔의 "서명키 rotate" 액션은 **현재 secret으로 새 서명키를 만드는 것**이지 이 마스터 시크릿 회전과 무관하다 — 혼동 주의. diff --git a/docs/improvement-report-2026-07-02.md b/docs/improvement-report-2026-07-02.md index 3a6fbf8..8b15b01 100644 --- a/docs/improvement-report-2026-07-02.md +++ b/docs/improvement-report-2026-07-02.md @@ -3,6 +3,15 @@ > 3개 병렬 분석(미구현·갭 전수 탐색 / 보안 취약점 리뷰 / 아키텍처·품질 리뷰) 결과를 종합. > 분석 기준 브랜치: `chore/remove-d1-binding` (origin/main과 동일, 미커밋 변경 없음). +> **⚠️ 정오표 (2026-07-06 추가)** — 이 리포트의 일부 서술은 이후 커밋으로 stale 해졌다. 최신 현황은 `docs/plans/project-improvement-audit/PLAN.md` 참조. +> +> - "Refresh Token 미구현"(A1) → **구현 완료** (`oidc/token`의 refresh_token grant, 회전+재사용 감지 family 폐기 포함). +> - "SAML Assertion 암호화 미구현" → **구현 완료** (`test/unit/saml-encrypt.test.ts`, `verify:saml-encryption` 스크립트 존재). +> - "TOTP enroll TOCTOU 미해소" → **해소** (deferred-followups Phase A — `totp_owner_id` unique index + INSERT 409 처리). +> - "테스트 프레임워크·CI 테스트 단계 전무" → vitest 62+ 테스트 및 CI 게이트(lint/check/test/build/CodeQL/gitleaks) 구축됨. +> - groups/address/organization scope "보류" → **구현 완료** (claims.ts, token/userinfo 매핑). +> - E9(보안 감사 문서 public repo 잔존) → **처리 완료** (2026-07-06, repo에서 제거 — 히스토리에는 잔존). + ## 종합 판정 기존 보안 감사(2026-05-12)의 Critical/High 대부분이 실제 코드에 반영되어 있고, **신규 Critical/High 취약점은 없다**. 현재 프로젝트의 부채는 세 갈래로 요약된다. diff --git a/docs/plans/deferred-followups/TODO.md b/docs/plans/deferred-followups/TODO.md index 0d6d97f..8f1595f 100644 --- a/docs/plans/deferred-followups/TODO.md +++ b/docs/plans/deferred-followups/TODO.md @@ -1,8 +1,8 @@ # 실행 투두 — 보류 3건 (병렬 A/B/C) -> **상태(2026-07-05): A/B/C 전부 구현 완료 → 메인 브랜치 통합 완료 → 독립 검증 통과.** -> 게이트: `bun run test` 54 passed(9 files) · `svelte-check` 0 errors · `eslint`·`build` 통과. -> 마이그레이션 생성만(d1 0019, pg/mysql/sqlite 0002) — **적용은 사용자 몫**. 커밋 승인 대기. +> **상태(2026-07-06 갱신): A/B/C 전부 구현·검증·커밋 완료(병합됨).** 이후 admin 콘솔 영어화(cc44d7c)·auth 서버 에러 i18n(1c8c5c0)으로 당초 "Out" 범위였던 항목도 추가 반영됨. +> 마이그레이션 생성분(d1 0019, pg/mysql/sqlite 0002)의 **실제 DB 적용 여부는 사용자 확인 필요**(미적용이면 배포 전 적용 요망). +> 후속 전수 개선은 `docs/plans/project-improvement-audit/` 참조. > 승인된 결정: 기획서 승인 · Phase C FK 검증 **추가** · Phase B 자동판별+**수동 토글** · **병렬 진행**(worktree 격리). > 규칙: 스키마 변경은 `db:generate` 까지만(적용 금지). 커밋은 검증 통과 + 승인 후. 쓰기=Opus 위임, 검증=작성자와 분리된 독립 Opus 에이전트. diff --git a/docs/plans/followup-round2/PLAN.md b/docs/plans/followup-round2/PLAN.md new file mode 100644 index 0000000..5b86d15 --- /dev/null +++ b/docs/plans/followup-round2/PLAN.md @@ -0,0 +1,97 @@ +# 후속 2차 개선 기획서 — KeyStone (rate-limit 추상화 제외) + +> 요청: "rate-limit을 제외하고 나머지 계속 다 진행" — 1차 감사(`project-improvement-audit`)에서 후속 후보로 남긴 항목 전부. +> 조사: 2026-07-06, 2개 병렬 Explore. 코드 근거 확인 완료. +> 규칙: 스키마 변경은 `db:generate:all`까지만(적용은 사용자). 커밋은 검증 통과 + 승인 후. + +--- + +## 배경 / 목표 + +1차에서 "별도 트랙" 또는 "후속"으로 남긴 것 중 rate-limit 저장소 추상화만 제외하고 진행: + +- **G1 세션 셀프서비스** — 사용자가 활성 세션 목록을 보고 개별/일괄 철회. (메타 컬럼 이미 존재 → 최저 비용) +- **G2 초대(invite) 플로우** — admin이 초대 메일 발송, 수신자가 비밀번호 최초 설정. +- **G3 계정 삭제(탈퇴) 셀프서비스** — step-up 재인증 후 하드 삭제. +- **G4 마스터 시크릿 다중 버전(무중단 회전)** — old/new secret 동시 시도 + 재암호화 스크립트. +- **G5 잔존 한국어 에러 i18n** — saml-sps/[id], validation.ts, crud-factory/schemas, (정책 결정 후) 프로토콜 API. +- **G6 통합 테스트 하네스** — libSQL 인메모리 + 라우트 직접 호출로 OIDC 풀플로우. + +--- + +## 범위 (In / Out) + +### Phase 6 — 세션 셀프서비스 (G1, 최저 비용) + +- **In**: `listActiveSessions(db, userId)` + `revokeSessionById(db, sessionId, userId)`(IDOR 방지 userId 조건) 신규. 신규 라우트 `account/sessions/`(목록 — ip/userAgent/lastSeenAt/createdAt 표시, `locals.session.id`로 현재 세션 배지, 개별 revoke, "다른 세션 모두 로그아웃"=기존 `revokeOtherSessions` 재사용). 개별 철회 시 `revokeRefreshTokensForSession` 세트 호출(refresh 연쇄 폐기). audit + 보안 알림. i18n ko/en. +- **Out**: step-up 재인증(세션 철회는 passkey 삭제만큼 파괴적이지 않음 — 열린 질문 ②). **스키마 변경 없음.** + +### Phase 7 — 초대 플로우 (G2) + +- **In**: `invite_tokens` 테이블(신규, password_reset 패턴). admin `invite` 액션(비밀번호 없이 계정 생성 — `credentials` row 생략, `emailVerifiedAt` NULL) + 초대 메일. `accept-invite` 라우트(토큰 검증 → 비밀번호 최초 설정 + `emailVerifiedAt` 세팅 → credentials insert, 초대 클릭이 이메일 소유 증명이므로 별도 인증 스킵). 로그인 경로에서 "credential 없는 계정"의 로그인 시도가 안전하게 실패하는지 확인·보강. 계정 생성 원자화(`runAtomic`). i18n. `db:generate:all`. +- **Out**: `users.status`에 `pending` enum 추가(옵션 A) — 대신 **옵션 B 채택**: `emailVerifiedAt IS NULL AND credential 부재`로 초대중 판별(스키마 enum 무변경). admin UI에서 초대중 배지 표시. + +### Phase 8 — 계정 삭제 셀프서비스 (G3) + +- **In**: `account/danger-zone/` 라우트 + self-delete 액션. step-up 재인증(비밀번호 또는 TOTP — passkey delete 패턴 재사용). `assertNotLastAdmin` 재사용(마지막 admin 자기삭제 차단). **하드 삭제**(FK cascade가 credentials/sessions/grants/refresh/saml/memberships 전부 정리; audit는 set null로 보존). audit(`user_self_deleted`) + 탈퇴 확인 메일(세션 만료 전 발송). i18n. +- **Out**: 소프트 삭제/유예기간+복구(enum·GC 배치 필요 — 규모 크고 법적 보존 요건 미정, 열린 질문 ①). 즉시 하드 삭제로 진행. + +### Phase 9 — 마스터 시크릿 무중단 회전 (G4) + +- **In**: `IDP_SIGNING_KEY_SECRET_PREVIOUS` env 추가. `runtime.ts`의 `signingKeySecret: string` → `signingKeySecrets: string[]`(current 우선, previous 후행) 또는 `{current, previous?}`. 공용 `tryWithSecrets(secrets, fn)` 헬퍼로 복호/검증 함수(unwrapPrivateKey/verifyAccessToken/decryptSecret/decryptTotpSecret/HMAC 검증)를 감싸 순차 시도(호출부 최소 변경). **서명·암호화(발급)는 항상 current로**, 검증·복호만 fallback. 재암호화 스크립트 `scripts/reencrypt-secrets.ts`(openScriptDb 패턴 — signing_keys 활성행·TOTP·LDAP bindPassword를 old→new 재작성). `SECRET_ROTATION.md`를 무중단 절차로 갱신. +- **Out**: `audit_events.hash` 재계산 스크립트(선택 후처리 — 별도, 열린 질문 ③). HMAC 도메인 분리 리팩터(#2/#5/#6/#7 원문 공유 — 별도). + +### Phase 10 — 잔존 한국어 에러 i18n (G5) + +- **In**: + - **admin 잔여**: `saml-sps/[id]/+page.server.ts`(7건) `adminError` 전환, `admin/schemas.ts`+`crud-factory.ts` i18n화(`test/unit/admin-zod.test.ts`·`crud-factory.test.ts`의 한국어 기대값 **테스트도 함께 갱신** — 작성/검증 레인 분리 유지). + - **validation.ts**: `reason: string` → `{key, params}`로 시그니처 변경, 호출부(oidc-clients/saml-sps/skins/ldap-providers)에서 `translate()`. + - **프로토콜 API**: 사람이 보는 에러 문구만 i18n(`oidc/saml/webauthn/totp` 네임스페이스 신설). **OAuth/SAML 표준 필드(`error`/`error_description` RFC 규격)는 영어 유지** — RP/SP 파싱 대상이므로. 사용자 대면 HTML 에러 페이지·플래시만 번역. (범위는 열린 질문 ④에서 확정) +- **Out**: `passkey-client/+server.ts`의 클라이언트 JS 문자열(클라이언트 i18n 전략 별도 — 소규모라 이번 포함 검토). + +### Phase 11 — 통합 테스트 하네스 (G6) + +- **In**: `$app/environment` alias 스텁(`dev:false`) 추가로 runtime/bootstrap import 가능화. `drizzle/sqlite/*.sql` 프로그래매틱 적용 유틸(libSQL `:memory:`). 실 라우트 직접 호출 통합 테스트: **OIDC authorize→token→userinfo 풀플로우**(실 DB·실 서명키). 이 하네스 위에 Phase 6~10 신규 로직의 통합 테스트도 추가(초대 수락, 세션 철회, 시크릿 fallback). +- **Out**: SAML SSO 통합(XML 서명 fixture 비용 큼 — 후속), 로그인+MFA HTTP 통합(어댑터-node 기동 필요 — 후속), Playwright 브라우저 e2e(신규 의존성·부트스트랩 — ROI 최하, 제외). + +--- + +## 코드베이스 사실관계 (조사 근거) + +- **세션**: 메타(ip/userAgent/lastSeenAt/createdAt) 이미 저장(`schema.sqlite.ts:180-208`), `locals.session.id`로 현재 세션 식별. `revokeSessionById`·목록 헬퍼만 신규. refresh 연쇄는 `revokeRefreshTokensForSession`(`refresh.ts:73-78`) 세트 호출 필요. +- **초대**: `credentials.secret` nullable(`schema.sqlite.ts:92`) → 비밀번호 없는 계정 생성이 스키마상 가능. `username` nullable, `email` NOT NULL. status enum엔 pending 없음(`:45-47`). admin create는 password 필수(`admin/users/+page.server.ts:77-84`), users+credentials insert가 비원자적(`:109-126`). +- **삭제**: 모든 직접 자식 FK cascade(credentials/sessions/grants/refresh/saml/memberships), audit는 set null. `assertNotLastAdmin`(`guards.ts:40-61`) 재사용. +- **시크릿**: `signingKeySecret?: string` 단일(`runtime.ts:7,32`), 소비처 25+ 파일. 복호/검증 함수 전부 `secret: string` 단일 인자 순수함수 → 래퍼로 순차 시도 가능. `bootstrap.ts:62` unwrapPrivateKey는 try/catch 없음(전수 점검 필요). 재암호화 스크립트 자리 = `scripts/`(openScriptDb 패턴). +- **i18n 잔여**: saml-sps/[id] 7건, validation.ts 6 reason 문자열(시그니처 변경 필요), schemas.ts 8건+crud-factory 2건(테스트가 한국어 단정), 프로토콜 60건+(SAML 31·WebAuthn 19·OIDC 9·TOTP 4). `translate(locale, key)`(`i18n/server.ts:5`), `locals.locale`(`hooks.server.ts:48`). +- **테스트**: vitest 순수 유닛만, 라우트 구동 0건. runtime/bootstrap만 `$app/environment` 사용(alias 부재로 현재 import 불가). libSQL 인메모리 가능(`@libsql/client` 기설치), `scripts/lib/db.ts`에 SQL 실행 유틸 존재. adapter-node 경로가 e2e 마찰 최소. + +--- + +## 접근법 / 리스크 / 순서 + +| Phase | 접근 | 리스크 & 완화 | +| --------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| 6 세션 | 기존 함수·account 라우트 패턴 복제, 스키마 무변경 | 개별 철회 시 refresh 연쇄 누락 → 세트 호출 강제. IDOR → userId 조건 필수 | +| 7 초대 | credentials 생략 계정 + 토큰 패턴, 옵션 B(enum 무변경) | credential 없는 계정 로그인 안전 실패 확인이 관문 → 로그인 경로 점검·보강 필수 | +| 8 삭제 | admin delete 복제 + step-up + assertNotLastAdmin | 비가역 → step-up 재인증·확인 메일·마지막 admin 차단 | +| 9 시크릿 | tryWithSecrets 래퍼(호출부 최소 변경), 발급은 current | 25+ 소비처 파급 → 헬퍼로 국소화, unwrapPrivateKey 무보호 지점 전수 점검. 재암호화 스크립트는 사용자가 실행(적용 안 함) | +| 10 i18n | admin/validation은 완전 전환, 프로토콜은 사용자 대면만(표준 필드 영어 유지) | 테스트 계약 깨짐 → 작성/검증 레인 분리해 테스트 동반 갱신. 표준 준수 위해 범위 신중 | +| 11 테스트 | libSQL 인메모리 + 라우트 직접 호출 | $app/environment 스텁·마이그레이션 적용 유틸이 관문 → Phase 11을 6~10 뒤에 둬 신규 로직까지 커버 | + +**순서**: 6 → 7 → 8(계정 생명주기 묶음, 일부 병렬) → 9(독립) → 10(독립, 병렬 가능) → 11(마지막, 신규 로직 통합 테스트 포함). 6·9·10은 파일 겹침 적어 병렬 착수 가능. + +--- + +## 열린 질문 (승인 시 확정) + +1. **계정 삭제(Phase 8) 방식** — 즉시 하드 삭제(권장, 저비용) vs 소프트 삭제+유예기간(법적 보존/오탈퇴 복구 필요 시). 하드 삭제로 진행할지? +2. **세션/삭제 step-up 재인증** — 세션 개별 철회에도 비밀번호 재확인을 요구할지(계정 삭제는 필수 권장). 세션은 생략, 삭제만 step-up이 기본안. +3. **audit_events.hash 재계산(Phase 9)** — 무중단 회전에 필수는 아님(향후 무결성 검증 오탐 방지용). 이번 스크립트에 포함할지, 제외할지(권장: 제외, 문서로만 안내). +4. **프로토콜 API i18n 범위(Phase 10)** — (a) 사용자 대면 문구만 번역·표준 필드 영어 유지(권장), (b) 전량 번역, (c) 프로토콜은 이번 제외하고 admin/validation만. SAML/WebAuthn가 60건+라 범위가 작업량을 크게 좌우. + +--- + +## 산출물 + +- `docs/plans/followup-round2/PLAN.md` (본 문서) → 승인 후 `TODO.md` +- 스키마 변경분(invite_tokens 등)은 `db:generate:all`까지만 — 적용은 사용자. diff --git a/docs/plans/followup-round2/TODO.md b/docs/plans/followup-round2/TODO.md new file mode 100644 index 0000000..3b49cba --- /dev/null +++ b/docs/plans/followup-round2/TODO.md @@ -0,0 +1,170 @@ +# 실행 투두 — 후속 2차 개선 (6 페이즈) + +> 승인(2026-07-06, 게이트①): 기획 승인 · Phase 8 = **소프트 삭제+유예기간**(enum 확장+GC 배치) · Phase 10 프로토콜 = **전량 번역**(표준 필드 포함) · Phase 9 audit hash 재계산 = **제외**(문서 안내만). +> 규칙: 스키마 변경은 `db:generate:all`까지만(적용 금지, CLAUDE.md). 커밋은 전체 검증 통과 + 커밋 승인 후, 푸시 금지. 쓰기=Opus 위임, 검증=작성자와 분리된 독립 Opus. 스텁/TODO/skip은 블로커. +> 순서: 6·9·10 병렬 착수 가능 → 7 → 8(7의 계정 생명주기 확장) → 11(마지막, 신규 로직 통합 테스트 포함). 스키마 건드리는 7·8은 마이그레이션 생성 순서 조율(직렬). + +--- + +## Phase 6 — 세션 셀프서비스 (스키마 무변경, 최저 비용) + +**목적**: 사용자가 활성 세션 목록 확인 + 개별/일괄 철회. + +### [x] 6-1. 세션 조회·철회 헬퍼 + +- 파일: `src/lib/server/auth/session.ts` +- 작업: `listActiveSessions(db, userId)`(revokedAt null·미만료 세션의 id/ip/userAgent/lastSeenAt/createdAt 반환), `revokeSessionById(db, sessionId, userId, revokedAt)`(**userId 조건 필수** — IDOR 방지, sessionId+userId 동시 일치만 revoke). +- 수용 기준: 타 사용자 sessionId 철회 불가(userId 불일치 시 no-op/0행). 만료·이미 revoked 세션은 목록 제외. + +### [x] 6-2. account/sessions 라우트 + +- 파일: `src/routes/account/sessions/+page.server.ts`·`+page.svelte`(신규) +- 작업: load(목록 + `locals.session.id`로 "현재 세션" 배지, account 라우트 관례 준수). 액션 `revoke`(개별 — `revokeSessionById` + **`revokeRefreshTokensForSession` 세트 호출**로 refresh 연쇄 폐기), `revokeOthers`(기존 `revokeOtherSessions` 재사용, 현재 세션 유지). audit(`session_revoked`) + `dispatchSecurityAlert`(best-effort). i18n ko/en. 현재 세션 개별 철회 시 로그아웃 처리 일관성 확인. +- 수용 기준: 목록에 메타 정상 표시, 개별 철회 시 해당 세션+refresh 폐기, 다른 세션 유지. IDOR 없음. ko/en 대칭. + +### [x] 6-검증 (독립 Opus) — 통과(2026-07-06, APPROVE): IDOR·refresh 연쇄·현재세션 검증. 보안알림은 스코프 제약으로 audit 갈음(후속 소량 추가 여지) + +- IDOR(userId 조건), refresh 연쇄 폐기 세트 호출, 현재 세션 철회 동작. 게이트 4종. + +--- + +## Phase 7 — 초대 플로우 (스키마 변경 — 옵션 B: enum 무변경) + +**목적**: admin 초대 메일 → 수신자 비밀번호 최초 설정. "초대중" = `emailVerifiedAt IS NULL AND credential 부재`. + +### [x] 7-0. 로그인 안전성 선점검 (관문) + +- 파일: `src/lib/server/auth/users.ts`(로그인 크레덴셜 조회), `src/routes/(auth)/login/+page.server.ts` +- 작업: password credential이 **없는** 계정의 로그인 시도가 안전하게 실패하는지 확인. 취약(예외/우회)하면 보강 — credential 부재 시 명확한 인증 실패(타이밍 균등화 유지, Phase 1의 더미 scrypt 경로와 정합). LDAP/패스키 사용자와의 구분 확인. +- 수용 기준: credential 없는(초대 대기) 계정으로 비밀번호 로그인 불가. 사용자 열거 오라클 신규 발생 없음. + +### [x] 7-1. invite_tokens 테이블 + +- 파일: `src/lib/server/db/schema.{sqlite,pg,mysql}.ts` +- 작업: `password_reset_tokens` 동일 구조(userId cascade FK, tokenHash, expiresAt, usedAt, createdAt) 3방언 추가. TTL은 발급부에서(예: 72시간). parity 유지. +- 수용 기준: 3방언 컬럼·인덱스 동일. schema-parity 테스트 통과. + +### [x] 7-2. 발급·메일·admin invite 액션 + +- 파일: `src/lib/server/auth/invite.ts`(신규, email-verification 패턴), `src/lib/server/email.ts`(`sendInviteEmail`), `src/routes/admin/users/+page.server.ts`(`invite` 액션) +- 작업: admin `invite` 액션 — email/displayName/role 받아 **비밀번호 없이** 계정 생성(credentials row 생략, emailVerifiedAt NULL), 초대 토큰 발급+메일. users insert를 `runAtomic`로(기존 create의 비원자성 답습 금지). 기존 create 액션은 유지(직접 비밀번호 생성 경로 병존). +- 수용 기준: 초대 계정 생성 + 메일 발송(best-effort 격리). role 검증. 중복 이메일 차단. audit(`user_invited`). + +### [x] 7-3. accept-invite 라우트 + +- 파일: `src/routes/(auth)/accept-invite/+page.server.ts`·`+page.svelte`(신규, verify-email/reset-password 패턴) +- 작업: 토큰 검증(해시·만료·1회용) → 비밀번호 최초 설정(정책 준수) + `emailVerifiedAt` 세팅(초대 클릭=이메일 소유 증명) + credentials(password) insert를 원자적으로(`runAtomic`). i18n. `db:generate:all`(7-1 반영). +- 수용 기준: 유효 토큰으로 비밀번호 설정→로그인 가능. 만료/재사용 거부. emailVerifiedAt·credential 동시 반영(원자). admin UI에 초대중 배지. + +### [x] 7-검증 (독립 Opus) — 통과(2026-07-06, APPROVE): 열거 오라클 없음, 원자성·토큰 보안·정상계정 구분 확인. federated 사용자 배지 오표시(V5 엣지) 별도 수정 + +- 로그인 선점검 보강 유효성, 토큰 보안(해시·1회용·만료), 계정 생성/수락 원자성, 초대중 판별. 마이그레이션 생성만. 게이트. + +--- + +## Phase 8 — 계정 삭제 셀프서비스 (소프트 삭제 + 유예기간) + +**목적**: step-up 재인증 후 탈퇴 신청 → 유예기간 후 GC 하드 삭제. 복구 가능. + +### [x] 8-1. status enum 확장 + 삭제예정 메타 + +- 파일: `src/lib/server/db/schema.{sqlite,pg,mysql}.ts` +- 작업: `users.status` enum에 `deletion_pending`(또는 유사) 추가(3방언 동시). 하드삭제 시점 판단용 `deletionScheduledAt`(nullable timestamp_ms) 컬럼 추가. parity 유지. `db:generate:all`. +- 수용 기준: 3방언 enum·컬럼 동일. 기존 status 값 무영향. parity 통과. + +### [x] 8-2. self-delete(탈퇴 신청) 액션 + +- 파일: `src/routes/account/danger-zone/+page.server.ts`·`+page.svelte`(신규) +- 작업: step-up 재인증(비밀번호 또는 TOTP — passkey delete 패턴 재사용). `assertNotLastAdmin`(마지막 admin 자기삭제 차단). 상태를 `deletion_pending` + `deletionScheduledAt = now + 유예(예: 30일)` 세팅, 전 세션+refresh 즉시 폐기(로그아웃). audit(`user_deletion_requested`) + 탈퇴 접수 메일(세션 만료 전 발송). 복구 안내 포함. +- 수용 기준: step-up 없이는 탈퇴 불가. 마지막 admin 차단. 신청 즉시 로그아웃+상태 전환, 유예기간 기록. 확인 메일 발송. + +### [x] 8-3. 로그인 차단 + 복구 + GC 하드삭제 + +- 파일: `src/routes/(auth)/login/+page.server.ts`(deletion_pending 처리), 복구 경로(로그인 시 유예 내 복구 옵션 또는 admin 복구), `src/lib/server/db/gc.ts`(유예 경과분 하드 삭제) +- 작업: `deletion_pending` 계정 로그인 시 → 유예기간 내면 "복구하시겠습니까"(복구=status active 환원, deletionScheduledAt null) 또는 로그인 거부. GC에 `deletionScheduledAt < now`인 users 하드 삭제 추가(FK cascade가 자식 정리, audit set null 보존). 보수적 삭제 조건. +- 수용 기준: 유예 내 복구 가능. 유예 경과 시 GC가 하드 삭제(미경과·활성 계정 절대 삭제 안 함). 로그인 흐름 일관. + +### [x] 8-검증 (독립 Opus) — 통과(2026-07-06, APPROVE): GC 활성/미경과 계정 삭제 불가(NULL 비교), step-up 우회 불가, 타이밍 오라클 없음, 복구 회귀 없음, 배지 정확(prettier 2건 정리). 엣지: 스킨 로그인은 복구 패널 미표시(기본 스킨만, 후속) + +- step-up 우회 불가, 마지막 admin 차단, GC 삭제 조건 보수성(미경과 삭제 없음), 복구 경로, cascade 정합. 마이그레이션 생성만. 게이트. + +--- + +## Phase 9 — 마스터 시크릿 무중단 회전 (audit 재계산 제외) + +**목적**: old/new secret 동시 검증 + 재암호화 스크립트로 무중단 회전. + +### [x] 9-1. runtime 다중 시크릿 + tryWithSecrets 헬퍼 + +- 파일: `src/lib/server/auth/runtime.ts`, `src/lib/server/crypto/keys.ts`(헬퍼 배치) +- 작업: `IDP_SIGNING_KEY_SECRET_PREVIOUS` env 추가. `signingKeySecret: string` → `signingKeySecrets: string[]`(current 우선, previous 후행; previous 없으면 길이1). 공용 `tryWithSecrets(secrets, fn)`(순차 시도, 마지막 실패만 throw) 도입. **발급/암호화(wrapPrivateKey/서명/encryptSecret)는 항상 `secrets[0]`(current)**, 검증/복호만 fallback. +- 수용 기준: previous 미설정 시 기존과 동일 동작(회귀 0). current로 실패한 복호가 previous로 성공. 발급은 current 고정. + +### [x] 9-2. 복호/검증 소비처 fallback 적용 + +- 파일: unwrapPrivateKey/verifyAccessToken/decryptSecret/decryptTotpSecret 호출부 및 HMAC 검증부(mfa/webauthn/audit 검증), `bootstrap.ts:62`(try/catch 없는 unwrapPrivateKey 전수 점검) +- 작업: 검증·복호 경로를 `tryWithSecrets`로 감싸 current→previous 순차. **소비처 25+ 파일 전수 점검** — 발급 경로가 실수로 previous를 쓰지 않는지, 무보호 예외 지점(bootstrap unwrap)이 fallback 루프에 들어가는지. RuntimeConfig 타입 변경 파급을 기계적으로 반영. +- 수용 기준: 모든 복호/검증이 회전 창에서 old/new 양쪽 수용. 발급은 current. 회귀 0(전체 게이트+통합 테스트). + +### [x] 9-3. 재암호화 스크립트 + 문서 + +- 파일: `scripts/reencrypt-secrets.ts`(신규, openScriptDb 패턴), `docs/SECRET_ROTATION.md`(무중단 절차로 갱신) +- 작업: signing_keys 활성행·credentials(totp)·identity_providers(LDAP bindPassword)를 old→new 재작성(4방언, `DB_DIALECT` 분기). dry-run 옵션 권장. **스크립트는 사용자가 실행**(자동 실행 금지). SECRET_ROTATION.md를 "previous 설정→배포→재암호화→previous 제거" 무중단 순서로 갱신, audit hash 재계산은 **제외하되 필요 시 수동 절차만 문서 안내**. +- 수용 기준: 스크립트가 dry-run으로 대상 건수 보고. 실제 재작성 로직이 unwrap(any secret)→wrap(current) 정확. 문서가 무중단 절차 반영. 자동 실행 안 함. + +### [x] 9-검증 (독립 Opus) — 통과(2026-07-06, APPROVE): 발급=current 위반 0건, fallback 헬퍼 선택 정확, 무보호 지점 커버, reencrypt dry-run. 158 테스트 + +- 발급=current 고정(previous 오사용 없음), 복호 fallback 정확, 무보호 예외 지점 커버, 재암호화 로직 정합. 게이트+통합 테스트. + +--- + +## Phase 10 — 잔존 한국어 에러 i18n (프로토콜 전량 번역) + +**목적**: admin 잔여 + validation.ts + 프로토콜 API(60건+) 전량 i18n. ko/en 대칭. + +### [x] 10-1. admin 잔여 + validation.ts + +- 파일: `src/routes/admin/saml-sps/[id]/+page.server.ts`(7건 `adminError`), `src/lib/server/admin/schemas.ts`+`crud-factory.ts`(i18n화), `src/lib/server/validation.ts`(`reason: string`→`{key,params}` 시그니처 변경)+호출부(oidc-clients/saml-sps/skins/ldap-providers), `src/lib/i18n/{ko,en}.json` +- 작업: schemas/crud-factory i18n화 시 **`test/unit/admin-zod.test.ts`·`crud-factory.test.ts`의 한국어 기대값도 함께 갱신**(별도 검증 레인은 유지 — 작성자가 테스트 갱신, 독립 에이전트가 검증). validation reason은 키+파라미터로 바꿔 호출부에서 `translate(locals.locale, ...)`. +- 수용 기준: 대상 파일 하드코딩 한국어 0(grep). 갱신된 테스트 통과. ko/en 대칭. 에러 shape 무변경. + +### [x] 10-2. 프로토콜 API 전량 번역 + +- 파일: OIDC(`oidc/{authorize,introspect,revoke,end-session,userinfo}`), SAML(`saml/{sso,slo,metadata}`), WebAuthn(`api/webauthn/**` + `passkey-client/+server.ts` 클라이언트 문자열), TOTP(`api/totp/{verify,enroll/confirm}`), `src/lib/i18n/{ko,en}.json`(oidc/saml/webauthn/totp 네임스페이스 신설) +- 작업: 사람이 보는 에러 문구를 `translate(locals.locale, ".errors.")`로 전환. **전량 번역 결정** — 단, OAuth `error`/SAML StatusCode 등 **기계 파싱 규격 코드값 자체는 표준 유지**하고 `error_description`·사람 대면 텍스트를 번역(표준 필드에 로케일 문구를 넣는 것이 규격 위반이 아닌 범위에서). `passkey-client`의 클라이언트 JS 문자열은 서버에서 로케일별 문자열을 주입하는 방식으로. +- 수용 기준: 프로토콜 라우트 사용자 대면 한국어 리터럴 0(grep). RP/SP 파싱 대상 코드값은 불변(표준 준수 확인). ko/en 대칭. 각 라우트 정상 동작. + +### [x] 10-검증 (독립 Opus) — 통과(2026-07-06, APPROVE): 표준 error 코드·StatusCode 보존, 사람대면 한국어 0, 704 대칭, 82키 신설. token 제외 타당. + 인접 버그(mfa_login 5키 부재) 별도 수정 + +- 한국어 리터럴 grep 0, 표준 코드값 불변(OAuth/SAML 파싱 호환), ko/en 대칭, 테스트 갱신 정합. 게이트. + +--- + +## Phase 11 — 통합 테스트 하네스 (마지막) + +**목적**: 실 DB(libSQL 인메모리) + 실 라우트 직접 호출로 OIDC 풀플로우 + 6~10 신규 로직 통합 검증. + +### [x] 11-1. 하네스 구축 + +- 파일: `vitest.config.ts`(`$app/environment` alias 스텁 추가), `test/integration/harness.ts`(신규 — libSQL `:memory:` + `drizzle/sqlite/*.sql` 프로그래매틱 적용, RequestEvent 빌더) +- 작업: `$app/environment` 스텁(`dev:false` export)로 runtime/bootstrap import 가능화. `scripts/lib/db.ts`의 SQL 실행 유틸 참고해 마이그레이션 순차 적용. `IDP_SIGNING_KEY_SECRET`/`IDP_ISSUER_URL` 테스트 값 주입. 테스트 격리(케이스별 새 DB 또는 트랜잭션 롤백). +- 수용 기준: 하네스로 실 스키마 인메모리 DB 구성 + 라우트 핸들러 import 성공. 순수 유닛 테스트(기존 151)와 공존(설정 충돌 없음). + +### [x] 11-2. 통합 테스트 작성 + +- 파일: `test/integration/*.test.ts`(신규) +- 작업: **OIDC authorize→token→userinfo 풀플로우**(실 서명키·PKCE·code 소진·클레임). 6~10 신규 로직: 세션 개별 철회(refresh 연쇄), 초대 수락(credential 생성), 계정 삭제 유예/복구, 시크릿 current/previous fallback(회전 시나리오). 프로토콜 i18n 로케일별 에러 문구 스모크. +- 수용 기준: 풀플로우 그린. 신규 로직 통합 경로 커버. `test.skip`/tautology 없음. 전체 `bun run test` 그린. + +### [x] 11-검증 (독립 Opus) — 통과(2026-07-06, APPROVE): 실 libSQL DB+실 라우트 구동(tautology 없음), 159 유닛 + 15 통합 = 174 그린 + +- 하네스가 실 SQL/실 라우트 구동하는지(mock-echo 아님), 격리 정상, 풀플로우 단정 실질성. 게이트 전체. + +--- + +## 최종 게이트 & 커밋 (STEP 7-8) + +- [ ] 전체: `bun run test`(통합 포함) · `check` · `lint` · `build` · `typecheck` · `db:check`(4방언) · `db:generate:all` no-op — 전부 그린 +- [ ] 마이그레이션: invite_tokens·users status enum/deletionScheduledAt 생성물 보고, **적용은 사용자 요청** (migrate/push 금지) +- [ ] 재암호화 스크립트: 실행하지 않음(사용자 몫), dry-run 사용법 안내 +- [ ] 커밋 승인 게이트 → 페이즈별 원자 커밋(리포 스타일). 푸시 금지. diff --git a/docs/plans/followup-round3/PLAN.md b/docs/plans/followup-round3/PLAN.md new file mode 100644 index 0000000..a4a14ed --- /dev/null +++ b/docs/plans/followup-round3/PLAN.md @@ -0,0 +1,123 @@ +# 후속 3차 개선 기획서 — KeyStone (마감 라운드) + +> 요청: 남은 항목 전부 진행 (C 기능·UX 마감 + A rate-limit 추상화 + B 테스트 심화 + 새 전수 재조사). +> 조사: 2026-07-06, 4개 병렬 Explore(전수 재조사=Opus, A/B/C=Sonnet). 코드 근거 확인. +> 규칙: 스키마 변경은 `db:generate:all`까지만(적용은 사용자). 커밋은 검증 통과 + 승인 후. + +--- + +## 배경 / 목표 + +두 라운드로 핵심 결함·기능 공백은 해소됨. 이번은 **마감 라운드**: 전수 재조사로 드러난 신규 갭 + 미룬 A/B/C를 처리한다. 조사 종합 결과 6개 페이즈: + +- **P12 잔여 버그·보안·운영 마감** — 전수 재조사 발견(저비용 고가치). +- **P13 메일 i18n + 세션 철회 알림 + 프로필 이메일 변경** — 기능 공백. +- **P14 UX a11y/loading + 스킨 복구 패널** — 접근성·사용성. +- **P15 rate-limit 저장소 추상화(A)** — 인터페이스 + Node in-memory. +- **P16 테스트 심화(B)** — SAML SSO·로그인+MFA·LDAP 통합 테스트. +- **P17 문서·잔여 마감** — admin 매뉴얼, organization 세분화, 관측성. + +--- + +## 범위 (In / Out) + +### Phase 12 — 잔여 버그·보안·운영 마감 (스키마 소폭) + +- **In**: + - **B1 invite_tokens GC 누락**(Medium): `gc.ts`가 users 하드삭제는 넣으면서 invite_tokens(만료/소진) purge를 누락 — 무한 성장. 다른 토큰 테이블과 동일 시맨틱으로 추가. + - **B2 organization scope id_token 불일치**(표준 위배): `oidc/token/+server.ts`가 `scopes.has("organization")`을 안 봐서 organization만 요청 시 id_token에 조직정보 누락(userinfo만 실림). userinfo와 동일 로직 공유로 수정. + - **B3 accept-invite status 검증 + 초대계정 상태**(Low보안): accept-invite `lookupToken`에 `status="active"` 필터 추가(disabled/deletion_pending 계정에 credential 심기 방지). 초대 미수락 계정이 즉시 `active`라 admin 카운트 오염 — credential 존재 기준으로 admin 판정하거나 상태 구분. + - **B4 헬스체크 readiness**(Low운영): `api/health`가 DB 다운에도 200 → DB unavailable 시 503. + - **B5 users 하드삭제 인덱스+배치**(Low): `deletionScheduledAt`/`status` 부분 인덱스 + GC 배치 LIMIT(대량 cascade 락 방지). `db:generate:all`. + - **B6 잔여 하드코딩 한국어**(Low): `account/profile/+page.server.ts:52`(생년월일), `accept-invite:77`(credential label) translate 전환. +- **Out**: DCR/PAR/JAR/Device Flow(로드맵 — 정책 판단, 열린 질문 ⑤), 계정 병합. + +### Phase 13 — 메일 i18n + 세션 철회 알림 + 프로필 이메일 변경 + +- **In**: + - **F1 메일 전량 locale 인지**: 재설정/인증/초대 메일이 수신자 locale 무시(ko 고정) — `email.ts` 템플릿을 locale 인자 받도록(보안 알림 메일 패턴 확장). `baseHtml` 푸터·제목 i18n. + - **F2 세션 철회 보안 알림**: `account/sessions` revoke/revokeOthers에 `dispatchSecurityAlert` 추가(`session_revoked`/`sessions_revoked_all` kind + i18n). 본인 직접 철회에도 보낼지는 열린 질문 ④(기본: 보냄 — 탈취 방어). + - **F3 프로필 이메일 변경**(중간~높음): `users.pendingEmail`+`pendingEmailRequestedAt` 컬럼, `account/profile` changeEmail 액션(현 비밀번호 재인증 → pendingEmail 저장 → 새 주소 인증 메일), confirm 라우트(토큰 검증 → email 교체). 기존 이메일에 "변경 시도" 알림(탈취 방어). `email_change_tokens` 또는 email_verification_tokens 파라미터화. `db:generate:all`. +- **Out**: 이메일 변경 시 기존 세션 유지 정책(변경만, 세션 무효화는 안 함 — 표준). + +### Phase 14 — UX a11y/loading + 스킨 복구 패널 + +- **In**: + - **U1 a11y 에러 배너**: 8개 auth 라우트 에러 배너에 `role="alert"`/`aria-live="assertive"` 추가. 실패 후 포커스 이동. + - **U2 loading 상태**: 메인 제출 폼(login/signup/mfa/reset/find-\*/accept-invite)에 `use:enhance` + 제출 중 버튼 disabled/스피너. 공용 패턴/컴포넌트로 추출. + - **U3 스킨 로그인 복구 패널**(옵션 B, 저비용): `login/+page.svelte`의 스킨 조건을 `{#if skinHtmlEffective && !form?.recovery}`로 바꿔 복구 케이스는 기본 UI 강제 노출(스킨 작성자 개입 불필요). 또는 recovery 반환에 skinHtml 채우기. +- **Out**: 전면 스킨 슬롯 시스템(옵션 A — 등록 스킨 소급 수정 부담 큼). + +### Phase 15 — rate-limit 저장소 추상화 (A) + +- **In**: + - **A1 인터페이스**: `RateLimitStore { increment(key,windowMs), peek(key,windowMs) }` 추출. `checkRateLimit(store, key, opts)`로 슬라이딩 윈도우 알고리즘 유지·저장소만 위임. `login`의 중복 `accountLockStatus` 구현을 `store.peek()`로 대체. + - **A2 Node in-memory**: `Map` 기반 store(기존 globalThis 전역 패턴). 단일 인스턴스 가정 한계 문서화(다중 인스턴스는 Redis 필요 — 이번 미도입). + - **A3 Workers 저장소**: **DO는 adapter-cloudflare 제약(fetch 단일 export)으로 wrapper 엔트리 필요 = 과침습**(GC scheduled 포기와 동일). 열린 질문 ①에 따라 (a) Workers는 현행 DB 저장소 유지(인터페이스만 추상화, 나중에 DO 삽입 가능), (b) wrapper 엔트리 도입해 DO. **기본안: (a)** — 추상화로 정리 효과(DB 4방언 분기·중복 제거)만 취하고 DO는 별도 결정. + - 21곳 호출부 시그니처 교체(기계적), `locals.rateLimitStore` 요청당 1회 해석(db.ts 패턴). +- **Out**: Redis(다중 인스턴스 시에만), KV(원자 increment 없어 보안 rate-limit 부적합). + +### Phase 16 — 테스트 심화 (B, Playwright 제외) + +- **In**: + - **T1 하네스 확장**: `seedSamlSp`, `seedIdentityProvider`, cookies 체이닝 헬퍼, SAML 서명 fixture 헬퍼(기존 유닛 테스트 `makeKeyCert`/`signAuthnRequest` 승격). + - **T2 SAML SSO 통합**: SP-initiated POST 바인딩(authorize→Response 검증, 권한 게이트, replay 가드). saml 커버리지 15%→상승. + - **T3 로그인+MFA 체이닝 통합**: 같은 cookies 인스턴스 재사용으로 idp_session/idp_mfa_pending 왕복. auth 34%→상승. + - **T4 LDAP 로그인 통합**: `seedIdentityProvider`로 login LDAP 분기. ldap 0%→상승. +- **Out**: admin CRUD 전체 통합(라우트 다수 — 시간 크나 선택적, 열린 질문 없이 여력 되면), SAML Redirect 바인딩 서명(반나절 추가), Playwright e2e(ROI 최하 — 통합으로 대부분 커버). + +### Phase 17 — 문서·잔여 마감 + +- **In**: + - **D1 admin 운영 매뉴얼**: `docs/ADMIN_GUIDE.md`(조직/클라이언트/스킨/서명키/감사로그). skins/guide placeholder 6개 실제값 문서화(가이드 페이지도 갱신). + - **D2 organization 노출 세분화 UI**(중간, 열린 질문 ③): oidc-clients 상세에 organization 클레임 토글(department/team/position 개별). `oidcClients.organizationClaimConfig` JSON 컬럼 + userinfo/token 참조. `db:generate:all`. + - **D3 관측성 훅**(Low): 헬스체크 개선(B4와 통합), GC 결과 구조화 로깅. 구조화 로거·request-id·메트릭 export는 기반만(전면 도입은 별도). + - **D4 백업코드 저잔량 경고**(Low): 로그인 시 backup_code 소비 후 잔량 경고, 소진 시 알림 메일. +- **Out**: Sentry/OTel 전면 배선, 감사뷰어 필터 대폭 확장(날짜/actor/CSV — 별도), scope 자유텍스트→체크박스 전면 개편. + +--- + +## 코드베이스 사실관계 (조사 근거) + +- **invite_tokens GC 누락**: `gc.ts:26,157-188` — users 하드삭제는 추가됐으나 invite_tokens purge 없음(다른 토큰과 동일 시맨틱). +- **organization 버그**: `oidc/token/+server.ts:148-153`이 `groups`만 체크, userinfo(`userinfo:120-154`)는 department/team/position 매핑 → organization scope만 요청 시 id_token 누락. +- **accept-invite**: `accept-invite:15-27` status 필터 없음. 초대 계정 `admin/users:227`이 즉시 `status:"active"`. +- **헬스체크**: `api/health:11-21` DB 상태 무관 200. +- **rate-limit**: `ratelimit/index.ts`(86줄) 2버킷 슬라이딩 윈도우, DB 4방언 분기. 호출부 21곳 `rl.allowed`만 사용(`remaining` 미참조). `login`이 `accountLockStatus`로 peek 중복 구현(20-46). DO는 adapter 제약(`_worker.js` fetch 단일 export)으로 wrapper 엔트리 필요. +- **테스트 하네스**: `harness.ts`가 makeEvent/makeCookies/seed 유틸 제공. cookies 인스턴스 재사용으로 라우트 체이닝 가능(신규 패턴). SAML fixture는 `test/unit/saml-verify-xml-signature.test.ts`의 makeKeyCert/signAuthnRequest 재사용. 커버리지: saml 15%·ldap 0%·org 0%·access 0%·admin/user-actions 0%. adapter-node는 이미 설정됨(svelte.config.js BUILD_TARGET 분기). Playwright 미설치. +- **a11y**: 8개 auth 폼 에러 배너 role/aria 전무, logout만 use:enhance. +- **프로필 이메일 변경**: `account/profile:35-71` email 필드 없음, pendingEmail 컬럼 없음. +- **메일 i18n**: 보안 알림만 locale 인자, reset/verify/invite 메일은 ko 고정. +- **스킨 복구**: `login/+page.server.ts:271-274` recovery 반환에 skinHtml 없음 → 스킨 로그인에서 복구 패널 미표시(무한 로그인 폼). + +--- + +## 접근법 / 리스크 / 순서 + +| Phase | 접근 | 리스크 & 완화 | +| ------------- | ------------------------------------------------------------- | -------------------------------------------------------------- | +| 12 마감 | 기존 패턴 소폭 수정, 저비용 | organization 버그는 표준 정합 회귀 주의 → 통합 테스트로 커버 | +| 13 기능 | email.ts locale화 + pendingEmail 플로우(email 인증 패턴 복제) | 이메일 변경은 탈취 벡터 → 재인증+기존주소 알림 필수 | +| 14 UX | role=alert/use:enhance 일관 적용(공용화) | 스킨 복구는 옵션 B(코드만)로 스킨 작성자 무개입 | +| 15 rate-limit | 인터페이스 추상화 + Node in-memory, Workers는 기본 DB 유지 | DO는 과침습이라 열린 질문 ①. 추상화만으로 4방언 분기 제거 효과 | +| 16 테스트 | 하네스 확장(기존 fixture 재사용) | Playwright 제외(ROI). 커버리지 0% 구간 집중 | +| 17 문서 | 문서 + organization UI(스키마 소폭) + 관측성 기반 | organization UI는 열린 질문 ③(버그만 vs UI까지) | + +**순서**: 12(마감·기반) → 13/14 병렬(기능·UX, 파일 대체로 분리) → 15(rate-limit, 21곳 광범위) → 16(테스트 — 12~15 신규 로직 포함) → 17(문서·마감). 스키마 건드리는 12(B5)·13(F3)·17(D2)는 마이그레이션 순서 조율(직렬). + +--- + +## 열린 질문 (승인 시 확정) + +1. **rate-limit Workers 저장소(P15)** — (a) 인터페이스만 추상화하고 Workers는 현행 DB 유지(Node만 in-memory 개선, 권장 — 저리스크), (b) wrapper 엔트리 도입해 Durable Object(성능 최적이나 과침습·빌드구조 변경). 어느 쪽? +2. **프로필 이메일 변경(P13 F3)** — 포함할지(중간~높음, 스키마+confirm 라우트). 표준 IdP 기능이라 권장하나 규모 있음. +3. **organization 노출 세분화 UI(P17 D2)** — id_token 불일치 버그 수정(P12 B2)만 할지, 클라이언트별 세분화 토글 UI(스키마 컬럼)까지 할지. +4. **세션 철회 알림(P13 F2)** — 본인이 직접 자기 세션 철회할 때도 알림 메일 보낼지(탈취 방어 vs 정상 로그아웃 스팸). 기본: 보냄. +5. **DCR/PAR/Device Flow(P12 Out)** — 표준 확장. 이번 로드맵 제외가 기본. 원하면 별도 트랙. + +--- + +## 산출물 + +- `docs/plans/followup-round3/PLAN.md` (본 문서) → 승인 후 `TODO.md` +- 스키마 변경분은 `db:generate:all`까지만 — 적용은 사용자. diff --git a/docs/plans/followup-round3/TODO.md b/docs/plans/followup-round3/TODO.md new file mode 100644 index 0000000..4c1c45b --- /dev/null +++ b/docs/plans/followup-round3/TODO.md @@ -0,0 +1,212 @@ +# 실행 투두 — 후속 3차 마감 (6 페이즈) + +> 승인(2026-07-06, 게이트①): 기획 승인 · P15 rate-limit = **인터페이스만 추상화**(Workers DB 유지, Node in-memory, DO 미도입) · 기능 = **프로필 이메일 변경 + organization 세분화 UI + 백업코드 경고 전부 포함** · 세션 철회 알림 = **본인 직접 철회도 발송**. +> 규칙: 스키마 변경은 `db:generate:all`까지만(적용 금지). 커밋은 전체 검증 통과 + 커밋 승인 후, 푸시 금지. 쓰기=Opus 위임, 검증=작성자와 분리된 독립 Opus. 스텁/TODO/skip은 블로커. +> 순서: P12 → P13/P14 병렬 → P15 → P16(신규 로직 통합) → P17. 스키마 건드리는 P12(B5)·P13(F3)·P17(D2)는 마이그레이션 순서 직렬. + +--- + +## Phase 12 — 잔여 버그·보안·운영 마감 + +**목적**: 전수 재조사 발견 항목(저비용 고가치) 해소. + +### [x] 12-1. B1 invite_tokens GC 누락 (Medium) + +- 파일: `src/lib/server/db/gc.ts` +- 작업: invite_tokens purge 추가(다른 토큰 테이블과 동일 시맨틱 — `lt(expiresAt, now)` 또는 `or(lt(expiresAt,now), isNotNull(usedAt))`). 기존 테이블별 에러 격리 패턴 준수. `test/unit/gc.test.ts`에 조건 테스트 추가. +- 수용 기준: invite_tokens가 GC 대상에 포함, 미만료·미소진 보존. gc 테스트 통과. + +### [x] 12-2. B2 organization scope id_token 불일치 (표준 위배) + +- 파일: `src/routes/oidc/token/+server.ts`, 참고 `oidc/userinfo/+server.ts:120-154` +- 작업: token의 id_token 클레임 생성에 `scopes.has("organization")` 분기 추가(userinfo와 동일하게 department/team/position/job_title 매핑). 공통 로직은 `oidc/claims.ts`로 추출해 token/userinfo 공유(중복 방지). `groups`와 `organization`의 각 노출 범위를 정확히 재현. +- 수용 기준: organization scope 요청 시 id_token·userinfo가 동일 조직 클레임. groups scope 동작 무회귀. 통합 테스트(P16)로 커버. + +### [x] 12-3. B3 accept-invite status 검증 + 초대계정 상태 (Low보안) + +- 파일: `src/routes/(auth)/accept-invite/+page.server.ts`, `src/routes/admin/users/+page.server.ts` +- 작업: (a) `lookupToken` 조인에 `eq(users.status, "active")` 추가, 소진 직전 status 재확인(disabled/deletion_pending 계정에 credential 심기 방지). (b) 초대 미수락 계정의 admin 카운트 오염 — `assertNotLastAdmin`/admin 집계가 credential 존재(=실사용 가능)를 기준으로 판정하도록 보강. 초대 계정 상태 표현은 기존 배지 로직(invite_token 보유)과 정합 유지. +- 수용 기준: 비활성/삭제예정 계정은 초대 수락 불가. 미수락 초대 admin이 마지막 admin 삭제 차단을 잘못 완화하지 않음. + +### [x] 12-4. B4 헬스체크 readiness (Low운영) + +- 파일: `src/routes/api/health/+server.ts` +- 작업: DB 연결 확인(가벼운 SELECT 1) 후 실패 시 503, 성공 시 200. liveness/readiness 구분이 유용하면 쿼리파라미터로. 기존 응답 형식 최대한 보존. +- 수용 기준: DB unavailable 시 503. 정상 시 200. 과도한 부하 없음(경량 쿼리). + +### [x] 12-5. B5 users 하드삭제 인덱스+배치 (Low) + +- 파일: `src/lib/server/db/schema.{sqlite,pg,mysql}.ts`, `src/lib/server/db/gc.ts` +- 작업: users에 `deletionScheduledAt`(+status) 조회를 지원하는 인덱스 추가(가능하면 부분 인덱스 `WHERE status='deletion_pending'` — 방언별 표현, mysql 미지원 시 일반 인덱스). GC users 하드삭제에 배치 LIMIT(대량 cascade 락 방지). `db:generate:all`. +- 수용 기준: 3방언 parity(인덱스 예외 목록 갱신). GC가 배치 삭제. 미경과 계정 미삭제 유지. + +### [x] 12-6. B6 잔여 하드코딩 한국어 (Low) + +- 파일: `src/routes/account/profile/+page.server.ts:52`(생년월일 형식), `src/routes/(auth)/accept-invite/+page.server.ts:77`(credential label) +- 작업: `translate(locals.locale, ...)` 전환, ko/en 키 추가. (email.ts 메일 i18n은 P13 F1에서.) +- 수용 기준: 대상 2곳 한국어 리터럴 제거, ko/en 대칭. + +### [x] 12-검증 (독립 Opus) — 통과(2026-07-06, 로직 VERIFIED): organization 버그 표준정합·groups 무회귀, admin카운트·GC·헬스체크·인덱스 정확(prettier 정리). V2/V3/V6 전용 테스트는 P16에 반영 + +- organization 버그 수정의 표준 정합(id_token=userinfo), GC 조건 보수성, accept-invite status 가드, 헬스체크 503, 인덱스 parity. 게이트 4종. + +--- + +## Phase 13 — 메일 i18n + 세션 철회 알림 + 프로필 이메일 변경 + +**목적**: 메일 locale 인지, 세션 철회 알림, 이메일 변경 플로우. +**파일 경계**: F1(email.ts)·F2(sessions)·F3(profile+email.ts) — email.ts 공유이므로 F1→F2/F3 순서 또는 단일 에이전트. + +### [x] 13-1. F1 메일 전량 locale 인지 + +- 파일: `src/lib/server/email.ts`, 발송 호출부(reset-password/verify-email/accept-invite/invite) +- 작업: `sendPasswordResetEmail`/`sendEmailVerificationEmail`/`sendInviteEmail`·`baseHtml`을 수신자 locale 인자 받도록(보안 알림 메일 패턴 확장). 제목·본문·푸터·`lang` 속성 i18n. 호출부에서 대상 사용자 locale 전달(없으면 요청 locale 또는 ko 기본). i18n 키 ko/en. +- 수용 기준: 각 메일이 수신자 locale로 렌더. escape/safe-URL 유지. 발송 best-effort 유지. + +### [x] 13-2. F2 세션 철회 보안 알림 + +- 파일: `src/routes/account/sessions/+page.server.ts`, `src/lib/server/security-notify.ts`(kind 추가), i18n +- 작업: `SecurityEventKind`에 `session_revoked`/`sessions_revoked_all` 추가 + `security_alert.*` ko/en 3줄씩. sessions revoke/revokeOthers 액션에 `dispatchSecurityAlert`(best-effort) 추가 — **본인 직접 철회도 발송**(승인됨). 현재 세션 철회(로그아웃) 케이스에도 일관. +- 수용 기준: 세션 철회 시 알림 메일 경로 존재. best-effort 격리. i18n 대칭. + +### [x] 13-3. F3 프로필 이메일 변경 (중간~높음) + +- 파일: `src/lib/server/db/schema.{sqlite,pg,mysql}.ts`(`pendingEmail`+`pendingEmailRequestedAt`), `src/routes/account/profile/+page.server.ts`(changeEmail 액션)·`+page.svelte`, confirm 라우트 `src/routes/account/confirm-email-change/`(신규), 토큰(email_verification_tokens 파라미터화 또는 신규 `email_change_tokens`), `security-notify.ts`(email_change kind), i18n +- 작업: changeEmail 액션 — **현 비밀번호 재인증** → 중복 이메일 체크 → `pendingEmail` 저장 + 새 주소로 인증 토큰 메일 → confirm 라우트에서 토큰 검증 후 `email` 교체·`pendingEmail` 클리어·`emailVerifiedAt` 갱신(원자적 `runAtomic`). **기존 이메일에 "변경 시도" 알림**(탈취 방어). profile UI에 이메일 input + 현 비밀번호 + "확인 대기" 배너. rate-limit. `db:generate:all`. +- 수용 기준: 이메일 변경→새 주소 인증→교체 동작. 재인증 없이 변경 불가. 기존 주소 알림. 토큰 1회용·만료. 중복 이메일 차단. 스키마 parity. + +### [x] 13-검증 (독립 Opus) — 통과(APPROVE): 메일 locale, 세션 알림, 이메일변경(재인증·targetEmail 바인딩·원자성·기존주소 알림). profile UI 별도 마무리. sessions 하드코딩 한국어 2건은 P17로 + +- 메일 locale 렌더, 세션 알림 격리, 이메일 변경의 재인증·토큰 보안·기존주소 알림·원자성·중복 차단. 마이그레이션 생성만. 게이트. + +--- + +## Phase 14 — UX a11y/loading + 스킨 복구 패널 + +**목적**: 접근성·제출 피드백·스킨 복구. + +### [x] 14-1. U1 a11y 에러 배너 + 포커스 + +- 파일: 8개 auth `.svelte`(login/signup/mfa/reset-password/find-id/find-password/accept-invite/verify-email), 가능하면 공용 컴포넌트 `src/lib/components/FormError.svelte`(신규) +- 작업: 에러 배너에 `role="alert"`/`aria-live="assertive"`. 실패 후 배너/실패 필드로 포커스 이동(`tabindex=-1`+focus 또는 autofocus 로직). 공용 컴포넌트로 추출해 일관 적용. +- 수용 기준: 8개 폼 에러가 스크린리더 announce. 포커스 이동. 시각 동작 무회귀. svelte-autofixer 통과. + +### [x] 14-2. U2 loading 상태 + +- 파일: 위 8개 폼(패스키 별도 흐름 제외), 공용 enhance 패턴 +- 작업: 메인 제출 폼에 `use:enhance` + 제출 중 버튼 disabled + 스피너/텍스트. SSR 폴백 유지(JS 없어도 동작). 공용 패턴/컴포넌트로. +- 수용 기준: 제출 중 중복 클릭 방지·로딩 표시. JS 비활성에도 폼 동작(progressive enhancement). 기존 리다이렉트/에러 흐름 무회귀. + +### [x] 14-3. U3 스킨 로그인 복구 패널 (옵션 B) + +- 파일: `src/routes/(auth)/login/+page.svelte`, 필요시 `+page.server.ts` +- 작업: 스킨 렌더 조건을 `{#if skinHtmlEffective && !form?.recovery}`로 바꿔 복구(soft-delete) 케이스는 기본 UI 강제 노출(스킨 작성자 개입 불필요). 또는 recovery 반환에 skinHtml 채우고 기본 패널 우선. 스킨 로그인에서도 복구 프롬프트·`recover=1` 접근 가능. +- 수용 기준: 스킨 로그인에서 삭제예정 계정이 복구 패널 도달. 일반 스킨 로그인 무회귀. + +### [x] 14-검증 (독립 Opus) — 통과(APPROVE): FormError role=alert+포커스, use:enhance progressive enhancement(JS off 동작), 스킨 복구 도달. 803키 대칭 + +- role/aria 적용, 포커스, progressive enhancement(JS off 동작), 스킨 복구 도달. 게이트. + +--- + +## Phase 15 — rate-limit 저장소 추상화 (인터페이스만, Node in-memory) + +**목적**: 저장소 추상화로 DB 4방언 분기·login 중복 제거. Workers는 현행 DB 유지, Node는 in-memory. + +### [x] 15-1. A1 RateLimitStore 인터페이스 + +- 파일: `src/lib/server/ratelimit/store.ts`(신규 인터페이스+구현), `src/lib/server/ratelimit/index.ts` +- 작업: `RateLimitStore { increment(key,windowMs): {current,prev}; peek(key,windowMs): {current,prev} }` 정의. `checkRateLimit(store, key, opts)`로 슬라이딩 윈도우 수식 유지·저장소 위임. **DbRateLimitStore**(현 DB 로직 이관 — 4방언 분기 캡슐화)와 **MemoryRateLimitStore**(Map) 구현. `locals.rateLimitStore`를 hooks에서 요청당 1회 해석(Workers=DB, Node=memory; `db/index.ts` isWorkers 판별 재사용). +- 수용 기준: 알고리즘 동일(기존 ratelimit 테스트 통과). peek/increment 분리. Workers=DB·Node=memory 분기. + +### [x] 15-2. A2 호출부 이관 + login 중복 제거 + +- 파일: rate-limit 호출부 21곳, `src/routes/(auth)/login/+page.server.ts`(accountLockStatus) +- 작업: `checkRateLimit(db,...)` → `checkRateLimit(locals.rateLimitStore,...)` 기계적 교체. login의 중복 `accountLockStatus` 구현을 `store.peek()`로 대체. 기존 `rate_limits` 테이블·스키마·`purgeExpiredRateLimits`/gc 호출은 Workers(DB store)가 계속 쓰므로 **유지**(Node memory는 자체 evict). +- 수용 기준: 21곳 정상 동작(429 분기 무회귀). login 계정 잠금 동작 동일. Node/Workers 양쪽 rate-limit 동작. + +### [x] 15-3. A3 Node in-memory 한계 문서화 + +- 파일: `.env.example` 또는 README 관련 섹션 +- 작업: Node in-memory rate-limit이 단일 인스턴스 가정임을 명시(다중 인스턴스 확장 시 한도 완화 → Redis store 필요, 이번 미도입). 인터페이스가 Redis/DO store 추가에 열려있음 안내. +- 수용 기준: 한계·확장 경로 문서화. + +### [x] 15-검증 (독립 Opus) — 통과(APPROVE): 알고리즘 byte-identical, peek=accountLockStatus 정합, 24곳 무회귀, Workers/Node 빌드 그린. single-now 스레딩 미세개선 별도 처리 + +- 알고리즘 동일성(peek/increment가 기존 upsert+select와 같은 결과), 21곳 무회귀, login 잠금 정합, Node memory evict·격리. 게이트 + rate-limit 통합/유닛 테스트. + +--- + +## Phase 16 — 테스트 심화 (하네스 확장, Playwright 제외) + +**목적**: 커버리지 0% 구간(saml/ldap) + auth 통합. P12~15 신규 로직 포함. + +### [x] 16-1. T1 하네스 확장 + +- 파일: `test/integration/harness.ts` +- 작업: `seedSamlSp`(samlSps 삽입), `seedIdentityProvider`(LDAP provider), cookies 체이닝 헬퍼(같은 makeCookies 재사용 공식화), SAML 서명 fixture(`test/unit/saml-verify-xml-signature.test.ts`의 makeKeyCert/signAuthnRequest 승격·공용화). +- 수용 기준: 헬퍼 추가, 기존 통합 테스트 무영향. + +### [x] 16-2. T2 SAML SSO 통합 테스트 + +- 파일: `test/integration/saml-sso.test.ts`(신규) +- 작업: SP-initiated POST 바인딩 — 로그인 상태에서 authorize→SAML Response 검증(서명·audience·ACS), 서비스 권한 게이트 실패, AuthnRequest replay 가드. 실 서명키·실 라우트. +- 수용 기준: 풀플로우 그린, saml 커버리지 상승. tautology 없음. + +### [x] 16-3. T3 로그인+MFA 체이닝 통합 + +- 파일: `test/integration/login-mfa.test.ts`(신규) +- 작업: 같은 cookies 인스턴스로 login(비번)→idp_mfa_pending→mfa(TOTP)→idp_session 발급 검증. 잘못된 코드 거부, rate-limit, 백업코드 경로(P17 D4와 정합). +- 수용 기준: 쿠키 왕복 재현, auth 커버리지 상승. + +### [x] 16-4. T4 LDAP 로그인 통합 + 신규 로직 통합 + +- 파일: `test/integration/ldap-login.test.ts`(신규), 기존 통합에 P12~15 신규 로직 추가 +- 작업: `seedIdentityProvider`로 login LDAP 분기(가능 범위 — 실 LDAP 서버 없이 되는 부분, mock LDAP client 필요 시 사유 보고). organization id_token 일치(B2), 이메일 변경(F3), rate-limit store(A) 통합 검증. +- 수용 기준: ldap 분기 커버(가능 범위), 신규 로직 통합 그린. 무리한 것은 제외·사유 보고. + +### [x] 16-검증 (독립 Opus) — 통과(11기준 VERIFIED): SAML SSO·login+MFA·LDAP·P12로직·세션알림 통합 전부 실 DB/실 라우트(mock-echo 없음), single-now 정합. 총 211테스트(prettier 정리) + +- 실 DB/실 라우트 구동(mock-echo 아님), fixture 정합, tautology·skip 없음. 게이트 + 전체 테스트 수. + +--- + +## Phase 17 — 문서·잔여 마감 + +**목적**: admin 매뉴얼, organization 세분화 UI, 백업코드 경고, 관측성 기반. + +### [x] 17-1. D1 admin 운영 매뉴얼 + +- 파일: `docs/ADMIN_GUIDE.md`(신규), `src/routes/admin/skins/guide/+page.svelte`(placeholder 갱신) +- 작업: 조직(dept/team/part/position) 관리, OIDC/SAML 클라이언트 등록, scope/role 설정, 스킨 등록 + placeholder 6종 전체(`IDP_FORM_ACTION`/`IDP_REDIRECT_TO`/`IDP_SKIN_HINT`/`IDP_REGISTERED`/`IDP_PASSWORD_RESET`/`IDP_FLASH_MSG`) 문서화, 서명키 회전, 감사로그 조회. skins/guide 페이지의 불완전한 placeholder 문서도 갱신. +- 수용 기준: 매뉴얼 존재, placeholder 실제값 정확. + +### [x] 17-2. D2 organization 노출 세분화 UI (중간) + +- 파일: `src/lib/server/db/schema.*.ts`(`oidcClients.organizationClaimConfig` JSON), `src/routes/admin/oidc-clients/[id]/+page.svelte`·server, `oidc/claims.ts`/token/userinfo(config 참조), i18n +- 작업: 클라이언트별 organization 클레임 토글(department/team/position/job_title 개별 노출). config 컬럼 추가, 매핑 로직이 config 참조(미설정 시 기존 전량 노출로 하위호환). oidc-clients 상세에 체크박스 그룹 + 설명. `db:generate:all`. +- 수용 기준: 토글대로 클레임 노출. 기존 클라이언트 하위호환(config null=전량). B2(id_token 일치)와 정합. parity. + +### [x] 17-3. D4 백업코드 저잔량 경고 + +- 파일: `src/routes/(auth)/mfa/+page.server.ts`, `account/mfa`, security-notify(kind), i18n +- 작업: 로그인 시 backup_code 소비 후 잔량 계산 → 저잔량(예: ≤2) 경고 표시, 소진 시 알림 메일(`dispatchSecurityAlert` kind 추가). account/mfa의 잔량 표시와 정합. +- 수용 기준: 저잔량 경고·소진 알림. 오탐 없음. + +### [x] 17-4. D3 관측성 기반 (Low) + +- 파일: `src/lib/server/db/gc.ts`(GC 결과 구조화 로깅), 필요시 로깅 헬퍼 +- 작업: GC 결과·주요 이벤트를 구조화 형태(JSON, tenant/requestId 가능 시)로 로깅하는 최소 기반. 전면 로거/메트릭/Sentry는 별도(범위 밖) — 훅 지점만 정리. +- 수용 기준: GC 결과 구조화 로그. 과설계 없음. + +### [x] 17-검증 (독립 Opus) — 통과(로직 VERIFIED): organization 하위호환·id_token=userinfo 정합, 백업코드 오탐 방지, 문서 정확. config off 경로 유닛테스트 15케이스 추가(226테스트) + +- organization config 하위호환·클레임 정합, 백업코드 경고 정확, 문서 정확성. 마이그레이션 생성만. 게이트. + +--- + +## 최종 게이트 & 커밋 (STEP 7-8) + +- [x] 전체(2026-07-06): `bun run test`(통합 포함) · `check` · `lint` · `build` · `typecheck` · `db:check`(4방언) · `db:generate:all` no-op — 전부 그린 +- [x] 마이그레이션 생성물: users 인덱스(0025/0008)·email_change_tokens+pendingEmail(0026/0009)·organizationClaimConfig(0027/0010) — **적용은 사용자 몫** +- [ ] 커밋 승인 게이트 → 페이즈별 원자 커밋(공유 파일은 논리 배치). 푸시 금지. diff --git a/docs/plans/project-improvement-audit/PLAN.md b/docs/plans/project-improvement-audit/PLAN.md new file mode 100644 index 0000000..375f5d0 --- /dev/null +++ b/docs/plans/project-improvement-audit/PLAN.md @@ -0,0 +1,111 @@ +# 프로젝트 전수 개선 감사 기획서 — KeyStone + +> 요청: "이 프로젝트 내에서 개선하거나 수정할 내용들, 추가할 내용들을 모두 파악해서 적용될 수 있도록" +> 조사: 2026-07-05~06, 5개 병렬 Explore 에이전트(보안=Opus, 아키텍처·DB=Opus, 품질/테스트·CI/기능·UX=Sonnet). +> 전제: Critical/High 보안 취약점 없음. 이전 리포트(2026-07-02)·deferred-followups(TOTP TOCTOU/i18n/CRUD 팩토리)는 모두 구현·커밋 완료 확인. + +--- + +## 배경 / 목표 + +프로젝트는 문서·CI·보안 방어(세션/CSRF/OIDC/SAML/LDAP) 수준이 이미 높으나, 조사 결과 다음 5축의 격차가 확인됨: + +1. **보안 Medium 6건** — 타이밍 열거, 계정 잠금 부재, tenant 격리 우회 1건, code 교환 세션 검사 누락 등. +2. **운영 시한폭탄** — 만료 데이터 GC 전무(purge 함수 3개가 orphan, cron 없음 → 9종 테이블 무한 성장), 마이그레이션 4트랙 비대칭(d1 21개 vs 나머지 4개). +3. **기능 공백** — 이메일 인증 플로우 부재로 `email_verified` 클레임이 **항상 false**(RP 신뢰 시 실질 버그), 보안 알림 메일 부재. +4. **테스트 불균형** — 62개 유닛 테스트가 전부 그린이지만 정작 **토큰 서명/발급·세션·패스워드·rate-limit 알고리즘은 테스트 0건**. schema-parity가 인덱스/제약 drift를 못 잡음. +5. **품질·문서 부채** — `users/[id]` 677줄, admin "라벨은 영어·에러는 한국어" 혼재, **보안 감사 문서 2건 public repo 잔존(E9 미처리)**, 문서·메모리 drift. + +목표: 위 격차를 페이즈별로 해소해 적용 가능한 상태로 만든다. 스키마 변경은 `db:generate`까지만(적용은 사용자). + +--- + +## 범위 (In / Out) + +### Phase 1 — 보안 Medium 일괄 해소 + +- **In**: + - **S1 로그인 타이밍 열거**: 미존재 계정에도 더미 scrypt 검증 수행해 응답시간 균등화 (`src/lib/server/auth/users.ts:46-63`; find-id/find-password의 기존 균등화 패턴 재사용). + - **S2 계정 단위 잠금**: 기존 rate-limit 인프라를 `login:user:` 키로 재사용해 계정별 backoff 추가 — **스키마 변경 없이** 구현 (IP rate-limit과 병행). + - **S3 lookup API tenant 격리**: `api/users/lookup/+server.ts:35-38`의 `?id=` 경로에 tenant 스코프 강제(또는 호출자 tenant 명시 요구). + - **S4 code 교환 세션 검사**: authorization_code 교환 시 `grant.sessionId` 세션의 `revokedAt`/만료 확인 (`oidc/token/+server.ts:392-438`; refresh 경로의 기존 검사 로직 재사용). + - **S5 issuer fail-closed**: 프로덕션에서 `IDP_ISSUER_URL` 미설정 시 host-header fallback 대신 명시적 실패(503) + 부트스트랩 시 필수 env(`IDP_SIGNING_KEY_SECRET` 포함) 검증(fail-fast). +- **Out**: 마스터 시크릿 용도 분리·회전 절차(운영 하드닝 트랙, 별도 작업 — 회전 절차 문서화만 Phase 5에 포함), skin sanitize 강화(Low, CSP로 완화 중), WebAuthn rpID(Low, 실질 위조 불가). + +### Phase 2 — 운영 안정성 (GC·마이그레이션·원자성·캐시) + +- **In**: + - **O1 만료 데이터 GC**: Cloudflare Cron Trigger + `scheduled()` 핸들러 신설, 만료 대상 전 테이블(sessions/oidc_grants/refresh_tokens/password_reset_tokens/webauthn challenges/rate_limits/saml_slo_states/saml_authn_request_ids/saml_sessions) 일괄 purge. Node 런타임 대응 경로 포함. 기존 orphan purge 함수 3개 재사용·확장. wrangler.example.jsonc에 cron 설정 추가. + - **O2 마이그레이션 트랙 대칭화**: `db:generate:all` 스크립트 추가, 4트랙 미생성 diff를 잡는 CI 체크(또는 parity 테스트), pg/mysql/sqlite 트랙의 밀린 마이그레이션 생성(**적용은 안 함**). + - **O3 refresh 회전 원자화**: claim→insert 2-write를 signing-keys/totp-confirm과 동일한 batch/transaction 분기로 원자화. 이 분기 패턴을 `atomic()` 공용 유틸로 추출(3곳 재사용). + - **O4 서명키 캐시**: `getActiveSigningKey`/`getPublicJwks`에 baseline과 동일한 globalThis 단기 캐시(회전 시 무효화). + - **O5 스키마 정리**: `client_skins.createdAt` sqlite 단위 통일(timestamp_ms), legacy `oidc_grants.code` 컬럼+인덱스 drop — `db:generate`까지만. `drizzle.config.ts:9`의 사실과 다른 gitignore 주석 정리. +- **Out**: **rate-limit 저장소 DO/KV 추상화**(H3 — 구조 변경이 커서 별도 트랙 권장, 열린 질문 ③), mysql signing_keys 부트스트랩 경로 FOR UPDATE(Low, 발생 조건 희귀). + +### Phase 3 — 기능 추가 (이메일 인증 + 보안 알림) + +- **In**: + - **F1 이메일 인증 플로우**: 가입 시 인증 메일 발송 → 토큰 검증 라우트 → `users.emailVerifiedAt` 세팅 → `email_verified` 클레임 정상화. 재발송·만료 처리 포함. (토큰 테이블은 기존 password_reset_tokens 패턴 재사용, 스키마 필요 시 `db:generate`까지만.) + - **F2 보안 알림 메일**: 비밀번호 변경/재설정 완료, MFA 등록·삭제, 패스키 등록·삭제, admin의 비밀번호 초기화·계정 잠금 시 대상 사용자에게 통지 메일 (기존 email.ts 템플릿 패턴 확장). + - **F3 admin 강제 로그아웃 액션**: `revokeAllUserSessions` 재사용한 전용 버튼/액션 (소규모). +- **Out(별도 후속 후보)**: 초대(invite) 플로우, 셀프서비스 활성 세션 목록/철회, 계정 삭제 — 각각 독립 기획 필요(열린 질문 ①). 새 기기/위치 로그인 알림(기기 지문 인프라 필요). + +### Phase 4 — 테스트 확충 (P0/P1) + +- **In**: + - **T1 P0 유닛 테스트**: `crypto/keys.ts`(JWT 서명·access token HMAC·키 래핑), `oidc/grant.ts`, `oidc/refresh.ts`(회전·재사용 감지), `auth/session.ts`, `auth/password.ts`(scrypt·레거시 업그레이드), `ratelimit/index.ts`(슬라이딩 윈도우 본체). Phase 1~3 신규 로직(계정 잠금, 이메일 인증 토큰, GC purge) 테스트 포함. + - **T2 schema-parity 강화**: 인덱스/unique 제약 parity 비교 추가(방언별 API 차이는 명시적 예외 목록으로 — mysql partial-unique 등 known gap을 테스트가 문서화), 컬럼 타입·nullable 비교. + - **T3 인프라**: `test:coverage`(@vitest/coverage-v8) + `typecheck`·`lint:fix`·`db:check` 스크립트, lefthook pre-commit에 eslint 추가. +- **Out**: 통합/e2e 하네스(Playwright 등 — 가치 크지만 별도 기획 규모), LDAP/SAML response 유닛(P2, 후속). + +### Phase 5 — 품질·문서 정리 + +- **In**: + - **Q1 보안 문서 격리**: `docs/security-audit-2026-05-12.md`·`docs/security-pentest-report.md` repo에서 제거(E9) — 처리 방식은 열린 질문 ②. + - **Q2 `users/[id]/+page.server.ts`(677줄) 분리**: actions를 도메인별 모듈(profile/org/service)로 분리, 동작 무변경. + - **Q3 admin 에러 메시지 i18n**: 비정형 admin 라우트의 하드코딩 한국어 에러를 t()/translate() 전환(UI 라벨은 이미 영어 — 혼재 해소). 공통 `requireFormId` 류 헬퍼 추출로 중복 문구 12곳+ 정리. + - **Q4 문서·메모리 정합**: improvement-report의 stale 항목(refresh token "미구현" 표기 등) 갱신, deferred-followups TODO 상태 갱신, 프로젝트 메모리(OIDC scope 후속) 최신화, 시크릿 회전 절차 문서화. +- **Out**: saml/sso·oidc/token 파일 물리 분리(Low, 함수 단위로는 이미 정리됨), knip/depcheck 도입(Low), en.json 원어민 검수(외부 작업). + +--- + +## 코드베이스 사실관계 (조사 근거 요약) + +- **보안 양호 확인**: 세션(해시 저장·회전·cascade 폐기), CSRF(전역 Origin 강제+double-submit), OIDC(PKCE S256 전용, redirect_uri 정확일치, code 1회 소진, refresh 회전+family 폐기), SAML(XSW 다층 방어, ACS 강제), LDAP(필터/DN 이스케이프), scrypt(N=2^15,r=8,p=3)+레거시 자동 업그레이드, hash 기반 CSP. +- **보안 결함 근거**: S1=`users.ts:46-63`(미존재 시 해시 미수행), S2=`login/+page.server.ts:120`(IP rate-limit뿐), S3=`api/users/lookup/+server.ts:35-38`(id 경로 tenant 무필터), S4=`oidc/token/+server.ts:392-438`(code 경로만 세션 미검사), S5=`runtime.ts:55-62`(Host fallback)+`bootstrap.ts:106`(시크릿 미설정 시 조용히 스킵). +- **GC 부재**: `webauthn.ts:158`·`refresh.ts:178`·`ratelimit/index.ts:84`의 purge 함수 호출부 0건, wrangler cron 전무. sessions/oidc*grants/password_reset_tokens/saml*\* 은 purge 함수조차 없음. rate_limits는 요청마다 신규 key 생성으로 폭증. +- **마이그레이션 비대칭**: d1 트랙 21개(0000–0020) vs pg/mysql/sqlite 각 4개. d1·sqlite가 같은 스키마 공유하는데 히스토리 상이. `drizzle.config.ts:9` 주석은 gitignore라 하나 실제 tracked. +- **테스트 현황**: 10파일 62테스트 전부 순수 로직. `+server.ts` 23 + `+page.server.ts` 27개 라우트 핸들러 직접 구동 테스트 0. CI(lint/check/test/build/CodeQL/gitleaks/dependabot)는 양호. +- **기능 공백 근거**: `emailVerifiedAt` 세팅 코드 0건(읽기 전용)인데 `token/+server.ts:86`·`userinfo/+server.ts:73`이 클레임으로 노출. email.ts 템플릿 2종(find-id/reset)뿐. +- **이미 구현 확인(문서만 stale)**: OIDC groups/address/organization scope, refresh_token grant, introspect/revoke, 감사 해시, TOTP TOCTOU, CRUD 팩토리+zod+FK 검증, i18n en 476키 1:1 대칭+LocaleToggle+SSR 로케일. SAML assertion 암호화도 구현된 것으로 판단(saml-encrypt.test.ts 라운드트립 + verify:saml-encryption 스크립트 존재; 기능 조사 레인의 "미구현" 보고는 오판으로 보이며 실행 시 재확인). + +--- + +## 접근법 / 리스크 / 트레이드오프 + +| Phase | 접근 | 리스크 & 완화 | +| -------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| 1 보안 | 기존 패턴 재사용(타이밍 균등화·rate-limit 키·refresh의 세션 검사)으로 스키마 무변경 최소 수정 | 인증 핫패스 수정 → Phase 4 테스트가 커버 + 독립 검증 레인. S2는 정상 사용자 오탐 위험 → 창/임계값 보수적(예: 10회/15분 backoff) | +| 2 운영 | cron+scheduled 신설, 나머지는 기존 유틸 패턴 추출·재사용 | O2 마이그레이션 생성물은 커밋만, 적용은 사용자(CLAUDE.md 규칙). GC DELETE는 만료분만·배치 크기 제한으로 타임아웃 방지 | +| 3 기능 | password_reset_tokens 검증된 패턴 복제로 이메일 인증 구현 | 기존 가입 사용자 처리(전원 미인증 상태) → 로그인 차단은 하지 않고 클레임·배너만(비파괴적) | +| 4 테스트 | WebCrypto/HMAC은 실제 키로, DB는 기존 mock 패턴 | 없음(추가만) | +| 5 품질 | 동작 무변경 리팩터 + 문서 갱신 | Q2는 diff 리뷰로 회귀 방지, Q1은 히스토리에 문서가 남는 한계 명시 | + +**의존성**: Phase 1·2·3은 파일 겹침 적어 부분 병렬 가능(단 S5와 O1이 hooks/bootstrap 근처에서 겹칠 수 있어 순서 조율). Phase 4는 1~3 이후(신규 로직 테스트 포함). Phase 5는 독립. + +--- + +## 열린 질문 (승인 시 확정) + +1. **Phase 3 범위** — 이메일 인증+보안 알림+강제 로그아웃(제안)만? 아니면 초대 플로우/세션 셀프서비스까지 이번에 포함? (후자는 규모가 커져 별도 기획 권장) +2. **보안 감사 문서 2건(E9)** — (a) repo에서 삭제만(히스토리 잔존 감수, 권장), (b) private 위치로 이동 후 삭제, (c) 유지. ※ 히스토리 rewrite는 파괴적이라 제안하지 않음. +3. **rate-limit 저장소 추상화(DO/KV)** — 이번 범위에 포함할지, 별도 트랙으로 미룰지(권장: 미룸 — 구조 변경이 크고 GC(O1)로 폭증 문제는 완화됨). +4. **진행 방식** — 5페이즈 전체 승인 후 순차(1→2→3→4→5) 진행 권장. 일부 페이즈만 선별 진행도 가능. + +--- + +## 산출물 + +- `docs/plans/project-improvement-audit/PLAN.md` (본 문서) +- 승인 후 `docs/plans/project-improvement-audit/TODO.md` (페이즈별 상세 투두 + 수용 기준) +- 스키마 변경분은 `db:generate` 마이그레이션 생성까지만 — 적용은 사용자 요청. diff --git a/docs/plans/project-improvement-audit/TODO.md b/docs/plans/project-improvement-audit/TODO.md new file mode 100644 index 0000000..df01e5a --- /dev/null +++ b/docs/plans/project-improvement-audit/TODO.md @@ -0,0 +1,194 @@ +# 실행 투두 — 프로젝트 전수 개선 (5 페이즈) + +> 승인된 결정(2026-07-06, 게이트 ①): 기획서 승인 · Phase 3 = **제안 범위**(이메일 인증 + 보안 알림 + 강제 로그아웃, 초대/세션 셀프서비스는 후속) · 보안 감사 문서 2건 = **git rm 삭제**(히스토리 rewrite 없음) · rate-limit 저장소 추상화 = **별도 트랙으로 미룸**. +> 규칙: 스키마 변경은 `db:generate`까지만(적용 금지, CLAUDE.md). 커밋은 전체 검증 통과 + 커밋 승인(게이트 이후)에만, 푸시 금지. 쓰기=Opus 위임, 검증=작성자와 분리된 독립 Opus 에이전트. 스텁/TODO 플레이스홀더/`test.skip`은 블로커. +> 순서: Phase 1 → 2 → 3 → 4 → 5 (4는 1~3의 신규 로직 테스트 포함, 5는 독립이라 병렬 가능). 페이즈 내 파일 겹침 태스크는 직렬. + +--- + +## Phase 1 — 보안 Medium 5건 수정 + +**목적**: 조사에서 확인된 Medium 결함을 스키마 변경 없이 기존 패턴 재사용으로 해소. +**파일 경계**: S1·S2는 login 경로 공유 → 같은 에이전트가 직렬 처리. S3·S4·S5는 서로 독립. + +### [x] 1-1. S1 로그인 타이밍 열거 균등화 + +- 파일: `src/lib/server/auth/users.ts` (46-63 부근), 참고: `find-password/+page.server.ts`의 timing 균등화 패턴 +- 작업: username 미존재 시에도 더미 scrypt 검증(고정 더미 해시에 대한 verify)을 수행해 존재/미존재 응답시간을 균등화. 더미 해시는 실제 파라미터(N=2^15,r=8,p=3)와 동일 비용. +- 수용 기준: 미존재/존재-오답 경로 모두 scrypt 1회 비용 발생. 기존 로그인 성공/실패 동작·에러 메시지 무변경. 레거시 해시(argon2id/PBKDF2) 업그레이드 경로 회귀 없음. + +### [x] 1-2. S2 계정 단위 잠금(backoff) + +- 파일: `src/routes/(auth)/login/+page.server.ts` (120 부근), `src/lib/server/ratelimit/` 재사용 +- 작업: 기존 rate-limit 인프라를 `login:user:` 키로 추가 적용(스키마 무변경). 보수적 임계값(예: 10회 실패/15분)으로 IP 제한과 병행. 실패 시에만 카운트(성공 로그인은 미카운트). 잠금 시 에러 메시지는 계정 존재를 새로 노출하지 않는 문구(기존 generic 실패 문구 계열, i18n ko/en 키 추가). +- 수용 기준: 분산 IP에서도 동일 계정 연속 실패가 제한됨. 정상 사용자 오탐 최소화(임계값·창 명시). MFA/LDAP 로그인 경로와 충돌 없음. i18n 키 ko/en 대칭. + +### [x] 1-3. S3 lookup API tenant 격리 + +- 파일: `src/routes/api/users/lookup/+server.ts` (35-38) +- 작업: `?id=` 경로에도 tenant 스코프 강제. 기존 호출자(dispatcher)의 사용 방식을 먼저 읽기 위임으로 확인 후, tenant 명시 파라미터 요구 또는 요청 컨텍스트의 tenant로 필터 — 호환성 깨지지 않는 쪽 선택. +- 수용 기준: 타 tenant 사용자 id 조회가 차단됨. 기존 정상 호출 시나리오 무회귀(주석의 "tenant 무관" 의도가 실제 요구사항이었는지 확인 근거 포함). + +### [x] 1-4. S4 authorization_code 교환 시 세션 폐기 검사 + +- 파일: `src/routes/oidc/token/+server.ts` (392-438), 참고: 같은 파일 refresh 경로(305-318)의 세션 검사 +- 작업: code 교환 시 `grant.sessionId` 세션의 `revokedAt`/만료를 확인, 폐기된 세션이면 `invalid_grant` 반환. refresh 경로 검사 로직을 헬퍼로 추출해 양쪽 재사용. +- 수용 기준: 로그아웃된 세션의 미소진 code가 교환 거부됨. 정상 플로우(authorize→token) 무회귀. 세션 없는 grant(있다면 service 계열)의 기존 동작 보존. + +### [x] 1-5. S5 issuer fail-closed + 필수 env 검증 + +- 파일: `src/lib/server/auth/runtime.ts` (55-62), `src/lib/server/auth/bootstrap.ts` (106 부근) +- 작업: 프로덕션(`import.meta.env.PROD` 또는 동등 판별)에서 `IDP_ISSUER_URL` 미설정 시 host fallback 대신 명시적 오류(503). `IDP_SIGNING_KEY_SECRET` 미설정 시 조용한 스킵 대신 명확한 오류/경고 집계(부트스트랩 1회). dev 모드는 기존 fallback 유지(로컬 DX 보존). `.env.example`·README 해당 항목에 "프로덕션 필수" 명시. +- 수용 기준: 프로덕션 미설정 시 토큰 발급 시점이 아니라 요청 초기에 명확한 오류. dev 로컬 플로우 무회귀. 문서 갱신 포함. + +### [x] 1-검증 (독립 Opus 에이전트) — 통과(2026-07-06): 5건 전부 VERIFIED, 게이트 4종 그린(README prettier·wrangler types 스테일은 기계적 정리 완료). 참고: 잠금 실효 임계 11회(peek 1회 지연, 코드 주석화), argon2id 레거시 계정 잔존 타이밍 편차는 마이그레이션 창 고유 한계. + +- 수용 기준 5건 각각 통과/실패 판정(파일:라인 근거). `bun run test`·`bun run check`·`bun run lint`·`bun run build` 그린. 타이밍 균등화가 실제로 동일 비용 경로인지, 계정 잠금이 사용자 열거 신규 오라클을 만들지 않는지 중점 리뷰. 가짜 완료(스텁/skip) 스캔. + +--- + +## Phase 2 — 운영 안정성 (GC·마이그레이션·원자성·캐시·스키마 정리) + +**목적**: 무한 성장 테이블 GC, 4트랙 마이그레이션 대칭화, 비원자 write 해소, 핫패스 캐시. +**파일 경계**: 2-1(GC)과 2-3(원자화)이 `refresh.ts`를 공유 → 직렬. 2-2/2-4/2-5는 독립. + +### [x] 2-1. O1 만료 데이터 GC — Workers는 adapter 제약으로 cron 대신 확률적 waitUntil GC(1%), Node는 setInterval. sessions 유예 30일(refresh 우회 방지 근거 검증됨) + +- 파일: `src/worker.ts`(또는 Workers 엔트리 — 실제 엔트리 파일은 읽기 위임으로 확인), `src/lib/server/db/gc.ts`(신규), `wrangler.example.jsonc`, 기존 `webauthn.ts:158`·`refresh.ts:178`·`ratelimit/index.ts:84` purge 함수 +- 작업: (a) `gc.ts`에 만료 대상 전 테이블 purge 통합 함수 — sessions(만료+revoked 유예 경과), oidc_grants(만료/소진), oidc_refresh_tokens, password_reset_tokens, webauthn_challenges, rate_limits(경과 윈도우), saml_slo_states, saml_authn_request_ids, saml_sessions. 배치 크기 제한(예: 테이블당 LIMIT n 반복 또는 1패스)으로 타임아웃 방지. (b) Workers: `triggers.crons`(예: 1시간 간격) + `scheduled()` 핸들러. (c) Node: 서버 기동 시 setInterval(중복 기동 가드). (d) 기존 orphan purge 3함수는 gc.ts로 통합하거나 호출 연결. +- 수용 기준: 두 런타임 모두에서 GC 실행 경로 존재. 만료되지 않은 데이터는 절대 삭제 안 됨(각 테이블 조건 명시). wrangler.example.jsonc에 cron 문서화. 삭제 실패가 요청 처리에 영향 없음(격리된 에러 처리). + +### [x] 2-2. O2 마이그레이션 트랙 대칭화 — db:generate:all + CI 드리프트 체크 + d1 offline generate 침묵 버그(d1-http driver) 수정 + +- 파일: `package.json`(scripts), `drizzle/**`(4트랙), `test/unit/`(신규 parity 체크) 또는 `.github/workflows/ci.yml` +- 작업: (a) `db:generate:all` 스크립트(4방언 순차 실행). (b) pg/mysql/sqlite 트랙의 밀린 마이그레이션을 `db:generate:{pg,mysql,sqlite}`로 생성 — **생성만, 적용 금지**. (c) 미생성 diff 감지 장치: CI 스텝(4방언 generate 후 git diff 검사) 또는 스냅샷 기반 테스트 중 실행 가능한 쪽. (d) `drizzle.config.ts:9`의 사실과 다른 ".gitignore 대상" 주석 제거(트랙 커밋 정책 확정 반영). +- 수용 기준: 4트랙 모두 현 스키마와 스냅샷 일치(추가 generate 시 no-op). 이후 스키마 변경 시 한 트랙만 갱신하면 CI/테스트가 실패하는 안전망 존재. 마이그레이션 SQL은 커밋 대상으로 보고만. + +### [x] 2-3. O3 refresh 회전 원자화 + atomic 유틸 — 무조건 insert+원자 단위 설계, 패자 stray는 family 폐기로 무효화(검증 증명됨) + +- 파일: `src/lib/server/db/atomic.ts`(신규), `src/lib/server/oidc/refresh.ts` (147 부근), `src/routes/admin/signing-keys/+page.server.ts` (86-93), `src/routes/api/totp/enroll/confirm/+server.ts` (93-100) +- 작업: d1/sqlite=batch, pg/mysql=transaction 분기를 `atomic()` 공용 유틸로 추출. refresh 회전의 claim(UPDATE)→insert 2-write를 원자화(단, 기존 "원자적 claim으로 동시 회전 경쟁 판정" 시맨틱 보존 — claim 결과 확인 후 insert가 같은 원자 단위에 들어가도록 설계 검토, batch 제약상 불가하면 claim은 유지하고 insert 실패 시 claim 롤백/복구 경로라도 확보). 기존 2곳 복붙 분기를 유틸로 치환. +- 수용 기준: claim 성공·insert 실패로 old-revoked+new-부재가 되는 창이 제거(또는 복구 가능). 재사용 감지(family 폐기) 시맨틱 무회귀. 기존 signing-keys/totp-confirm 동작 무변경. 3곳 모두 유틸 사용. + +### [x] 2-4. O4 서명키/JWKS 캐시 — tenant별 60초 TTL, 회전 시 무효화, null 미캐시 + +- 파일: `src/lib/server/crypto/keys.ts`, 참고: `bootstrap.ts:94-110`의 globalThis 캐시 패턴 +- 작업: `getActiveSigningKey`/`getPublicJwks`에 tenant별 globalThis 단기 캐시(TTL 예: 60~300초). admin 서명키 회전/비활성화 액션에서 캐시 무효화 호출. +- 수용 기준: 토큰 발급·JWKS 요청이 캐시 히트 시 DB 미조회. 키 회전 후 TTL 내 무효화로 새 키 반영. 멀티테넌트 키 혼선 없음(캐시 키에 tenant 포함). + +### [x] 2-5. O5 스키마 정리 (`db:generate`까지만) — legacy code drop + client_skins ×1000 보정(가드 포함), 4트랙 생성·no-op 확인, 적용은 사용자 몫 + +- 파일: `src/lib/server/db/schema.{sqlite,pg,mysql}.ts`, `drizzle/**`(생성물) +- 작업: (a) `client_skins.createdAt` sqlite `timestamp` → `timestamp_ms` 통일. (b) legacy `oidc_grants.code` 컬럼 + `oidc_grants_code_uidx` 3방언 drop(잔존 평문 code 데이터가 있어도 TTL 5분이라 안전 — 마이그레이션 주석에 명시). (c) 4트랙 `db:generate:all`로 마이그레이션 생성. **적용 명령 실행 금지.** +- 수용 기준: schema-parity 테스트 통과. 4트랙 마이그레이션 생성됨. codeHash 경로만 남고 `code` 참조 코드 0건(사전 grep 확인). 적용은 사용자 요청으로 보고. + +### [x] 2-검증 (독립 Opus 에이전트) — 통과(2026-07-06): A1~D5 전부 VERIFIED, 게이트 4종 그린(refresh.ts prettier만 정리). 잔여 권고: gc.ts SESSION_GC_GRACE_MS 주석 근거를 revoke-on-logout 불변식 기준으로 정밀화(Phase 4-2 에이전트에 위임) + +- GC 삭제 조건의 안전성(미만료 데이터 삭제 불가) 집중 리뷰, 마이그레이션 SQL 4트랙 내용 검토(파괴적 변경 없는지), refresh 원자화 시맨틱 검증. `bun run test`·`check`·`lint`·`build` 그린. + +--- + +## Phase 3 — 기능 추가 (이메일 인증 · 보안 알림 · 강제 로그아웃) + +**목적**: `email_verified` 클레임 정상화(현재 항상 false), 보안 이벤트 사용자 통지, admin 세션 강제 종료. +**파일 경계**: 3-1과 3-2가 `email.ts`를 공유 → 직렬(또는 같은 에이전트). 3-3은 독립. + +### [x] 3-1. F1 이메일 인증 플로우 — email_verification_tokens 3방언+마이그레이션 생성(0022/0005×3), verify-email 라우트(GET 비소진·POST 원자 소진), 재발송+미인증 배너, GC 연결 + +- 파일: `src/lib/server/email.ts`(템플릿 추가), `src/routes/(auth)/signup/+page.server.ts`, `src/routes/verify-email/+server.ts`(또는 `(auth)/verify-email/` — 기존 라우트 관례에 맞춤, 읽기 위임으로 확인), 스키마(인증 토큰 — `password_reset_tokens` 패턴 재사용해 신규 테이블 또는 범용화), `account/profile`(재발송 UI) +- 작업: (a) 가입 성공 시 인증 메일 발송(토큰 SHA-256 해시 저장, 24시간 TTL, rate-limit). (b) 검증 라우트: 토큰 검증 → `users.emailVerifiedAt` 세팅 → 완료 화면. (c) 재발송 액션(rate-limit, 이미 인증 시 no-op). (d) 미인증 사용자도 로그인은 허용(비파괴적) — account 화면에 미인증 배너+재발송 버튼. (e) i18n ko/en, 스키마 변경은 `db:generate:all`까지만. +- 수용 기준: 신규 가입→메일 링크→`email_verified=true` 클레임 확인 가능(토큰/userinfo). 토큰 1회용·만료 준수. 기존 사용자 로그인 무영향. 이메일 템플릿은 기존 escape/safe-URL 패턴 준수. 발송 실패가 가입 자체를 실패시키지 않음. + +### [x] 3-2. F2 보안 알림 메일 — 9종 이벤트(security-notify.ts, waitUntil 격리, users.locale 기반 ko/en), errors.rate_limit 잠복 키 부재 버그도 함께 해소 + +- 파일: `src/lib/server/email.ts`(알림 템플릿), 발송 지점: `(auth)/reset-password`(완료 시), `account/mfa/+page.server.ts`(TOTP 등록·삭제·백업코드 재생성), `account/passkeys/+page.server.ts`(등록·삭제), `admin/users/+page.server.ts`·`admin/users/[id]/+page.server.ts`(resetPassword·updateStatus 잠금 시) +- 작업: 공용 `sendSecurityNotification(...)` 헬퍼 + 이벤트별 문구(i18n은 수신자 locale 기반 — 사용자 profile locale 활용 가능 여부 읽기 위임으로 확인, 불가하면 ko 기본+en 병기). **발송은 best-effort**(실패해도 본 동작 성공, waitUntil 활용). +- 수용 기준: 나열된 각 이벤트에서 대상 사용자에게 메일 발송 경로 존재. 발송 실패가 본 액션을 실패시키지 않음. 이메일 주소 없는 계정은 조용히 스킵. 템플릿 escape 준수. + +### [x] 3-3. F3 admin 강제 로그아웃 액션 — forceLogout(세션+refresh 폐기, tenant 가드, audit, confirm UI) + +- 파일: `src/routes/admin/users/[id]/+page.server.ts`(액션 추가), `admin/users/[id]/+page.svelte`(버튼), `src/lib/server/auth/session.ts`(`revokeAllUserSessions` 재사용) +- 작업: 상태/비번 변경 없이 세션만 전체 폐기하는 전용 액션(확인 다이얼로그, audit `user_sessions_revoked` 기록, refresh token cascade 포함 — 기존 revoke 유틸 시맨틱 확인). i18n ko/en. +- 수용 기준: 대상 사용자의 모든 세션+refresh token 폐기. audit 기록. `assertUserInTenant` 가드 적용. UI 무결(기존 페이지 액션 패턴 준수). + +### [x] 3-검증 (독립 Opus 에이전트) — 통과(2026-07-06): V1~V6 전부 VERIFIED, 게이트 그린(prettier 2건 정리 완료). 참고: 재발송 시 기존 미사용 토큰 병존은 rate-limit+TTL로 한정(결함 아님) + +- 이메일 인증 토큰 보안(해시 저장·1회용·만료·rate-limit), 발송 best-effort 격리, 강제 로그아웃 tenant 가드 집중 리뷰. `bun run test`·`check`·`lint`·`build` 그린. 마이그레이션 생성물 확인(적용 안 함). + +--- + +## Phase 4 — 테스트 확충 (P0/P1 + 인프라) + +**목적**: 보안 핵심 로직 회귀 안전망 구축. Phase 1~3 신규 로직 포함. +**의존성**: Phase 1~3 완료 후 착수(신규 로직 테스트 포함 위해). 태스크 간 파일 독립 → 병렬 가능. + +### [x] 4-1. T1a 암호·토큰 코어 테스트 — crypto-keys(22)·password(19), WebCrypto 독립 교차검증, argon2 레거시 포함 + +- 파일: `test/unit/crypto-keys.test.ts`(신규), `test/unit/password.test.ts`(신규) +- 작업: `crypto/keys.ts` — JWT 서명/검증 라운드트립(RS256, typ/aud/exp), HMAC access token 발급/검증/변조 거부, private JWK 래핑/언래핑 라운드트립, HKDF 도메인 분리(용도별 키 상이). `auth/password.ts` — scrypt 해시/검증, 오답 거부, 레거시(argon2id/PBKDF2) 검증→scrypt 자동 업그레이드, PBKDF2 하한 거부, 상수시간 비교 경로. +- 수용 기준: 실제 WebCrypto/node:crypto로 동작(mock 최소화). 전체 스위트 그린. + +### [x] 4-2. T1b OIDC grant/refresh/세션/rate-limit 테스트 — grant(6)·refresh(8)·ratelimit(7)·email-verification(6) + session(9)·gc(7)는 4-3에서. mysql 분기는 d1 하네스 한계로 미커버(보고됨) + +- 파일: `test/unit/oidc-grant.test.ts`, `test/unit/oidc-refresh.test.ts`, `test/unit/session.test.ts`, `test/unit/ratelimit.test.ts` (모두 신규) +- 작업: 기존 crud-factory 테스트의 mock DB 패턴 재사용 — grant 1회 소진·만료 거부·codeHash 검증, refresh 회전·재사용 감지 family 폐기·원자화(2-3 결과) 경로, 세션 생성/검증/만료/revoke cascade, 슬라이딩 윈도우 경계(윈도우 전환·한도 초과·`login:user:` 키), Phase 1 신규 로직(더미 해시 경로·code 교환 세션 검사)과 Phase 3 이메일 인증 토큰 로직. +- 수용 기준: 각 모듈 핵심 분기 커버. mock DB 계약이 실제 drizzle 호출 shape와 일치(기존 패턴 준수). 전체 그린. + +### [x] 4-3. T2 schema-parity 강화 — nullable·타입 계열·인덱스/unique 비교, 예외 목록(signing_keys mysql 1건), drift 주입 자가검증 통과 + +- 파일: `test/unit/schema-parity.test.ts` +- 작업: 컬럼명 집합 비교에 더해 — 컬럼 타입 계열/nullable/default 유무 비교, 인덱스·unique 제약 이름/컬럼 비교. 방언별 정당한 차이(mysql partial-unique 불가 등)는 **명시적 예외 목록**으로 테스트 코드에 문서화(신규 drift만 실패). +- 수용 기준: 현 스키마로 그린(기존 known-gap은 예외 목록에). 가상의 drift(한 방언만 인덱스 누락) 주입 시 실패함을 확인. + +### [x] 4-4. T3 테스트 인프라·스크립트 — test:coverage(v8)·typecheck(tsc)·lint:fix·db:check(4방언), lefthook eslint --cache. 커버리지: 핵심 모듈 74~90%(keys 74.5/password 90.2/session 87/ratelimit 86.4), 전체 29%(미테스트 I/O 모듈 0% 영향) + +- 파일: `package.json`, `lefthook.yml`, `vitest.config.ts` +- 작업: `@vitest/coverage-v8` 추가 + `test:coverage` 스크립트, `lint:fix`, `db:check`(drizzle-kit check 4방언). lefthook pre-commit에 staged 파일 eslint 추가(느리면 --cache). CI에 coverage 아티팩트는 선택(과설계 금지 — 스크립트만 우선). +- 수용 기준: 각 스크립트 정상 동작. pre-commit이 정상 커밋 흐름을 과도하게 늦추지 않음(수 초 내). + +### [x] 4-검증 (독립 Opus 에이전트) — 통과(2026-07-06): 비-tautology 표본 검증, skip/only 0, 게이트 8종 그린 + +- 테스트가 실제 로직을 검증하는지(tautology/mock-echo 테스트 아닌지) 샘플 리뷰, `test.skip`/`.only` 0건, 전체 `bun run test` + `check`+`lint`+`build` 그린, coverage 리포트 생성 확인. + +--- + +## Phase 5 — 품질·문서 정리 (Phase 1~4와 독립, 병렬 가능) + +**목적**: 파일 비대 해소, admin 언어 혼재 해소, 보안 문서 격리, 문서·메모리 drift 정리. +**파일 경계**: 5-2와 5-3이 admin 라우트 겹침 가능 → 5-2 먼저(구조 확정) 후 5-3. + +### [x] 5-1. Q1 보안 감사 문서 삭제 (승인됨) — git rm 완료, 잔존 참조 0건 + +- 파일: `docs/security-audit-2026-05-12.md`, `docs/security-pentest-report.md` +- 작업: `git rm` 두 파일. README/다른 문서에서 이 파일들 참조가 있으면 제거. (히스토리 잔존 한계는 커밋 메시지에 명시하지 않음 — 문서 위치만 정리.) +- 수용 기준: 워킹트리에서 두 파일 제거, 깨진 참조 0건. + +### [x] 5-2. Q2 `users/[id]/+page.server.ts` 분리 — 677→181줄, user-actions/{profile,org,service,security}.ts 4모듈, 순수 이동 독립 검증 통과(11개 액션 계약 동일) + +- 파일: `src/routes/admin/users/[id]/+page.server.ts` (677줄) → `src/lib/server/admin/user-actions/{profile,org,service}.ts`(또는 라우트 인접 모듈 — 리포 관례에 맞춰 읽기 위임으로 결정) +- 작업: actions 10개를 도메인별 모듈로 분리, `+page.server.ts`는 조립만. **동작·에러 shape·audit kind 완전 보존**(순수 이동 리팩터). load 함수도 비대하면 함께 정리. +- 수용 기준: diff가 이동 중심(로직 변경 0). 모든 액션 이름·계약 무변경. `check`·`lint`·`build` 그린. + +### [x] 5-3. Q3 admin 에러 i18n + 공통 헬퍼 — adminError/requireFormId 헬퍼, 98곳 전환, ko/en 605키 대칭. 잔존(의도적 범위 외): crud-factory/schemas 한국어(테스트 계약과 충돌해 유지, 후속 시 테스트와 함께 전환), saml-sps/[id] 7건, validation.ts reason 문자열, 프로토콜 API 에러 + +- 파일: `src/routes/admin/{users,users/[id],oidc-clients,oidc-clients/[id],ldap-providers,skins,signing-keys,saml-sps,login}/+page.server.ts`, `src/lib/i18n/{ko,en}.json`, 공통 헬퍼(`src/lib/server/admin/form.ts` 신규 등) +- 작업: (a) `requireFormId` 류 공통 헬퍼 추출(중복 문구 12곳+). (b) admin 서버 액션의 하드코딩 한국어 에러를 auth 서버와 동일한 `translate(locals.locale, ...)` 패턴으로 전환, ko/en 키 대칭 추가. 프로토콜 레이어(oidc/saml/webauthn API 에러)는 이번 범위 제외(운영자·개발자 대상, 후속). +- 수용 기준: admin 라우트 서버 에러 하드코딩 한국어 0건(grep 근거). ko/en 키 대칭 유지(기존 대칭성 테스트 있으면 통과). 에러 shape(`{error}`/`{create:true,error}`) 무변경. + +### [x] 5-4. Q4 문서·메모리 정합 — improvement-report 정오표 추가, deferred-followups 상태 갱신, SECRET_ROTATION.md 신설(읽기 위임 사실관계 기반: 7용도·재암호화 3종·무중단 회전 불가 명시), 프로젝트 메모리 최신화 + +- 파일: `docs/improvement-report-2026-07-02.md`(stale 표기 갱신 — refresh token/OIDC scope/TOTP TOCTOU/E9 처리 현황), `docs/plans/deferred-followups/TODO.md`(1행 "커밋 승인 대기" → 병합 완료로), `docs/SECRET_ROTATION.md`(신규 — `IDP_SIGNING_KEY_SECRET` 회전 절차: 용도 4가지 나열, 회전 시 영향·순서·재암호화 필요 대상), 프로젝트 메모리(`oidc-scope-expansion.md` — groups/address/organization 구현 완료로 갱신) +- 작업: 문서 갱신은 Fable가 직접 가능(기획 산출물·문서). 단 SECRET_ROTATION.md의 기술 사실관계(어떤 데이터가 이 시크릿으로 암호화돼 있는지)는 읽기 위임으로 확인 후 작성. +- 수용 기준: 문서와 코드 현실 일치. 메모리 갱신 완료. + +### [x] 5-검증 (독립 Opus 에이전트) — 통과(2026-07-06): W1~W5 전부 VERIFIED, APPROVE. 스코프 오염 0(전 변경 파일 5페이즈 귀속 확인) + +- 5-2 diff의 순수-이동 여부(로직 변경 혼입 없는지) 집중 리뷰, 5-3 grep 검증(한국어 하드코딩 잔존 0), `bun run test`·`check`·`lint`·`build` 그린. + +--- + +## 최종 게이트 & 커밋 (STEP 7-8) + +- [x] 전체 스위트: `bun run test`(151) · `bun run check`(0 err) · `bun run lint` · `bun run build` · `typecheck` · `db:check`(4방언) · `test:coverage` · `db:generate:all` no-op — 전부 그린 (2026-07-06 최종 검증) +- [ ] 마이그레이션: **적용은 사용자 몫** — d1: `drizzle/0021`(legacy code drop+client_skins 보정)·`0022`(email_verification_tokens) / pg·mysql·sqlite: 각 `0004`·`0005` +- [x] 커밋 승인 게이트 → 승인(2026-07-06), 페이즈별 원자 커밋 7개 완료(fix(security)/chore(db)/refactor(db)/feat(gc)/feat(email)/test/refactor(admin)+docs). 푸시는 하지 않음. diff --git a/docs/security-audit-2026-05-12.md b/docs/security-audit-2026-05-12.md deleted file mode 100644 index 33ca4f9..0000000 --- a/docs/security-audit-2026-05-12.md +++ /dev/null @@ -1,617 +0,0 @@ -# IDP 보안 감사 리포트 (레드팀 전수감사) - -**대상 프로젝트:** `mack-erel/idp` — SvelteKit + Cloudflare Workers + D1 기반 Identity Provider (OIDC / SAML / WebAuthn / LDAP) -**감사 일시:** 2026-05-12 -**감사 브랜치:** `feat/root-account-hub` (HEAD: `281717c`) -**감사 방식:** 외부 침입(red-team) 시나리오 기반 — 실제 익스플로잇 가능성 중심, 8개 영역 병렬 전수감사 -**감사 범위:** 110개 소스 파일 + 설정/시크릿/CI/배포/의존성 전수 -**감사 영역:** - -1. OIDC provider (authorize/token/userinfo/jwks/end-session/discovery + admin) -2. SAML provider (sso/slo/metadata + admin) -3. 인증/세션/MFA/WebAuthn -4. 관리자 UI / 조직(부서·팀·파트·직급) / DB 스키마 / 감사 로그 / 스킨 / LDAP -5. 설정 / 시크릿 / CI/CD / 배포 -6. XSS / CSP / 보안 헤더 / 프론트엔드 -7. 공통 API / 메일 / SSRF -8. 의존성 CVE / 공급망 - ---- - -## 1. Executive Summary - -본 IDP 의 **코드 품질은 일반 OSS IDP 평균보다 명백히 높다.** PKCE S256 강제, authorization code 원자 소진, WebAuthn challenge 1회용 + dummy publicKey timing equalization, SAML AuthnRequest replay + DTD/ENTITY 차단, exc-c14n, session fixation 방지, TOTP secret AES-GCM + userId AAD, LDAP filter/DN 이스케이프, redirect double-decoding 등 IDP 가 실수하기 쉬운 지점 대부분이 정확히 막혀 있다. - -그러나 **(1) 운영 위생 — `.env` 평문 시크릿**, **(2) 공급망 — CI workflow 자동 commit + PAT, 비공식 의존성 fork**, **(3) 관리자 멀티테넌트 IDOR / last-admin 보호 race**, **(4) 외부 신뢰 모델 — skin HTML, DNS, LDAP fork** 4축에서 사고가 터지면 코드 품질이 무의미해진다. 발견된 Critical 14건 중 9건이 이 4축에 집중되어 있다. - -### Risk Level: **CRITICAL** - -### 누적 발견 통계 - -| Severity | 합계 | 대표 사례 | -| ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Critical** | **11건** | CI workflow PAT exfiltration, authorization code 평문 저장, SAML SLO 서명 우회, find-password Host header injection, skin SSRF DNS rebinding, cross-tenant IDOR, 부서 트리 순환 | -| **High** | **25건** | wildcard redirect_uri, JWT typ/crit 미검증, CSRF_SKIP 광범위, audit log 무결성 부재, signing-key rotate race, argon2 파라미터 OWASP 미달, `@hicaru/argon2-pure.js` 0.0.x 가 패스워드 해싱 경로에 실사용 | -| **Medium** | **30건** | CSP form-action 사실상 무제한, csrf.trustedOrigins=["*"], SAML LogoutResponse XML 파싱 0, signup vs login 정규화 비대칭(homoglyph), `@yrneh_jang/ldapjs` self-fork upstream patch lag | -| **Low** | **22건** | HSTS preload 미설정, COEP 미설정, tsconfig strict 옵션 부족 | - -### 정정 사항 (2026-05-12 본 리포트 작성 후 실측 결과 반영) - -| 원래 평가 | 실측 결과 | 정정 | -| ---------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------- | -| `nodemailer^8.0.5` 메이저 비실재 의심 | `nodemailer@8.0.7` 정상 존재 | 정상 | -| `typescript^6.0.2` 메이저 비실재 의심 | `6.0.3` 정상 | 정상 | -| `eslint^10`, `vite^8` 메이저 비실재 의심 | `10.3.0`, `8.0.12` 정상 | 정상 | -| `@yrneh_jang/ldapjs` 외부 maintainer fork (Critical) | `mack-erel` org 의 self-fork (`mack-erel/node-ldapjs`). 외부 공급망 아님 | **Medium 으로 강등** (upstream patch lag risk) | -| `@xmldom/xmldom 0.9.9` (4 high CVE) | `bun update` 로 `0.9.10` 적용 완료 (CVE 4건 해소) | 해결 완료 | - ---- - -## 1.1. 감사 중 즉시 적용된 조치 (Already Applied) - -- ✅ **`@xmldom/xmldom 0.9.9 → 0.9.10`** (`bun update`) — High 4건 CVE 해소 (GHSA-2v35-w6hq-6mfw / GHSA-f6ww-3ggp-fr8h / GHSA-x6wf-f3px-wcqx / GHSA-j759-j44w-7fr8) -- ✅ **git history 시크릿 검사 통과**: `gitleaks detect --log-opts="--all"` → `no leaks found` (false positive 1건 `.gitleaksignore` 처리) -- ✅ **CI 의존성 audit 게이트 추가**: `.github/workflows/ci.yml` 에 `bun audit --audit-level=high` 단계 추가 → High 이상 신규 발견 시 PR 머지 차단 -- ✅ **남은 의존성 vuln 3건** (postcss/esbuild/cookie): 모두 transitive dev-only + practical risk 낮음으로 무시 결정. Major upstream (vite/wrangler/kit) 다음 릴리스에서 자연 해결 대기 -- ✅ **C-1, C-2 (.env / wrangler.jsonc 평문 시크릿) 등급 재평가**: 사용자 확인 결과 `.env` / `wrangler.jsonc` `vars` 의 값은 **로컬 dev 전용**이며 production 시크릿은 `wrangler secret put` 으로 별도 관리됨이 확인됨. Git history 도 깨끗. → **Critical 등급 제거** (dev 머신 위생 항목으로 강등, Low 분류). 회전 작업 불요. - -## 2. 사전 안전 조치 (Bash 권한이 거부되어 사용자 직접 실행 필요) - -```bash -# 시크릿 history 검사 -git log --all -p -- .env wrangler.jsonc \ - | grep -E "IDP_SIGNING_KEY_SECRET|CLOUDFLARE_D1_TOKEN|github_pat_|SMTP_PASSWORD" -gitleaks detect --log-opts="--all" --no-git=false --redact - -# 의존성 CVE 실측 -bun audit -bunx osv-scanner --lockfile=bun.lock -bun pm ls --all | grep -E "nodemailer|argon2|ldapjs|typescript|eslint|vite" - -# 비실재/비정상 메이저 확인 -npm view eslint versions --json | tail -20 -npm view typescript versions --json | tail -10 -npm view vite versions --json | tail -10 -npm view nodemailer versions --json | tail -10 -npm view @yrneh_jang/ldapjs maintainers time - -# 운영 secret 위치 확인 -wrangler secret list -``` - ---- - -## 3. Critical Findings (11건) — 즉시 조치 - -### ~~C-1. `.env` 평문 / C-2. `wrangler.jsonc` `vars`~~ → **등급 제거 (Low / 운영 위생)** - -**원래 Severity:** Critical → **재평가 후 Low** - -**재평가 사유:** 사용자 확인 결과 `.env` 와 `wrangler.jsonc` `vars` 의 값은 **로컬 dev 전용**이며 production 시크릿은 `wrangler secret put` 으로 별도 관리됨이 확인됨. `.gitignore` 처리 정상, git history `gitleaks no leaks found`, 외부 노출 경로 없음. → 시크릿 회전 불요. - -**남는 권장 (Low):** - -- FileVault 디스크 암호화 ON -- iCloud/Dropbox/Time Machine 가 `.env` 가 들어있는 디렉토리 동기화 안 하는지 확인 -- IDE AI/telemetry 가 `.env` 안 읽도록 `.cursorignore` / IDE 설정 -- 향후 production 시크릿을 실수로 `.env` 에 넣지 않도록 `.env.example` 만 dev placeholder 유지 - ---- - -### C-3. CI `format` job 의 PAT 자동 commit 패턴 — 공급망 RCE - -**파일:** `/Users/henry/github/mack-erel/idp/.github/workflows/ci.yml:42-72` -**Severity:** Critical - -```yaml -- uses: actions/checkout@v6 - with: - token: ${{ secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }} -- run: bun install -- run: bun run format -- uses: stefanzweifel/git-auto-commit-action@v7 -``` - -**공격 시나리오:** - -1. PR 작성자가 `prettier.config.js`, `prettier-plugin-tailwindcss`, 또는 `eslint.config.js` 를 악의적으로 수정 (prettier plugin 은 임의 JS). -2. `bun install` 또는 `bun run format` 실행 단계에서 임의 코드 실행. -3. `secrets.WORKFLOW_PAT` 가 환경에 주입되어 있어 탈취. -4. PAT 권한으로 main 직커밋 + branch protection 우회 가능 → 다음 deploy.yml 이 main push 트리거로 Cloudflare 자동 배포 → **IDP 자체 백도어 코드**. - -**Remediation:** - -- format 단계를 `workflow_run` 으로 분리해 격리, 또는 자동 commit 제거(작성자가 로컬에서 lint:fix). -- WORKFLOW_PAT 를 GitHub App token 으로 대체. -- `bun install --frozen-lockfile --ignore-scripts` 강제. - ---- - -### C-4. `deploy.yml` 의 shell injection 패턴 - -**파일:** `/Users/henry/github/mack-erel/idp/.github/workflows/deploy.yml:63-64` -**Severity:** Critical - -```yaml -- name: Setup Wrangler config - run: echo '${{ secrets.WRANGLER_JSONC }}' > wrangler.jsonc -``` - -시크릿이 단일 따옴표 안에 직접 보간 → 시크릿에 `'` 가 포함되면 shell quote 깨져 임의 명령 실행. GitHub Actions 마스킹은 출력에만 적용되며 syntax injection 은 막지 못한다. - -**Remediation:** - -```yaml -- name: Setup Wrangler config - env: - WRANGLER_JSONC: ${{ secrets.WRANGLER_JSONC }} - run: printf '%s' "$WRANGLER_JSONC" > wrangler.jsonc -``` - ---- - -### ~~C-5. `@yrneh_jang/ldapjs` 비공식 fork~~ → **Medium 으로 강등** (M-DEP-1 참조) - -**정정**: 실측 결과 `@yrneh_jang/ldapjs` 는 외부 maintainer 패키지가 아니라 **`mack-erel` org (이 IDP 와 동일 owner)** 의 self-fork (`mack-erel/node-ldapjs`) 임이 확인됨. Cloudflare Workers 호환을 위한 fork. - -→ "외부 npm maintainer 손상" 시나리오 소멸. Critical 등급 부적절. **Medium (upstream ldapjs 보안 패치 동기화 책임이 본인에게 있음)** 으로 강등하여 본 문서 후반의 Medium 섹션 참조. - ---- - -### C-6. Authorization code 가 D1 에 평문 저장 - -**파일:** `src/routes/oidc/authorize/+server.ts:150-165`, `src/lib/server/oidc/grant.ts:24-44` -**Category:** A02 / A04 -**Severity:** Critical - -```ts -const code = crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, ""); -await createGrant(db, { code, ... }); // ← 평문 저장 -``` - -**공격 경로:** D1 read-replica · 로그 dump · 백업 유출 · readonly admin. authorize 직후 5분 내 평문 code 읽히면 token 교환 가능. - -**PoC:** `SELECT code, client_id, redirect_uri FROM oidc_grants WHERE used_at IS NULL;` → `POST /oidc/token`. - -**Remediation:** code 는 client 에만 노출, DB 에는 SHA-256 해시 저장. - -```ts -const code = b64uEncode(crypto.getRandomValues(new Uint8Array(32))); -const codeHash = b64uEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(code))); -await createGrant(db, { codeHash, ... }); -``` - ---- - -### C-7. `end-session` GET 의 인증 무관 confirm 페이지 + clickjacking - -**파일:** `src/routes/oidc/end-session/+server.ts:70-128` -**Severity:** Critical - -GET 핸들러가 `locals.user` 가 null 이어도 `id_token_hint` 만 valid 면 confirm 페이지를 렌더. `frame-ancestors` / `X-Frame-Options` 응답 헤더 부재. - -**공격:** - -```html - - -``` - -또는 phishing 메일로 IDP 공식 도메인의 confirm 페이지를 표시 → 사용자가 의심 없이 클릭 → 강제 로그아웃. - -**Remediation:** - -- 응답에 `X-Frame-Options: DENY`, `Content-Security-Policy: frame-ancestors 'none'; default-src 'none'; form-action 'self'`, `Cache-Control: no-store`. -- `locals.user` 미존재 시 GET 단계에서 204 반환. -- confirm form 에 server-signed CSRF token. - ---- - -### C-8. SAML SP cert 미등록 시 LogoutRequest 서명 검증 우회 - -**파일:** `src/routes/saml/slo/+server.ts:271-277` -**Severity:** Critical - -```ts -if (sp.cert) { - const valid = await verifySamlRedirectSignature(rawQuery, sp.cert); - if (!valid) throw error(400, "Invalid SAMLRequest signature"); -} -``` - -`sp.cert IS NULL` 이면 모든 SP-initiated `LogoutRequest` 가 **서명 검증 없이 신뢰**됨. 공격자가 임의 entityId 로 LogoutRequest 위조 → 피해자 IdP 세션 revoke + SLO 체인 트리거 → 모든 연결 SP 로 LogoutRequest 발사. - -**Remediation:** - -```ts -if (!sp.cert) throw error(400, "SP 인증서가 등록되지 않아 SLO 요청을 검증할 수 없습니다."); -const valid = await verifySamlRedirectSignature(rawQuery, sp.cert); -if (!valid) throw error(400, "Invalid SAMLRequest signature"); -if (!url.searchParams.has("Signature")) throw error(400, "LogoutRequest 는 반드시 서명되어야 합니다."); -``` - -또한 admin SP 등록 액션에서 SLO 활성 SP 는 cert 필수로 강제. - ---- - -### C-9. SAML SLO 체인 응답에 세션-state binding 없음 - -**파일:** `src/routes/saml/slo/+server.ts:160-225` -**Severity:** Critical - -`initiatingSpEntityId` 가 NULL 또는 SP cert NULL 일 때 LogoutResponse 서명 검증 완전 스킵. stateId 만 알면 누구나 SLO 체인 진전 가능 → IdP 가 서명한 LogoutRequest 임의 발사. - -**Remediation:** - -- LogoutResponse 도 SP cert 필수. -- IdP-initiated 체인의 경우 `locals.session?.id === state.idpSessionRecordId` 일관성 검사 추가. - ---- - -### C-10. `find-password` Host header injection → 계정 takeover - -**파일:** `src/routes/(auth)/find-password/+page.server.ts:105-112` -**Category:** A07 / Identity Recovery -**Severity:** Critical - -```ts -const issuer = (env.IDP_ISSUER_URL ?? event.url.origin).replace(/\.+$/, "").replace(/\/+$/, ""); -const resetUrl = `${issuer}/reset-password?${resetParams.toString()}`; -``` - -`IDP_ISSUER_URL` 누락 시 `event.url.origin` fallback 은 Host 헤더에 의존. 공격자가 `Host: evil.com` 으로 폼 제출 → victim 메일 본문에 `https://evil.com/reset-password?token=<유효토큰>` 박혀서 발송 → victim 이 클릭하면 토큰 유출 → **1시간 내 계정 takeover**. - -**Remediation:** - -```ts -const issuer = env.IDP_ISSUER_URL?.replace(/\/+$/, ""); -if (!issuer) return fail(503, { error: "메일 발송 설정 오류가 있습니다." }); -``` - ---- - -### C-11. 부서 트리 간접 순환 참조 무방어 - -**파일:** `src/routes/admin/departments/+page.server.ts:60-73` -**Severity:** Critical - -`parentId === id` 직접 순환만 차단. 간접 순환(A→B→A, A→B→C→A) 미검증 → 트리 traversal 무한루프 → Workers CPU 타임아웃, 권한 상속 계산 붕괴. - -**PoC:** - -``` -POST /admin/departments?/update { id=A, parentId=B } -POST /admin/departments?/update { id=B, parentId=A } ← 통과 -``` - -**Remediation:** update 전 `parentId` 부터 root 까지 traversal 하여 `id` 가 조상에 있는지 검사 + depth ≤ 8 제한. - ---- - -### C-12. Last-admin 보호 self/race 우회 - -**파일:** `src/routes/admin/users/[id]/+page.server.ts:160-251` -**Severity:** Critical - -`updateProfile` 의 self-prevention 이 "값 다를 때만" 차단. admin A, B 두 명 상황에서 A 가 B 의 role 변경 후 본인 갱신 race 시 last-admin 보호 우회 가능. - -**Remediation:** `assertNotLastAdmin` 을 self 케이스에도 호출. 자기 자신 role/status 변경 자체를 항상 차단 (값 일치 여부 무관). - ---- - -### C-13. `addAssignment` cross-tenant IDOR - -**파일:** `src/routes/admin/users/[id]/+page.server.ts:482-580` -**Severity:** Critical - -`addAssignment/addDept/addTeam/addPart` action 들이 `params.id` 의 tenant 검증 없이 `userServiceAssignments` INSERT. 테넌트 X admin 이 `/admin/users/` POST 하면 cross-tenant 권한 row 생성. 현재 single-tenant 라 임팩트 제한, **멀티테넌트 활성화 즉시 폭발**. - -**Remediation:** 모든 admin `[id]` action 진입부에서 `select users where id=params.id AND tenantId=tenant.id`. - ---- - -### C-14. Skin SSRF DNS rebinding + 외부 HTML `{@html}` 무제한 주입 - -**파일:** - -- `src/lib/server/skin/resolver.ts:26-44` (SSRF) -- `src/routes/(auth)/{login,signup,mfa,reset-password,find-id,find-password}/+page.svelte` (6개소 `{@html}`) - -**Severity:** Critical - -**SSRF DNS rebinding:** `isFetchUrlAllowed` 가 hostname 문자열만 검증. fetch 시점에 DNS 재해석되므로 `evil.com` 의 A 레코드를 일시적으로 공인 IP 로 응답해 통과 → 실제 요청 시 `10.0.0.1`. Workers `fetch()` 는 IP pinning 보장 안 함. - -**`{@html}` 주입:** admin 이 등록한 외부 https 호스트가 한 번이라도 침해되면 모든 로그인/MFA/회원가입 페이지에 임의 HTML 주입. CSP `script-src 'self'` 는 인라인 차단하지만 `form-action 'self' https:` 가 풀려있어 `` 그대로 통과 → credential 평문 전송. - -**Remediation:** - -- 단기: - - DOMPurify 로 sanitize: ` + +{#if message} + +{/if} diff --git a/src/lib/i18n/core.ts b/src/lib/i18n/core.ts index 41f1219..09ef687 100644 --- a/src/lib/i18n/core.ts +++ b/src/lib/i18n/core.ts @@ -44,6 +44,13 @@ function interpolate(message: string, params?: Record): }); } +// 임의의 locale 문자열("en-US"/"ko-KR"/null 등)을 지원 Locale(ko|en)로 정규화한다. +// en* 만 en, 그 외(미상/null 포함)는 ko 기본. 서버 알림/메일에서 수신자 locale 해석에 공유한다. +// 근거: 지원 Locale 은 ko|en 뿐이며, 미상 locale 은 FALLBACK_LOCALE(ko)로 안전하게 수렴시킨다. +export function normalizeLocale(locale: string | null | undefined): Locale { + return (locale ?? "").toLowerCase().startsWith("en") ? "en" : FALLBACK_LOCALE; +} + // 로케일 + 키 경로를 실제 메시지로 해석한다. 클라이언트 t() 와 서버 translate() 가 공유하는 단일 lookup 진입점. // 현재 로케일 → ko 폴백 → 원본 key 순으로 해석한다. export function resolveMessage(locale: Locale, key: string, params?: Record): string { diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index d81d5ad..9b2511c 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -35,7 +35,8 @@ "change": "Change", "saving": "Saved.", "search": "Search", - "next_page": "Next page →" + "next_page": "Next page →", + "processing": "Processing..." }, "login": { "username": "Username", @@ -53,8 +54,15 @@ "password_reset_success": "Your password has been changed. Please log in with your new password.", "err_missing_credentials": "Please enter your username and password.", "err_rate_limit": "Too many login attempts. Please try again in {{minutes}} minutes.", + "err_account_locked": "Too many login attempts. Please try again in {{minutes}} minutes.", "err_invalid_credentials": "The username or password is incorrect.", - "err_mfa_config": "There is a problem with the two-factor authentication configuration. Please contact your administrator." + "err_mfa_config": "There is a problem with the two-factor authentication configuration. Please contact your administrator.", + "err_account_deleting": "This account is being deleted and can no longer be used to sign in.", + "deletion_requested_notice": "Your account deletion has been received. You can recover your account by signing in again within the 30-day grace period.", + "recovery_desc": "This account is scheduled for deletion. To keep using it, re-enter your password to recover the account.", + "recovery_password_label": "Re-enter your password to recover", + "recovery_confirm": "Recover account and sign in", + "recovery_cancel": "← Back to login" }, "admin": { "title": "Admin Dashboard", @@ -73,7 +81,98 @@ "teams": "Teams", "parts": "Parts", "ldap_providers": "LDAP Providers", - "skins": "Login Skins" + "skins": "Login Skins", + "errors": { + "invalid_request": "Invalid request.", + "user_not_found": "User not found.", + "name_required": "Name is required.", + "password_min_length": "Password must be at least 8 characters.", + "csrf_failed": "CSRF validation failed. Please refresh the page and try again.", + "invalid_expiry_format": "Invalid expiry date format.", + "required_field_missing": "Required fields are missing.", + "signing_key_secret_missing": "IDP_SIGNING_KEY_SECRET is not configured.", + "email_password_required": "Email and password are required.", + "email_required": "Email is required.", + "invalid_role": "Invalid role.", + "email_taken": "This email is already in use.", + "username_taken": "This username is already in use.", + "cannot_change_own_status": "You cannot change your own status.", + "cannot_change_own_role": "You cannot change your own role.", + "password_required": "Please enter a password.", + "cannot_delete_self": "You cannot delete yourself.", + "invalid_role_value": "Invalid role value.", + "invalid_status_value": "Invalid status value.", + "cannot_change_own_role_status": "You cannot change your own role/status. Please ask another administrator.", + "last_admin_race": "Another administrator appears to have been demoted at the same time. The change was cancelled. Please try again.", + "select_department": "Please select a department.", + "department_not_found": "Department not found.", + "position_not_found": "Position not found.", + "select_team": "Please select a team.", + "team_not_found": "Team not found.", + "select_part": "Please select a part.", + "part_not_found": "Part not found.", + "select_service": "Please select a service.", + "invalid_service_type": "Invalid service type.", + "invalid_service_id": "Invalid service ID.", + "oidc_client_not_found": "OIDC client not found.", + "saml_sp_not_found": "SAML SP not found.", + "role_not_in_service": "The selected role does not belong to this service.", + "attributes_not_object": "attributesJson must be a JSON object.", + "attributes_parse_failed": "Failed to parse attributesJson.", + "assignment_exists": "A mapping already exists for this service. Please delete it first.", + "scope_openid_required": "scope must include 'openid'.", + "invalid_token_auth_method": "Invalid tokenEndpointAuthMethod value.", + "redirect_uri_required": "Redirect URI is required.", + "client_not_found": "Client not found.", + "invalid_role_key": "key allows only alphanumerics and ._- (1-64 chars).", + "label_required": "label is required.", + "role_key_exists": "This role key already exists.", + "ldap_host_required": "LDAP host is required.", + "ldap_bind_or_userdn_required": "Either Admin Bind DN or user DN pattern is required.", + "ldap_signing_key_secret_required": "IDP_SIGNING_KEY_SECRET must be configured to store the LDAP bindPassword.", + "invalid_url": "Please enter a valid URL.", + "https_only": "Only https URLs are allowed.", + "loopback_forbidden": "Loopback addresses are not allowed.", + "internal_addr_forbidden": "Internal/metadata addresses are not allowed.", + "required_fields": "Please fill in the required fields.", + "invalid_client_type": "Invalid clientType.", + "cache_ttl_negative": "cacheTtlSeconds must be 0 or greater.", + "cache_ttl_max": "cacheTtlSeconds cannot exceed {{max}} seconds (1 day).", + "skin_config_exists": "A configuration for the same client/skin type already exists.", + "skin_not_found": "Skin not found.", + "url_required": "Please enter a URL.", + "rotate_conflict": "Another rotate operation is in progress or a DB error occurred. Please try again shortly.", + "entity_id_required": "Entity ID is required.", + "acs_url_required": "ACS URL is required.", + "sp_cert_required_for_encryption": "An SP certificate is required to use assertion encryption.", + "nameid_format_forbidden": "This NameID Format is not allowed.", + "entity_id_taken": "This Entity ID is already registered.", + "login_missing_credentials": "Please enter your username and password.", + "login_invalid_credentials": "Invalid username or password.", + "login_not_admin": "This account does not have administrator privileges.", + "login_mfa_config": "MFA configuration error: IDP_SIGNING_KEY_SECRET is not configured.", + "login_mfa_required": "Administrator accounts must have MFA (OTP) configured. Please register MFA first.", + "login_rate_limit": "Too many login attempts. Please try again in {{minutes}} minute(s).", + "team_name_required": "Please enter a team name.", + "part_name_required": "Please enter a part name.", + "position_name_required": "Please enter a position name.", + "department_name_required": "Please enter a department name.", + "level_must_be_number": "Level must be a number.", + "url_invalid_format": "Invalid URL format: {{value}}", + "url_fragment_forbidden": "URIs containing a fragment (#) are not allowed: {{value}}", + "scheme_forbidden": "{{scheme}}: scheme is not allowed.", + "http_localhost_only": "http URLs are only allowed for localhost/127.0.0.1: {{value}}", + "custom_scheme_invalid": "Invalid custom scheme format: {{value}}", + "scheme_not_allowed": "Scheme is not allowed ({{scheme}}): {{value}}", + "scope_not_allowed": "Scope is not allowed: {{scope}}", + "saml_url_invalid_format": "{{label}}: The URL format is invalid.", + "saml_url_http_loopback_only": "{{label}}: http URLs are only allowed for localhost/127.0.0.1.", + "saml_url_https_only": "{{label}}: Only https URLs are allowed.", + "ldap_metadata_host_forbidden": "Cloud metadata hosts are not allowed.", + "ldap_linklocal_forbidden": "link-local (169.254/16) addresses are not allowed.", + "ldap_port_invalid": "The port number is invalid.", + "ldap_port_not_allowed": "This LDAP port is not allowed (allowed: {{allowed}})." + } }, "admin_login": { "subtitle": "Admin sign-in", @@ -86,7 +185,42 @@ "account": { "profile": "My Profile", "mfa": "Two-Factor Authentication", - "passkeys": "Passkeys" + "passkeys": "Passkeys", + "sessions": { + "title": "Active sessions", + "subtitle": "Review the devices and sessions currently signed in, and sign out any you don't recognize.", + "current_badge": "Current session", + "ip_label": "IP", + "device_label": "Device", + "last_seen_label": "Last active", + "created_label": "Signed in", + "unknown": "Unknown", + "revoke": "Sign out", + "revoke_current": "Sign out this device", + "revoke_confirm": "Sign out this session?", + "revoke_current_confirm": "This is the device you're currently using. Signing out will log you out here too. Continue?", + "revoke_others": "Sign out all other devices", + "revoke_others_confirm": "Sign out every session except this device?", + "empty": "No active sessions.", + "revoked_notice": "The selected session was signed out.", + "revoked_others_notice": "Signed out of all other devices.", + "err_select_session": "Please select a session.", + "err_not_found": "Session not found." + }, + "danger_zone": { + "title": "Delete account", + "subtitle": "Move your account into a pending-deletion state. During the grace period you can recover it by signing in again.", + "warning_title": "Requesting deletion will do the following.", + "warning_grace": "If you request deletion now, your account will be permanently deleted in {{days}} days. Until then you can recover it by signing in.", + "warning_recovery": "You will be signed out of all devices immediately, and once the grace period passes your account and related data are permanently deleted.", + "password_label": "Password for identity verification", + "totp_label": "Authenticator code (TOTP)", + "confirm_label": "I understand the above and want to delete my account.", + "confirm_dialog": "Are you sure you want to request deletion? You will be signed out immediately and permanently deleted after the grace period.", + "submit": "Request account deletion", + "err_reauth": "Identity verification failed. Please check your password or authentication code.", + "err_last_admin": "The last active administrator cannot delete their account. Please assign another administrator first." + } }, "logout": { "in_progress": "Logging out..." @@ -100,7 +234,12 @@ "confirm": "Confirm", "use_backup": "Sign in with a backup code", "use_totp": "Sign in with an authenticator code", - "back_to_login": "← Back to login" + "back_to_login": "← Back to login", + "err_config": "There is a problem with your MFA configuration. Please try again later.", + "err_rate_limit": "Too many verification attempts. Please try again later.", + "err_missing_code": "Please enter your code.", + "err_invalid_totp": "Invalid authentication code.", + "err_invalid_backup": "Invalid backup code." }, "mfa_manage": { "title": "Two-Factor Authentication Settings", @@ -157,7 +296,34 @@ "locale_settings": "Regional Settings", "org_membership": "Organization Membership", "team_label": "Team", - "part_label": "Part" + "part_label": "Part", + "email_unverified_title": "Your email is not verified yet.", + "email_unverified_desc": "We sent a verification email to {{email}}. Please check your inbox.", + "email_resend": "Resend verification email", + "email_resend_sent": "Verification email resent. Please check your inbox shortly.", + "err_birthdate_format": "The date of birth format is invalid. (YYYY-MM-DD)", + "email_change_title": "Change Email Address", + "email_change_desc": "We will send a confirmation email to the new address. The change completes only after you click the confirmation link.", + "email_change_new_label": "New Email Address", + "email_change_password_label": "Current Password", + "email_change_submit": "Send confirmation email", + "email_change_sent": "A confirmation email was sent to {{email}}. Click the link to complete the change.", + "email_change_pending": "A change to {{email}} is pending confirmation. Please check your inbox.", + "err_email_change_password": "Your current password is incorrect.", + "err_email_change_format": "The email address format is invalid.", + "err_email_change_same": "That is the same as your current email address.", + "err_email_change_taken": "That email address is already in use.", + "err_email_change_no_password": "This account has no password set, so the email address cannot be changed." + }, + "confirm_email_change": { + "title": "Confirm Email Change", + "subtitle": "Please complete the change to your new email address.", + "confirm_prompt": "Click the button below to complete the change to your email address.", + "submit": "Confirm email change", + "success": "Your email address has been changed.", + "invalid_link": "This link has expired or is invalid.", + "go_login": "Go to login", + "go_profile": "Go to profile" }, "audit": { "title": "Audit Log", @@ -247,6 +413,11 @@ "users": { "title": "User Management", "add_btn": "+ Add User", + "invite_btn": "Invite User", + "invite_title": "Invite User", + "invite_hint": "Creates an account without a password and sends an invitation email. The invited user sets their own password from the link.", + "invite_submit": "Send Invite", + "badge_pending_invite": "Pending Invite", "search_placeholder": "Search by email, username, or name", "search_empty": "No results found.", "create_title": "Add New User", @@ -274,6 +445,11 @@ }, "user_detail": { "back": "← List", + "danger_zone": "Danger Zone", + "force_logout": "Force logout", + "force_logout_desc": "Immediately revokes all of this user's sessions and OIDC refresh tokens. Status and password are not changed.", + "force_logout_confirm": "Force this user to log out on all devices?", + "force_logout_done": "All sessions have been revoked.", "saved": "Saved.", "profile_section": "Profile Information", "given_name": "Given Name", @@ -382,7 +558,49 @@ "empty": "No clients have been registered.", "regenerate_secret": "Regenerate Secret", "regenerate_confirm": "Regenerate the secret? The existing secret will be invalidated immediately.", - "delete_confirm": "Delete this client?" + "delete_confirm": "Delete this client?", + "org_claims": { + "title": "Organization claim exposure", + "desc": "Choose which fields the organization scope exposes for this client. Applied identically to both the id_token and the userinfo response.", + "saved": "Organization claim settings saved.", + "all_note": "If every field is enabled, the setting is stored as unconfigured (all fields exposed).", + "field_department": "Department (department)", + "field_department_desc": "The list of departments the user belongs to, including each department's code, primary flag, job title, and position.", + "field_team": "Team (team)", + "field_team_desc": "The list of teams the user belongs to, including each team's code, parent department, primary flag, and job title.", + "field_position": "Position (position)", + "field_position_desc": "The position name of the user's primary department.", + "field_jobTitle": "Job title (job_title)", + "field_jobTitle_desc": "The job title of the user's primary department." + }, + "errors": { + "rate_limited": "Too many requests. Please try again shortly.", + "rate_limited_short": "Too many requests.", + "client_id_redirect_uri_required": "client_id and redirect_uri are required.", + "response_type_unsupported": "Only response_type=code is supported.", + "unknown_client": "Unknown client_id.", + "redirect_uri_mismatch": "redirect_uri does not match a registered value.", + "pkce_required": "A PKCE code_challenge is required.", + "empty_code_challenge": "code_challenge is empty.", + "code_challenge_method_unsupported": "Only code_challenge_method=S256 is supported.", + "openid_scope_required": "The openid scope is required.", + "prompt_none_conflict": "prompt=none cannot be combined with other prompt values.", + "max_age_invalid": "max_age must be a non-negative integer.", + "login_required": "Cannot complete authentication without user interaction.", + "session_missing_after_gate": "No session found after the authentication gate.", + "service_access_denied": "You do not have access to this service.", + "token_param_required": "The token parameter is required.", + "id_token_hint_required": "id_token_hint is required.", + "aud_mismatch": "aud mismatch", + "cross_origin_post_blocked": "Cross-origin POST blocked.", + "logging_out": "Logging out...", + "signing_key_not_set": "IDP_SIGNING_KEY_SECRET is not configured.", + "bearer_token_required": "A Bearer token is required.", + "access_token_invalid": "Invalid or expired access token.", + "token_aud_mismatch": "The token's aud does not match the client.", + "issuing_client_invalid": "The issuing client is no longer valid.", + "user_not_found": "User not found." + } }, "saml": { "title": "SAML SP Management", @@ -411,7 +629,38 @@ "empty": "No SAML SPs have been registered.", "delete_confirm": "Delete this SP?", "parse_error_xml": "XML parse error", - "parse_error_generic": "Parse failed" + "parse_error_generic": "Parse failed", + "errors": { + "rate_limited": "Too many requests. Please try again shortly.", + "issuer_not_set": "IDP_ISSUER_URL is not configured.", + "signing_key_not_set": "IDP_SIGNING_KEY_SECRET is not configured.", + "signing_key_missing": "No signing key available. Restart the server to generate one.", + "access_denied": "You do not have access to this SP.", + "authn_request_replay": "This AuthnRequest ID has already been used (replay).", + "unknown_sp": "Unregistered SP: {{entityId}}", + "destination_mismatch": "AuthnRequest Destination does not match the IdP SSO endpoint.", + "acs_url_mismatch": "The AuthnRequest ACS URL does not match the registered SP ACS URL.", + "saml_request_missing": "Missing SAMLRequest parameter.", + "saml_request_parse_failed": "Failed to parse SAMLRequest.", + "sp_cert_missing_for_authn_sig": "Cannot verify the AuthnRequest signature because no SP certificate is registered.", + "authn_signature_invalid": "AuthnRequest signature verification failed.", + "sso_redirecting": "Redirecting for SSO...", + "slo_no_remaining_sp": "No SPs remaining in the chain.", + "slo_signing_key_not_configured": "Cannot continue the SLO chain because no signing key is configured.", + "slo_active_signing_key_missing": "No active signing key available.", + "slo_state_invalid": "Invalid or expired SLO state.", + "slo_initiating_sp_cert_missing": "Cannot verify the LogoutResponse because the initiating SP certificate is not registered.", + "slo_logout_response_must_be_signed": "The LogoutResponse must be signed.", + "slo_logout_response_sig_invalid": "LogoutResponse signature verification failed.", + "slo_invalid_saml_request": "Invalid SAMLRequest.", + "slo_destination_mismatch": "LogoutRequest Destination does not match the IdP SLO endpoint.", + "slo_unknown_sp": "Unknown SAML SP.", + "slo_sp_cert_missing": "Cannot verify the SLO request because no SP certificate is registered.", + "slo_logout_request_must_be_signed": "The LogoutRequest must be signed.", + "slo_saml_request_sig_invalid": "Invalid SAMLRequest signature.", + "slo_logout_request_replay": "This LogoutRequest ID has already been used (replay).", + "slo_chain_init_failed": "Failed to initialize the SLO chain." + } }, "roles": { "list_title": "Roles", @@ -467,6 +716,148 @@ "submit": "Change Password", "invalid_link": "This link has expired or is invalid." }, + "verify_email": { + "title": "Email Verification", + "subtitle": "Please complete verification of your email address.", + "confirm_prompt": "Click the button below to complete verification of your email address.", + "submit": "Verify email", + "success": "Your email address has been verified.", + "invalid_link": "This link has expired or is invalid.", + "go_login": "Go to login" + }, + "accept_invite": { + "title": "Accept Invitation", + "subtitle": "Set a password to complete your registration.", + "password_label": "Password", + "password_placeholder": "At least 8 characters", + "confirm_label": "Confirm Password", + "submit": "Complete Registration", + "success": "Registration complete. You can now sign in.", + "invalid_link": "This link has expired or is invalid.", + "go_login": "Go to login", + "err_password_short": "Password must be at least 8 characters.", + "err_password_mismatch": "Passwords do not match.", + "credential_label": "Password" + }, + "errors": { + "rate_limit": "Too many requests. Please try again in {{minutes}} minute(s).", + "db_not_ready": "The D1 binding \"DB\" is not ready. Please run in a Wrangler preview/dev environment." + }, + "email": { + "footer": "If you did not request this, please ignore this email.", + "find_id": { + "subject": "Your username", + "title": "Username lookup", + "intro": "Here is the username you requested." + }, + "password_reset": { + "subject": "Reset your password", + "title": "Reset password", + "body": "Click the button below to reset your password. The link is valid for 1 hour.", + "button": "Reset password", + "text": "Use the link below to reset your password. The link is valid for 1 hour." + }, + "verify": { + "subject": "Verify your email", + "title": "Email verification", + "body": "Click the button below to verify your email address. The link is valid for 24 hours.", + "button": "Verify email", + "text": "Use the link below to verify your email address. The link is valid for 24 hours." + }, + "email_change": { + "subject": "Confirm your email change", + "title": "Confirm email change", + "body": "Click the button below to confirm the change to your new email address. The link is valid for 24 hours.", + "button": "Confirm email change", + "text": "Use the link below to confirm the change to your new email address. The link is valid for 24 hours." + }, + "invite": { + "subject": "You have been invited", + "title": "Account invitation", + "body": "You have been invited to an account. Click the button below to set your password and finish signing up. The link is valid for 72 hours.", + "button": "Accept invitation", + "text": "You have been invited to an account. Use the link below to set your password and finish signing up. The link is valid for 72 hours." + } + }, + "security_alert": { + "when": "Occurred at: {{time}}", + "footer": "If this wasn't you, change your password immediately and contact your administrator.", + "password_changed": { + "subject": "[Security Alert] Your password was changed", + "heading": "Your password was changed", + "body": "The password for your account was just changed." + }, + "password_reset_by_admin": { + "subject": "[Security Alert] An administrator reset your password", + "heading": "Your password was reset", + "body": "An administrator reset the password for your account. Please sign in with the new password." + }, + "account_locked": { + "subject": "[Security Alert] Your account was locked", + "heading": "Your account was locked", + "body": "Your account was locked by an administrator. You cannot sign in." + }, + "account_disabled": { + "subject": "[Security Alert] Your account was disabled", + "heading": "Your account was disabled", + "body": "Your account was disabled by an administrator. You cannot sign in." + }, + "mfa_enrolled": { + "subject": "[Security Alert] Two-factor authentication was enabled", + "heading": "Two-factor authentication was enabled", + "body": "Two-factor authentication (TOTP) was newly enabled on your account." + }, + "mfa_disabled": { + "subject": "[Security Alert] Two-factor authentication was disabled", + "heading": "Two-factor authentication was disabled", + "body": "Two-factor authentication (TOTP) was removed from your account." + }, + "backup_codes_regenerated": { + "subject": "[Security Alert] Backup codes were regenerated", + "heading": "Backup codes were regenerated", + "body": "New two-factor backup codes were generated for your account. Your previous backup codes no longer work." + }, + "backup_codes_low": { + "subject": "[Security Alert] Your backup codes are running low", + "heading": "Your backup codes are running low", + "body": "A backup code was just used to sign in, and only a few unused backup codes remain. Please regenerate your backup codes soon so you don't get locked out." + }, + "backup_codes_depleted": { + "subject": "[Security Alert] Your backup codes are used up", + "heading": "Your backup codes are used up", + "body": "The last backup code on your account was just used to sign in. You have no unused backup codes left. Please regenerate your backup codes now to keep a recovery option available." + }, + "passkey_added": { + "subject": "[Security Alert] A new passkey was registered", + "heading": "A new passkey was registered", + "body": "A new passkey was registered on your account." + }, + "passkey_removed": { + "subject": "[Security Alert] A passkey was removed", + "heading": "A passkey was removed", + "body": "A passkey was removed from your account." + }, + "account_deletion_requested": { + "subject": "[Security Alert] Account deletion requested", + "heading": "Account deletion requested", + "body": "Your account deletion was received and moved to a pending-deletion state. You can recover it by signing in again within the 30-day grace period. If you did not request this, sign in immediately to recover and change your password." + }, + "email_change_requested": { + "subject": "[Security Alert] An email address change was requested", + "heading": "An email address change was requested", + "body": "A request to change your account's email address was received. The change completes only after the confirmation link sent to the new address is clicked. If you did not request this, change your password immediately and contact your administrator." + }, + "session_revoked": { + "subject": "[Security Alert] A session was signed out", + "heading": "A session was signed out", + "body": "One of your account's active sessions was just signed out (revoked)." + }, + "sessions_revoked_all": { + "subject": "[Security Alert] All other sessions were signed out", + "heading": "All other sessions were signed out", + "body": "All active sessions on your account except the current one were just signed out (revoked)." + } + }, "skins": { "title": "Login Skins", "add_btn": "+ Add Skin", @@ -500,6 +891,9 @@ "placeholder_form_action": "Form action (POSTs to the current URL if empty)", "placeholder_redirect_to": "Redirect target URL after login", "placeholder_skin_hint": "Skin hint value (used in a hidden input)", + "placeholder_registered": "\"1\" right after signup completes, empty otherwise (used to show a post-signup notice on the login skin)", + "placeholder_password_reset": "\"1\" right after a password reset completes, empty otherwise (used to show a reset-complete notice on the login skin)", + "placeholder_flash_msg": "Server-filled flash message (e.g. an error). Already HTML-escaped; empty when there is nothing to show", "guide_title": "Custom Skin Development Guide", "guide_subtitle": "Fetch HTML from an external URL to fully customize the login and sign-up screens.", "guide_overview_title": "Overview", @@ -534,5 +928,33 @@ "guide_example_login": "Log in", "guide_example_username": "Username", "guide_example_password": "Password" + }, + "webauthn": { + "errors": { + "login_required": "Sign-in required.", + "signing_key_not_configured": "IDP_SIGNING_KEY_SECRET is not configured.", + "invalid_origin": "Invalid origin.", + "register_session_expired": "The registration session has expired. Please try again.", + "register_session_invalid": "The registration session is invalid. Please try again.", + "register_rate_limited": "Too many registration attempts. Please try again shortly.", + "register_verify_failed": "Passkey registration verification failed.", + "rate_limited_retry": "Too many requests. Please try again in {{seconds}} seconds.", + "auth_rate_limited": "Too many authentication attempts. Please try again shortly.", + "auth_session_invalid": "The authentication session is invalid.", + "auth_session_expired": "The authentication session has expired or was already used.", + "auth_failed": "Passkey authentication failed.", + "account_disabled": "This account is disabled.", + "access_denied": "Access denied.", + "auth_cancelled": "Passkey authentication was cancelled." + } + }, + "totp": { + "errors": { + "signing_key_not_set": "IDP_SIGNING_KEY_SECRET is not configured.", + "verify_rate_limited": "Too many TOTP attempts. Please try again shortly.", + "enroll_rate_limited": "Too many TOTP enrollment attempts. Please try again shortly." + }, + "default_authenticator_label": "TOTP authenticator (dispatcher)", + "default_backup_code_label": "Backup code (dispatcher)" } } diff --git a/src/lib/i18n/ko.json b/src/lib/i18n/ko.json index de9fc85..739e50a 100644 --- a/src/lib/i18n/ko.json +++ b/src/lib/i18n/ko.json @@ -35,7 +35,8 @@ "change": "변경", "saving": "저장되었습니다.", "search": "검색", - "next_page": "다음 페이지 →" + "next_page": "다음 페이지 →", + "processing": "처리 중..." }, "login": { "username": "아이디", @@ -53,8 +54,15 @@ "password_reset_success": "비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요.", "err_missing_credentials": "아이디와 비밀번호를 입력해 주세요.", "err_rate_limit": "로그인 시도가 너무 많습니다. {{minutes}}분 후 다시 시도해 주세요.", + "err_account_locked": "로그인 시도가 너무 많습니다. {{minutes}}분 후 다시 시도해 주세요.", "err_invalid_credentials": "아이디 또는 비밀번호가 올바르지 않습니다.", - "err_mfa_config": "2단계 인증 설정에 문제가 있습니다. 관리자에게 문의해 주세요." + "err_mfa_config": "2단계 인증 설정에 문제가 있습니다. 관리자에게 문의해 주세요.", + "err_account_deleting": "이 계정은 삭제 처리 중이며 더 이상 로그인할 수 없습니다.", + "deletion_requested_notice": "탈퇴가 접수되었습니다. 유예기간(30일) 내에 다시 로그인하면 계정을 복구할 수 있습니다.", + "recovery_desc": "이 계정은 탈퇴(삭제) 예정 상태입니다. 계속 사용하려면 비밀번호를 다시 입력해 계정을 복구하세요.", + "recovery_password_label": "비밀번호를 다시 입력해 복구", + "recovery_confirm": "계정 복구 후 로그인", + "recovery_cancel": "← 로그인으로 돌아가기" }, "admin": { "title": "관리자 대시보드", @@ -73,7 +81,98 @@ "teams": "팀", "parts": "파트", "ldap_providers": "LDAP 프로바이더", - "skins": "로그인 스킨" + "skins": "로그인 스킨", + "errors": { + "invalid_request": "잘못된 요청입니다.", + "user_not_found": "사용자를 찾을 수 없습니다.", + "name_required": "이름은 필수입니다.", + "password_min_length": "비밀번호는 8자 이상이어야 합니다.", + "csrf_failed": "CSRF 검증에 실패했습니다. 페이지를 새로 고친 뒤 다시 시도해 주세요.", + "invalid_expiry_format": "만료일 형식이 올바르지 않습니다.", + "required_field_missing": "필수 항목이 누락되었습니다.", + "signing_key_secret_missing": "IDP_SIGNING_KEY_SECRET 이 설정되지 않았습니다.", + "email_password_required": "이메일과 비밀번호는 필수입니다.", + "email_required": "이메일은 필수입니다.", + "invalid_role": "역할이 올바르지 않습니다.", + "email_taken": "이미 사용 중인 이메일입니다.", + "username_taken": "이미 사용 중인 아이디입니다.", + "cannot_change_own_status": "자기 자신의 상태를 변경할 수 없습니다.", + "cannot_change_own_role": "자기 자신의 역할을 변경할 수 없습니다.", + "password_required": "비밀번호를 입력해 주세요.", + "cannot_delete_self": "자기 자신을 삭제할 수 없습니다.", + "invalid_role_value": "잘못된 role 값입니다.", + "invalid_status_value": "잘못된 status 값입니다.", + "cannot_change_own_role_status": "자기 자신의 role/status 는 변경할 수 없습니다. 다른 관리자에게 요청해 주세요.", + "last_admin_race": "동시에 다른 관리자도 강등된 것으로 보입니다. 변경을 취소했습니다. 다시 시도해 주세요.", + "select_department": "부서를 선택해 주세요.", + "department_not_found": "부서를 찾을 수 없습니다.", + "position_not_found": "직책을 찾을 수 없습니다.", + "select_team": "팀을 선택해 주세요.", + "team_not_found": "팀을 찾을 수 없습니다.", + "select_part": "파트를 선택해 주세요.", + "part_not_found": "파트를 찾을 수 없습니다.", + "select_service": "서비스를 선택해 주세요.", + "invalid_service_type": "잘못된 서비스 종류입니다.", + "invalid_service_id": "잘못된 서비스 ID 입니다.", + "oidc_client_not_found": "OIDC 클라이언트를 찾을 수 없습니다.", + "saml_sp_not_found": "SAML SP 를 찾을 수 없습니다.", + "role_not_in_service": "선택한 role 이 해당 서비스에 속하지 않습니다.", + "attributes_not_object": "attributesJson 은 JSON object 여야 합니다.", + "attributes_parse_failed": "attributesJson 파싱 실패.", + "assignment_exists": "이미 해당 서비스에 매핑이 존재합니다. 먼저 삭제해 주세요.", + "scope_openid_required": "scope 에는 'openid' 가 포함되어야 합니다.", + "invalid_token_auth_method": "tokenEndpointAuthMethod 값이 올바르지 않습니다.", + "redirect_uri_required": "Redirect URI 는 필수입니다.", + "client_not_found": "클라이언트를 찾을 수 없습니다.", + "invalid_role_key": "key 는 영숫자/._- 만 허용 (1~64자).", + "label_required": "label 은 필수입니다.", + "role_key_exists": "이미 존재하는 role key 입니다.", + "ldap_host_required": "LDAP 호스트는 필수입니다.", + "ldap_bind_or_userdn_required": "Admin Bind DN 또는 유저 DN 패턴 중 하나는 필수입니다.", + "ldap_signing_key_secret_required": "IDP_SIGNING_KEY_SECRET 이 설정되어야 LDAP bindPassword 를 저장할 수 있습니다.", + "invalid_url": "유효한 URL을 입력해 주세요.", + "https_only": "https URL만 허용됩니다.", + "loopback_forbidden": "loopback 주소는 사용할 수 없습니다.", + "internal_addr_forbidden": "내부망/메타데이터 주소는 사용할 수 없습니다.", + "required_fields": "필수 항목을 입력해 주세요.", + "invalid_client_type": "clientType이 올바르지 않습니다.", + "cache_ttl_negative": "cacheTtlSeconds 는 0 이상이어야 합니다.", + "cache_ttl_max": "cacheTtlSeconds 는 {{max}} 초(1일) 를 초과할 수 없습니다.", + "skin_config_exists": "이미 동일한 클라이언트/스킨 타입 설정이 있습니다.", + "skin_not_found": "스킨을 찾을 수 없습니다.", + "url_required": "URL을 입력해 주세요.", + "rotate_conflict": "다른 rotate 작업이 진행 중이거나 DB 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", + "entity_id_required": "Entity ID 는 필수입니다.", + "acs_url_required": "ACS URL 은 필수입니다.", + "sp_cert_required_for_encryption": "Assertion 암호화를 사용하려면 SP 인증서가 필요합니다.", + "nameid_format_forbidden": "허용되지 않는 NameID Format 입니다.", + "entity_id_taken": "이미 등록된 Entity ID 입니다.", + "login_missing_credentials": "아이디와 비밀번호를 입력해 주세요.", + "login_invalid_credentials": "아이디 또는 비밀번호가 올바르지 않습니다.", + "login_not_admin": "관리자 권한이 없는 계정입니다.", + "login_mfa_config": "MFA 설정 오류: IDP_SIGNING_KEY_SECRET 이 설정되지 않았습니다.", + "login_mfa_required": "관리자 계정은 MFA(OTP) 설정이 필수입니다. 먼저 MFA를 등록해 주세요.", + "login_rate_limit": "로그인 시도가 너무 많습니다. {{minutes}}분 후 다시 시도해 주세요.", + "team_name_required": "팀명을 입력해 주세요.", + "part_name_required": "파트명을 입력해 주세요.", + "position_name_required": "직급명을 입력해 주세요.", + "department_name_required": "부서명을 입력해 주세요.", + "level_must_be_number": "레벨은 숫자여야 합니다.", + "url_invalid_format": "URL 형식이 올바르지 않습니다: {{value}}", + "url_fragment_forbidden": "fragment(#) 가 포함된 URI 는 허용되지 않습니다: {{value}}", + "scheme_forbidden": "{{scheme}}: 스킴은 허용되지 않습니다.", + "http_localhost_only": "http URL 은 localhost/127.0.0.1 만 허용됩니다: {{value}}", + "custom_scheme_invalid": "커스텀 scheme 형식이 올바르지 않습니다: {{value}}", + "scheme_not_allowed": "허용되지 않는 scheme 입니다 ({{scheme}}): {{value}}", + "scope_not_allowed": "허용되지 않는 scope 입니다: {{scope}}", + "saml_url_invalid_format": "{{label}}: URL 형식이 올바르지 않습니다.", + "saml_url_http_loopback_only": "{{label}}: http URL 은 localhost/127.0.0.1 만 허용됩니다.", + "saml_url_https_only": "{{label}}: https URL 만 허용됩니다.", + "ldap_metadata_host_forbidden": "클라우드 메타데이터 호스트는 사용할 수 없습니다.", + "ldap_linklocal_forbidden": "link-local(169.254/16) 주소는 사용할 수 없습니다.", + "ldap_port_invalid": "포트 번호가 올바르지 않습니다.", + "ldap_port_not_allowed": "허용되지 않는 LDAP 포트입니다 (허용: {{allowed}})." + } }, "admin_login": { "subtitle": "관리자 로그인", @@ -86,7 +185,42 @@ "account": { "profile": "내 프로필", "mfa": "2단계 인증", - "passkeys": "패스키" + "passkeys": "패스키", + "sessions": { + "title": "활성 세션", + "subtitle": "현재 로그인된 기기와 세션을 확인하고, 낯선 기기를 로그아웃할 수 있습니다.", + "current_badge": "현재 세션", + "ip_label": "IP", + "device_label": "기기", + "last_seen_label": "마지막 활동", + "created_label": "로그인 시각", + "unknown": "알 수 없음", + "revoke": "로그아웃", + "revoke_current": "이 기기 로그아웃", + "revoke_confirm": "이 세션을 로그아웃할까요?", + "revoke_current_confirm": "현재 사용 중인 기기입니다. 로그아웃하면 이 화면에서도 로그아웃됩니다. 계속할까요?", + "revoke_others": "다른 모든 기기에서 로그아웃", + "revoke_others_confirm": "현재 기기를 제외한 모든 세션을 로그아웃할까요?", + "empty": "활성 세션이 없습니다.", + "revoked_notice": "선택한 세션을 로그아웃했습니다.", + "revoked_others_notice": "다른 모든 기기에서 로그아웃했습니다.", + "err_select_session": "세션을 지정해 주세요.", + "err_not_found": "세션을 찾을 수 없습니다." + }, + "danger_zone": { + "title": "계정 탈퇴", + "subtitle": "계정을 삭제 예정 상태로 전환합니다. 유예기간 동안은 다시 로그인해 복구할 수 있습니다.", + "warning_title": "탈퇴하면 다음이 진행됩니다.", + "warning_grace": "지금 탈퇴를 신청하면 계정이 {{days}}일 후 영구 삭제됩니다. 그때까지는 로그인해서 복구할 수 있습니다.", + "warning_recovery": "탈퇴 즉시 모든 기기에서 로그아웃되며, 유예기간이 지나면 계정과 관련 데이터가 영구 삭제됩니다.", + "password_label": "본인 확인을 위한 비밀번호", + "totp_label": "인증 앱 코드 (TOTP)", + "confirm_label": "위 내용을 이해했으며 계정 탈퇴를 신청합니다.", + "confirm_dialog": "정말 탈퇴를 신청하시겠습니까? 즉시 로그아웃되며 유예기간이 지나면 영구 삭제됩니다.", + "submit": "계정 탈퇴 신청", + "err_reauth": "본인 확인에 실패했습니다. 비밀번호 또는 인증 코드를 확인해 주세요.", + "err_last_admin": "마지막 활성 관리자는 탈퇴할 수 없습니다. 먼저 다른 관리자를 지정해 주세요." + } }, "logout": { "in_progress": "로그아웃 중..." @@ -100,7 +234,12 @@ "confirm": "확인", "use_backup": "백업 코드로 로그인", "use_totp": "인증 앱 코드로 로그인", - "back_to_login": "← 로그인으로 돌아가기" + "back_to_login": "← 로그인으로 돌아가기", + "err_config": "MFA 설정에 문제가 있습니다. 잠시 후 다시 시도해 주세요.", + "err_rate_limit": "인증 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.", + "err_missing_code": "코드를 입력해 주세요.", + "err_invalid_totp": "인증 코드가 올바르지 않습니다.", + "err_invalid_backup": "백업 코드가 올바르지 않습니다." }, "mfa_manage": { "title": "2단계 인증 관리", @@ -157,7 +296,34 @@ "locale_settings": "지역 설정", "org_membership": "조직 소속", "team_label": "팀", - "part_label": "파트" + "part_label": "파트", + "email_unverified_title": "이메일이 아직 인증되지 않았습니다.", + "email_unverified_desc": "{{email}} 주소로 인증 메일을 보냈습니다. 받은 편지함을 확인해 주세요.", + "email_resend": "인증 메일 재발송", + "email_resend_sent": "인증 메일을 다시 보냈습니다. 잠시 후 받은 편지함을 확인해 주세요.", + "err_birthdate_format": "생년월일 형식이 올바르지 않습니다. (YYYY-MM-DD)", + "email_change_title": "이메일 주소 변경", + "email_change_desc": "새 이메일 주소로 확인 메일을 보냅니다. 확인 링크를 클릭해야 변경이 완료됩니다.", + "email_change_new_label": "새 이메일 주소", + "email_change_password_label": "현재 비밀번호", + "email_change_submit": "확인 메일 보내기", + "email_change_sent": "{{email}} 주소로 확인 메일을 보냈습니다. 링크를 클릭하면 변경이 완료됩니다.", + "email_change_pending": "{{email}} 주소로의 변경 확인 대기 중입니다. 받은 편지함을 확인해 주세요.", + "err_email_change_password": "현재 비밀번호가 올바르지 않습니다.", + "err_email_change_format": "이메일 형식이 올바르지 않습니다.", + "err_email_change_same": "현재 이메일과 동일한 주소입니다.", + "err_email_change_taken": "이미 사용 중인 이메일 주소입니다.", + "err_email_change_no_password": "이 계정은 비밀번호가 설정되어 있지 않아 이메일을 변경할 수 없습니다." + }, + "confirm_email_change": { + "title": "이메일 주소 변경 확인", + "subtitle": "새 이메일 주소로의 변경을 완료해 주세요.", + "confirm_prompt": "아래 버튼을 클릭하면 이메일 주소 변경이 완료됩니다.", + "submit": "이메일 변경 확인", + "success": "이메일 주소가 변경되었습니다.", + "invalid_link": "링크가 만료되었거나 유효하지 않습니다.", + "go_login": "로그인하러 가기", + "go_profile": "프로필로 가기" }, "audit": { "title": "감사 로그", @@ -247,6 +413,11 @@ "users": { "title": "사용자 관리", "add_btn": "+ 사용자 추가", + "invite_btn": "사용자 초대", + "invite_title": "사용자 초대", + "invite_hint": "비밀번호 없이 계정을 만들고 초대 메일을 발송합니다. 초대받은 사용자가 링크에서 비밀번호를 직접 설정합니다.", + "invite_submit": "초대 보내기", + "badge_pending_invite": "초대중", "search_placeholder": "이메일 · 아이디 · 이름 검색", "search_empty": "검색 결과가 없습니다.", "create_title": "새 사용자 추가", @@ -274,6 +445,11 @@ }, "user_detail": { "back": "← 목록", + "danger_zone": "위험 작업", + "force_logout": "강제 로그아웃", + "force_logout_desc": "이 사용자의 모든 세션과 OIDC refresh token 을 즉시 폐기합니다. 상태나 비밀번호는 변경되지 않습니다.", + "force_logout_confirm": "이 사용자를 모든 기기에서 강제 로그아웃하시겠습니까?", + "force_logout_done": "모든 세션이 폐기되었습니다.", "saved": "저장되었습니다.", "profile_section": "프로필 정보", "given_name": "이름 (Given Name)", @@ -382,7 +558,49 @@ "empty": "등록된 클라이언트가 없습니다.", "regenerate_secret": "시크릿 재생성", "regenerate_confirm": "시크릿을 재생성하시겠습니까? 기존 시크릿은 즉시 무효화됩니다.", - "delete_confirm": "클라이언트를 삭제하시겠습니까?" + "delete_confirm": "클라이언트를 삭제하시겠습니까?", + "org_claims": { + "title": "조직 클레임 노출 설정", + "desc": "이 클라이언트의 organization scope 가 노출하는 필드를 선택합니다. id_token 과 userinfo 응답에 동일하게 적용됩니다.", + "saved": "조직 클레임 설정을 저장했습니다.", + "all_note": "모든 필드를 켜면 미설정(전량 노출) 상태로 저장됩니다.", + "field_department": "부서 (department)", + "field_department_desc": "사용자가 소속된 부서 목록. 각 부서의 코드·주소속 여부·직책·직급을 포함합니다.", + "field_team": "팀 (team)", + "field_team_desc": "사용자가 소속된 팀 목록. 각 팀의 코드·상위 부서·주소속 여부·직책을 포함합니다.", + "field_position": "직급 (position)", + "field_position_desc": "사용자 주소속 부서의 직급명.", + "field_jobTitle": "직책 (job_title)", + "field_jobTitle_desc": "사용자 주소속 부서의 직책." + }, + "errors": { + "rate_limited": "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.", + "rate_limited_short": "요청이 너무 많습니다.", + "client_id_redirect_uri_required": "client_id 와 redirect_uri 는 필수입니다.", + "response_type_unsupported": "response_type=code 만 지원합니다.", + "unknown_client": "등록되지 않은 client_id 입니다.", + "redirect_uri_mismatch": "redirect_uri 가 등록된 값과 일치하지 않습니다.", + "pkce_required": "PKCE code_challenge 가 필요합니다.", + "empty_code_challenge": "code_challenge 가 비어 있습니다.", + "code_challenge_method_unsupported": "code_challenge_method=S256 만 지원합니다.", + "openid_scope_required": "openid scope 가 필요합니다.", + "prompt_none_conflict": "prompt=none 은 다른 prompt 값과 함께 쓸 수 없습니다.", + "max_age_invalid": "max_age 는 음이 아닌 정수여야 합니다.", + "login_required": "사용자 상호작용 없이 인증을 완료할 수 없습니다.", + "session_missing_after_gate": "인증 게이트 이후 세션이 없습니다.", + "service_access_denied": "이 서비스에 대한 권한이 없습니다.", + "token_param_required": "token 파라미터가 필요합니다.", + "id_token_hint_required": "id_token_hint 가 필요합니다.", + "aud_mismatch": "aud 가 일치하지 않습니다.", + "cross_origin_post_blocked": "cross-origin POST 가 차단되었습니다.", + "logging_out": "로그아웃 중...", + "signing_key_not_set": "IDP_SIGNING_KEY_SECRET 미설정", + "bearer_token_required": "Bearer 토큰이 필요합니다.", + "access_token_invalid": "유효하지 않거나 만료된 액세스 토큰입니다.", + "token_aud_mismatch": "토큰의 aud 와 client 가 일치하지 않습니다.", + "issuing_client_invalid": "발급 client 가 더 이상 유효하지 않습니다.", + "user_not_found": "사용자를 찾을 수 없습니다." + } }, "saml": { "title": "SAML SP 관리", @@ -411,7 +629,38 @@ "empty": "등록된 SAML SP 가 없습니다.", "delete_confirm": "SP를 삭제하시겠습니까?", "parse_error_xml": "XML 파싱 오류", - "parse_error_generic": "파싱 실패" + "parse_error_generic": "파싱 실패", + "errors": { + "rate_limited": "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.", + "issuer_not_set": "IDP_ISSUER_URL 미설정", + "signing_key_not_set": "IDP_SIGNING_KEY_SECRET 미설정", + "signing_key_missing": "서명 키가 없습니다. 서버를 재시작하여 키를 생성하세요.", + "access_denied": "이 SP에 대한 권한이 없습니다.", + "authn_request_replay": "AuthnRequest ID 가 이미 사용되었습니다 (replay)", + "unknown_sp": "등록되지 않은 SP 입니다: {{entityId}}", + "destination_mismatch": "AuthnRequest Destination 이 IdP 의 SSO endpoint 와 일치하지 않습니다.", + "acs_url_mismatch": "AuthnRequest 의 ACS URL 이 등록된 SP ACS URL 과 일치하지 않습니다.", + "saml_request_missing": "SAMLRequest 파라미터가 없습니다.", + "saml_request_parse_failed": "SAMLRequest 파싱에 실패했습니다.", + "sp_cert_missing_for_authn_sig": "SP 인증서가 등록되지 않아 AuthnRequest 서명을 검증할 수 없습니다.", + "authn_signature_invalid": "AuthnRequest 서명 검증에 실패했습니다.", + "sso_redirecting": "SSO 리다이렉트 중...", + "slo_no_remaining_sp": "체인에 남은 SP 가 없습니다.", + "slo_signing_key_not_configured": "서명 키가 설정되지 않아 SLO 체인을 계속 진행할 수 없습니다.", + "slo_active_signing_key_missing": "활성 서명 키가 없습니다.", + "slo_state_invalid": "SLO 상태가 유효하지 않거나 만료되었습니다.", + "slo_initiating_sp_cert_missing": "initiating SP 인증서가 등록되지 않아 LogoutResponse 를 검증할 수 없습니다.", + "slo_logout_response_must_be_signed": "LogoutResponse 는 반드시 서명되어야 합니다.", + "slo_logout_response_sig_invalid": "LogoutResponse 서명 검증에 실패했습니다.", + "slo_invalid_saml_request": "유효하지 않은 SAMLRequest 입니다.", + "slo_destination_mismatch": "LogoutRequest Destination 이 IdP 의 SLO endpoint 와 일치하지 않습니다.", + "slo_unknown_sp": "알 수 없는 SAML SP 입니다.", + "slo_sp_cert_missing": "SP 인증서가 등록되지 않아 SLO 요청을 검증할 수 없습니다.", + "slo_logout_request_must_be_signed": "LogoutRequest 는 반드시 서명되어야 합니다.", + "slo_saml_request_sig_invalid": "SAMLRequest 서명이 유효하지 않습니다.", + "slo_logout_request_replay": "LogoutRequest ID 가 이미 사용되었습니다 (replay)", + "slo_chain_init_failed": "SLO 체인 초기화에 실패했습니다." + } }, "roles": { "list_title": "Role 목록", @@ -467,6 +716,148 @@ "submit": "비밀번호 변경", "invalid_link": "링크가 만료되었거나 유효하지 않습니다." }, + "verify_email": { + "title": "이메일 인증", + "subtitle": "이메일 주소 인증을 완료해 주세요.", + "confirm_prompt": "아래 버튼을 클릭하면 이메일 주소 인증이 완료됩니다.", + "submit": "이메일 인증하기", + "success": "이메일 주소가 인증되었습니다.", + "invalid_link": "링크가 만료되었거나 유효하지 않습니다.", + "go_login": "로그인하러 가기" + }, + "accept_invite": { + "title": "초대 수락", + "subtitle": "비밀번호를 설정하여 가입을 완료해 주세요.", + "password_label": "비밀번호", + "password_placeholder": "8자 이상", + "confirm_label": "비밀번호 확인", + "submit": "가입 완료", + "success": "가입이 완료되었습니다. 이제 로그인할 수 있습니다.", + "invalid_link": "링크가 만료되었거나 유효하지 않습니다.", + "go_login": "로그인하러 가기", + "err_password_short": "비밀번호는 8자 이상이어야 합니다.", + "err_password_mismatch": "비밀번호가 일치하지 않습니다.", + "credential_label": "비밀번호" + }, + "errors": { + "rate_limit": "요청이 너무 많습니다. {{minutes}}분 후 다시 시도해 주세요.", + "db_not_ready": "D1 binding \"DB\" 가 준비되지 않았습니다. Wrangler preview/dev 환경에서 실행해 주세요." + }, + "email": { + "footer": "본인이 요청하지 않았다면 이 이메일을 무시해 주세요.", + "find_id": { + "subject": "아이디 안내", + "title": "아이디 확인", + "intro": "요청하신 아이디 정보입니다." + }, + "password_reset": { + "subject": "비밀번호 재설정 안내", + "title": "비밀번호 재설정", + "body": "아래 버튼을 클릭하여 비밀번호를 재설정하세요. 링크는 1시간 동안 유효합니다.", + "button": "비밀번호 재설정", + "text": "아래 링크에서 비밀번호를 재설정하세요. 링크는 1시간 동안 유효합니다." + }, + "verify": { + "subject": "이메일 인증 안내", + "title": "이메일 인증", + "body": "아래 버튼을 클릭하여 이메일 주소를 인증하세요. 링크는 24시간 동안 유효합니다.", + "button": "이메일 인증", + "text": "아래 링크에서 이메일 주소를 인증하세요. 링크는 24시간 동안 유효합니다." + }, + "email_change": { + "subject": "이메일 주소 변경 확인", + "title": "이메일 주소 변경 확인", + "body": "아래 버튼을 클릭하여 새 이메일 주소로의 변경을 확인하세요. 링크는 24시간 동안 유효합니다.", + "button": "이메일 변경 확인", + "text": "아래 링크에서 새 이메일 주소로의 변경을 확인하세요. 링크는 24시간 동안 유효합니다." + }, + "invite": { + "subject": "계정 초대 안내", + "title": "계정 초대", + "body": "계정에 초대되었습니다. 아래 버튼을 클릭하여 비밀번호를 설정하고 가입을 완료하세요. 링크는 72시간 동안 유효합니다.", + "button": "초대 수락하기", + "text": "계정에 초대되었습니다. 아래 링크에서 비밀번호를 설정하고 가입을 완료하세요. 링크는 72시간 동안 유효합니다." + } + }, + "security_alert": { + "when": "발생 시각: {{time}}", + "footer": "본인이 한 작업이 아니라면 즉시 비밀번호를 변경하고 관리자에게 문의해 주세요.", + "password_changed": { + "subject": "[보안 알림] 비밀번호가 변경되었습니다", + "heading": "비밀번호가 변경되었습니다", + "body": "계정의 비밀번호가 방금 변경되었습니다." + }, + "password_reset_by_admin": { + "subject": "[보안 알림] 관리자가 비밀번호를 초기화했습니다", + "heading": "비밀번호가 초기화되었습니다", + "body": "관리자가 계정의 비밀번호를 초기화했습니다. 새 비밀번호로 로그인해 주세요." + }, + "account_locked": { + "subject": "[보안 알림] 계정이 잠겼습니다", + "heading": "계정이 잠겼습니다", + "body": "계정이 관리자에 의해 잠금 처리되었습니다. 로그인할 수 없습니다." + }, + "account_disabled": { + "subject": "[보안 알림] 계정이 비활성화되었습니다", + "heading": "계정이 비활성화되었습니다", + "body": "계정이 관리자에 의해 비활성화되었습니다. 로그인할 수 없습니다." + }, + "mfa_enrolled": { + "subject": "[보안 알림] 2단계 인증이 등록되었습니다", + "heading": "2단계 인증이 등록되었습니다", + "body": "계정에 2단계 인증(TOTP)이 새로 등록되었습니다." + }, + "mfa_disabled": { + "subject": "[보안 알림] 2단계 인증이 해제되었습니다", + "heading": "2단계 인증이 해제되었습니다", + "body": "계정의 2단계 인증(TOTP)이 삭제되었습니다." + }, + "backup_codes_regenerated": { + "subject": "[보안 알림] 백업 코드가 재생성되었습니다", + "heading": "백업 코드가 재생성되었습니다", + "body": "계정의 2단계 인증 백업 코드가 새로 생성되었습니다. 기존 백업 코드는 더 이상 사용할 수 없습니다." + }, + "backup_codes_low": { + "subject": "[보안 알림] 백업 코드가 얼마 남지 않았습니다", + "heading": "백업 코드가 얼마 남지 않았습니다", + "body": "방금 백업 코드로 로그인했으며, 사용하지 않은 백업 코드가 몇 개 남지 않았습니다. 로그인이 불가능해지지 않도록 곧 백업 코드를 재생성해 주세요." + }, + "backup_codes_depleted": { + "subject": "[보안 알림] 백업 코드를 모두 사용했습니다", + "heading": "백업 코드를 모두 사용했습니다", + "body": "방금 계정의 마지막 백업 코드로 로그인했습니다. 사용할 수 있는 백업 코드가 더 이상 없습니다. 복구 수단을 유지하려면 지금 백업 코드를 재생성해 주세요." + }, + "passkey_added": { + "subject": "[보안 알림] 새 패스키가 등록되었습니다", + "heading": "새 패스키가 등록되었습니다", + "body": "계정에 새 패스키가 등록되었습니다." + }, + "passkey_removed": { + "subject": "[보안 알림] 패스키가 삭제되었습니다", + "heading": "패스키가 삭제되었습니다", + "body": "계정에서 패스키가 삭제되었습니다." + }, + "account_deletion_requested": { + "subject": "[보안 알림] 계정 탈퇴가 접수되었습니다", + "heading": "계정 탈퇴가 접수되었습니다", + "body": "계정 탈퇴가 접수되어 삭제 예정 상태로 전환되었습니다. 유예기간(30일) 내에 다시 로그인하면 계정을 복구할 수 있습니다. 본인이 요청하지 않았다면 즉시 로그인해 복구하고 비밀번호를 변경해 주세요." + }, + "email_change_requested": { + "subject": "[보안 알림] 이메일 주소 변경이 요청되었습니다", + "heading": "이메일 주소 변경이 요청되었습니다", + "body": "계정의 이메일 주소를 변경하려는 요청이 접수되었습니다. 새 주소로 보낸 확인 링크를 클릭해야 변경이 완료됩니다. 본인이 요청하지 않았다면 즉시 비밀번호를 변경하고 관리자에게 문의해 주세요." + }, + "session_revoked": { + "subject": "[보안 알림] 세션이 로그아웃되었습니다", + "heading": "세션이 로그아웃되었습니다", + "body": "계정의 활성 세션 하나가 방금 로그아웃(철회)되었습니다." + }, + "sessions_revoked_all": { + "subject": "[보안 알림] 다른 모든 세션이 로그아웃되었습니다", + "heading": "다른 모든 세션이 로그아웃되었습니다", + "body": "현재 세션을 제외한 계정의 모든 활성 세션이 방금 로그아웃(철회)되었습니다." + } + }, "skins": { "title": "로그인 스킨", "add_btn": "+ 스킨 추가", @@ -500,6 +891,9 @@ "placeholder_form_action": "폼 action (비어 있으면 현재 URL로 POST)", "placeholder_redirect_to": "로그인 후 리다이렉트 대상 URL", "placeholder_skin_hint": "스킨 힌트 값 (hidden input에 활용)", + "placeholder_registered": "회원가입 완료 직후 \"1\", 그 외에는 빈 값 (로그인 스킨에서 가입 완료 안내 노출에 활용)", + "placeholder_password_reset": "비밀번호 재설정 완료 직후 \"1\", 그 외에는 빈 값 (로그인 스킨에서 재설정 완료 안내 노출에 활용)", + "placeholder_flash_msg": "서버가 채우는 플래시 메시지(예: 오류). 이미 HTML 이스케이프됨. 표시할 내용이 없으면 빈 값", "guide_title": "커스텀 스킨 개발 가이드", "guide_subtitle": "외부 URL에서 HTML을 가져와 로그인·회원가입 화면을 완전히 커스터마이즈할 수 있습니다.", "guide_overview_title": "개요", @@ -534,5 +928,33 @@ "guide_example_login": "로그인", "guide_example_username": "아이디", "guide_example_password": "비밀번호" + }, + "webauthn": { + "errors": { + "login_required": "로그인이 필요합니다.", + "signing_key_not_configured": "IDP_SIGNING_KEY_SECRET 이 설정되지 않았습니다.", + "invalid_origin": "유효하지 않은 출처입니다.", + "register_session_expired": "등록 세션이 만료되었습니다. 다시 시도해 주세요.", + "register_session_invalid": "등록 세션이 유효하지 않습니다. 다시 시도해 주세요.", + "register_rate_limited": "등록 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.", + "register_verify_failed": "패스키 등록 검증에 실패했습니다.", + "rate_limited_retry": "요청이 너무 많습니다. {{seconds}}초 후 다시 시도해 주세요.", + "auth_rate_limited": "인증 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.", + "auth_session_invalid": "인증 세션이 유효하지 않습니다.", + "auth_session_expired": "인증 세션이 만료되었거나 이미 사용되었습니다.", + "auth_failed": "패스키 인증에 실패했습니다.", + "account_disabled": "비활성화된 계정입니다.", + "access_denied": "접근 권한이 없습니다.", + "auth_cancelled": "패스키 인증이 취소되었습니다." + } + }, + "totp": { + "errors": { + "signing_key_not_set": "IDP_SIGNING_KEY_SECRET 미설정", + "verify_rate_limited": "TOTP 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.", + "enroll_rate_limited": "TOTP 등록 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요." + }, + "default_authenticator_label": "TOTP 인증기 (dispatcher)", + "default_backup_code_label": "백업 코드 (dispatcher)" } } diff --git a/src/lib/server/admin/crud-factory.ts b/src/lib/server/admin/crud-factory.ts index 0a5b744..cb482d3 100644 --- a/src/lib/server/admin/crud-factory.ts +++ b/src/lib/server/admin/crud-factory.ts @@ -18,6 +18,7 @@ import type { Column } from "drizzle-orm"; import type { z } from "zod"; import { requireAdminContext } from "$lib/server/auth/guards"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; +import { translate } from "$lib/i18n/server"; import type { DB } from "$lib/server/db"; type AdminContext = ReturnType; @@ -74,9 +75,13 @@ function formDataToRecord(fd: FormData): Record { return obj; } -/** zod 검증 실패 시 첫 이슈 메시지. */ -function firstIssue(error: z.ZodError): string { - return error.issues[0]?.message ?? "잘못된 요청입니다."; +/** + * zod 검증 실패 시 첫 이슈 메시지를 현재 로케일로 해석한다. + * 스키마 메시지는 i18n 키(admin.errors.*)로 담겨 있으므로 translate 로 표시 문자열을 만든다. + * 키가 아닌 zod 기본 메시지(enum 등)는 translate 폴백이 원문을 그대로 반환한다. + */ +function firstIssue(error: z.ZodError, locale: App.Locals["locale"]): string { + return translate(locale, error.issues[0]?.message ?? "admin.errors.invalid_request"); } /** @@ -96,7 +101,7 @@ export function createAdminCrudRoute; @@ -126,7 +131,7 @@ export function createAdminCrudRoute; @@ -159,7 +164,7 @@ export function createAdminCrudRoute 를 현재 로케일로 해석한다. params 는 {{placeholder}} 치환용. */ +export function adminError(locale: Locale, key: string, params?: Record): string { + return translate(locale, `admin.errors.${key}`, params); +} + +/** + * FormData 에서 필수 id 계열 필드를 파싱한다. 값이 없으면 400 fail(로케일 인지 "잘못된 요청입니다") 반환. + * admin 라우트 전반에서 12곳+ 반복되던 `if (!id) return fail(400, { error: "잘못된 요청입니다." })` 를 일원화한다. + * + * 반환은 판별 유니온: ok=true 면 파싱된 id, ok=false 면 그대로 반환할 fail 결과. + * extra 로 기존 에러 shape 의 부가 필드(예: { create: true }, { update: true, updateId })를 보존한다. + */ +export function requireFormId( + fd: FormData, + locale: Locale, + opts: { field?: string; extra?: Record } = {}, +): { ok: true; id: string } | { ok: false; failure: ReturnType } { + const field = opts.field ?? "id"; + const id = String(fd.get(field) ?? ""); + if (!id) { + return { ok: false, failure: fail(400, { ...(opts.extra ?? {}), error: adminError(locale, "invalid_request") }) }; + } + return { ok: true, id }; +} diff --git a/src/lib/server/admin/schemas.ts b/src/lib/server/admin/schemas.ts index ff00778..121cfb3 100644 --- a/src/lib/server/admin/schemas.ts +++ b/src/lib/server/admin/schemas.ts @@ -36,16 +36,19 @@ export const intField = (message: string) => z.preprocess((v) => (typeof v === " /** displayOrder: 유효하지 않거나 빈값이면 0 (기존 parseInt isNaN→0 동작 보존). */ export const displayOrderField = z.coerce.number().int().catch(0); +// 폼 검증 메시지는 i18n 키(전체 경로)로 담는다. 실제 표시 시점(crud-factory)에서 +// translate(locale, key) 로 해석된다 — 스키마는 로케일 비의존 정적 모듈이므로 키만 보관. + // ── teams ───────────────────────────────────────────────────────────────── export const teamCreateSchema = z.object({ - name: requiredText("팀명을 입력해 주세요."), + name: requiredText("admin.errors.team_name_required"), code: optionalText, departmentId: optionalText, description: optionalText, }); export const teamUpdateSchema = z.object({ - id: requiredText("잘못된 요청입니다."), - name: requiredText("잘못된 요청입니다."), + id: requiredText("admin.errors.invalid_request"), + name: requiredText("admin.errors.invalid_request"), code: optionalText, departmentId: optionalText, description: optionalText, @@ -54,14 +57,14 @@ export const teamUpdateSchema = z.object({ // ── parts ───────────────────────────────────────────────────────────────── export const partCreateSchema = z.object({ - name: requiredText("파트명을 입력해 주세요."), + name: requiredText("admin.errors.part_name_required"), code: optionalText, teamId: optionalText, description: optionalText, }); export const partUpdateSchema = z.object({ - id: requiredText("잘못된 요청입니다."), - name: requiredText("잘못된 요청입니다."), + id: requiredText("admin.errors.invalid_request"), + name: requiredText("admin.errors.invalid_request"), code: optionalText, teamId: optionalText, description: optionalText, @@ -70,28 +73,28 @@ export const partUpdateSchema = z.object({ // ── positions ─────────────────────────────────────────────────────────────── export const positionCreateSchema = z.object({ - name: requiredText("직급명을 입력해 주세요."), + name: requiredText("admin.errors.position_name_required"), code: optionalText, - level: intField("레벨은 숫자여야 합니다."), + level: intField("admin.errors.level_must_be_number"), }); export const positionUpdateSchema = z.object({ - id: requiredText("잘못된 요청입니다."), - name: requiredText("잘못된 요청입니다."), + id: requiredText("admin.errors.invalid_request"), + name: requiredText("admin.errors.invalid_request"), code: optionalText, - level: intField("레벨은 숫자여야 합니다."), + level: intField("admin.errors.level_must_be_number"), }); // ── departments ───────────────────────────────────────────────────────────── export const departmentCreateSchema = z.object({ - name: requiredText("부서명을 입력해 주세요."), + name: requiredText("admin.errors.department_name_required"), code: optionalText, parentId: optionalText, description: optionalText, displayOrder: displayOrderField, }); export const departmentUpdateSchema = z.object({ - id: requiredText("잘못된 요청입니다."), - name: requiredText("잘못된 요청입니다."), + id: requiredText("admin.errors.invalid_request"), + name: requiredText("admin.errors.invalid_request"), code: optionalText, parentId: optionalText, description: optionalText, diff --git a/src/lib/server/admin/user-actions/org.ts b/src/lib/server/admin/user-actions/org.ts new file mode 100644 index 0000000..bd3976e --- /dev/null +++ b/src/lib/server/admin/user-actions/org.ts @@ -0,0 +1,244 @@ +import { fail } from "@sveltejs/kit"; +import type { RequestEvent } from "@sveltejs/kit"; +import { and, eq } from "drizzle-orm"; +import { requireAdminContext } from "$lib/server/auth/guards"; +import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; +import { departments, parts, positions, teams, userDepartments, userParts, userTeams, users } from "$lib/server/db/schema"; +import { adminError, requireFormId } from "$lib/server/admin/errors"; + +// 사용자 상세 페이지의 조직 소속(부서/팀/파트) 배치 액션. +type UserActionEvent = RequestEvent<{ id: string }, "/admin/users/[id]">; + +// 부서 소속 추가 +export async function addDept(event: UserActionEvent) { + const { locals, params, request } = event; + const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; + const fd = await request.formData(); + const userId = params.id; + const departmentId = String(fd.get("departmentId") ?? ""); + const positionId = String(fd.get("positionId") ?? "").trim() || null; + const jobTitle = String(fd.get("jobTitle") ?? "").trim() || null; + const isPrimary = fd.get("isPrimary") === "true"; + + if (!departmentId) return fail(400, { error: adminError(locale, "select_department") }); + + const [targetUser] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))) + .limit(1); + if (!targetUser) return fail(404, { error: adminError(locale, "user_not_found") }); + + const [dept] = await db + .select({ id: departments.id }) + .from(departments) + .where(and(eq(departments.id, departmentId), eq(departments.tenantId, tenant.id))) + .limit(1); + if (!dept) return fail(404, { error: adminError(locale, "department_not_found") }); + + if (positionId) { + const [pos] = await db + .select({ id: positions.id }) + .from(positions) + .where(and(eq(positions.id, positionId), eq(positions.tenantId, tenant.id))) + .limit(1); + if (!pos) return fail(404, { error: adminError(locale, "position_not_found") }); + } + + const membershipId = crypto.randomUUID(); + await db.insert(userDepartments).values({ id: membershipId, tenantId: tenant.id, userId, departmentId, positionId, jobTitle, isPrimary }); + + const meta = getRequestMetadata(event); + await recordAuditEvent(db, { + tenantId: tenant.id, + userId, + actorId: locals.user!.id, + kind: "membership_change", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + detail: { membershipId, action: "add_dept", departmentId, positionId, isPrimary }, + }); + + return { addedDept: true }; +} + +// 부서 소속 제거 (endedAt 설정) +export async function removeDept(event: UserActionEvent) { + const { locals, params, request } = event; + const { db, tenant } = requireAdminContext(locals); + const fd = await request.formData(); + const idr = requireFormId(fd, locals.locale, { field: "membershipId" }); + if (!idr.ok) return idr.failure; + const membershipId = idr.id; + + // IDOR 방어: membershipId가 본 페이지의 userId 소유인지도 확인 + const result = await db + .update(userDepartments) + .set({ endedAt: new Date() }) + .where(and(eq(userDepartments.id, membershipId), eq(userDepartments.userId, params.id), eq(userDepartments.tenantId, tenant.id))); + + const meta = getRequestMetadata(event); + await recordAuditEvent(db, { + tenantId: tenant.id, + userId: params.id, + actorId: locals.user!.id, + kind: "membership_change", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + detail: { membershipId, action: "remove_dept" }, + }); + + void result; + return { removedDept: true }; +} + +// 팀 소속 추가 +export async function addTeam(event: UserActionEvent) { + const { locals, params, request } = event; + const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; + const fd = await request.formData(); + const userId = params.id; + const teamId = String(fd.get("teamId") ?? ""); + const jobTitle = String(fd.get("jobTitle") ?? "").trim() || null; + const isPrimary = fd.get("isPrimary") === "true"; + + if (!teamId) return fail(400, { error: adminError(locale, "select_team") }); + + const [targetUser] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))) + .limit(1); + if (!targetUser) return fail(404, { error: adminError(locale, "user_not_found") }); + + const [team] = await db + .select({ id: teams.id }) + .from(teams) + .where(and(eq(teams.id, teamId), eq(teams.tenantId, tenant.id))) + .limit(1); + if (!team) return fail(404, { error: adminError(locale, "team_not_found") }); + + const membershipId = crypto.randomUUID(); + await db.insert(userTeams).values({ id: membershipId, tenantId: tenant.id, userId, teamId, jobTitle, isPrimary }); + + const meta = getRequestMetadata(event); + await recordAuditEvent(db, { + tenantId: tenant.id, + userId, + actorId: locals.user!.id, + kind: "membership_change", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + detail: { membershipId, action: "add_team", teamId, isPrimary }, + }); + + return { addedTeam: true }; +} + +// 팀 소속 제거 +export async function removeTeam(event: UserActionEvent) { + const { locals, params, request } = event; + const { db, tenant } = requireAdminContext(locals); + const fd = await request.formData(); + const idr = requireFormId(fd, locals.locale, { field: "membershipId" }); + if (!idr.ok) return idr.failure; + const membershipId = idr.id; + + await db + .update(userTeams) + .set({ endedAt: new Date() }) + .where(and(eq(userTeams.id, membershipId), eq(userTeams.userId, params.id), eq(userTeams.tenantId, tenant.id))); + + const meta = getRequestMetadata(event); + await recordAuditEvent(db, { + tenantId: tenant.id, + userId: params.id, + actorId: locals.user!.id, + kind: "membership_change", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + detail: { membershipId, action: "remove_team" }, + }); + + return { removedTeam: true }; +} + +// 파트 소속 추가 +export async function addPart(event: UserActionEvent) { + const { locals, params, request } = event; + const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; + const fd = await request.formData(); + const userId = params.id; + const partId = String(fd.get("partId") ?? ""); + const jobTitle = String(fd.get("jobTitle") ?? "").trim() || null; + const isPrimary = fd.get("isPrimary") === "true"; + + if (!partId) return fail(400, { error: adminError(locale, "select_part") }); + + const [targetUser] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))) + .limit(1); + if (!targetUser) return fail(404, { error: adminError(locale, "user_not_found") }); + + const [part] = await db + .select({ id: parts.id }) + .from(parts) + .where(and(eq(parts.id, partId), eq(parts.tenantId, tenant.id))) + .limit(1); + if (!part) return fail(404, { error: adminError(locale, "part_not_found") }); + + const membershipId = crypto.randomUUID(); + await db.insert(userParts).values({ id: membershipId, tenantId: tenant.id, userId, partId, jobTitle, isPrimary }); + + const meta = getRequestMetadata(event); + await recordAuditEvent(db, { + tenantId: tenant.id, + userId, + actorId: locals.user!.id, + kind: "membership_change", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + detail: { membershipId, action: "add_part", partId, isPrimary }, + }); + + return { addedPart: true }; +} + +// 파트 소속 제거 +export async function removePart(event: UserActionEvent) { + const { locals, params, request } = event; + const { db, tenant } = requireAdminContext(locals); + const fd = await request.formData(); + const idr = requireFormId(fd, locals.locale, { field: "membershipId" }); + if (!idr.ok) return idr.failure; + const membershipId = idr.id; + + await db + .update(userParts) + .set({ endedAt: new Date() }) + .where(and(eq(userParts.id, membershipId), eq(userParts.userId, params.id), eq(userParts.tenantId, tenant.id))); + + const meta = getRequestMetadata(event); + await recordAuditEvent(db, { + tenantId: tenant.id, + userId: params.id, + actorId: locals.user!.id, + kind: "membership_change", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + detail: { membershipId, action: "remove_part" }, + }); + + return { removedPart: true }; +} diff --git a/src/lib/server/admin/user-actions/profile.ts b/src/lib/server/admin/user-actions/profile.ts new file mode 100644 index 0000000..9b0ba52 --- /dev/null +++ b/src/lib/server/admin/user-actions/profile.ts @@ -0,0 +1,145 @@ +import { fail } from "@sveltejs/kit"; +import type { RequestEvent } from "@sveltejs/kit"; +import { and, eq } from "drizzle-orm"; +import { requireAdminContext, assertNotLastAdmin, assertUserInTenant } from "$lib/server/auth/guards"; +import { revokeAllUserSessions } from "$lib/server/auth/session"; +import { revokeAllUserRefreshTokens } from "$lib/server/oidc/refresh"; +import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; +import { users } from "$lib/server/db/schema"; +import { adminError } from "$lib/server/admin/errors"; + +// 사용자 상세 페이지의 프로필/역할/상태 수정 액션. +type UserActionEvent = RequestEvent<{ id: string }, "/admin/users/[id]">; + +// 프로필 수정 +export async function updateProfile(event: UserActionEvent) { + const { locals, params, request } = event; + const { db, tenant } = requireAdminContext(locals); + const uiLocale = locals.locale; + const fd = await request.formData(); + const userId = params.id; + + const rawRole = String(fd.get("role") ?? "user"); + const rawStatus = String(fd.get("status") ?? "active"); + + if (rawRole !== "admin" && rawRole !== "user") { + return fail(400, { error: adminError(uiLocale, "invalid_role_value") }); + } + if (rawStatus !== "active" && rawStatus !== "disabled" && rawStatus !== "locked") { + return fail(400, { error: adminError(uiLocale, "invalid_status_value") }); + } + + const role = rawRole as "admin" | "user"; + const status = rawStatus as "active" | "disabled" | "locked"; + + // ctrls C-13: cross-tenant IDOR 차단. params.id 가 본 tenant user 인지 명시 검증. + const tenantCheck = await assertUserInTenant(db, tenant.id, userId); + if (!tenantCheck.ok) return tenantCheck.error; + + // 변경 전 role/status 캡처 — 자기-자신 가드, race 가드, role 변경 감지 모두에 사용 + const [before] = await db + .select({ role: users.role, status: users.status }) + .from(users) + .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))) + .limit(1); + + // ctrls C-12: 자기 자신의 role/status 변경은 무조건 차단. + // 값이 현재와 같더라도 폼 안에서 admin 이 자기 권한을 손대는 흐름 자체를 + // 차단해야 race 우회 가능성을 없앤다 (다른 admin 에게 요청해야 함). + // 또한 폼이 전송한 role/status 를 무시하고 DB 현재 값을 그대로 유지한다. + let effectiveRole = role; + // status enum 에 deletion_pending(셀프서비스 탈퇴)이 추가되어 users.status 추론 타입이 넓어졌다. + // 자기-자신 편집 시 DB 현재 값(before.status)을 그대로 유지하는데, 그 값이 deletion_pending 일 + // 수도 있으므로 넓은 유니온으로 선언한다(관리자 폼 입력은 위에서 좁게 검증됨). + let effectiveStatus: "active" | "disabled" | "locked" | "deletion_pending" = status; + if (userId === locals.user!.id) { + if (before && (before.role !== role || before.status !== status)) { + return fail(400, { error: adminError(uiLocale, "cannot_change_own_role_status") }); + } + effectiveRole = before?.role ?? role; + effectiveStatus = before?.status ?? status; + } + + // 마지막 활성 관리자 보호 — admin 강등 또는 active 해제 시 사전 검사 + const isAdminRemoval = effectiveRole === "user" || effectiveStatus !== "active"; + if (isAdminRemoval) { + const lastAdminFail = await assertNotLastAdmin(db, tenant.id, userId); + if (lastAdminFail) return lastAdminFail; + } + + const displayName = String(fd.get("displayName") ?? "").trim() || null; + const givenName = String(fd.get("givenName") ?? "").trim() || null; + const familyName = String(fd.get("familyName") ?? "").trim() || null; + const phoneNumber = String(fd.get("phoneNumber") ?? "").trim() || null; + const bio = String(fd.get("bio") ?? "").trim() || null; + const birthdate = String(fd.get("birthdate") ?? "").trim() || null; + const locale = String(fd.get("locale") ?? "ko-KR").trim(); + const zoneinfo = String(fd.get("zoneinfo") ?? "Asia/Seoul").trim(); + // 주소 (OIDC address 클레임 구성요소). 빈 값은 null 로 저장. + const addressStreet = String(fd.get("addressStreet") ?? "").trim() || null; + const addressLocality = String(fd.get("addressLocality") ?? "").trim() || null; + const addressRegion = String(fd.get("addressRegion") ?? "").trim() || null; + const addressPostalCode = String(fd.get("addressPostalCode") ?? "").trim() || null; + const addressCountry = String(fd.get("addressCountry") ?? "").trim() || null; + + await db + .update(users) + .set({ + displayName, + givenName, + familyName, + phoneNumber, + bio, + birthdate, + locale, + zoneinfo, + addressStreet, + addressLocality, + addressRegion, + addressPostalCode, + addressCountry, + role: effectiveRole, + status: effectiveStatus, + updatedAt: new Date(), + }) + .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))); + + // ctrls C-12: race 가드 — UPDATE 직후 invariant 재확인. + // 두 admin 이 동시에 서로 강등하면 사전 assertNotLastAdmin 가 둘 다 + // 통과해 0 admin 상태가 될 수 있다. UPDATE 직후 활성 admin 카운트를 + // 다시 세고 0 이면 본 UPDATE 의 role/status 만 즉시 되돌린다. + if (isAdminRemoval) { + const remaining = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.tenantId, tenant.id), eq(users.role, "admin"), eq(users.status, "active"))) + .limit(1); + if (remaining.length === 0 && before) { + await db + .update(users) + .set({ role: before.role, status: before.status, updatedAt: new Date() }) + .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))); + return fail(409, { error: adminError(uiLocale, "last_admin_race") }); + } + } + + // role 변경 시 기존 세션 + OIDC refresh token 전부 파기 — 이전 권한 캐시 차단 + if (before && before.role !== effectiveRole) { + await revokeAllUserSessions(db, userId); + await revokeAllUserRefreshTokens(db, userId); + } + + const meta = getRequestMetadata(event); + await recordAuditEvent(db, { + tenantId: tenant.id, + userId, + actorId: locals.user!.id, + kind: "user_profile_updated", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + detail: { role: effectiveRole, status: effectiveStatus }, + }); + + return { updated: true }; +} diff --git a/src/lib/server/admin/user-actions/security.ts b/src/lib/server/admin/user-actions/security.ts new file mode 100644 index 0000000..d274631 --- /dev/null +++ b/src/lib/server/admin/user-actions/security.ts @@ -0,0 +1,41 @@ +import type { RequestEvent } from "@sveltejs/kit"; +import { requireAdminContext, assertUserInTenant } from "$lib/server/auth/guards"; +import { revokeAllUserSessions } from "$lib/server/auth/session"; +import { revokeAllUserRefreshTokens } from "$lib/server/oidc/refresh"; +import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; + +// 사용자 상세 페이지의 보안 관련 액션(강제 로그아웃 등). +type UserActionEvent = RequestEvent<{ id: string }, "/admin/users/[id]">; + +// ── 강제 로그아웃 ────────────────────────────────────────────────────────── +// 상태/비밀번호 변경 없이 대상 유저의 모든 세션 + OIDC refresh token 을 폐기한다. +// revokeAllUserSessions 는 세션만 무효화하고 refresh token 은 별도 store 이므로, +// resetPassword/role 변경 액션과 동일한 폐기 조합(세션 + refresh token)을 사용한다. +export async function forceLogout(event: UserActionEvent) { + const { locals, params, request } = event; + const { db, tenant } = requireAdminContext(locals); + const userId = params.id; + + // ctrls C-13: cross-tenant IDOR 차단 — 대상이 본 테넌트 user 인지 검증. + const tenantCheck = await assertUserInTenant(db, tenant.id, userId); + if (!tenantCheck.ok) return tenantCheck.error; + + // 폼 요청 파싱(사용 값은 없지만 액션 계약 일관성 유지). + await request.formData(); + + await revokeAllUserSessions(db, userId); + await revokeAllUserRefreshTokens(db, userId); + + const meta = getRequestMetadata(event); + await recordAuditEvent(db, { + tenantId: tenant.id, + userId, + actorId: locals.user!.id, + kind: "sessions_revoked", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + }); + + return { forcedLogout: true }; +} diff --git a/src/lib/server/admin/user-actions/service.ts b/src/lib/server/admin/user-actions/service.ts new file mode 100644 index 0000000..68c2264 --- /dev/null +++ b/src/lib/server/admin/user-actions/service.ts @@ -0,0 +1,169 @@ +import { fail } from "@sveltejs/kit"; +import type { RequestEvent } from "@sveltejs/kit"; +import { and, eq } from "drizzle-orm"; +import { requireAdminContext, assertUserInTenant } from "$lib/server/auth/guards"; +import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; +import { oidcClients, samlSps, serviceRoles, userServiceAssignments } from "$lib/server/db/schema"; +import { adminError, requireFormId } from "$lib/server/admin/errors"; + +// 사용자 상세 페이지의 서비스 권한(assignment) 액션. +type UserActionEvent = RequestEvent<{ id: string }, "/admin/users/[id]">; + +// ── 서비스 권한 부여 ────────────────────────────────────────────────────── +export async function addAssignment(event: UserActionEvent) { + const { locals, params, request } = event; + const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; + const fd = await request.formData(); + const userId = params.id; + + // ctrls C-13: 다른 tenant 의 userId 가 본 tenant 의 권한 row 로 박혀 들어가는 + // cross-tenant IDOR 차단. 기존엔 service ref 만 tenant 검증하고 userId 는 + // 검증 없이 INSERT 했음. + const tenantCheck = await assertUserInTenant(db, tenant.id, userId); + if (!tenantCheck.ok) return tenantCheck.error; + + // form 의 service 필드는 "oidc:" 또는 "saml:" 형태. + const serviceRaw = String(fd.get("service") ?? ""); + const colonIdx = serviceRaw.indexOf(":"); + if (colonIdx <= 0) return fail(400, { error: adminError(locale, "select_service") }); + const serviceType = serviceRaw.slice(0, colonIdx); + const serviceRefId = serviceRaw.slice(colonIdx + 1); + if (serviceType !== "oidc" && serviceType !== "saml") return fail(400, { error: adminError(locale, "invalid_service_type") }); + if (!serviceRefId) return fail(400, { error: adminError(locale, "invalid_service_id") }); + + const serviceRoleIdRaw = String(fd.get("serviceRoleId") ?? "").trim(); + const serviceRoleId = serviceRoleIdRaw || null; + const expiresAtRaw = String(fd.get("expiresAt") ?? "").trim(); + const attributesJsonRaw = String(fd.get("attributesJson") ?? "").trim(); + + // service ref 가 우리 테넌트의 활성 서비스인지 검증 + if (serviceType === "oidc") { + const [c] = await db + .select({ id: oidcClients.id }) + .from(oidcClients) + .where(and(eq(oidcClients.id, serviceRefId), eq(oidcClients.tenantId, tenant.id))) + .limit(1); + if (!c) return fail(404, { error: adminError(locale, "oidc_client_not_found") }); + } else { + const [s] = await db + .select({ id: samlSps.id }) + .from(samlSps) + .where(and(eq(samlSps.id, serviceRefId), eq(samlSps.tenantId, tenant.id))) + .limit(1); + if (!s) return fail(404, { error: adminError(locale, "saml_sp_not_found") }); + } + + // role 이 지정됐다면, 같은 service 의 role 인지 검증 + if (serviceRoleId) { + const [r] = await db + .select({ id: serviceRoles.id }) + .from(serviceRoles) + .where(and(eq(serviceRoles.id, serviceRoleId), eq(serviceRoles.tenantId, tenant.id), eq(serviceRoles.serviceType, serviceType), eq(serviceRoles.serviceRefId, serviceRefId))) + .limit(1); + if (!r) return fail(400, { error: adminError(locale, "role_not_in_service") }); + } + + let expiresAt: Date | null = null; + if (expiresAtRaw) { + const d = new Date(expiresAtRaw); + if (Number.isNaN(d.getTime())) return fail(400, { error: adminError(locale, "invalid_expiry_format") }); + expiresAt = d; + } + + let attributesJson: string | null = null; + if (attributesJsonRaw) { + try { + const parsed = JSON.parse(attributesJsonRaw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return fail(400, { error: adminError(locale, "attributes_not_object") }); + } + attributesJson = JSON.stringify(parsed); + } catch { + return fail(400, { error: adminError(locale, "attributes_parse_failed") }); + } + } + + try { + await db.insert(userServiceAssignments).values({ + id: crypto.randomUUID(), + tenantId: tenant.id, + userId, + serviceType, + serviceRefId, + serviceRoleId, + attributesJson, + grantedBy: locals.user!.id, + expiresAt, + }); + } catch { + // unique (tenantId, userId, serviceType, serviceRefId) + return fail(409, { error: adminError(locale, "assignment_exists") }); + } + + const meta = getRequestMetadata(event); + await recordAuditEvent(db, { + tenantId: tenant.id, + userId, + actorId: locals.user!.id, + spOrClientId: serviceRefId, + kind: "service_assignment_granted", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + detail: { serviceType, serviceRefId, serviceRoleId, expiresAt }, + }); + + return { addedAssignment: true }; +} + +export async function revokeAssignment(event: UserActionEvent) { + const { locals, params, request } = event; + const { db, tenant } = requireAdminContext(locals); + const fd = await request.formData(); + const idr = requireFormId(fd, locals.locale, { field: "assignmentId" }); + if (!idr.ok) return idr.failure; + const assignmentId = idr.id; + + // IDOR 가드: 본 페이지 user 의 assignment 만 영향 + await db.delete(userServiceAssignments).where(and(eq(userServiceAssignments.id, assignmentId), eq(userServiceAssignments.userId, params.id), eq(userServiceAssignments.tenantId, tenant.id))); + + const meta = getRequestMetadata(event); + await recordAuditEvent(db, { + tenantId: tenant.id, + userId: params.id, + actorId: locals.user!.id, + kind: "service_assignment_revoked", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + detail: { assignmentId }, + }); + + return { revokedAssignment: true }; +} + +export async function updateAssignmentExpiry(event: UserActionEvent) { + const { locals, params, request } = event; + const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; + const fd = await request.formData(); + const idr = requireFormId(fd, locale, { field: "assignmentId" }); + if (!idr.ok) return idr.failure; + const assignmentId = idr.id; + + const expiresAtRaw = String(fd.get("expiresAt") ?? "").trim(); + let expiresAt: Date | null = null; + if (expiresAtRaw) { + const d = new Date(expiresAtRaw); + if (Number.isNaN(d.getTime())) return fail(400, { error: adminError(locale, "invalid_expiry_format") }); + expiresAt = d; + } + + await db + .update(userServiceAssignments) + .set({ expiresAt }) + .where(and(eq(userServiceAssignments.id, assignmentId), eq(userServiceAssignments.userId, params.id), eq(userServiceAssignments.tenantId, tenant.id))); + + return { updatedExpiry: true }; +} diff --git a/src/lib/server/auth/bootstrap.ts b/src/lib/server/auth/bootstrap.ts index 68811f5..068622d 100644 --- a/src/lib/server/auth/bootstrap.ts +++ b/src/lib/server/auth/bootstrap.ts @@ -1,9 +1,10 @@ +import { dev } from "$app/environment"; import { and, eq } from "drizzle-orm"; import type { DB } from "$lib/server/db"; import { signingKeys, type Tenant, tenants } from "$lib/server/db/schema"; import { DEFAULT_TENANT_SLUG } from "./constants"; -import { getRuntimeConfig } from "./runtime"; -import { generateRsaSigningKey, generateSelfSignedCert, unwrapPrivateKey, wrapPrivateKey } from "$lib/server/crypto/keys"; +import { getRuntimeConfig, type RuntimeConfig } from "./runtime"; +import { generateRsaSigningKey, generateSelfSignedCert, tryWithSecrets, unwrapPrivateKey, wrapPrivateKey } from "$lib/server/crypto/keys"; function isUniqueConstraintError(error: unknown): boolean { return error instanceof Error && /unique constraint failed/i.test(error.message); @@ -38,7 +39,7 @@ export async function ensureDefaultTenant(db: DB, platform: App.Platform | undef return tenant; } -export async function ensureSigningKey(db: DB, tenant: Tenant, signingKeySecret: string, issuerUrl?: string): Promise { +export async function ensureSigningKey(db: DB, tenant: Tenant, signingKeySecrets: string[], issuerUrl?: string): Promise { // SAML KeyDescriptor 용 CN let cn = "idp"; if (issuerUrl) { @@ -58,7 +59,8 @@ export async function ensureSigningKey(db: DB, tenant: Tenant, signingKeySecret: // 키가 있지만 cert_pem 이 없는 경우 (M1 → M2 업그레이드): backfill if (existing) { if (!existing.certPem) { - const privateKey = await unwrapPrivateKey(existing.privateJwkEncrypted, signingKeySecret); + // 무보호 예외 지점: 무중단 회전 창에서 previous 로 래핑된 키도 복호되도록 fallback. + const privateKey = await tryWithSecrets(signingKeySecrets, (s) => unwrapPrivateKey(existing.privateJwkEncrypted, s)); const publicJwk = JSON.parse(existing.publicJwk) as JsonWebKey; const publicKey = await crypto.subtle.importKey("jwk", publicJwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["verify"]); const certPem = await generateSelfSignedCert(publicKey, privateKey, cn); @@ -68,7 +70,8 @@ export async function ensureSigningKey(db: DB, tenant: Tenant, signingKeySecret: } const { kid, publicKey, privateKey, publicJwk } = await generateRsaSigningKey(); - const privateJwkEncrypted = await wrapPrivateKey(privateKey, signingKeySecret); + // 발급/암호화는 항상 current(=secrets[0])만 사용한다. previous fallback 금지. + const privateJwkEncrypted = await wrapPrivateKey(privateKey, signingKeySecrets[0]); const certPem = await generateSelfSignedCert(publicKey, privateKey, cn); await db.insert(signingKeys).values({ @@ -84,6 +87,34 @@ export async function ensureSigningKey(db: DB, tenant: Tenant, signingKeySecret: }); } +/** + * S5 fail-fast: 프로덕션 필수 환경변수 검증. + * + * `IDP_ISSUER_URL` / `IDP_SIGNING_KEY_SECRET` 는 프로덕션에서 반드시 설정되어야 + * 한다. 미설정이면 토큰 발급 시점(ensureSigningKey 조용한 스킵, resolveIssuerUrl + * Host fallback)이 아니라 요청 초기(ensureAuthBaseline) 에 명확한 오류로 실패시켜 + * 오구성을 즉시 드러낸다. + * + * dev 에서는 로컬 DX(변수 없이 구동) 보존을 위해 검증을 건너뛴다. + * + * 검증 결과는 성공 시 1회만 계산되도록 캐시한다(설정은 isolate 수명 내 불변). + * 실패 시 캐시하지 않으므로 요청마다 재검증되어 fail-closed 를 유지한다. + */ +let requiredConfigValidated = false; +function assertRequiredConfig(config: RuntimeConfig): void { + if (dev || requiredConfigValidated) return; + + const missing: string[] = []; + if (!config.issuerUrl) missing.push("IDP_ISSUER_URL"); + if (!config.signingKeySecret) missing.push("IDP_SIGNING_KEY_SECRET"); + + if (missing.length > 0) { + throw new Error(`프로덕션 필수 환경변수가 설정되지 않았습니다: ${missing.join(", ")}. 배포 환경 변수/시크릿을 확인해 주세요.`); + } + + requiredConfigValidated = true; +} + const BASELINE_TTL_MS = 5 * 60 * 1000; // 5분 interface BaselineCache { @@ -102,9 +133,11 @@ export async function ensureAuthBaseline(db: DB, platform: App.Platform | undefi } const config = getRuntimeConfig(platform); + // 프로덕션 필수값 검증 — DB 작업 전에 요청 초기에 fail-fast. + assertRequiredConfig(config); const tenant = await ensureDefaultTenant(db, platform); - if (config.signingKeySecret) { - await ensureSigningKey(db, tenant, config.signingKeySecret, config.issuerUrl); + if (config.signingKeySecrets.length > 0) { + await ensureSigningKey(db, tenant, config.signingKeySecrets, config.issuerUrl); } g.__idpBaselineCache = { tenant, expiresAt: now + BASELINE_TTL_MS }; diff --git a/src/lib/server/auth/email-change.ts b/src/lib/server/auth/email-change.ts new file mode 100644 index 0000000..b4ec290 --- /dev/null +++ b/src/lib/server/auth/email-change.ts @@ -0,0 +1,48 @@ +import { env } from "$env/dynamic/private"; +import { and, eq, isNull } from "drizzle-orm"; +import type { Locale } from "$lib/i18n/core"; +import type { DB } from "$lib/server/db"; +import { emailChangeTokens } from "$lib/server/db/schema"; +import { generateToken, sendEmailChangeVerificationEmail } from "$lib/server/email"; + +export const EMAIL_CHANGE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24시간 + +/** + * F3: 이메일 변경 확인 토큰 발급 + 새 주소로 확인 메일 발송(best-effort, 완전 격리). + * issueEmailVerification 과 동일한 격리 규약을 따른다: + * - issuer(IDP_ISSUER_URL) 미설정 시 발송 스킵(host header injection 방지 목적). + * - Workers 는 waitUntil 로 응답 경로에서 분리, Node 는 await. + * - 토큰 발급/발송 예외는 삼켜서 호출부(변경 요청 응답)에 전파하지 않는다. + * + * 토큰에는 변경 대상 주소(targetEmail)를 바인딩한다 — 확인 라우트가 이 값으로만 email 을 + * 교체하므로, 링크가 다른 주소로 재사용되거나 대기값이 바뀌어도 확인 대상이 어긋나지 않는다. + * 같은 user 의 기존 미사용 변경 토큰은 새 토큰 발급 전에 모두 소진해 최신 요청만 유효하게 한다. + */ +export async function issueEmailChange(db: DB, userId: string, targetEmail: string, locale: Locale, platform: App.Platform | undefined): Promise { + const issuer = env.IDP_ISSUER_URL?.replace(/\.+$/, "").replace(/\/+$/, ""); + if (!issuer) { + console.error("[email-change] IDP_ISSUER_URL 미설정 — 이메일 변경 확인 메일 발송 불가"); + return; + } + try { + // 같은 user 의 미사용 변경 토큰을 모두 소진 처리 → 새 토큰만 유효. + await db + .update(emailChangeTokens) + .set({ usedAt: new Date() }) + .where(and(eq(emailChangeTokens.userId, userId), isNull(emailChangeTokens.usedAt))); + + const { token, tokenHash } = await generateToken(); + const expiresAt = new Date(Date.now() + EMAIL_CHANGE_EXPIRY_MS); + await db.insert(emailChangeTokens).values({ userId, tokenHash, targetEmail, expiresAt }); + + const confirmUrl = `${issuer}/account/confirm-email-change?token=${encodeURIComponent(token)}`; + const sendPromise = sendEmailChangeVerificationEmail(targetEmail, confirmUrl, locale, platform).catch(() => { + // 발송 실패는 조용히 무시 + }); + const wait = platform?.ctx?.waitUntil?.bind(platform.ctx); + if (wait) wait(sendPromise); + else await sendPromise; + } catch { + // 토큰 발급/발송 실패가 상위 흐름을 실패시키지 않도록 격리 + } +} diff --git a/src/lib/server/auth/email-verification.ts b/src/lib/server/auth/email-verification.ts new file mode 100644 index 0000000..b96089c --- /dev/null +++ b/src/lib/server/auth/email-verification.ts @@ -0,0 +1,36 @@ +import { env } from "$env/dynamic/private"; +import type { Locale } from "$lib/i18n/core"; +import type { DB } from "$lib/server/db"; +import { emailVerificationTokens } from "$lib/server/db/schema"; +import { generateToken, sendEmailVerificationEmail } from "$lib/server/email"; + +export const EMAIL_VERIFY_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24시간 + +/** + * F1: 이메일 인증 토큰 발급 + 인증 메일 발송(공통 — 가입/재발송에서 사용). + * 발송 실패가 상위 흐름(가입/재발송 응답)을 실패시키지 않도록 완전 격리한다: + * - issuer(IDP_ISSUER_URL) 미설정 시 발송 스킵(host header injection 방지 목적). + * - Workers 는 waitUntil 로 응답 경로에서 분리, Node 는 await. + * - 토큰 발급/발송 예외는 삼켜서 호출부에 전파하지 않는다. + */ +export async function issueEmailVerification(db: DB, userId: string, email: string, locale: Locale, platform: App.Platform | undefined): Promise { + const issuer = env.IDP_ISSUER_URL?.replace(/\.+$/, "").replace(/\/+$/, ""); + if (!issuer) { + console.error("[email-verification] IDP_ISSUER_URL 미설정 — 이메일 인증 메일 발송 불가"); + return; + } + try { + const { token, tokenHash } = await generateToken(); + const expiresAt = new Date(Date.now() + EMAIL_VERIFY_EXPIRY_MS); + await db.insert(emailVerificationTokens).values({ userId, tokenHash, expiresAt }); + const verifyUrl = `${issuer}/verify-email?token=${encodeURIComponent(token)}`; + const sendPromise = sendEmailVerificationEmail(email, verifyUrl, locale, platform).catch(() => { + // 발송 실패는 조용히 무시 + }); + const wait = platform?.ctx?.waitUntil?.bind(platform.ctx); + if (wait) wait(sendPromise); + else await sendPromise; + } catch { + // 토큰 발급/발송 실패가 상위 흐름을 실패시키지 않도록 격리 + } +} diff --git a/src/lib/server/auth/guards.ts b/src/lib/server/auth/guards.ts index 57014d7..47ea6b0 100644 --- a/src/lib/server/auth/guards.ts +++ b/src/lib/server/auth/guards.ts @@ -1,14 +1,16 @@ import { error, fail } from "@sveltejs/kit"; -import { and, eq, ne } from "drizzle-orm"; +import { and, eq, ne, or, exists, sql } from "drizzle-orm"; import type { DB } from "$lib/server/db"; -import { users } from "$lib/server/db/schema"; +import { users, credentials, identities } from "$lib/server/db/schema"; export function requireDbContext(locals: App.Locals) { if (!locals.db || !locals.tenant) { throw error(503, locals.runtimeError ?? "데이터베이스 연결을 초기화하지 못했습니다. DB 바인딩/DATABASE_URL 및 DB_DIALECT 설정을 확인해 주세요."); } - return { db: locals.db, tenant: locals.tenant }; + // rateLimitStore 는 hooks 에서 db 와 동일 블록에서 세팅되므로 db 가 있으면 반드시 존재한다. + // (레이트 리밋을 쓰지 않는 엔드포인트도 이 컨텍스트를 쓰므로 store 부재로 503 을 내지는 않는다.) + return { db: locals.db, tenant: locals.tenant, rateLimitStore: locals.rateLimitStore! }; } /** @@ -48,10 +50,29 @@ export async function assertNotLastAdmin(db: DB, tenantId: string, userIdToBeCha if (!target) return null; if (target.role !== "admin" || target.status !== "active") return null; + // "다른 활성 admin" 은 실제로 **로그인 가능한** 계정만 센다. status=active 지만 아직 + // credential/identity 가 없는 계정(예: 미수락 초대 admin)은 로그인 자체가 불가능하므로, + // 이런 계정이 last-admin 보호를 완화(다른 관리자 존재로 오인)하지 못하게 한다. + // 로그인 가능 판정: password/passkey 등 credential 이 있거나, 연합(identities) 계정이면 사용 가능. + // (LDAP/OIDC/SAML 은 identities row, password/webauthn 은 credentials row 를 가진다.) + const usableAdmin = or( + exists( + db + .select({ one: sql`1` }) + .from(credentials) + .where(eq(credentials.userId, users.id)), + ), + exists( + db + .select({ one: sql`1` }) + .from(identities) + .where(eq(identities.userId, users.id)), + ), + ); const otherAdmins = await db .select({ id: users.id }) .from(users) - .where(and(eq(users.tenantId, tenantId), eq(users.role, "admin"), eq(users.status, "active"), ne(users.id, userIdToBeChanged))) + .where(and(eq(users.tenantId, tenantId), eq(users.role, "admin"), eq(users.status, "active"), ne(users.id, userIdToBeChanged), usableAdmin)) .limit(1); if (otherAdmins.length === 0) { @@ -75,7 +96,7 @@ export async function assertUserInTenant( db: DB, tenantId: string, userId: string, -): Promise<{ ok: true; user: { id: string; role: "admin" | "user"; status: "active" | "disabled" | "locked" } } | { ok: false; error: ReturnType }> { +): Promise<{ ok: true; user: { id: string; role: "admin" | "user"; status: "active" | "disabled" | "locked" | "deletion_pending" } } | { ok: false; error: ReturnType }> { const [row] = await db .select({ id: users.id, role: users.role, status: users.status, tenantId: users.tenantId }) .from(users) @@ -84,5 +105,5 @@ export async function assertUserInTenant( if (!row) { return { ok: false, error: fail(404, { error: "사용자를 찾을 수 없습니다." }) }; } - return { ok: true, user: { id: row.id, role: row.role as "admin" | "user", status: row.status as "active" | "disabled" | "locked" } }; + return { ok: true, user: { id: row.id, role: row.role as "admin" | "user", status: row.status as "active" | "disabled" | "locked" | "deletion_pending" } }; } diff --git a/src/lib/server/auth/invite.ts b/src/lib/server/auth/invite.ts new file mode 100644 index 0000000..02c7da2 --- /dev/null +++ b/src/lib/server/auth/invite.ts @@ -0,0 +1,36 @@ +import { env } from "$env/dynamic/private"; +import type { Locale } from "$lib/i18n/core"; +import type { DB } from "$lib/server/db"; +import { inviteTokens } from "$lib/server/db/schema"; +import { generateToken, sendInviteEmail } from "$lib/server/email"; + +export const INVITE_EXPIRY_MS = 72 * 60 * 60 * 1000; // 72시간 + +/** + * 초대 토큰 발급 + 초대 메일 발송(admin invite 액션에서 사용). + * email-verification.ts 의 issueEmailVerification 과 동일한 격리 규약을 따른다: + * - issuer(IDP_ISSUER_URL) 미설정 시 발송 스킵(host header injection 방지 목적). + * - Workers 는 waitUntil 로 응답 경로에서 분리, Node 는 await. + * - 토큰 발급/발송 예외는 삼켜서 호출부(계정 생성 응답)에 전파하지 않는다(best-effort). + */ +export async function issueInvite(db: DB, userId: string, email: string, locale: Locale, platform: App.Platform | undefined): Promise { + const issuer = env.IDP_ISSUER_URL?.replace(/\.+$/, "").replace(/\/+$/, ""); + if (!issuer) { + console.error("[invite] IDP_ISSUER_URL 미설정 — 초대 메일 발송 불가"); + return; + } + try { + const { token, tokenHash } = await generateToken(); + const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS); + await db.insert(inviteTokens).values({ userId, tokenHash, expiresAt }); + const inviteUrl = `${issuer}/accept-invite?token=${encodeURIComponent(token)}`; + const sendPromise = sendInviteEmail(email, inviteUrl, locale, platform).catch(() => { + // 발송 실패는 조용히 무시 + }); + const wait = platform?.ctx?.waitUntil?.bind(platform.ctx); + if (wait) wait(sendPromise); + else await sendPromise; + } catch { + // 토큰 발급/발송 실패가 상위 흐름을 실패시키지 않도록 격리 + } +} diff --git a/src/lib/server/auth/runtime.ts b/src/lib/server/auth/runtime.ts index 61b89a8..dfdde20 100644 --- a/src/lib/server/auth/runtime.ts +++ b/src/lib/server/auth/runtime.ts @@ -1,7 +1,21 @@ +import { dev } from "$app/environment"; +import { error } from "@sveltejs/kit"; + export interface RuntimeConfig { defaultTenantName: string; issuerUrl?: string; + /** + * 마스터 서명/암호화 시크릿의 **current** 값(=`signingKeySecrets[0]`). + * 발급/암호화(토큰 서명, private key 래핑, 시크릿·TOTP 암호화, 쿠키·audit 서명)는 + * **반드시 이 값(current)만** 사용한다. 미설정이면 undefined. + */ signingKeySecret?: string; + /** + * 무중단 회전용 시크릿 목록. `[current]` 또는 `[current, previous]`. + * **복호/검증 경로만** 이 배열을 current→previous 순차로 시도한다(`tryWithSecrets`). + * 미설정이면 빈 배열. `IDP_SIGNING_KEY_SECRET_PREVIOUS` 로 previous 를 주입한다. + */ + signingKeySecrets: string[]; /** * stardust dispatcher 가 idp 의 /api/totp/* 를 호출할 때 사용하는 service token. * Authorization: Bearer 헤더로 검증. 단일 fixed token (rotation 은 수동). @@ -23,10 +37,18 @@ export function getRuntimeConfig(platform: App.Platform | undefined): RuntimeCon const nodeEnv = typeof process !== "undefined" ? (process.env as EnvLookup) : undefined; const getString = (key: string): string | undefined => readString(platformEnv, key) ?? readString(nodeEnv, key); + // 무중단 회전: current(=IDP_SIGNING_KEY_SECRET) 를 [0], previous 를 [1] 로 둔다. + // - current 미설정이면 secrets 는 빈 배열(previous 단독으로는 발급/검증하지 않는다). + // - previous 가 current 와 동일하거나 미설정이면 length 1 → 기존과 동일 동작(회귀 0). + const signingKeyCurrent = getString("IDP_SIGNING_KEY_SECRET"); + const signingKeyPrevious = getString("IDP_SIGNING_KEY_SECRET_PREVIOUS"); + const signingKeySecrets = signingKeyCurrent ? (signingKeyPrevious && signingKeyPrevious !== signingKeyCurrent ? [signingKeyCurrent, signingKeyPrevious] : [signingKeyCurrent]) : []; + return { defaultTenantName: getString("IDP_DEFAULT_TENANT_NAME") ?? "Default Tenant", issuerUrl: getString("IDP_ISSUER_URL")?.trim().replace(/\/$/, ""), - signingKeySecret: getString("IDP_SIGNING_KEY_SECRET"), + signingKeySecret: signingKeyCurrent, + signingKeySecrets, dispatcherServiceToken: getString("DISPATCHER_SERVICE_TOKEN"), }; } @@ -34,12 +56,16 @@ export function getRuntimeConfig(platform: App.Platform | undefined): RuntimeCon /** * ctrls C-10 후속 sweep: issuer URL 결정 헬퍼. * - * IDP_ISSUER_URL 환경변수가 설정되어 있으면 그 값을 사용. 미설정 시 들어온 - * 요청의 origin 으로 fallback (dev 환경 호환). production 에서 이 fallback 이 - * 동작하면 Host 헤더 주입으로 iss 클레임이 오염될 수 있어 운영 가시화를 위해 - * console.warn 한 번 남긴다 (cold start 마다 1회). + * IDP_ISSUER_URL 환경변수가 설정되어 있으면 그 값을 사용. + * + * S5 fail-closed: + * - production(`!dev`): 미설정이면 요청 Host 로 fallback 하지 않고 503 오류로 즉시 + * 차단한다. Host 헤더 주입으로 iss 클레임/SAML Issuer 가 오염되는 것을 막는다. + * (부트스트랩 경로에서 요청 초기 검증도 하지만, discovery 등 baseline 을 + * 건너뛰는 라우트를 위한 최종 방어선.) + * - dev: 로컬 DX 보존을 위해 요청 origin fallback + 1회 warn 유지. * - * 본 sweep 의 호스트 주입 영향: + * production 에서 fallback 을 허용했을 때의 호스트 주입 영향: * - discovery/token/userinfo: 발급 토큰의 iss 가 attacker 도메인 — RP 가 * strict iss 검증하면 거부 (대부분의 RP) → self-DoS. RP 가 느슨하면 * attacker 도메인 신뢰로 이어질 수 있음. @@ -47,13 +73,14 @@ export function getRuntimeConfig(platform: App.Platform | undefined): RuntimeCon * verifyIdToken 실패 → self-DoS. * - SAML SLO/logout: SAML 메시지의 Issuer 가 attacker 도메인 — 마찬가지로 * SP 의 strict 검증으로 거부 → self-DoS. - * - * 직접 RCE/탈취는 아니지만 RP 호환성 + 운영 위생 차원에서 정상 발급은 - * issuerUrl 가 명시된 경우에만 발생하도록 한다. */ let warnedMissingIssuer = false; export function resolveIssuerUrl(runtimeConfig: RuntimeConfig | undefined, fallbackOrigin: string): string { if (runtimeConfig?.issuerUrl) return runtimeConfig.issuerUrl; + if (!dev) { + // production fail-closed: Host 주입을 신뢰하지 않는다. + throw error(503, "IDP_ISSUER_URL 이 설정되지 않았습니다. 프로덕션에서는 필수 설정입니다."); + } if (!warnedMissingIssuer) { warnedMissingIssuer = true; console.warn("[runtime] IDP_ISSUER_URL 미설정 — 요청 Host 기반 origin fallback. 운영에서는 반드시 명시할 것."); diff --git a/src/lib/server/auth/session.ts b/src/lib/server/auth/session.ts index 8bbe0cd..0989077 100644 --- a/src/lib/server/auth/session.ts +++ b/src/lib/server/auth/session.ts @@ -1,4 +1,4 @@ -import { and, eq, gt, isNull, ne } from "drizzle-orm"; +import { and, desc, eq, gt, isNull, ne } from "drizzle-orm"; import type { Cookies } from "@sveltejs/kit"; import type { DB } from "$lib/server/db"; import { sessions, users } from "$lib/server/db/schema"; @@ -78,6 +78,57 @@ export async function revokeOtherSessions(db: DB, userId: string, keepSessionId: .where(and(eq(sessions.userId, userId), ne(sessions.id, keepSessionId), isNull(sessions.revokedAt))); } +export interface ActiveSessionInfo { + id: string; + ip: string | null; + userAgent: string | null; + lastSeenAt: Date; + createdAt: Date; +} + +/** + * 사용자의 활성(revokedAt IS NULL·미만료) 세션 목록을 최근 활동 순으로 반환한다. + * 셀프서비스 세션 관리 화면에서 사용한다. + */ +export async function listActiveSessions(db: DB, userId: string): Promise { + const now = new Date(); + return db + .select({ + id: sessions.id, + ip: sessions.ip, + userAgent: sessions.userAgent, + lastSeenAt: sessions.lastSeenAt, + createdAt: sessions.createdAt, + }) + .from(sessions) + .where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt), gt(sessions.expiresAt, now))) + .orderBy(desc(sessions.lastSeenAt)); +} + +/** + * `sessionId` + `userId` 가 **동시에 일치**하는 활성 세션만 폐기한다. + * + * IDOR 방지: userId 조건이 select·update 양쪽에 걸려 있어 다른 사용자의 sessionId 를 + * 넘겨도 어떤 행도 폐기되지 않는다. 이미 폐기된 세션은 건드리지 않는다(멱등). + * + * 반환값: 실제로 한 행을 폐기했으면 `true`, 대상이 없거나(타 사용자/미존재/이미 폐기) `false`. + */ +export async function revokeSessionById(db: DB, sessionId: string, userId: string, revokedAt = new Date()): Promise { + // 방언 독립적으로 "영향 행 존재" 를 판정하기 위해 소유·활성 가드를 건 select 로 먼저 확인한다. + const [target] = await db + .select({ id: sessions.id }) + .from(sessions) + .where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId), isNull(sessions.revokedAt))) + .limit(1); + if (!target) return false; + + await db + .update(sessions) + .set({ revokedAt }) + .where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId), isNull(sessions.revokedAt))); + return true; +} + export async function getSessionContext(db: DB, sessionToken: string) { const now = new Date(); const tokenHash = await hashSessionToken(sessionToken); diff --git a/src/lib/server/auth/users.ts b/src/lib/server/auth/users.ts index 690f243..6061d3d 100644 --- a/src/lib/server/auth/users.ts +++ b/src/lib/server/auth/users.ts @@ -4,6 +4,18 @@ import { credentials, type Credential, type User, users } from "$lib/server/db/s import { PASSWORD_CREDENTIAL_TYPE, TOTP_CREDENTIAL_TYPE } from "./constants"; import { verifyPassword } from "./password"; +// S1(타이밍 계정 열거 차단): username 미존재/비활성/자격증명 부재 경로에서도 실제 scrypt 1회 +// 비용을 발생시켜 존재/미존재 계정의 응답시간을 균등화한다. 아래 상수는 운영 파라미터 +// (scrypt N=2^15, r=8, p=3)와 동일 비용으로 사전 생성한 고정 더미 해시로, 모듈 로드 시 생성하면 +// cold start 비용이 들므로 상수 문자열로 박아 둔다. verifyPassword 는 이 scrypt 레코드에 대해 +// 파생 1회만 수행하고(비교 실패 → rehash 없음) 결과는 폐기된다. +const TIMING_DUMMY_HASH = "scrypt$N=32768,r=8,p=3$laGnY6fbAMkDKdFTKRUGyg==$Jm6an31vv6UDMaa2dn2B2riImIX6qmwMUcc6BWcccg8="; + +async function equalizeAuthTiming(password: string): Promise { + // 결과는 의도적으로 폐기 — 존재/미존재 계정 응답시간 균등화 목적의 더미 검증. + await verifyPassword(password, TIMING_DUMMY_HASH); +} + export function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } @@ -47,12 +59,15 @@ export async function authenticateLocalUser(db: DB, tenantId: string, username: const user = await findUserByUsername(db, tenantId, username); if (!user || user.status !== "active") { + // 존재-오답 경로와 동일하게 scrypt 1회 비용을 태워 타이밍 오라클을 제거. + await equalizeAuthTiming(password); return null; } const credential = await findPasswordCredential(db, user.id); if (!credential?.secret) { + await equalizeAuthTiming(password); return null; } @@ -69,6 +84,44 @@ export async function authenticateLocalUser(db: DB, tenantId: string, username: return user; } +/** + * 탈퇴 신청(soft-delete) 상태 계정의 복구용 재인증. + * + * `authenticateLocalUser` 는 status='active' 만 통과시키므로 deletion_pending 계정은 항상 null 을 + * 반환한다. 로그인 액션에서 그 null 경로 뒤에 이 함수를 호출해 "비밀번호는 맞지만 탈퇴 예정" 인 + * 계정을 식별하고 복구 확인 흐름으로 분기한다. + * + * 타이밍/열거 방어: status 가 deletion_pending 이 아니거나 credential 이 없으면 실제 검증 대신 + * 동일 비용의 더미 scrypt 1회를 태운다. 따라서 이 함수는 어느 경로에서도 정확히 scrypt 1회를 + * 수행하며, authenticateLocalUser 의 null 경로(역시 scrypt 1회)와 합쳐 실패 로그인의 총 비용이 + * 계정 상태와 무관하게 균등해진다(deletion_pending 존재를 타이밍으로 노출하지 않음). + * + * status='deletion_pending' 이고 비밀번호가 일치할 때만 해당 User 를 반환한다. + */ +export async function authenticatePendingDeletionUser(db: DB, tenantId: string, username: string, password: string): Promise { + const user = await findUserByUsername(db, tenantId, username); + + if (!user || user.status !== "deletion_pending") { + await equalizeAuthTiming(password); + return null; + } + + const credential = await findPasswordCredential(db, user.id); + + if (!credential?.secret) { + await equalizeAuthTiming(password); + return null; + } + + const result = await verifyPassword(password, credential.secret); + + if (!result.valid) { + return null; + } + + return user; +} + export async function findActiveUserById(db: DB, userId: string): Promise { const [user] = await db .select() diff --git a/src/lib/server/crypto/keys.ts b/src/lib/server/crypto/keys.ts index 06bc9fd..d6e92f1 100644 --- a/src/lib/server/crypto/keys.ts +++ b/src/lib/server/crypto/keys.ts @@ -32,6 +32,48 @@ export function b64uDecode(str: string): Uint8Array { return arr; } +// ── 무중단 시크릿 회전 헬퍼 ──────────────────────────────────────────────────── + +/** + * 마스터 시크릿 무중단 회전 지원 헬퍼 (Phase 9). + * + * `secrets` 를 순서대로(current=[0] → previous=[1]) 시도하며, `fn` 이 성공하면 그 + * 결과를 반환한다. 전부 실패(throw)하면 **마지막** 에러를 다시 throw 한다. + * + * - **복호/검증 경로 전용**이다. 발급/암호화는 절대 fallback 하지 않고 항상 + * `secrets[0]`(current) 만 사용해야 한다. + * - throw 로 실패를 알리는 함수(`unwrapPrivateKey`, `decryptSecret`, + * `decryptTotpSecret` 등)에 사용한다. null 로 실패를 알리는 검증 함수는 + * `tryWithSecretsNullable` 를 사용한다. + */ +export async function tryWithSecrets(secrets: string[], fn: (secret: string) => Promise): Promise { + if (secrets.length === 0) throw new Error("tryWithSecrets: 시크릿이 설정되지 않았습니다."); + let lastError: unknown; + for (const secret of secrets) { + try { + return await fn(secret); + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +/** + * `tryWithSecrets` 의 null-반환 검증 함수용 변형. + * + * `verifyAccessToken` / `verifyMfaPendingToken` / `verifyChallengeCookie` 처럼 + * 실패 시 throw 대신 `null` 을 반환하는 함수에 사용한다. current→previous 순차로 + * 시도해 **최초의 non-null** 결과를 반환하고, 전부 null 이면 null 을 반환한다. + */ +export async function tryWithSecretsNullable(secrets: string[], fn: (secret: string) => Promise): Promise { + for (const secret of secrets) { + const result = await fn(secret); + if (result !== null) return result; + } + return null; +} + // ── private key wrapping ────────────────────────────────────────────────────── async function deriveWrappingKey(secret: string, salt: Uint8Array): Promise { @@ -281,32 +323,92 @@ export async function decryptSecret(encrypted: string, masterSecret: string, con // ── DB helpers ──────────────────────────────────────────────────────────────── -export async function getActiveSigningKey( - db: DB, - tenantId: string, - secret: string, -): Promise<{ +/** + * 서명키/JWKS 단기 캐시 (task 2-4). + * + * getActiveSigningKey/getPublicJwks 는 모든 토큰 발급·JWKS 요청 경로에서 호출되어 + * 매번 DB 조회를 유발한다. tenant 별 globalThis isolate 캐시로 반복 조회를 절감한다 + * (bootstrap.ts 의 baseline 캐시와 동일 패턴). + * + * 한계(의도된 트레이드오프): + * - 캐시는 isolate 로컬이다. 서명키 회전/비활성화 액션은 같은 isolate 에서 + * invalidateSigningKeyCache 로 즉시 반영되지만, 다른 isolate 는 최대 TTL 만큼 + * 이전 활성 키를 계속 볼 수 있고 TTL 만료로 수렴한다. 회전은 드문 관리 작업이며 + * 짧은 TTL 로 이 수렴 창을 제한한다. + * - null(활성 키 없음)/빈 JWKS 는 캐시하지 않는다 — 최초 부트스트랩으로 키가 막 + * 생성된 직후에도 다음 호출이 곧바로 DB 를 다시 조회해 새 키를 반영하기 위함이다. + * - 캐시된 privateKey(CryptoKey)/publicJwk 는 메모리에만 머무르며 로그로 노출되지 + * 않는다. wrapped private key 원문은 캐시에 담지 않는다(복호화된 CryptoKey 만 보관). + * + * secret 은 isolate 수명 내 불변(env 유래)이므로 캐시 키에서 제외한다 — secret 이 + * 바뀌면 isolate 자체가 재시작되어 캐시가 사라진다. + */ +const SIGNING_KEY_CACHE_TTL_MS = 60 * 1000; // 60초 + +interface ActiveSigningKeyValue { kid: string; privateKey: CryptoKey; publicJwk: JsonWebKey; certPem: string | null; -} | null> { +} + +interface CacheEntry { + value: T; + expiresAt: number; +} + +// tenant 별 캐시 — 멀티테넌트 혼선 방지를 위해 tenantId 를 키로 사용. +const g = globalThis as typeof globalThis & { + __idpActiveSigningKeyCache?: Map>; + __idpPublicJwksCache?: Map>>>; +}; + +function activeSigningKeyCache(): Map> { + return (g.__idpActiveSigningKeyCache ??= new Map()); +} + +function publicJwksCache(): Map>>> { + return (g.__idpPublicJwksCache ??= new Map()); +} + +/** 서명키 회전/비활성화 후 호출 — 해당 tenant 의 서명키·JWKS 캐시를 즉시 무효화한다. */ +export function invalidateSigningKeyCache(tenantId: string): void { + activeSigningKeyCache().delete(tenantId); + publicJwksCache().delete(tenantId); +} + +export async function getActiveSigningKey(db: DB, tenantId: string, secrets: string[]): Promise { + const cache = activeSigningKeyCache(); + const now = Date.now(); + const hit = cache.get(tenantId); + if (hit && hit.expiresAt > now) return hit.value; + const [row] = await db .select() .from(signingKeys) .where(and(eq(signingKeys.tenantId, tenantId), eq(signingKeys.active, true), isNull(signingKeys.rotatedAt))) .limit(1); - if (!row) return null; - const privateKey = await unwrapPrivateKey(row.privateJwkEncrypted, secret); - return { + if (!row) return null; // 활성 키 없음 → 캐시하지 않는다(키 생성 직후 즉시 반영). + // 무중단 회전: 저장된 private key 가 previous 시크릿으로 래핑돼 있을 수 있으므로 + // current→previous 순차로 unwrap 을 시도한다. (서명 자체는 RSA 키로 하므로 발급 + // "current 고정" 원칙과 무관 — 여기선 저장 키 복호에만 fallback 을 적용한다.) + const privateKey = await tryWithSecrets(secrets, (s) => unwrapPrivateKey(row.privateJwkEncrypted, s)); + const value: ActiveSigningKeyValue = { kid: row.kid, privateKey, publicJwk: JSON.parse(row.publicJwk) as JsonWebKey, certPem: row.certPem ?? null, }; + cache.set(tenantId, { value, expiresAt: now + SIGNING_KEY_CACHE_TTL_MS }); + return value; } export async function getPublicJwks(db: DB, tenantId: string): Promise>> { + const cache = publicJwksCache(); + const now = Date.now(); + const hit = cache.get(tenantId); + if (hit && hit.expiresAt > now) return hit.value; + // 회전 또는 비활성 처리된 키는 JWKS 에 노출하지 않는다. const rows = await db .select({ @@ -317,10 +419,12 @@ export async function getPublicJwks(db: DB, tenantId: string): Promise ({ + const value = rows.map((r) => ({ ...(JSON.parse(r.publicJwk) as Record), kid: r.kid, use: r.use, alg: r.alg, })); + if (value.length > 0) cache.set(tenantId, { value, expiresAt: now + SIGNING_KEY_CACHE_TTL_MS }); // 빈 JWKS 는 캐시하지 않는다. + return value; } diff --git a/src/lib/server/db/atomic.ts b/src/lib/server/db/atomic.ts new file mode 100644 index 0000000..0a8d170 --- /dev/null +++ b/src/lib/server/db/atomic.ts @@ -0,0 +1,50 @@ +/** + * 방언 무관 원자적 다중 write 유틸. + * + * drizzle 은 방언마다 "여러 문장을 원자적으로 실행"하는 API 가 다르다: + * - d1 / sqlite(libSQL): interactive transaction 미지원 → `db.batch([...])`. + * 빌더 배열을 받아 하나의 batch(트랜잭션)로 실행하며, 중간 문장이 실패하면 전체 + * rollback 된다(부분 적용 없음). + * - postgres / mysql: interactive transaction → `db.transaction(cb)`. + * 콜백에서 문장을 순차 await 하며, 콜백이 throw 하면 전체 rollback 된다. + * + * `runAtomic` 은 이 차이(배열 vs 콜백)를 흡수한다. 호출부는 executor 를 받아 drizzle + * write 빌더를 반환하는 op 배열만 넘기면 되고, 두 경로 모두 동일한 원자성·순서 보장을 + * 받는다. 반환값은 각 op 의 실행 결과(예: `.returning()` rows, mysql `affectedRows`)를 + * op 순서대로 담은 배열이다 — 결과가 필요 없는 호출부는 무시하면 된다. + */ + +import { type DB, DB_DIALECT } from "$lib/server/db"; + +/** + * op 이 받는 실행자. 전체 `db`(batch 경로) 또는 transaction 핸들(transaction 경로)이 + * 전달되며, 둘 다 write 빌더 진입점을 제공한다. + */ +export type AtomicExecutor = Pick; + +/** + * 원자 단위로 실행할 단일 write. executor 를 받아 drizzle 빌더를 반환한다. + * batch 경로에서는 반환된 빌더가 배열로 수집되고, transaction 경로에서는 순차 await 된다. + */ +export type AtomicOp = (h: AtomicExecutor) => unknown; + +/** + * ops 를 활성 방언에 맞는 원자 단위로 실행한다. 결과 배열은 op 순서와 일치한다. + * 두 경로 모두 중간 실패 시 전체 rollback 되므로 부분 적용이 발생하지 않는다. + */ +export async function runAtomic(db: DB, ops: AtomicOp[]): Promise { + if (DB_DIALECT === "d1" || DB_DIALECT === "sqlite") { + // 정규 DB 타입에 batch 가 노출되지 않는 방언 조합이 있어 캐스팅. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const builders = ops.map((op) => op(db as unknown as AtomicExecutor)) as any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (await (db as any).batch(builders)) as unknown[]; + } + // postgres / mysql: interactive transaction. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (await (db as any).transaction(async (tx: AtomicExecutor) => { + const results: unknown[] = []; + for (const op of ops) results.push(await op(tx)); + return results; + })) as unknown[]; +} diff --git a/src/lib/server/db/gc.ts b/src/lib/server/db/gc.ts new file mode 100644 index 0000000..1721a82 --- /dev/null +++ b/src/lib/server/db/gc.ts @@ -0,0 +1,325 @@ +/** + * 만료 데이터 GC (Garbage Collection) + * + * 만료·소진된 행이 무한히 쌓이는 테이블을 주기적으로 정리한다. 정의만 되어 있고 + * 호출부가 없던 purge 함수 3개(webauthn 챌린지 / refresh token / rate limit)를 + * 통합 호출하고, purge 함수조차 없던 나머지 테이블은 여기서 직접 만료 DELETE 한다. + * + * 설계 원칙 — **미만료·미소진 데이터는 절대 삭제하지 않는다.** + * - 각 테이블의 만료 판정 컬럼/시맨틱을 스키마·사용처 코드에서 확인해 보수적으로 삭제. + * - 테이블별 에러 격리: 하나가 실패해도 나머지는 계속 진행한다. + * - 삭제 건수는 best-effort 로 로깅한다(방언별 결과 형태가 달라 미상일 수 있음). + * + * 실행 경로(두 런타임): + * - Cloudflare Workers: adapter-cloudflare 는 커스텀 worker 엔트리 없이는 `scheduled()` + * 핸들러(Cron Trigger)를 노출할 수 없다(생성된 `_worker.js` 는 fetch 만 export). 빌드 + * 구조를 바꾸는 것은 과침습이므로, 요청의 ~1% 에서 `ctx.waitUntil` 로 GC 를 백그라운드 + * 발사한다(응답 지연 0). → maybeRunWorkersGc() + * - Node(adapter-node): 프로세스가 장수하므로 setInterval(1시간, unref) 로 주기 실행하고 + * globalThis 플래그로 중복 기동을 막는다. → ensureNodeGcScheduler() + * + * GC 실패는 요청 처리에 절대 영향을 주지 않는다(전부 try/catch + waitUntil 격리). + */ + +import { and, eq, lt, or, isNotNull, inArray } from "drizzle-orm"; +import { getDb, DB_DIALECT, type DB } from "./index"; +import { sessions, oidcGrants, passwordResetTokens, emailVerificationTokens, emailChangeTokens, samlSloStates, samlAuthnRequestIds, samlSessions, users, inviteTokens } from "./schema"; +import { purgeExpiredChallenges } from "$lib/server/auth/webauthn"; +import { purgeExpiredRefreshTokens, REFRESH_TOKEN_TTL_MS } from "$lib/server/oidc/refresh"; +import { purgeExpiredRateLimits } from "$lib/server/ratelimit"; +import { SESSION_TTL_MS } from "$lib/server/auth/constants"; + +// ── 유예(grace) 상수 ───────────────────────────────────────────────────────────── + +/** + * sessions 삭제 유예 = refresh token TTL(30일). 단, 이 값은 안전성의 **근거가 아니라 + * 보수적 버퍼**다 — 실제 안전성은 아래 revoke-on-logout 불변식이 보장한다. + * + * 문제 표면: 세션 row 가 삭제되면 refresh token/grant 의 sessionId 가 FK + * `onDelete: set null` 로 NULL 이 되고, 토큰 엔드포인트는 sessionId 가 NULL 이면 연결 + * 세션의 폐기/만료 검사를 건너뛴다. 따라서 "성급히 삭제된 세션의 토큰이 되살아나는" 우회를 + * 막아야 한다. + * + * "토큰 TTL 산술"(토큰 expiresAt ≤ session.expiresAt + 30일)은 이 안전성의 엄밀한 근거가 + * 아니다: 자연 만료(expired)된 세션은 refresh 토큰 회전을 막지 않으므로, 세션이 자연 만료된 + * 채로 남아 있어도 우회 자체가 문제되지 않고, 반대로 산술만으로 우회 부재를 증명할 수도 없다. + * + * 실제 안전 근거(revoke-on-logout 불변식): 우회가 위험한 경우는 **폐기(revoked)** 세션뿐인데, + * 로그아웃/강제 종료 시 세션 폐기와 **동시에** 그 세션에 묶인 refresh token 이 전부 revoke + * 된다(logout 플로우의 revokeSession + revokeRefreshTokensForSession). 그리고 + * rotateRefreshToken 은 `record.revokedAt` 이 설정된 토큰을 sessionId 가 NULL 인지와 + * 무관하게 거부한다. 즉 GC 가 폐기 세션을 일찍 삭제해 sessionId 를 NULL 로 만들어도, 그 토큰은 + * 이미 revoked 이므로 계속 거부된다 — 세션 검사 우회로 되살아나지 않는다. 30일 유예는 이 불변식 + * 위에 얹는 여유일 뿐 정확성 요건은 아니다. + */ +const SESSION_GC_GRACE_MS = REFRESH_TOKEN_TTL_MS; + +/** + * saml_sessions 삭제 유예 = IdP 세션 TTL(12시간). + * + * SLO 체인은 `endedAt IS NULL` 로 활성 SAML 세션을 조회한다(notOnOrAfter 무관). + * 만료(notOnOrAfter 경과)됐거나 로그아웃(endedAt 설정)된 SAML 세션이라도, 부모 IdP + * 세션이 살아있는 동안 진행 중인 SLO 체인에서 참조될 수 있다. 부모 IdP 세션 TTL(12h) + * 만큼 유예를 두면 부모 세션도 확실히 만료된 뒤에만 삭제되어 SLO 일관성을 해치지 않는다. + */ +const SAML_SESSION_GC_GRACE_MS = SESSION_TTL_MS; + +// ── 실행 주기/샘플링 ───────────────────────────────────────────────────────────── + +/** Node setInterval 주기(1시간). */ +const NODE_GC_INTERVAL_MS = 60 * 60 * 1000; + +/** Workers 확률적 GC 발사 비율(요청의 ~1%). */ +const WORKERS_GC_SAMPLE_RATE = 0.01; + +/** + * users 하드삭제 배치 크기. 대량의 deletion_pending 계정이 한 번에 삭제되면 FK cascade + * (credentials/sessions/identities/…)로 인한 광범위한 락이 걸릴 수 있어, id 를 배치 단위로 + * 조회해 나눠 삭제한다. DELETE … LIMIT 는 방언별(sqlite/pg/mysql) 이식성이 없어 "id 조회 → + * IN(...) 삭제" 2단계 루프로 이식성 있게 구현한다. + */ +const USERS_GC_BATCH_SIZE = 100; + +// ── 결과 타입 ──────────────────────────────────────────────────────────────────── + +export interface GcTableResult { + table: string; + /** 삭제 행 수. 방언별 결과 형태로 산출 불가하면 null. */ + deleted: number | null; + ok: boolean; + error?: string; +} + +export interface GcResult { + startedAt: number; + durationMs: number; + tables: GcTableResult[]; +} + +/** + * 방언별 DELETE 결과에서 영향받은 행 수를 best-effort 로 추출한다. + * d1: meta.changes / libsql: rowsAffected / postgres-js: count / mysql2: [header].affectedRows. + * 어느 것도 아니면 null(미상). + */ +function extractAffected(res: unknown): number | null { + if (res == null) return null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const r = res as any; + if (typeof r.rowsAffected === "number") return r.rowsAffected; // libsql + if (r.meta && typeof r.meta.changes === "number") return r.meta.changes; // d1 + if (typeof r.count === "number") return r.count; // postgres-js + if (typeof r.affectedRows === "number") return r.affectedRows; // mysql2 (일부 경로) + if (Array.isArray(r) && r[0] && typeof r[0].affectedRows === "number") return r[0].affectedRows; // mysql2 + return null; +} + +/** + * users 하드삭제를 배치로 수행한다(대량 cascade 락 방지). deletion_pending & 유예 경과 계정의 + * id 를 USERS_GC_BATCH_SIZE 만큼 조회 → IN(...) 삭제하는 루프. 마지막 배치(조회 수 < 배치 크기) + * 이후 종료한다. 방언 이식성을 위해 DELETE … LIMIT 대신 2단계(select→delete)로 구현한다. + * 자체 try/catch 로 에러를 격리하고 결과를 tables 에 push 한다(다른 테이블 GC 에 영향 없음). + */ +async function runUsersBatchDelete(db: DB, now: Date, tables: GcTableResult[]): Promise { + try { + let total = 0; + for (;;) { + const rows = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.status, "deletion_pending"), lt(users.deletionScheduledAt, now))) + .limit(USERS_GC_BATCH_SIZE); + if (rows.length === 0) break; + await db.delete(users).where( + inArray( + users.id, + rows.map((r) => r.id), + ), + ); + total += rows.length; + if (rows.length < USERS_GC_BATCH_SIZE) break; + } + tables.push({ table: "users", deleted: total, ok: true }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[gc] delete users 실패:`, msg); + tables.push({ table: "users", deleted: null, ok: false, error: msg }); + } +} + +// ── 통합 GC ────────────────────────────────────────────────────────────────────── + +/** + * 만료 데이터를 정리한다. 테이블별 에러 격리 + 삭제 건수 로깅. + * GC 는 조회 성능·저장공간을 위한 것이며, 어떤 만료 판정도 **인증 쿼리와 동일하거나 더 + * 보수적인 조건**만 사용한다(미만료·미소진 행 보존이 최우선). + */ +export async function runExpiredDataGc(db: DB): Promise { + const startedAt = Date.now(); + const now = new Date(); + const tables: GcTableResult[] = []; + + // 각 정리 단계를 개별 격리 실행. purge 함수(void 반환)는 건수 미상(null). + const runPurge = async (table: string, fn: () => Promise) => { + try { + await fn(); + tables.push({ table, deleted: null, ok: true }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[gc] purge ${table} 실패:`, msg); + tables.push({ table, deleted: null, ok: false, error: msg }); + } + }; + + const runDelete = async (table: string, exec: () => Promise) => { + try { + const res = await exec(); + const deleted = extractAffected(res); + tables.push({ table, deleted, ok: true }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.error(`[gc] delete ${table} 실패:`, msg); + tables.push({ table, deleted: null, ok: false, error: msg }); + } + }; + + // 1) refresh token 을 먼저 purge — 만료 토큰을 제거해 아래 sessions 삭제 시 FK set-null + // 대상(살아있는 토큰)이 없음을 확실히 한다. + await runPurge("oidc_refresh_tokens", () => purgeExpiredRefreshTokens(db)); + // 2) 기존 purge 함수들 (import 호출) + await runPurge("webauthn_challenges", () => purgeExpiredChallenges(db)); + await runPurge("rate_limits", () => purgeExpiredRateLimits(db)); + + // 3) 직접 만료 DELETE ───────────────────────────────────────────────────────── + const sessionCutoff = new Date(now.getTime() - SESSION_GC_GRACE_MS); + const samlSessionCutoff = new Date(now.getTime() - SAML_SESSION_GC_GRACE_MS); + + // oidc_grants: authorization code(수 분 TTL). expiresAt 경과 시 삭제(미사용·소진 무관 — + // 소진(usedAt) 여부와 상관없이 만료된 grant 는 재사용 불가하며 5분 창을 넘기면 무의미). + await runDelete("oidc_grants", () => db.delete(oidcGrants).where(lt(oidcGrants.expiresAt, now))); + + // password_reset_tokens: expiresAt 경과 시 삭제. 사용됨(usedAt)·미사용 모두 만료 후엔 무효. + await runDelete("password_reset_tokens", () => db.delete(passwordResetTokens).where(lt(passwordResetTokens.expiresAt, now))); + + // email_verification_tokens: expiresAt 경과 시 삭제. 사용됨·미사용 모두 만료 후엔 무효. + await runDelete("email_verification_tokens", () => db.delete(emailVerificationTokens).where(lt(emailVerificationTokens.expiresAt, now))); + + // email_change_tokens: 만료(expiresAt 경과) 또는 소진(usedAt 설정)된 이메일 변경 토큰 삭제. + // invite_tokens 와 동일 시맨틱 — 소진분도 재사용 불가하므로 함께 정리(무한 성장 차단). + // 미만료·미소진 토큰(유효한 변경 대기)은 두 조건 모두 거짓이라 보존된다. + await runDelete("email_change_tokens", () => db.delete(emailChangeTokens).where(or(lt(emailChangeTokens.expiresAt, now), isNotNull(emailChangeTokens.usedAt)))); + + // invite_tokens: 만료(expiresAt 경과) 또는 소진(usedAt 설정)된 초대 토큰 삭제. 다른 토큰 + // 테이블과 동일 시맨틱이되, 소진분도 재사용 불가하므로 함께 정리한다(무한 성장 차단). + // 미만료·미소진 토큰(유효한 초대 대기)은 두 조건 모두 거짓이라 보존된다. + await runDelete("invite_tokens", () => db.delete(inviteTokens).where(or(lt(inviteTokens.expiresAt, now), isNotNull(inviteTokens.usedAt)))); + + // saml_slo_states: SLO 체인 상태. 자체 expiresAt 경과 시 삭제(만료된 체인은 죽은 상태). + await runDelete("saml_slo_states", () => db.delete(samlSloStates).where(lt(samlSloStates.expiresAt, now))); + + // saml_authn_request_ids: AuthnRequest ID replay 캐시. **반드시 expiresAt 이후에만** 삭제 + // (조기 삭제 시 replay 창이 다시 열림). 만료된 요청 ID 는 재생 방어 대상이 아니므로 안전. + await runDelete("saml_authn_request_ids", () => db.delete(samlAuthnRequestIds).where(lt(samlAuthnRequestIds.expiresAt, now))); + + // saml_sessions: notOnOrAfter(SAML 세션 유효창, 8h) 또는 endedAt(로그아웃)이 유예(12h)를 + // 넘겨 지난 경우만 삭제. endedAt IS NULL 인 활성 세션은 notOnOrAfter 분기로만 잡힌다 + // (NULL 비교는 참이 아니므로 활성 세션이 성급히 삭제되지 않는다). + await runDelete("saml_sessions", () => db.delete(samlSessions).where(or(lt(samlSessions.notOnOrAfter, samlSessionCutoff), lt(samlSessions.endedAt, samlSessionCutoff)))); + + // sessions: expiresAt 이 refresh TTL(30일) 유예를 넘겨 지난 경우만 삭제. 위 SESSION_GC_GRACE_MS + // 주석의 근거대로, 이 시점엔 세션에 묶인 모든 refresh token 이 만료(및 purge)돼 있어 + // FK set-null 로 인한 세션 검사 우회가 발생하지 않는다. + await runDelete("sessions", () => db.delete(sessions).where(lt(sessions.expiresAt, sessionCutoff))); + + // users: 셀프서비스 탈퇴(soft-delete)로 status='deletion_pending' + deletionScheduledAt(유예 만료 + // 30일) 이 지난 계정만 하드 삭제한다. FK onDelete:cascade 가 자식 행(credentials/sessions/…)을 + // 정리하고, audit_events.userId 는 onDelete:set null 로 감사 로그를 보존한다. + // **보수적 조건**: (a) status 가 정확히 deletion_pending 이고 (b) deletionScheduledAt < now 인 + // 행만 대상이다. deletionScheduledAt 이 NULL 인 활성/일반 계정은 `<` 비교에서 참이 되지 않아 + // 절대 매칭되지 않으며(활성 계정 오삭제 불가), 유예 미경과 계정도 삭제되지 않는다. + // 대량 cascade 락을 피하려고 배치(USERS_GC_BATCH_SIZE)로 id 를 조회해 나눠 삭제한다. + await runUsersBatchDelete(db, now, tables); + + const result: GcResult = { startedAt, durationMs: Date.now() - startedAt, tables }; + + const totalDeleted = tables.reduce((sum, t) => sum + (t.deleted ?? 0), 0); + const failed = tables.filter((t) => !t.ok).map((t) => t.table); + // 구조화(JSON) 로깅 — 로그 수집기에서 파싱 가능한 단일 라인으로 GC 결과를 남긴다. + // 과설계(전면 로거/메트릭 파이프라인)는 범위 밖 — 결과 요약 + 테이블별 상세만 직렬화한다. + // deleted 는 방언별로 산출 불가할 수 있어 null 이면 "+" 의미(최소 건수)로 totalDeleted 에 0 처리. + console.log( + JSON.stringify({ + event: "gc_complete", + startedAt: new Date(result.startedAt).toISOString(), + durationMs: result.durationMs, + totalDeleted, + ok: failed.length === 0, + failedTables: failed, + tables: result.tables, + }), + ); + + return result; +} + +// ── 실행 경로: Workers(확률적) ──────────────────────────────────────────────────── + +/** + * Cloudflare Workers 요청 훅에서 호출. 확률적으로(~1%) GC 를 백그라운드 발사한다. + * - `platform.ctx.waitUntil` 이 없으면(=Workers 아님) no-op. + * - 요청 응답을 지연시키지 않는다(waitUntil 백그라운드). + * - 요청 DB 연결의 생명주기(dispose)와 얽히지 않도록 GC 전용 연결을 새로 열고 닫는다. + * - GC 실패는 요청 처리에 영향 없음(내부에서 전부 catch). + */ +export function maybeRunWorkersGc(platform: App.Platform | undefined): void { + const waitUntil = platform?.ctx?.waitUntil?.bind(platform.ctx); + if (!waitUntil) return; // Workers 아님 + if (Math.random() >= WORKERS_GC_SAMPLE_RATE) return; + + waitUntil( + (async () => { + let dispose: (() => Promise) | undefined; + try { + const handle = await getDb(platform); + dispose = handle.dispose; + await runExpiredDataGc(handle.db); + } catch (error) { + console.error("[gc] Workers GC 실행 실패:", error); + } finally { + if (dispose) await dispose().catch((e) => console.error("[gc] GC 연결 정리 실패:", e)); + } + })(), + ); +} + +// ── 실행 경로: Node(setInterval) ────────────────────────────────────────────────── + +declare global { + var __keystoneGcTimer: ReturnType | undefined; +} + +/** + * Node(adapter-node) 서버에서 호출. 최초 1회만 1시간 간격 GC 타이머를 건다. + * - globalThis 플래그로 중복 기동을 막는다(HMR/다중 import 안전). + * - `unref()` 로 이 타이머가 프로세스 종료를 막지 않게 한다. + * - DB_DIALECT="d1" 은 Workers 전용이라 Node 경로에선 스케줄하지 않는다. + * - 매 tick 마다 getDb(undefined)(Node 전역 재사용 연결)로 GC 를 돌린다. + */ +export function ensureNodeGcScheduler(): void { + if (globalThis.__keystoneGcTimer) return; + if (DB_DIALECT === "d1") return; // d1 은 Workers 전용 — Node 스케줄 불가 + + const timer = setInterval(() => { + void (async () => { + try { + const { db } = await getDb(undefined); + await runExpiredDataGc(db); + } catch (error) { + console.error("[gc] Node GC 실행 실패:", error); + } + })(); + }, NODE_GC_INTERVAL_MS); + + // 프로세스 종료를 막지 않도록 unref (테스트/CLI 환경 안전). + timer.unref?.(); + globalThis.__keystoneGcTimer = timer; +} diff --git a/src/lib/server/db/schema.mysql.ts b/src/lib/server/db/schema.mysql.ts index 5d0c507..50b86d1 100644 --- a/src/lib/server/db/schema.mysql.ts +++ b/src/lib/server/db/schema.mysql.ts @@ -38,13 +38,21 @@ export const users = mysqlTable( username: varchar("username", { length: 255 }), email: varchar("email", { length: 320 }).notNull(), emailVerifiedAt: datetime("email_verified_at", { mode: "date", fsp: 3 }), + // F3: 이메일 변경 대기 상태. 새 주소 확인(email_change_tokens) 전까지 여기 보관하고, + // 확인 완료 시 email 로 승격 후 NULL 로 클리어한다. requestedAt 은 대기 시작 시각. + // (email 과 동일 계열 타입 유지를 위해 varchar(320) — parity 는 string 계열로 정규화됨.) + pendingEmail: varchar("pending_email", { length: 320 }), + pendingEmailRequestedAt: datetime("pending_email_requested_at", { mode: "date", fsp: 3 }), displayName: text("display_name"), role: varchar("role", { length: 64, enum: ["admin", "user"] }) .notNull() .default("user"), - status: varchar("status", { length: 64, enum: ["active", "disabled", "locked"] }) + status: varchar("status", { length: 64, enum: ["active", "disabled", "locked", "deletion_pending"] }) .notNull() .default("active"), + // 셀프서비스 계정 삭제(소프트 삭제) 예정 시각. status='deletion_pending' 일 때만 값이 있으며, + // 이 시각이 지나면 GC 가 하드 삭제한다. 복구(로그인) 시 status='active' 환원 + 이 값 NULL. + deletionScheduledAt: datetime("deletion_scheduled_at", { mode: "date", fsp: 3 }), // 프로필 givenName: text("given_name"), familyName: text("family_name"), @@ -69,7 +77,15 @@ export const users = mysqlTable( .notNull() .default(sql`(CURRENT_TIMESTAMP(3))`), }, - (t) => [uniqueIndex("users_tenant_email_uidx").on(t.tenantId, t.email), uniqueIndex("users_tenant_username_uidx").on(t.tenantId, t.username), index("users_tenant_idx").on(t.tenantId)], + (t) => [ + uniqueIndex("users_tenant_email_uidx").on(t.tenantId, t.email), + uniqueIndex("users_tenant_username_uidx").on(t.tenantId, t.username), + index("users_tenant_idx").on(t.tenantId), + // GC(하드삭제) 조회 지원. MySQL 은 부분(WHERE) 인덱스를 지원하지 않으므로 sqlite/pg 의 + // 부분 인덱스(users_deletion_pending_idx) 대신 (status, deletionScheduledAt) 복합 인덱스로 + // 동일 조회(status='deletion_pending' & deletionScheduledAt < now)를 지원한다. parity 예외 등재. + index("users_deletion_gc_idx").on(t.status, t.deletionScheduledAt), + ], ); /** @@ -248,6 +264,9 @@ export const oidcClients = mysqlTable( idTokenSignedResponseAlg: text("id_token_signed_response_alg").notNull().default("RS256"), jwksUri: text("jwks_uri"), jwks: text("jwks"), + // organization scope 클레임의 클라이언트별 노출 토글(JSON). null=미설정=전량 노출(하위호환). + // 예: {"department":true,"team":true,"position":false,"jobTitle":true} + organizationClaimConfig: text("organization_claim_config"), enabled: boolean("enabled").notNull().default(true), createdAt: datetime("created_at", { mode: "date", fsp: 3 }) .notNull() @@ -273,10 +292,8 @@ export const oidcGrants = mysqlTable( .notNull() .references(() => users.id, { onDelete: "cascade" }), sessionId: varchar("session_id", { length: 64 }).references(() => sessions.id, { onDelete: "set null" }), - // ctrls C-6: authorization code 평문 저장 제거. - // 신규 grant 는 codeHash (SHA-256) 만 저장. code 평문 컬럼은 legacy 호환 - // 위해 nullable 로 유지 — 다음 PR 에서 컬럼 자체 drop. - code: varchar("code", { length: 255 }), + // ctrls C-6: authorization code 평문 저장 제거. 신규/기존 grant 모두 codeHash + // (SHA-256) 만 저장한다. (legacy code 평문 컬럼은 본 PR 에서 drop 완료.) codeHash: varchar("code_hash", { length: 255 }), codeChallenge: text("code_challenge"), codeChallengeMethod: varchar("code_challenge_method", { length: 64, enum: ["S256", "plain"] }), @@ -292,9 +309,7 @@ export const oidcGrants = mysqlTable( .default(sql`(CURRENT_TIMESTAMP(3))`), }, (t) => [ - // 기존 code 평문 unique 는 유지 (legacy grants 가 남아 있는 동안). NULL 다중 허용. - uniqueIndex("oidc_grants_code_uidx").on(t.code), - // codeHash unique — 신규 grant 의 1회용 invariant. NULL 다중 허용 (legacy row). + // codeHash unique — grant 의 1회용 invariant. NULL 다중 허용 (legacy row). uniqueIndex("oidc_grants_code_hash_uidx").on(t.codeHash), index("oidc_grants_tenant_client_idx").on(t.tenantId, t.clientId), index("oidc_grants_expires_idx").on(t.expiresAt), @@ -892,6 +907,82 @@ export const passwordResetTokens = mysqlTable( export type PasswordResetToken = typeof passwordResetTokens.$inferSelect; +// ---------- Email Verification ---------- +// password_reset_tokens 와 동일 패턴(SHA-256 해시 저장, TTL, 1회용). TTL 24시간. + +export const emailVerificationTokens = mysqlTable( + "email_verification_tokens", + { + id: varchar("id", { length: 64 }) + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: varchar("user_id", { length: 64 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: varchar("token_hash", { length: 255 }).notNull(), + expiresAt: datetime("expires_at", { mode: "date", fsp: 3 }).notNull(), + usedAt: datetime("used_at", { mode: "date", fsp: 3 }), + createdAt: datetime("created_at", { mode: "date", fsp: 3 }) + .notNull() + .default(sql`(CURRENT_TIMESTAMP(3))`), + }, + (t) => [index("email_verification_tokens_user_idx").on(t.userId), uniqueIndex("email_verification_tokens_hash_uidx").on(t.tokenHash)], +); + +export type EmailVerificationToken = typeof emailVerificationTokens.$inferSelect; + +// ---------- Invite ---------- +// email_verification_tokens 와 동일 패턴(SHA-256 해시 저장, TTL, 1회용). TTL 72시간. +// 초대는 관리자가 비밀번호 없이 계정을 선생성하고, 이 토큰 링크로 최초 비밀번호를 설정한다. + +export const inviteTokens = mysqlTable( + "invite_tokens", + { + id: varchar("id", { length: 64 }) + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: varchar("user_id", { length: 64 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: varchar("token_hash", { length: 255 }).notNull(), + expiresAt: datetime("expires_at", { mode: "date", fsp: 3 }).notNull(), + usedAt: datetime("used_at", { mode: "date", fsp: 3 }), + createdAt: datetime("created_at", { mode: "date", fsp: 3 }) + .notNull() + .default(sql`(CURRENT_TIMESTAMP(3))`), + }, + (t) => [index("invite_tokens_user_idx").on(t.userId), uniqueIndex("invite_tokens_hash_uidx").on(t.tokenHash)], +); + +export type InviteToken = typeof inviteTokens.$inferSelect; + +// ---------- Email change ---------- +// F3: 프로필 이메일 변경 확인 토큰. email_verification_tokens 와 분리한다 — 변경 대상 주소 +// (targetEmail)를 토큰에 바인딩해야 하고(확인 링크가 다른 주소로 재사용되지 않도록), 확인 +// 라우트/시맨틱도 다르기 때문이다. SHA-256 해시 저장, TTL 24시간, 1회용(usedAt). +export const emailChangeTokens = mysqlTable( + "email_change_tokens", + { + id: varchar("id", { length: 64 }) + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: varchar("user_id", { length: 64 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: varchar("token_hash", { length: 255 }).notNull(), + // 변경하려는 새 이메일 주소(토큰에 바인딩). 확인 시 이 값으로 users.email 을 교체한다. + targetEmail: varchar("target_email", { length: 320 }).notNull(), + expiresAt: datetime("expires_at", { mode: "date", fsp: 3 }).notNull(), + usedAt: datetime("used_at", { mode: "date", fsp: 3 }), + createdAt: datetime("created_at", { mode: "date", fsp: 3 }) + .notNull() + .default(sql`(CURRENT_TIMESTAMP(3))`), + }, + (t) => [index("email_change_tokens_user_idx").on(t.userId), uniqueIndex("email_change_tokens_hash_uidx").on(t.tokenHash)], +); + +export type EmailChangeToken = typeof emailChangeTokens.$inferSelect; + export type User = typeof users.$inferSelect; export type Credential = typeof credentials.$inferSelect; export type Identity = typeof identities.$inferSelect; diff --git a/src/lib/server/db/schema.pg.ts b/src/lib/server/db/schema.pg.ts index 0bdd96c..6852313 100644 --- a/src/lib/server/db/schema.pg.ts +++ b/src/lib/server/db/schema.pg.ts @@ -34,13 +34,20 @@ export const users = pgTable( username: text("username"), email: text("email").notNull(), emailVerifiedAt: timestamp("email_verified_at", { mode: "date", withTimezone: true, precision: 3 }), + // F3: 이메일 변경 대기 상태. 새 주소 확인(email_change_tokens) 전까지 여기 보관하고, + // 확인 완료 시 email 로 승격 후 NULL 로 클리어한다. requestedAt 은 대기 시작 시각. + pendingEmail: text("pending_email"), + pendingEmailRequestedAt: timestamp("pending_email_requested_at", { mode: "date", withTimezone: true, precision: 3 }), displayName: text("display_name"), role: text("role", { enum: ["admin", "user"] }) .notNull() .default("user"), - status: text("status", { enum: ["active", "disabled", "locked"] }) + status: text("status", { enum: ["active", "disabled", "locked", "deletion_pending"] }) .notNull() .default("active"), + // 셀프서비스 계정 삭제(소프트 삭제) 예정 시각. status='deletion_pending' 일 때만 값이 있으며, + // 이 시각이 지나면 GC 가 하드 삭제한다. 복구(로그인) 시 status='active' 환원 + 이 값 NULL. + deletionScheduledAt: timestamp("deletion_scheduled_at", { mode: "date", withTimezone: true, precision: 3 }), // 프로필 givenName: text("given_name"), familyName: text("family_name"), @@ -60,7 +67,17 @@ export const users = pgTable( createdAt: timestamp("created_at", { mode: "date", withTimezone: true, precision: 3 }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true, precision: 3 }).notNull().defaultNow(), }, - (t) => [uniqueIndex("users_tenant_email_uidx").on(t.tenantId, t.email), uniqueIndex("users_tenant_username_uidx").on(t.tenantId, t.username), index("users_tenant_idx").on(t.tenantId)], + (t) => [ + uniqueIndex("users_tenant_email_uidx").on(t.tenantId, t.email), + uniqueIndex("users_tenant_username_uidx").on(t.tenantId, t.username), + index("users_tenant_idx").on(t.tenantId), + // GC(하드삭제) 조회 지원 — status='deletion_pending' & deletionScheduledAt 경과분만 스캔한다. + // 부분 인덱스(WHERE status='deletion_pending')로 삭제 예정 계정만 색인해 공간을 아낀다. + // (mysql 은 부분 인덱스 미지원 → users_deletion_gc_idx 복합 인덱스로 대체; parity 예외 등재.) + index("users_deletion_pending_idx") + .on(t.deletionScheduledAt) + .where(sql`${t.status} = 'deletion_pending'`), + ], ); /** @@ -224,6 +241,9 @@ export const oidcClients = pgTable( idTokenSignedResponseAlg: text("id_token_signed_response_alg").notNull().default("RS256"), jwksUri: text("jwks_uri"), jwks: text("jwks"), + // organization scope 클레임의 클라이언트별 노출 토글(JSON). null=미설정=전량 노출(하위호환). + // 예: {"department":true,"team":true,"position":false,"jobTitle":true} + organizationClaimConfig: text("organization_claim_config"), enabled: boolean("enabled").notNull().default(true), createdAt: timestamp("created_at", { mode: "date", withTimezone: true, precision: 3 }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true, precision: 3 }).notNull().defaultNow(), @@ -245,10 +265,8 @@ export const oidcGrants = pgTable( .notNull() .references(() => users.id, { onDelete: "cascade" }), sessionId: text("session_id").references(() => sessions.id, { onDelete: "set null" }), - // ctrls C-6: authorization code 평문 저장 제거. - // 신규 grant 는 codeHash (SHA-256) 만 저장. code 평문 컬럼은 legacy 호환 - // 위해 nullable 로 유지 — 다음 PR 에서 컬럼 자체 drop. - code: text("code"), + // ctrls C-6: authorization code 평문 저장 제거. 신규/기존 grant 모두 codeHash + // (SHA-256) 만 저장한다. (legacy code 평문 컬럼은 본 PR 에서 drop 완료.) codeHash: text("code_hash"), codeChallenge: text("code_challenge"), codeChallengeMethod: text("code_challenge_method", { enum: ["S256", "plain"] }), @@ -262,9 +280,7 @@ export const oidcGrants = pgTable( createdAt: timestamp("created_at", { mode: "date", withTimezone: true, precision: 3 }).notNull().defaultNow(), }, (t) => [ - // 기존 code 평문 unique 는 유지 (legacy grants 가 남아 있는 동안). NULL 다중 허용. - uniqueIndex("oidc_grants_code_uidx").on(t.code), - // codeHash unique — 신규 grant 의 1회용 invariant. NULL 다중 허용 (legacy row). + // codeHash unique — grant 의 1회용 invariant. NULL 다중 허용 (legacy row). uniqueIndex("oidc_grants_code_hash_uidx").on(t.codeHash), index("oidc_grants_tenant_client_idx").on(t.tenantId, t.clientId), index("oidc_grants_expires_idx").on(t.expiresAt), @@ -810,6 +826,76 @@ export const passwordResetTokens = pgTable( export type PasswordResetToken = typeof passwordResetTokens.$inferSelect; +// ---------- Email Verification ---------- +// password_reset_tokens 와 동일 패턴(SHA-256 해시 저장, TTL, 1회용). TTL 24시간. + +export const emailVerificationTokens = pgTable( + "email_verification_tokens", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true, precision: 3 }).notNull(), + usedAt: timestamp("used_at", { mode: "date", withTimezone: true, precision: 3 }), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true, precision: 3 }).notNull().defaultNow(), + }, + (t) => [index("email_verification_tokens_user_idx").on(t.userId), uniqueIndex("email_verification_tokens_hash_uidx").on(t.tokenHash)], +); + +export type EmailVerificationToken = typeof emailVerificationTokens.$inferSelect; + +// ---------- Invite ---------- +// email_verification_tokens 와 동일 패턴(SHA-256 해시 저장, TTL, 1회용). TTL 72시간. +// 초대는 관리자가 비밀번호 없이 계정을 선생성하고, 이 토큰 링크로 최초 비밀번호를 설정한다. + +export const inviteTokens = pgTable( + "invite_tokens", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true, precision: 3 }).notNull(), + usedAt: timestamp("used_at", { mode: "date", withTimezone: true, precision: 3 }), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true, precision: 3 }).notNull().defaultNow(), + }, + (t) => [index("invite_tokens_user_idx").on(t.userId), uniqueIndex("invite_tokens_hash_uidx").on(t.tokenHash)], +); + +export type InviteToken = typeof inviteTokens.$inferSelect; + +// ---------- Email change ---------- +// F3: 프로필 이메일 변경 확인 토큰. email_verification_tokens 와 분리한다 — 변경 대상 주소 +// (targetEmail)를 토큰에 바인딩해야 하고(확인 링크가 다른 주소로 재사용되지 않도록), 확인 +// 라우트/시맨틱도 다르기 때문이다. SHA-256 해시 저장, TTL 24시간, 1회용(usedAt). +export const emailChangeTokens = pgTable( + "email_change_tokens", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + // 변경하려는 새 이메일 주소(토큰에 바인딩). 확인 시 이 값으로 users.email 을 교체한다. + targetEmail: text("target_email").notNull(), + expiresAt: timestamp("expires_at", { mode: "date", withTimezone: true, precision: 3 }).notNull(), + usedAt: timestamp("used_at", { mode: "date", withTimezone: true, precision: 3 }), + createdAt: timestamp("created_at", { mode: "date", withTimezone: true, precision: 3 }).notNull().defaultNow(), + }, + (t) => [index("email_change_tokens_user_idx").on(t.userId), uniqueIndex("email_change_tokens_hash_uidx").on(t.tokenHash)], +); + +export type EmailChangeToken = typeof emailChangeTokens.$inferSelect; + export type User = typeof users.$inferSelect; export type Credential = typeof credentials.$inferSelect; export type Identity = typeof identities.$inferSelect; diff --git a/src/lib/server/db/schema.sqlite.ts b/src/lib/server/db/schema.sqlite.ts index 3a9fc80..752fe5e 100644 --- a/src/lib/server/db/schema.sqlite.ts +++ b/src/lib/server/db/schema.sqlite.ts @@ -38,13 +38,20 @@ export const users = sqliteTable( username: text("username"), email: text("email").notNull(), emailVerifiedAt: integer("email_verified_at", { mode: "timestamp_ms" }), + // F3: 이메일 변경 대기 상태. 새 주소 확인(email_change_tokens) 전까지 여기 보관하고, + // 확인 완료 시 email 로 승격 후 NULL 로 클리어한다. requestedAt 은 대기 시작 시각. + pendingEmail: text("pending_email"), + pendingEmailRequestedAt: integer("pending_email_requested_at", { mode: "timestamp_ms" }), displayName: text("display_name"), role: text("role", { enum: ["admin", "user"] }) .notNull() .default("user"), - status: text("status", { enum: ["active", "disabled", "locked"] }) + status: text("status", { enum: ["active", "disabled", "locked", "deletion_pending"] }) .notNull() .default("active"), + // 셀프서비스 계정 삭제(소프트 삭제) 예정 시각. status='deletion_pending' 일 때만 값이 있으며, + // 이 시각이 지나면 GC 가 하드 삭제한다. 복구(로그인) 시 status='active' 환원 + 이 값 NULL. + deletionScheduledAt: integer("deletion_scheduled_at", { mode: "timestamp_ms" }), // 프로필 givenName: text("given_name"), familyName: text("family_name"), @@ -68,7 +75,17 @@ export const users = sqliteTable( .notNull() .default(sql`(unixepoch() * 1000)`), }, - (t) => [uniqueIndex("users_tenant_email_uidx").on(t.tenantId, t.email), uniqueIndex("users_tenant_username_uidx").on(t.tenantId, t.username), index("users_tenant_idx").on(t.tenantId)], + (t) => [ + uniqueIndex("users_tenant_email_uidx").on(t.tenantId, t.email), + uniqueIndex("users_tenant_username_uidx").on(t.tenantId, t.username), + index("users_tenant_idx").on(t.tenantId), + // GC(하드삭제) 조회 지원 — status='deletion_pending' & deletionScheduledAt 경과분만 스캔한다. + // 부분 인덱스(WHERE status='deletion_pending')로 삭제 예정 계정만 색인해 공간을 아낀다. + // (mysql 은 부분 인덱스 미지원 → users_deletion_gc_idx 복합 인덱스로 대체; parity 예외 등재.) + index("users_deletion_pending_idx") + .on(t.deletionScheduledAt) + .where(sql`status = 'deletion_pending'`), + ], ); /** @@ -244,6 +261,9 @@ export const oidcClients = sqliteTable( idTokenSignedResponseAlg: text("id_token_signed_response_alg").notNull().default("RS256"), jwksUri: text("jwks_uri"), jwks: text("jwks"), + // organization scope 클레임의 클라이언트별 노출 토글(JSON). null=미설정=전량 노출(하위호환). + // 예: {"department":true,"team":true,"position":false,"jobTitle":true} + organizationClaimConfig: text("organization_claim_config"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), createdAt: integer("created_at", { mode: "timestamp_ms" }) .notNull() @@ -269,10 +289,8 @@ export const oidcGrants = sqliteTable( .notNull() .references(() => users.id, { onDelete: "cascade" }), sessionId: text("session_id").references(() => sessions.id, { onDelete: "set null" }), - // ctrls C-6: authorization code 평문 저장 제거. - // 신규 grant 는 codeHash (SHA-256) 만 저장. code 평문 컬럼은 legacy 호환 - // 위해 nullable 로 유지 — 다음 PR 에서 컬럼 자체 drop. - code: text("code"), + // ctrls C-6: authorization code 평문 저장 제거. 신규/기존 grant 모두 codeHash + // (SHA-256) 만 저장한다. (legacy code 평문 컬럼은 본 PR 에서 drop 완료.) codeHash: text("code_hash"), codeChallenge: text("code_challenge"), codeChallengeMethod: text("code_challenge_method", { enum: ["S256", "plain"] }), @@ -288,9 +306,7 @@ export const oidcGrants = sqliteTable( .default(sql`(unixepoch() * 1000)`), }, (t) => [ - // 기존 code 평문 unique 는 유지 (legacy grants 가 남아 있는 동안). NULL 다중 허용. - uniqueIndex("oidc_grants_code_uidx").on(t.code), - // codeHash unique — 신규 grant 의 1회용 invariant. NULL 다중 허용 (legacy row). + // codeHash unique — grant 의 1회용 invariant. NULL 다중 허용 (legacy row). uniqueIndex("oidc_grants_code_hash_uidx").on(t.codeHash), index("oidc_grants_tenant_client_idx").on(t.tenantId, t.clientId), index("oidc_grants_expires_idx").on(t.expiresAt), @@ -860,7 +876,9 @@ export const clientSkins = sqliteTable( fetchSecret: text("fetch_secret"), cacheTtlSeconds: integer("cache_ttl_seconds").notNull().default(3600), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), - createdAt: integer("created_at", { mode: "timestamp" }) + // ms 정밀도(timestamp_ms) 로 통일 — pg(precision 3)/mysql(fsp 3) 및 이 파일의 + // 다른 timestamp 컬럼과 동일 단위. (기존 초 단위 저장분은 마이그레이션에서 ×1000 보정.) + createdAt: integer("created_at", { mode: "timestamp_ms" }) .notNull() .$defaultFn(() => new Date()), }, @@ -890,6 +908,82 @@ export const passwordResetTokens = sqliteTable( export type PasswordResetToken = typeof passwordResetTokens.$inferSelect; +// ---------- Email Verification ---------- +// password_reset_tokens 와 동일 패턴(SHA-256 해시 저장, TTL, 1회용). TTL 24시간. + +export const emailVerificationTokens = sqliteTable( + "email_verification_tokens", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + usedAt: integer("used_at", { mode: "timestamp_ms" }), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .notNull() + .default(sql`(unixepoch() * 1000)`), + }, + (t) => [index("email_verification_tokens_user_idx").on(t.userId), uniqueIndex("email_verification_tokens_hash_uidx").on(t.tokenHash)], +); + +export type EmailVerificationToken = typeof emailVerificationTokens.$inferSelect; + +// ---------- Invite ---------- +// email_verification_tokens 와 동일 패턴(SHA-256 해시 저장, TTL, 1회용). TTL 72시간. +// 초대는 관리자가 비밀번호 없이 계정을 선생성하고, 이 토큰 링크로 최초 비밀번호를 설정한다. + +export const inviteTokens = sqliteTable( + "invite_tokens", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + usedAt: integer("used_at", { mode: "timestamp_ms" }), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .notNull() + .default(sql`(unixepoch() * 1000)`), + }, + (t) => [index("invite_tokens_user_idx").on(t.userId), uniqueIndex("invite_tokens_hash_uidx").on(t.tokenHash)], +); + +export type InviteToken = typeof inviteTokens.$inferSelect; + +// ---------- Email change ---------- +// F3: 프로필 이메일 변경 확인 토큰. email_verification_tokens 와 분리한다 — 변경 대상 주소 +// (targetEmail)를 토큰에 바인딩해야 하고(확인 링크가 다른 주소로 재사용되지 않도록), 확인 +// 라우트/시맨틱도 다르기 때문이다. SHA-256 해시 저장, TTL 24시간, 1회용(usedAt). +export const emailChangeTokens = sqliteTable( + "email_change_tokens", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + // 변경하려는 새 이메일 주소(토큰에 바인딩). 확인 시 이 값으로 users.email 을 교체한다. + targetEmail: text("target_email").notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + usedAt: integer("used_at", { mode: "timestamp_ms" }), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .notNull() + .default(sql`(unixepoch() * 1000)`), + }, + (t) => [index("email_change_tokens_user_idx").on(t.userId), uniqueIndex("email_change_tokens_hash_uidx").on(t.tokenHash)], +); + +export type EmailChangeToken = typeof emailChangeTokens.$inferSelect; + export type User = typeof users.$inferSelect; export type Credential = typeof credentials.$inferSelect; export type Identity = typeof identities.$inferSelect; diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts index e0007ad..ce614de 100644 --- a/src/lib/server/email.ts +++ b/src/lib/server/email.ts @@ -1,4 +1,6 @@ import { env } from "$env/dynamic/private"; +import type { Locale } from "$lib/i18n/core"; +import { translate } from "$lib/i18n/server"; // ── 발송 코어: 런타임 분기 ──────────────────────────────────────────────────── // B6: nodemailer(raw TCP SMTP)는 Cloudflare Workers 런타임에서 동작하지 않는다 @@ -121,43 +123,121 @@ function safeAbsoluteUrl(url: string): string | null { } } -function baseHtml(title: string, body: string): string { +// F1: 트랜잭션 메일 전량 수신자 locale 인지. 제목·본문·버튼·푸터·lang 속성을 모두 +// i18n(translate) 로 렌더한다(보안 알림 메일과 동일한 locale-aware 원칙). i18n 키는 +// `email.*` 네임스페이스(ko/en 대칭). lang 속성은 지원 Locale(ko|en)을 그대로 쓴다. +function baseHtml(locale: Locale, title: string, body: string, footer: string): string { return ` - +

${escapeHtml(title)}

${body} -

본인이 요청하지 않았다면 이 이메일을 무시해 주세요.

+

${escapeHtml(footer)}

`; } -export async function sendFindIdEmail(to: string, username: string, platform?: App.Platform): Promise { +// 버튼(CTA) 링크가 들어가는 본문 조각을 공통 구성한다. safeUrl 은 호출부에서 scheme 검증을 마친 값. +function ctaBody(bodyText: string, safeUrl: string, buttonLabel: string): string { + return `

${escapeHtml(bodyText)}

+

+ ${escapeHtml(buttonLabel)} +

`; +} + +export async function sendFindIdEmail(to: string, username: string, locale: Locale, platform?: App.Platform): Promise { + const footer = translate(locale, "email.footer"); + const intro = translate(locale, "email.find_id.intro"); const html = baseHtml( - "아이디 확인", - `

요청하신 아이디 정보입니다.

+ locale, + translate(locale, "email.find_id.title"), + `

${escapeHtml(intro)}

${escapeHtml(username)}

`, + footer, ); - const text = `요청하신 아이디 정보입니다.\n\n${username}\n\n본인이 요청하지 않았다면 이 이메일을 무시해 주세요.`; - await send(to, "아이디 안내", html, text, platform); + const text = `${intro}\n\n${username}\n\n${footer}`; + await send(to, translate(locale, "email.find_id.subject"), html, text, platform); } -export async function sendPasswordResetEmail(to: string, resetUrl: string, platform?: App.Platform): Promise { +export async function sendPasswordResetEmail(to: string, resetUrl: string, locale: Locale, platform?: App.Platform): Promise { const safeUrl = safeAbsoluteUrl(resetUrl); if (!safeUrl) { // 잘못된 URL 형식이면 메일 발송 자체 거부 — silent skip 으로 user enumeration 차단. console.error("[email] sendPasswordResetEmail: 잘못된 resetUrl scheme — 발송 취소"); return; } - const html = baseHtml( - "비밀번호 재설정", - `

아래 버튼을 클릭하여 비밀번호를 재설정하세요. 링크는 1시간 동안 유효합니다.

-

- 비밀번호 재설정 -

`, - ); - const text = `아래 링크에서 비밀번호를 재설정하세요. 링크는 1시간 동안 유효합니다.\n\n${safeUrl}\n\n본인이 요청하지 않았다면 이 이메일을 무시해 주세요.`; - await send(to, "비밀번호 재설정 안내", html, text, platform); + const footer = translate(locale, "email.footer"); + const body = translate(locale, "email.password_reset.body"); + const html = baseHtml(locale, translate(locale, "email.password_reset.title"), ctaBody(body, safeUrl, translate(locale, "email.password_reset.button")), footer); + const text = `${translate(locale, "email.password_reset.text")}\n\n${safeUrl}\n\n${footer}`; + await send(to, translate(locale, "email.password_reset.subject"), html, text, platform); +} + +export async function sendEmailVerificationEmail(to: string, verifyUrl: string, locale: Locale, platform?: App.Platform): Promise { + const safeUrl = safeAbsoluteUrl(verifyUrl); + if (!safeUrl) { + // 잘못된 URL 형식이면 메일 발송 자체 거부. + console.error("[email] sendEmailVerificationEmail: 잘못된 verifyUrl scheme — 발송 취소"); + return; + } + const footer = translate(locale, "email.footer"); + const body = translate(locale, "email.verify.body"); + const html = baseHtml(locale, translate(locale, "email.verify.title"), ctaBody(body, safeUrl, translate(locale, "email.verify.button")), footer); + const text = `${translate(locale, "email.verify.text")}\n\n${safeUrl}\n\n${footer}`; + await send(to, translate(locale, "email.verify.subject"), html, text, platform); +} + +// F3: 이메일 변경 확인 메일 — 새 주소로 발송하는 확인 링크. verify 와 별개 라우트/문구를 쓴다. +export async function sendEmailChangeVerificationEmail(to: string, confirmUrl: string, locale: Locale, platform?: App.Platform): Promise { + const safeUrl = safeAbsoluteUrl(confirmUrl); + if (!safeUrl) { + console.error("[email] sendEmailChangeVerificationEmail: 잘못된 confirmUrl scheme — 발송 취소"); + return; + } + const footer = translate(locale, "email.footer"); + const body = translate(locale, "email.email_change.body"); + const html = baseHtml(locale, translate(locale, "email.email_change.title"), ctaBody(body, safeUrl, translate(locale, "email.email_change.button")), footer); + const text = `${translate(locale, "email.email_change.text")}\n\n${safeUrl}\n\n${footer}`; + await send(to, translate(locale, "email.email_change.subject"), html, text, platform); +} + +export async function sendInviteEmail(to: string, inviteUrl: string, locale: Locale, platform?: App.Platform): Promise { + const safeUrl = safeAbsoluteUrl(inviteUrl); + if (!safeUrl) { + // 잘못된 URL 형식이면 메일 발송 자체 거부. + console.error("[email] sendInviteEmail: 잘못된 inviteUrl scheme — 발송 취소"); + return; + } + const footer = translate(locale, "email.footer"); + const body = translate(locale, "email.invite.body"); + const html = baseHtml(locale, translate(locale, "email.invite.title"), ctaBody(body, safeUrl, translate(locale, "email.invite.button")), footer); + const text = `${translate(locale, "email.invite.text")}\n\n${safeUrl}\n\n${footer}`; + await send(to, translate(locale, "email.invite.subject"), html, text, platform); +} + +// ── 보안 알림 메일 (best-effort) ───────────────────────────────────────────── +// 문구는 호출부에서 수신자 locale 로 번역해 넘긴다(email.ts 는 i18n 에 결합하지 않는다). +// baseHtml 의 고정 한국어 푸터 대신 전달받은 localized 문구로 본문을 구성한다. +export interface SecurityAlertContent { + subject: string; + heading: string; + body: string; + whenText: string; + footer: string; +} + +export async function sendSecurityAlertEmail(to: string, content: SecurityAlertContent, platform?: App.Platform): Promise { + const html = ` + + +

${escapeHtml(content.heading)}

+

${escapeHtml(content.body)}

+

${escapeHtml(content.whenText)}

+

${escapeHtml(content.footer)}

+ +`; + const text = `${content.heading}\n\n${content.body}\n${content.whenText}\n\n${content.footer}`; + await send(to, content.subject, html, text, platform); } export async function generateToken(): Promise<{ token: string; tokenHash: string }> { diff --git a/src/lib/server/oidc/claims.ts b/src/lib/server/oidc/claims.ts index 3f44531..10b8876 100644 --- a/src/lib/server/oidc/claims.ts +++ b/src/lib/server/oidc/claims.ts @@ -3,6 +3,115 @@ * token(id_token) 과 userinfo 응답이 동일 로직을 공유하도록 이곳에 모은다. */ +import type { UserMembership } from "$lib/server/org/membership"; + +/** `organization` scope 로 노출되는 조직 클레임 묶음. id_token / userinfo 동일. */ +export interface OrganizationClaims { + department: Array<{ + id: string; + name: string; + code: string | null; + is_primary: boolean; + job_title: string | null; + position: { id: string; name: string; code: string | null; level: number } | null; + }>; + team: Array<{ + id: string; + name: string; + code: string | null; + department: string | null; + is_primary: boolean; + job_title: string | null; + }>; + position: string | null; + job_title: string | null; +} + +/** + * 클라이언트별 `organization` 클레임 노출 토글. 각 필드가 `false` 면 해당 최상위 클레임 + * 키를 응답에서 생략한다. 저장은 oidcClients.organizationClaimConfig(JSON text)에 한다. + * **null/미설정이면 전량 노출**(하위호환) — parseOrganizationClaimConfig 가 null 을 반환한다. + */ +export interface OrganizationClaimConfig { + department?: boolean; + team?: boolean; + position?: boolean; + jobTitle?: boolean; +} + +/** organization 클레임 노출 토글의 UI/저장 키 목록(단일 출처). */ +export const ORGANIZATION_CLAIM_FIELDS = ["department", "team", "position", "jobTitle"] as const; + +/** + * oidcClients.organizationClaimConfig(JSON text) 를 파싱한다. 파싱 실패·null·미설정이면 + * null 을 반환해 **전량 노출(하위호환)** 로 폴백한다. 알려진 boolean 필드만 취해 오염을 막는다. + */ +export function parseOrganizationClaimConfig(raw: string | null | undefined): OrganizationClaimConfig | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Record; + if (parsed == null || typeof parsed !== "object") return null; + const config: OrganizationClaimConfig = {}; + for (const field of ORGANIZATION_CLAIM_FIELDS) { + if (typeof parsed[field] === "boolean") config[field] = parsed[field] as boolean; + } + return config; + } catch { + return null; + } +} + +/** + * `organization` scope 조직 클레임(department/team/position/job_title)을 구성한다. + * token 엔드포인트의 id_token 과 userinfo 응답이 **동일 로직**을 공유하도록 공용화한다 + * (과거 token 은 이 매핑이 없어 organization scope 만 요청 시 id_token 에 조직정보가 누락됐다). + * + * `config` 로 클라이언트별 필드 노출을 제어한다: + * - config == null → 네 필드 모두 노출(**하위호환** — 기존 클라이언트는 config 가 없다). + * - config. === false → 해당 최상위 클레임 키를 생략. + * - config. 가 undefined/true → 노출(누락 필드는 안전하게 노출로 취급). + * token 과 userinfo 가 **동일 config** 를 적용해야 id_token=userinfo 정합이 유지된다. + */ +export function buildOrganizationClaims(membership: UserMembership, config?: OrganizationClaimConfig | null): Partial { + const show = (field: keyof OrganizationClaimConfig): boolean => config == null || config[field] !== false; + + const claims: Partial = {}; + if (show("department")) { + claims.department = membership.departments.map((d) => ({ + id: d.id, + name: d.name, + code: d.code, + is_primary: d.isPrimary, + job_title: d.jobTitle, + position: d.position + ? { + id: d.position.id, + name: d.position.name, + code: d.position.code, + level: d.position.level, + } + : null, + })); + } + if (show("team")) { + claims.team = membership.teams.map((t) => ({ + id: t.id, + name: t.name, + code: t.code, + department: t.departmentName, + is_primary: t.isPrimary, + job_title: t.jobTitle, + })); + } + if (show("position")) { + claims.position = membership.primaryPosition?.name ?? null; + } + if (show("jobTitle")) { + claims.job_title = membership.primaryJobTitle ?? null; + } + return claims; +} + /** users 테이블의 주소 컬럼 부분집합. */ export interface AddressClaimSource { addressStreet: string | null; diff --git a/src/lib/server/oidc/grant.ts b/src/lib/server/oidc/grant.ts index ae08503..4d46d99 100644 --- a/src/lib/server/oidc/grant.ts +++ b/src/lib/server/oidc/grant.ts @@ -34,14 +34,13 @@ async function sha256Base64Url(input: string): Promise { export async function createGrant(db: DB, params: CreateGrantParams): Promise { const codeHash = await sha256Base64Url(params.code); - // code (평문) 컬럼은 legacy 호환을 위해 nullable 로 유지하되, 신규 grant 는 - // null 로 저장한다. 다음 PR 에서 컬럼 자체를 drop. + // raw code(평문)는 DB 에 저장하지 않는다 — SHA-256 해시(codeHash)만 보관. + // params.code 는 해시 계산에만 쓰이므로 insert 대상 컬럼에서 제외한다. const { code: _omit, ...rest } = params; void _omit; await db.insert(oidcGrants).values({ id: crypto.randomUUID(), ...rest, - code: null, codeHash, expiresAt: new Date(Date.now() + AUTH_CODE_TTL_MS), }); diff --git a/src/lib/server/oidc/refresh.ts b/src/lib/server/oidc/refresh.ts index 0db1526..9f7cfad 100644 --- a/src/lib/server/oidc/refresh.ts +++ b/src/lib/server/oidc/refresh.ts @@ -13,6 +13,7 @@ import { and, eq, isNull, lt } from "drizzle-orm"; import { type DB, DB_DIALECT } from "$lib/server/db"; +import { runAtomic } from "$lib/server/db/atomic"; import { oidcRefreshTokens } from "$lib/server/db/schema"; export type OidcRefreshTokenRecord = typeof oidcRefreshTokens.$inferSelect; @@ -141,36 +142,53 @@ export async function rotateRefreshToken(db: DB, tenantId: string, clientId: str const newToken = generateRefreshToken(); const newHash = await hashRefreshToken(newToken); - // old 토큰을 원자적으로 claim (revokedAt IS NULL 가드). 동시 사용은 하나만 성공. + // old 토큰 claim(revokedAt IS NULL 가드) 과 new 토큰 insert 를 하나의 원자 단위로 실행한다. + // + // 동시성 시맨틱: claim UPDATE 가 실제로 한 행을 revoke 했는가(비-mysql RETURNING rows, + // mysql affectedRows)가 동시 회전 경쟁의 승자를 결정한다 — revokedAt IS NULL 가드로 + // 동시 요청 중 단 하나만 claim 에 성공한다. (기존 시맨틱 그대로 보존) + // + // 원자성: claim 과 insert 를 같은 batch/transaction 에 묶어 부분 적용을 제거한다. + // insert 가 실패하면 claim 도 함께 rollback 되므로, 기존에 존재하던 "claim 성공 + + // insert 실패 → old-revoked·new-부재(세션 유실)" 창이 사라진다. 이 창이 닫히는 것은 + // batch(d1/sqlite)·transaction(pg/mysql) 모두 중간 실패 시 전체 rollback 하기 때문이다. + // + // 경쟁 패자 처리: batch 는 앞 문장 결과로 뒤 문장을 조건 분기할 수 없으므로 insert 는 + // 항상 실행된다. 패자(claim 0행)의 요청도 new 토큰을 삽입하지만, 이 raw 값은 호출자에게 + // 반환되지 않고(never handed out) 곧바로 아래 family 폐기로 승자 토큰과 함께 무효화된다 + // — 재사용 감지 시맨틱(RFC 6819 §5.2.2.3)과 결과가 동일하다. const claimWhere = and(eq(oidcRefreshTokens.id, record.id), isNull(oidcRefreshTokens.revokedAt)); - let claimed: boolean; - if (DB_DIALECT === "mysql") { - const res = (await db.update(oidcRefreshTokens).set({ revokedAt: now, replacedById: newId }).where(claimWhere)) as unknown as [{ affectedRows: number }]; - claimed = Boolean(res?.[0]?.affectedRows); - } else { + const buildClaim = (h: Pick) => { + const builder = h.update(oidcRefreshTokens).set({ revokedAt: now, replacedById: newId }).where(claimWhere); + // mysql 은 UPDATE ... RETURNING 미지원 → affectedRows 로 승자 판정. 그 외 방언은 RETURNING. // eslint-disable-next-line @typescript-eslint/no-explicit-any - const updateBuilder = db.update(oidcRefreshTokens).set({ revokedAt: now, replacedById: newId }).where(claimWhere) as any; - const rows = (await updateBuilder.returning({ id: oidcRefreshTokens.id })) as Array<{ id: string }>; - claimed = rows.length > 0; - } + return DB_DIALECT === "mysql" ? builder : (builder as any).returning({ id: oidcRefreshTokens.id }); + }; + const buildInsert = (h: Pick) => + h.insert(oidcRefreshTokens).values({ + id: newId, + tenantId, + clientId, + userId: record.userId, + sessionId: record.sessionId, + tokenHash: newHash, + scope: record.scope, + expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS), + }); + + // claim 이 index 0 → 결과 배열 첫 항목이 claim 결과. insert 실패는 여기서 throw 되어 + // (rollback 후) 호출자로 전파된다 — old 토큰은 원복되어 재시도 가능하다. + const [claimResult] = await runAtomic(db, [buildClaim, buildInsert]); + const claimed = + DB_DIALECT === "mysql" ? Boolean((claimResult as [{ affectedRows: number }] | undefined)?.[0]?.affectedRows) : ((claimResult as Array<{ id: string }> | undefined) ?? []).length > 0; if (!claimed) { // 이미 다른 요청이 회전 → 동시 재사용으로 간주하고 family 폐기. + // (위 insert 로 삽입된 new 토큰도 활성 상태이므로 family 폐기에 함께 무효화된다.) await revokeRefreshTokenFamily(db, tenantId, record.userId, clientId); return { ok: false, reason: "reuse" }; } - await db.insert(oidcRefreshTokens).values({ - id: newId, - tenantId, - clientId, - userId: record.userId, - sessionId: record.sessionId, - tokenHash: newHash, - scope: record.scope, - expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS), - }); - return { ok: true, record, newToken }; } diff --git a/src/lib/server/ratelimit/index.ts b/src/lib/server/ratelimit/index.ts index 5072c9b..ef88517 100644 --- a/src/lib/server/ratelimit/index.ts +++ b/src/lib/server/ratelimit/index.ts @@ -1,5 +1,5 @@ /** - * D1 기반 슬라이딩 윈도우 레이트 리밋 (두 버킷 근사법). + * 슬라이딩 윈도우 레이트 리밋 (두 버킷 근사법). * * 윈도우를 고정 인덱스(Math.floor(now/windowMs))로 분할해 두 버킷을 유지: * - 현재 버킷 카운터를 원자적으로 증가 @@ -7,11 +7,18 @@ * count ≈ prev * (1 - elapsed/window) + current * * Fixed Window 대비 경계 burst(최대 2x) 문제를 제거. + * + * 원자증가/조회라는 상태 연산은 RateLimitStore(store.ts) 뒤로 캡슐화했고, 이 파일은 + * 윈도우 감쇠 산식만 담당한다 — 백엔드(DB/in-memory/Redis)와 무관하게 결과가 동일하다. */ -import { eq, sql } from "drizzle-orm"; -import { type DB, DB_DIALECT } from "$lib/server/db"; +import { sql } from "drizzle-orm"; +import type { DB } from "$lib/server/db"; import { rateLimits } from "$lib/server/db/schema"; +import type { RateLimitCounts, RateLimitStore } from "./store"; + +export { DbRateLimitStore, MemoryRateLimitStore, resolveRateLimitStore } from "./store"; +export type { RateLimitCounts, RateLimitStore } from "./store"; export interface RateLimitResult { allowed: boolean; @@ -26,61 +33,47 @@ export interface RateLimitOptions { limit: number; } -export async function checkRateLimit(db: DB, key: string, options: RateLimitOptions): Promise { - const now = Date.now(); +/** + * 두 버킷 카운트에 슬라이딩 윈도우 감쇠 산식을 적용해 허용 여부를 판정한다. + * store.increment / store.peek 어느 쪽에서 얻은 카운트든 동일한 산식을 쓴다. + */ +function evaluate(counts: RateLimitCounts, options: RateLimitOptions, now: number = Date.now()): RateLimitResult { const windowIndex = Math.floor(now / options.windowMs); const windowStart = windowIndex * options.windowMs; const elapsed = now - windowStart; - const currentKey = `${key}:${windowIndex}`; - const prevKey = `${key}:${windowIndex - 1}`; - // 두 윈도우가 지나면 만료 - const currentExpiresAt = new Date(windowStart + options.windowMs * 2); - - // 현재 버킷 원자적 증가. - // d1/postgres 는 upsert + RETURNING 을 지원하지만, MySQL 은 RETURNING 이 없으므로 - // ON DUPLICATE KEY UPDATE 후 재조회로 카운트를 얻는다. - // 방언별 upsert API 차이를 캐스팅으로 흡수한다 (정규 DB 타입은 활성 방언 한 종류만 - // 노출하므로, 비활성 분기의 메서드는 타입에 존재하지 않아 캐스팅이 필요하다). - let currentCount: number; - const insertValues = { key: currentKey, count: 1, expiresAt: currentExpiresAt }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const insertBuilder = db.insert(rateLimits).values(insertValues) as any; - if (DB_DIALECT === "mysql") { - // MySQL: RETURNING 미지원 → ON DUPLICATE KEY UPDATE 후 재조회. - await insertBuilder.onDuplicateKeyUpdate({ set: { count: sql`${rateLimits.count} + 1` } }); - const [row] = await db.select({ count: rateLimits.count }).from(rateLimits).where(eq(rateLimits.key, currentKey)).limit(1); - currentCount = row?.count ?? 1; - } else { - // d1 / postgres: upsert + RETURNING. - const [row] = (await insertBuilder.onConflictDoUpdate({ target: rateLimits.key, set: { count: sql`${rateLimits.count} + 1` } }).returning({ count: rateLimits.count })) as Array<{ - count: number; - }>; - currentCount = row?.count ?? 1; - } - - // 이전 버킷 조회 (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)) + currentCount; + const slidingCount = Math.floor(counts.prev * (1 - elapsed / options.windowMs)) + counts.current; if (slidingCount > options.limit) { - return { - allowed: false, - remaining: 0, - retryAfterMs: options.windowMs - elapsed, - }; + return { allowed: false, remaining: 0, retryAfterMs: options.windowMs - elapsed }; } + return { allowed: true, remaining: Math.max(0, options.limit - slidingCount), retryAfterMs: 0 }; +} - return { - allowed: true, - remaining: Math.max(0, options.limit - slidingCount), - retryAfterMs: 0, - }; +/** + * 현재 버킷을 원자적으로 증가시키고 한도 초과 여부를 판정한다(쓰기 경로). + * 대부분의 호출부는 `if (!allowed) throw 429` 패턴으로 사용한다. + */ +export async function checkRateLimit(store: RateLimitStore, key: string, options: RateLimitOptions): Promise { + // 단일 타임스탬프를 캡처해 버킷 선택(store)과 elapsed 계산(evaluate)에 일관되게 사용한다. + const now = Date.now(); + const counts = await store.increment(key, options.windowMs, now); + return evaluate(counts, options, now); +} + +/** + * 증가 없이 현재 상태만으로 한도 초과 여부를 판정한다(read-only 경로). + * 로그인 계정 잠금처럼 "성공은 미카운트, 실패 시에만 기록" 요건에서, 인증 전에 잠금 여부를 + * 조기 판정할 때 사용한다(선증가 없이 조회). + */ +export async function peekRateLimit(store: RateLimitStore, key: string, options: RateLimitOptions): Promise { + // 단일 타임스탬프를 캡처해 버킷 선택(store)과 elapsed 계산(evaluate)에 일관되게 사용한다. + const now = Date.now(); + const counts = await store.peek(key, options.windowMs, now); + return evaluate(counts, options, now); } -/** 만료된 rate_limit 레코드를 정리 (주기적 호출 또는 훅에서 사용) */ +/** 만료된 rate_limit 레코드를 정리 (주기적 호출 또는 훅에서 사용). DbRateLimitStore(Workers) 전용. */ export async function purgeExpiredRateLimits(db: DB): Promise { await db.delete(rateLimits).where(sql`${rateLimits.expiresAt} <= ${Date.now()}`); } diff --git a/src/lib/server/ratelimit/store.ts b/src/lib/server/ratelimit/store.ts new file mode 100644 index 0000000..3d906b3 --- /dev/null +++ b/src/lib/server/ratelimit/store.ts @@ -0,0 +1,166 @@ +/** + * 레이트 리밋 저장소 추상화. + * + * `checkRateLimit`(두 버킷 슬라이딩 윈도우)의 산식은 그대로 두고, "현재 버킷 원자 증가 + + * 이전 버킷 조회"라는 상태 연산만 이 인터페이스 뒤로 캡슐화한다. 덕분에 백엔드(DB / in-memory / + * 향후 Redis·DO)를 런타임 환경에 맞춰 교체할 수 있다. + * + * 런타임 선택(resolveRateLimitStore): + * - Cloudflare Workers: isolate 는 요청 간 상태를 공유하지 못하므로 공유 저장소가 필요 → + * DB(rate_limits 테이블) 기반 DbRateLimitStore. 4방언 upsert 분기를 여기 캡슐화한다. + * - Node(adapter-node): 프로세스가 장수하므로 프로세스 내 Map 으로 충분 → + * MemoryRateLimitStore(전역 싱글턴). 핫패스에서 DB write 를 없앤다. + */ + +import { eq, sql } from "drizzle-orm"; +import { type DB, DB_DIALECT } from "$lib/server/db"; +import { rateLimits } from "$lib/server/db/schema"; + +/** 슬라이딩 윈도우 산식이 필요로 하는 두 버킷의 카운트. */ +export interface RateLimitCounts { + /** 현재 윈도우 버킷 카운트. */ + current: number; + /** 직전 윈도우 버킷 카운트(감쇠 가중치 적용 대상). */ + prev: number; +} + +/** + * 레이트 리밋 상태 저장소. + * - increment: 현재 버킷을 원자적으로 +1 하고, 증가 후 현재 카운트와 이전 버킷 카운트를 반환. + * - peek: 증가 없이 현재/이전 버킷 카운트만 조회(로그인 계정 잠금 등 read-only 판정용). + * windowMs 로부터 windowIndex(=floor(now/windowMs))를 계산하고 `${key}:${idx}` 키 규약과 + * 버킷 만료(windowStart + windowMs*2)를 각 구현이 내부적으로 관리한다. + */ +export interface RateLimitStore { + increment(key: string, windowMs: number, now?: number): Promise; + peek(key: string, windowMs: number, now?: number): Promise; +} + +/** + * `${key}:${windowIndex}` 규약 + 버킷 만료 시각을 한 번에 계산. + * `now` 를 넘기면 그 타임스탬프로 버킷 인덱스를 고정한다(미전달 시 `Date.now()`). + */ +function bucketKeys(key: string, windowMs: number, now: number = Date.now()): { currentKey: string; prevKey: string; windowStart: number } { + const windowIndex = Math.floor(now / windowMs); + return { + currentKey: `${key}:${windowIndex}`, + prevKey: `${key}:${windowIndex - 1}`, + windowStart: windowIndex * windowMs, + }; +} + +/** + * DB(rate_limits 테이블) 기반 저장소 — Cloudflare Workers 등 공유 상태가 필요한 환경용. + * 방언별 upsert 차이(d1/postgres 는 RETURNING, MySQL 은 ON DUPLICATE KEY + 재조회)를 + * 캡슐화한다. 정규 DB 타입은 활성 방언 하나만 노출하므로 비활성 분기의 메서드는 캐스팅으로 흡수. + */ +export class DbRateLimitStore implements RateLimitStore { + constructor(private readonly db: DB) {} + + async increment(key: string, windowMs: number, now?: number): Promise { + const { currentKey, prevKey, windowStart } = bucketKeys(key, windowMs, now); + // 두 윈도우가 지나면 만료. + const currentExpiresAt = new Date(windowStart + windowMs * 2); + + // 현재 버킷 원자적 증가. + // d1/postgres 는 upsert + RETURNING 을 지원하지만, MySQL 은 RETURNING 이 없으므로 + // ON DUPLICATE KEY UPDATE 후 재조회로 카운트를 얻는다. + let currentCount: number; + const insertValues = { key: currentKey, count: 1, expiresAt: currentExpiresAt }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const insertBuilder = this.db.insert(rateLimits).values(insertValues) as any; + if (DB_DIALECT === "mysql") { + await insertBuilder.onDuplicateKeyUpdate({ set: { count: sql`${rateLimits.count} + 1` } }); + const [row] = await this.db.select({ count: rateLimits.count }).from(rateLimits).where(eq(rateLimits.key, currentKey)).limit(1); + currentCount = row?.count ?? 1; + } else { + const [row] = (await insertBuilder.onConflictDoUpdate({ target: rateLimits.key, set: { count: sql`${rateLimits.count} + 1` } }).returning({ count: rateLimits.count })) as Array<{ + count: number; + }>; + currentCount = row?.count ?? 1; + } + + // 이전 버킷 조회 (best-effort). + const [prevRow] = await this.db.select({ count: rateLimits.count }).from(rateLimits).where(eq(rateLimits.key, prevKey)).limit(1); + return { current: currentCount, prev: prevRow?.count ?? 0 }; + } + + async peek(key: string, windowMs: number, now?: number): Promise { + const { currentKey, prevKey } = bucketKeys(key, windowMs, now); + const [curRow] = await this.db.select({ count: rateLimits.count }).from(rateLimits).where(eq(rateLimits.key, currentKey)).limit(1); + const [prevRow] = await this.db.select({ count: rateLimits.count }).from(rateLimits).where(eq(rateLimits.key, prevKey)).limit(1); + return { current: curRow?.count ?? 0, prev: prevRow?.count ?? 0 }; + } +} + +interface MemoryBucket { + count: number; + /** epoch ms — 이 시각 이후 버킷은 만료로 간주(조회 시 0, 스윕 시 삭제). */ + expiresAt: number; +} + +/** + * 프로세스 내 Map 기반 저장소 — Node(adapter-node) 단일 프로세스 환경용. + * DB write 없이 동일한 슬라이딩 윈도우 결과를 낸다. 만료 버킷은 조회 시 0 으로 취급하고, + * 메모리 무한 증가를 막기 위해 increment 핫패스에서 최대 1분 간격으로 전체 스윕한다. + * + * 한계: 단일 프로세스 가정 — 다중 인스턴스로 수평 확장하면 한도가 인스턴스 수만큼 완화된다 + * (공유 저장소 아님). 그 경우 RateLimitStore 를 구현하는 Redis/DO 백엔드로 교체해야 한다. + */ +export class MemoryRateLimitStore implements RateLimitStore { + private readonly buckets = new Map(); + private lastSweep = 0; + + /** 만료 키 정리 — 핫패스 O(n) 스윕을 최소 1분 간격으로만 수행. */ + private sweep(now: number): void { + if (now - this.lastSweep < 60_000) return; + this.lastSweep = now; + for (const [k, b] of this.buckets) { + if (b.expiresAt <= now) this.buckets.delete(k); + } + } + + /** 만료된 버킷은 0 으로 취급. */ + private read(fullKey: string, now: number): number { + const b = this.buckets.get(fullKey); + return b && b.expiresAt > now ? b.count : 0; + } + + async increment(key: string, windowMs: number, nowArg?: number): Promise { + const now = nowArg ?? Date.now(); + this.sweep(now); + const { currentKey, prevKey, windowStart } = bucketKeys(key, windowMs, now); + const expiresAt = windowStart + windowMs * 2; + const current = this.read(currentKey, now) + 1; + this.buckets.set(currentKey, { count: current, expiresAt }); + return { current, prev: this.read(prevKey, now) }; + } + + async peek(key: string, windowMs: number, nowArg?: number): Promise { + const now = nowArg ?? Date.now(); + const { currentKey, prevKey } = bucketKeys(key, windowMs, now); + return { current: this.read(currentKey, now), prev: this.read(prevKey, now) }; + } +} + +// Node 전역 재사용: 프로세스 내 단일 MemoryRateLimitStore 를 공유한다(HMR/다중 import 안전). +declare global { + var __keystoneRateLimitMemoryStore: MemoryRateLimitStore | undefined; +} + +function getMemoryRateLimitStore(): MemoryRateLimitStore { + if (!globalThis.__keystoneRateLimitMemoryStore) { + globalThis.__keystoneRateLimitMemoryStore = new MemoryRateLimitStore(); + } + return globalThis.__keystoneRateLimitMemoryStore; +} + +/** + * 런타임 환경에 맞는 RateLimitStore 를 반환한다. + * - Workers(platform.ctx.waitUntil 존재): 요청당 db 로 DbRateLimitStore(공유 상태). + * - Node: 프로세스 전역 MemoryRateLimitStore 싱글턴(DB write 없음). + */ +export function resolveRateLimitStore(platform: App.Platform | undefined, db: DB): RateLimitStore { + const isWorkers = typeof platform?.ctx?.waitUntil === "function"; + return isWorkers ? new DbRateLimitStore(db) : getMemoryRateLimitStore(); +} diff --git a/src/lib/server/security-notify.ts b/src/lib/server/security-notify.ts new file mode 100644 index 0000000..321ea9b --- /dev/null +++ b/src/lib/server/security-notify.ts @@ -0,0 +1,58 @@ +import { normalizeLocale } from "$lib/i18n/core"; +import { translate } from "$lib/i18n/server"; +import { sendSecurityAlertEmail } from "$lib/server/email"; + +// F2: 보안 알림 이벤트 종류. 각 kind 는 i18n `security_alert..{subject,heading,body}` 를 가진다. +export type SecurityEventKind = + | "password_changed" + | "password_reset_by_admin" + | "account_locked" + | "account_disabled" + | "mfa_enrolled" + | "mfa_disabled" + | "backup_codes_regenerated" + | "backup_codes_low" + | "backup_codes_depleted" + | "passkey_added" + | "passkey_removed" + | "account_deletion_requested" + | "email_change_requested" + | "session_revoked" + | "sessions_revoked_all"; + +// users.locale ("ko-KR"/"en-US"/"ja-JP" 등) → i18n Locale. 공용 normalizeLocale(core.ts) 로 정규화한다. +// 하위 호환 별칭 — 기존 호출부/테스트가 toLocale 을 참조한다. +export const toLocale = normalizeLocale; + +// UTC 기준 사람이 읽을 수 있는 타임스탬프(locale 무관, 시간대 모호성 제거). +function formatWhen(when: Date): string { + return when + .toISOString() + .replace("T", " ") + .replace(/\.\d+Z$/, " UTC"); +} + +/** + * 보안 알림 메일을 fire-and-forget 로 발송한다. 본 동작과 완전 격리한다: + * - 이메일이 없는 계정은 조용히 스킵 + * - 발송 실패는 무시(서버 로그만) + * - Workers 는 waitUntil 로 응답 경로에서 분리, Node 는 fire-and-forget + */ +export function dispatchSecurityAlert(params: { to: string | null | undefined; locale: string | null | undefined; kind: SecurityEventKind; when?: Date; platform?: App.Platform }): void { + const { to, kind, platform } = params; + if (!to) return; // 이메일 없는 계정 스킵 + const L = normalizeLocale(params.locale); + const when = params.when ?? new Date(); + const content = { + subject: translate(L, `security_alert.${kind}.subject`), + heading: translate(L, `security_alert.${kind}.heading`), + body: translate(L, `security_alert.${kind}.body`), + whenText: translate(L, "security_alert.when", { time: formatWhen(when) }), + footer: translate(L, "security_alert.footer"), + }; + const sendPromise = sendSecurityAlertEmail(to, content, platform).catch((e) => { + console.error("[security-notify] 발송 실패", kind, e); + }); + const wait = platform?.ctx?.waitUntil?.bind(platform.ctx); + if (wait) wait(sendPromise); +} diff --git a/src/lib/server/validation.ts b/src/lib/server/validation.ts index 49823ea..3700771 100644 --- a/src/lib/server/validation.ts +++ b/src/lib/server/validation.ts @@ -4,8 +4,14 @@ * 공통 판정을 한곳으로 모은다. */ +/** + * 검증 실패 사유. 로케일 비의존 — i18n 키(admin.errors.)와 치환 파라미터만 담는다. + * 호출부에서 `adminError(locale, reason.key, reason.params)` 로 표시 문자열을 만든다. + */ +export type ValidationReason = { key: string; params?: Record }; + /** URL/host 검증 결과 공통 형태. */ -export type ValidationResult = { ok: true } | { ok: false; reason: string }; +export type ValidationResult = { ok: true } | { ok: false; reason: ValidationReason }; /** * loopback 호스트 판정. http URL 허용(개발/내부) 여부나 SSRF 게이트에서 공통 사용. @@ -39,15 +45,15 @@ export function validateSamlUrl(value: string, label: string): ValidationResult try { parsed = new URL(value); } catch { - return { ok: false, reason: `${label}: URL 형식이 올바르지 않습니다.` }; + return { ok: false, reason: { key: "saml_url_invalid_format", params: { label } } }; } const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); if (scheme === "https") return { ok: true }; if (scheme === "http") { if (isLoopbackHost(parsed.hostname)) return { ok: true }; - return { ok: false, reason: `${label}: http URL 은 localhost/127.0.0.1 만 허용됩니다.` }; + return { ok: false, reason: { key: "saml_url_http_loopback_only", params: { label } } }; } - return { ok: false, reason: `${label}: https URL 만 허용됩니다.` }; + return { ok: false, reason: { key: "saml_url_https_only", params: { label } } }; } /** @@ -57,11 +63,11 @@ export function validateSamlUrl(value: string, label: string): ValidationResult export function validateLdapHost(host: string): ValidationResult { const lower = host.toLowerCase(); if (isCloudMetadataHost(lower)) { - return { ok: false, reason: "클라우드 메타데이터 호스트는 사용할 수 없습니다." }; + return { ok: false, reason: { key: "ldap_metadata_host_forbidden" } }; } // 169.254.0.0/16 link-local (AWS IMDS 169.254.169.254 포함) if (isLinkLocalHost(lower)) { - return { ok: false, reason: "link-local(169.254/16) 주소는 사용할 수 없습니다." }; + return { ok: false, reason: { key: "ldap_linklocal_forbidden" } }; } return { ok: true }; } @@ -72,10 +78,10 @@ const ALLOWED_LDAP_PORTS = new Set([389, 636, 3268, 3269]); /** LDAP 포트 검증(정수 범위 + 허용 목록). */ export function validateLdapPort(port: number): ValidationResult { if (!Number.isInteger(port) || port < 1 || port > 65535) { - return { ok: false, reason: "포트 번호가 올바르지 않습니다." }; + return { ok: false, reason: { key: "ldap_port_invalid" } }; } if (!ALLOWED_LDAP_PORTS.has(port)) { - return { ok: false, reason: `허용되지 않는 LDAP 포트입니다 (허용: ${[...ALLOWED_LDAP_PORTS].join(", ")}).` }; + return { ok: false, reason: { key: "ldap_port_not_allowed", params: { allowed: [...ALLOWED_LDAP_PORTS].join(", ") } } }; } return { ok: true }; } diff --git a/src/routes/(auth)/accept-invite/+page.server.ts b/src/routes/(auth)/accept-invite/+page.server.ts new file mode 100644 index 0000000..39f1a8d --- /dev/null +++ b/src/routes/(auth)/accept-invite/+page.server.ts @@ -0,0 +1,101 @@ +import { fail } from "@sveltejs/kit"; +import { and, eq, isNull } from "drizzle-orm"; +import type { Actions, PageServerLoad } from "./$types"; +import { requireDbContext } from "$lib/server/auth/guards"; +import { users, inviteTokens, credentials } from "$lib/server/db/schema"; +import { hashToken } from "$lib/server/email"; +import { hashPassword } from "$lib/server/auth/password"; +import { PASSWORD_CREDENTIAL_TYPE } from "$lib/server/auth/constants"; +import { runAtomic } from "$lib/server/db/atomic"; +import { checkRateLimit } from "$lib/server/ratelimit"; +import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit"; +import { translate } from "$lib/i18n/server"; + +// 초대 토큰 상태를 tenant 범위에서 조회한다(read-only, 비소진). 유효하면 record, 아니면 null. +async function lookupToken(db: App.Locals["db"], tenantId: string, token: string) { + if (!db) return null; + const tokenHash = await hashToken(token); + const now = new Date(); + const [record] = await db + .select({ tokenId: inviteTokens.id, userId: users.id, expiresAt: inviteTokens.expiresAt }) + .from(inviteTokens) + .innerJoin(users, eq(inviteTokens.userId, users.id)) + // status='active' 강제 — 비활성(disabled/locked)·삭제예정(deletion_pending) 계정에 credential 을 + // 심는 것을 차단한다. 소진 직전(action)에도 이 lookupToken 을 재호출하므로 TOCTOU 창을 최소화한다. + .where(and(eq(inviteTokens.tokenHash, tokenHash), isNull(inviteTokens.usedAt), eq(users.tenantId, tenantId), eq(users.status, "active"))) + .limit(1); + if (!record || record.expiresAt < now) return null; + return record; +} + +export const load: PageServerLoad = async ({ locals, url }) => { + const token = url.searchParams.get("token"); + if (!token || !locals.db || !locals.tenant) { + return { valid: false, token: null as string | null }; + } + const record = await lookupToken(locals.db, locals.tenant.id, token); + if (!record) return { valid: false, token: null as string | null }; + return { valid: true, token: token as string | null }; +}; + +export const actions: Actions = { + default: async (event) => { + const { db, tenant, rateLimitStore } = requireDbContext(event.locals); + const locale = event.locals.locale; + + const formData = await event.request.formData(); + const token = String(formData.get("token") ?? ""); + const password = String(formData.get("password") ?? ""); + const confirmPassword = String(formData.get("confirmPassword") ?? ""); + + // 토큰 제출 브루트포스 방어 — 형제 인증 라우트와 동일하게 IP 당 제한. + const meta = getRequestMetadata(event); + const rl = await checkRateLimit(rateLimitStore, `accept-invite:${meta.ipKey}`, { windowMs: 15 * 60 * 1000, limit: 10 }); + if (!rl.allowed) { + return fail(429, { error: translate(locale, "errors.rate_limit", { minutes: Math.ceil(rl.retryAfterMs / 60000) }) }); + } + + if (!token) return fail(400, { error: translate(locale, "accept_invite.invalid_link") }); + // 비밀번호 정책 재사용 — reset_password 와 동일(8자 이상, 확인 일치). + if (password.length < 8) return fail(400, { error: translate(locale, "accept_invite.err_password_short") }); + if (password !== confirmPassword) return fail(400, { error: translate(locale, "accept_invite.err_password_mismatch") }); + + const record = await lookupToken(db, tenant.id, token); + if (!record) return fail(400, { error: translate(locale, "accept_invite.invalid_link") }); + + const now = new Date(); + const hashedPw = await hashPassword(password); + + // 초대 클릭 = 이메일 소유 증명이므로 emailVerifiedAt 을 함께 세팅(별도 이메일 인증 스킵). + // 최초 비밀번호 설정(credentials insert) + emailVerifiedAt 세팅 + 토큰 소진을 원자적으로. + // 같은 user 의 미사용 초대 토큰을 모두 소진해 재사용을 차단한다. + await runAtomic(db, [ + (h) => + h.insert(credentials).values({ + id: crypto.randomUUID(), + userId: record.userId, + type: PASSWORD_CREDENTIAL_TYPE, + secret: hashedPw, + label: translate(locale, "accept_invite.credential_label"), + }), + (h) => h.update(users).set({ emailVerifiedAt: now, updatedAt: now }).where(eq(users.id, record.userId)), + (h) => + h + .update(inviteTokens) + .set({ usedAt: now }) + .where(and(eq(inviteTokens.userId, record.userId), isNull(inviteTokens.usedAt))), + ]); + + await recordAuditEvent(db, { + tenantId: tenant.id, + userId: record.userId, + actorId: record.userId, + kind: "invite_accepted", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + }); + + return { accepted: true }; + }, +}; diff --git a/src/routes/(auth)/accept-invite/+page.svelte b/src/routes/(auth)/accept-invite/+page.svelte new file mode 100644 index 0000000..44370bc --- /dev/null +++ b/src/routes/(auth)/accept-invite/+page.svelte @@ -0,0 +1,92 @@ + + +
+
+
+
+

{t("accept_invite.title")}

+

{t("accept_invite.subtitle")}

+
+ + {#if accepted} +
+ {t("accept_invite.success")} +
+ + + {t("accept_invite.go_login")} → + + {:else if data.valid} + + + +
+ + +
+
+ + +
+ + + {:else} +
+ {t("accept_invite.invalid_link")} +
+ + + {t("accept_invite.go_login")} → + + {/if} +
+
diff --git a/src/routes/(auth)/find-id/+page.server.ts b/src/routes/(auth)/find-id/+page.server.ts index 4ffe561..825febe 100644 --- a/src/routes/(auth)/find-id/+page.server.ts +++ b/src/routes/(auth)/find-id/+page.server.ts @@ -5,6 +5,7 @@ import { resolveSkinHtml, replacePlaceholders, escapeHtml } from "$lib/server/sk import { requireDbContext } from "$lib/server/auth/guards"; import { users } from "$lib/server/db/schema"; import { sendFindIdEmail } from "$lib/server/email"; +import { normalizeLocale } from "$lib/i18n/core"; import { checkRateLimit } from "$lib/server/ratelimit"; import { getRequestMetadata } from "$lib/server/audit"; import { translate } from "$lib/i18n/server"; @@ -60,7 +61,7 @@ async function resolveSkinForAction(event: Parameters[0], se export const actions: Actions = { default: async (event) => { - const { db, tenant } = requireDbContext(event.locals); + const { db, tenant, rateLimitStore } = requireDbContext(event.locals); const formData = await event.request.formData(); const email = String(formData.get("email") ?? "") @@ -76,14 +77,14 @@ export const actions: Actions = { // IP 기반 레이트리밋 — 60분/5회. const meta = getRequestMetadata(event); - const rl = await checkRateLimit(db, `find-id:${meta.ipKey}`, { windowMs: 60 * 60 * 1000, limit: 5 }); + const rl = await checkRateLimit(rateLimitStore, `find-id:${meta.ipKey}`, { windowMs: 60 * 60 * 1000, limit: 5 }); if (!rl.allowed) { const msg = translate(locale, "errors.rate_limit", { minutes: Math.ceil(rl.retryAfterMs / 60000) }); return fail(429, { error: msg, skinHtml: await resolveSkinForAction(event, false, null, msg) }); } const [user] = await db - .select({ username: users.username }) + .select({ username: users.username, locale: users.locale }) .from(users) .where(and(eq(users.tenantId, tenant.id), eq(users.email, email))) .limit(1); @@ -93,7 +94,7 @@ export const actions: Actions = { // 왕복을 돌면 응답 시간 차이로 존재 여부가 새어 나간다. 메일 발송을 응답 // 경로에서 분리해 (Workers: waitUntil, Node: fire-and-forget) 존재/비존재 // 응답 시간을 균일하게 맞춘다. 메일 발송은 best-effort 이므로 결과를 기다리지 않는다. - const sendPromise = sendFindIdEmail(email, user.username, event.platform).catch(() => { + const sendPromise = sendFindIdEmail(email, user.username, normalizeLocale(user.locale ?? locale), event.platform).catch(() => { // 메일 발송 실패는 조용히 무시 }); const wait = event.platform?.ctx?.waitUntil?.bind(event.platform.ctx); diff --git a/src/routes/(auth)/find-id/+page.svelte b/src/routes/(auth)/find-id/+page.svelte index d344631..a8fbe59 100644 --- a/src/routes/(auth)/find-id/+page.svelte +++ b/src/routes/(auth)/find-id/+page.svelte @@ -1,12 +1,24 @@ -{#if skinHtmlEffective} - +{#if skinHtmlEffective && !form?.recovery} + {#if !data.dbReady && data.runtimeError}
{data.runtimeError} @@ -120,94 +134,147 @@ async function loginWithPasskey() {
{/if} - {#if form?.error} -
- {form.error} + {#if data.deletionRequested} +
+ {t("login.deletion_requested_notice")}
{/if} - {#if passkeyError} -
- {passkeyError} + + + + + {#if form?.recovery} + +
+ {t("login.recovery_desc")}
- {/if} -
- - -
- - + + + + + +
+ + +
+ + + + + + {:else} +
+ + +
+ + +
+ +
+ + +
-
- - + + + +
+
+ {t("login.or")} +
- -
-
- {t("login.or")} -
-
- - - - + + {/if}
{/if} diff --git a/src/routes/(auth)/logout/+page.server.ts b/src/routes/(auth)/logout/+page.server.ts index b335151..498da2a 100644 --- a/src/routes/(auth)/logout/+page.server.ts +++ b/src/routes/(auth)/logout/+page.server.ts @@ -43,11 +43,11 @@ async function performLogout(event: RequestEvent): Promise { // OIDC back-channel 로그아웃 발송 (waitUntil) const issuerUrl = resolveIssuerUrl(event.locals.runtimeConfig, event.url.origin); - const signingKeySecret = event.locals.runtimeConfig.signingKeySecret; - if (signingKeySecret) { + const signingKeySecrets = event.locals.runtimeConfig.signingKeySecrets; + if (signingKeySecrets.length > 0) { const bcTargets = await getOidcBackchannelTargets(db, tenant.id, sessionId); if (bcTargets.length > 0) { - const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecrets); if (signingKey) { const bcPromises = bcTargets.map((t) => sendOneBackchannelLogout(t, userId, idpSessionId, issuerUrl, signingKey.privateKey, signingKey.kid).catch(() => undefined)); const wait = event.platform?.ctx?.waitUntil?.bind(event.platform.ctx); diff --git a/src/routes/(auth)/mfa/+page.server.ts b/src/routes/(auth)/mfa/+page.server.ts index 7cc92be..74704f8 100644 --- a/src/routes/(auth)/mfa/+page.server.ts +++ b/src/routes/(auth)/mfa/+page.server.ts @@ -5,6 +5,7 @@ import { getRequestMetadata, recordAuditEvent } from "$lib/server/audit"; import { requireDbContext } from "$lib/server/auth/guards"; import { createSessionRecord, revokeOtherSessions, setSessionCookie } from "$lib/server/auth/session"; import { verifyMfaPendingToken, MFA_PENDING_COOKIE } from "$lib/server/auth/mfa"; +import { tryWithSecrets, tryWithSecretsNullable } from "$lib/server/crypto/keys"; import { verifyTotp, decryptTotpSecret, encryptTotpSecret, isLegacyTotpCiphertext, verifyBackupCode } from "$lib/server/auth/totp"; import { checkRateLimit } from "$lib/server/ratelimit"; import { AMR_PASSWORD, AMR_TOTP, AMR_BACKUP_CODE, amrToAcr, TOTP_CREDENTIAL_TYPE, BACKUP_CODE_CREDENTIAL_TYPE } from "$lib/server/auth/constants"; @@ -12,6 +13,10 @@ import { getRuntimeConfig } from "$lib/server/auth/runtime"; import { credentials, users } from "$lib/server/db/schema"; import { resolveSkinHtml, replacePlaceholders, escapeHtml } from "$lib/server/skin/resolver"; import { translate } from "$lib/i18n/server"; +import { dispatchSecurityAlert } from "$lib/server/security-notify"; + +// 백업 코드 저잔량 경고 임계값(이하이면 경고 알림). account/mfa 의 backupCodesRemaining 표시와 정합. +const BACKUP_CODES_LOW_THRESHOLD = 2; export const load: PageServerLoad = async ({ locals, cookies, platform, url }) => { const mfaToken = cookies.get(MFA_PENDING_COOKIE); @@ -30,7 +35,7 @@ export const load: PageServerLoad = async ({ locals, cookies, platform, url }) = throw redirect(303, "/login"); } - const claims = await verifyMfaPendingToken(mfaToken, config.signingKeySecret); + const claims = await tryWithSecretsNullable(config.signingKeySecrets, (s) => verifyMfaPendingToken(mfaToken, s)); if (!claims) { cookies.delete(MFA_PENDING_COOKIE, { path: "/" }); throw redirect(303, "/login"); @@ -94,7 +99,7 @@ export const actions: Actions = { return fail(503, { error: msg, skinHtml: await resolveMfaSkinForAction(event, msg) }); } - const claims = await verifyMfaPendingToken(mfaToken, config.signingKeySecret); + const claims = await tryWithSecretsNullable(config.signingKeySecrets, (s) => verifyMfaPendingToken(mfaToken, s)); if (!claims) { event.cookies.delete(MFA_PENDING_COOKIE, { path: "/" }); throw redirect(303, "/login"); @@ -108,12 +113,12 @@ export const actions: Actions = { throw redirect(303, "/login"); } - if (!event.locals.db) { + if (!event.locals.db || !event.locals.rateLimitStore) { const msg = translate(locale, "errors.db_not_ready"); return fail(503, { error: msg, skinHtml: await resolveMfaSkinForAction(event, msg) }); } - const rl = await checkRateLimit(event.locals.db, `mfa:${claims.userId}`, { + const rl = await checkRateLimit(event.locals.rateLimitStore, `mfa:${claims.userId}`, { windowMs: 5 * 60 * 1000, limit: 10, }); @@ -173,7 +178,7 @@ export const actions: Actions = { .limit(1); if (totpCred?.secret) { - const plainSecret = await decryptTotpSecret(totpCred.secret, config.signingKeySecret, user.id); + const plainSecret = await tryWithSecrets(config.signingKeySecrets, (s) => decryptTotpSecret(totpCred.secret!, s, user.id)); // counter 컬럼을 마지막으로 사용된 TOTP 스텝으로 활용 (재사용 방지) const lastUsedStep = totpCred.counter ?? undefined; const matchedStep = await verifyTotp(code, plainSecret, lastUsedStep); @@ -209,6 +214,22 @@ export const actions: Actions = { return fail(400, { error: msg, skinHtml: await resolveMfaSkinForAction(event, msg) }); } + // 백업 코드로 통과한 경우: 소진 처리 후 남은 미사용 코드 수를 계산해 + // 저잔량(≤임계값) 경고 / 소진(0) 알림을 보안 메일로 발송한다(fire-and-forget). + // TOTP 로 통과한 경우엔 백업 코드가 소비되지 않으므로 검사하지 않는다(오탐 방지). + if (useBackup) { + const remainingRows = await db + .select({ id: credentials.id }) + .from(credentials) + .where(and(eq(credentials.userId, user.id), eq(credentials.type, BACKUP_CODE_CREDENTIAL_TYPE), isNull(credentials.usedAt))); + const remaining = remainingRows.length; + if (remaining === 0) { + dispatchSecurityAlert({ to: user.email, locale: user.locale, kind: "backup_codes_depleted", platform: event.platform }); + } else if (remaining <= BACKUP_CODES_LOW_THRESHOLD) { + dispatchSecurityAlert({ to: user.email, locale: user.locale, kind: "backup_codes_low", platform: event.platform }); + } + } + // MFA 통과 — 세션 생성 event.cookies.delete(MFA_PENDING_COOKIE, { path: "/" }); diff --git a/src/routes/(auth)/mfa/+page.svelte b/src/routes/(auth)/mfa/+page.svelte index 746b02f..ce7557c 100644 --- a/src/routes/(auth)/mfa/+page.svelte +++ b/src/routes/(auth)/mfa/+page.svelte @@ -1,12 +1,24 @@ + +
+
+
+
+

{t("verify_email.title")}

+

{t("verify_email.subtitle")}

+
+ + {#if isSuccess} +
+ {t("verify_email.success")} +
+ + + {t("verify_email.go_login")} → + + {:else if data.valid} + +

{t("verify_email.confirm_prompt")}

+
+ + +
+ {:else} +
+ {t("verify_email.invalid_link")} +
+ + + {t("verify_email.go_login")} → + + {/if} +
+
diff --git a/src/routes/account/confirm-email-change/+page.server.ts b/src/routes/account/confirm-email-change/+page.server.ts new file mode 100644 index 0000000..bae1a80 --- /dev/null +++ b/src/routes/account/confirm-email-change/+page.server.ts @@ -0,0 +1,96 @@ +import { fail } from "@sveltejs/kit"; +import { and, eq, isNull, ne } from "drizzle-orm"; +import type { Actions, PageServerLoad } from "./$types"; +import { requireDbContext } from "$lib/server/auth/guards"; +import { users, emailChangeTokens } from "$lib/server/db/schema"; +import { hashToken } from "$lib/server/email"; +import { runAtomic } from "$lib/server/db/atomic"; +import { checkRateLimit } from "$lib/server/ratelimit"; +import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit"; +import { translate } from "$lib/i18n/server"; + +// 토큰 상태를 tenant 범위에서 조회한다(read-only). 유효하면 record 반환, 아니면 null. +// targetEmail 은 토큰에 바인딩된 변경 대상 주소 — 확인 시 이 값으로만 email 을 교체한다. +async function lookupToken(db: App.Locals["db"], tenantId: string, token: string) { + if (!db) return null; + const tokenHash = await hashToken(token); + const now = new Date(); + const [record] = await db + .select({ + tokenId: emailChangeTokens.id, + userId: users.id, + targetEmail: emailChangeTokens.targetEmail, + expiresAt: emailChangeTokens.expiresAt, + }) + .from(emailChangeTokens) + .innerJoin(users, eq(emailChangeTokens.userId, users.id)) + .where(and(eq(emailChangeTokens.tokenHash, tokenHash), isNull(emailChangeTokens.usedAt), eq(users.tenantId, tenantId))) + .limit(1); + if (!record || record.expiresAt < now) return null; + return record; +} + +export const load: PageServerLoad = async ({ locals, url }) => { + const token = url.searchParams.get("token"); + if (!token || !locals.db || !locals.tenant) { + return { valid: false, token: null as string | null }; + } + const record = await lookupToken(locals.db, locals.tenant.id, token); + if (!record) return { valid: false, token: null as string | null }; + return { valid: true, token: token as string | null }; +}; + +export const actions: Actions = { + default: async (event) => { + const { db, tenant, rateLimitStore } = requireDbContext(event.locals); + const locale = event.locals.locale; + + const formData = await event.request.formData(); + const token = String(formData.get("token") ?? ""); + + // 토큰 제출 브루트포스 방어 — 형제 인증 라우트와 동일하게 IP 당 제한. + const meta = getRequestMetadata(event); + const rl = await checkRateLimit(rateLimitStore, `confirm-email-change:${meta.ipKey}`, { windowMs: 15 * 60 * 1000, limit: 10 }); + if (!rl.allowed) { + return fail(429, { error: translate(locale, "errors.rate_limit", { minutes: Math.ceil(rl.retryAfterMs / 60000) }) }); + } + + if (!token) return fail(400, { error: translate(locale, "confirm_email_change.invalid_link") }); + + const record = await lookupToken(db, tenant.id, token); + if (!record) return fail(400, { error: translate(locale, "confirm_email_change.invalid_link") }); + + // 확인 시점 중복 재검사 — 요청 이후 다른 계정이 같은 주소를 선점했을 수 있다. + // (users_tenant_email_uidx 가 최종 방어이지만 사용자 친화적 에러를 위해 선검사한다.) + const [taken] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.tenantId, tenant.id), eq(users.email, record.targetEmail), ne(users.id, record.userId))) + .limit(1); + if (taken) return fail(409, { error: translate(locale, "confirm_email_change.invalid_link") }); + + const now = new Date(); + // 1회용 소진 + email 교체 + pending 클리어 + emailVerifiedAt 세팅을 원자적으로. + // 같은 user 의 미사용 변경 토큰을 모두 소진해 재사용을 차단한다. + await runAtomic(db, [ + (h) => + h + .update(emailChangeTokens) + .set({ usedAt: now }) + .where(and(eq(emailChangeTokens.userId, record.userId), isNull(emailChangeTokens.usedAt))), + (h) => h.update(users).set({ email: record.targetEmail, pendingEmail: null, pendingEmailRequestedAt: null, emailVerifiedAt: now, updatedAt: now }).where(eq(users.id, record.userId)), + ]); + + await recordAuditEvent(db, { + tenantId: tenant.id, + userId: record.userId, + actorId: record.userId, + kind: "email_changed", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + }); + + return { changed: true }; + }, +}; diff --git a/src/routes/account/confirm-email-change/+page.svelte b/src/routes/account/confirm-email-change/+page.svelte new file mode 100644 index 0000000..f7eae51 --- /dev/null +++ b/src/routes/account/confirm-email-change/+page.svelte @@ -0,0 +1,49 @@ + + +
+
+
+
+

{t("confirm_email_change.title")}

+

{t("confirm_email_change.subtitle")}

+
+ + {#if changed} +
+ {t("confirm_email_change.success")} +
+ + + {t("confirm_email_change.go_profile")} → + + {:else if data.valid} + {#if err} +
{err}
+ {/if} +

{t("confirm_email_change.confirm_prompt")}

+
+ + +
+ {:else} +
+ {t("confirm_email_change.invalid_link")} +
+ + + {t("confirm_email_change.go_login")} → + + {/if} +
+
diff --git a/src/routes/account/danger-zone/+page.server.ts b/src/routes/account/danger-zone/+page.server.ts new file mode 100644 index 0000000..b3474e3 --- /dev/null +++ b/src/routes/account/danger-zone/+page.server.ts @@ -0,0 +1,152 @@ +import { fail, redirect } from "@sveltejs/kit"; +import { and, eq } from "drizzle-orm"; +import type { Actions, PageServerLoad } from "./$types"; +import { getRequestMetadata, recordAuditEvent } from "$lib/server/audit"; +import { requireDbContext, assertNotLastAdmin } from "$lib/server/auth/guards"; +import { clearSessionCookie, revokeAllUserSessions } from "$lib/server/auth/session"; +import { revokeAllUserRefreshTokens } from "$lib/server/oidc/refresh"; +import { findPasswordCredential } from "$lib/server/auth/users"; +import { verifyPassword } from "$lib/server/auth/password"; +import { verifyTotp, decryptTotpSecret, encryptTotpSecret, isLegacyTotpCiphertext } from "$lib/server/auth/totp"; +import { getRuntimeConfig } from "$lib/server/auth/runtime"; +import { tryWithSecrets } from "$lib/server/crypto/keys"; +import { TOTP_CREDENTIAL_TYPE } from "$lib/server/auth/constants"; +import { dispatchSecurityAlert } from "$lib/server/security-notify"; +import { credentials, users } from "$lib/server/db/schema"; +import { translate } from "$lib/i18n/server"; + +// 소프트 삭제 유예기간 = 30일. 이 기간 내 로그인하면 계정을 복구할 수 있고, 경과하면 GC 가 +// 하드 삭제한다. (login 복구 흐름·gc.ts 하드삭제 조건과 짝을 이룬다.) +const DELETION_GRACE_MS = 30 * 24 * 60 * 60 * 1000; + +export const load: PageServerLoad = async ({ locals, url }) => { + if (!locals.user) { + throw redirect(303, `/login?redirectTo=${encodeURIComponent(url.pathname)}`); + } + + const { db } = requireDbContext(locals); + + // step-up UI 구성용: 비밀번호 크레덴셜/ TOTP 보유 여부. + const pwCred = await findPasswordCredential(db, locals.user.id); + const [totpCred] = await db + .select({ id: credentials.id }) + .from(credentials) + .where(and(eq(credentials.userId, locals.user.id), eq(credentials.type, TOTP_CREDENTIAL_TYPE))) + .limit(1); + + return { + email: locals.user.email, + hasPassword: Boolean(pwCred?.secret), + hasTotp: Boolean(totpCred), + graceDays: Math.round(DELETION_GRACE_MS / (24 * 60 * 60 * 1000)), + }; +}; + +export const actions: Actions = { + requestDeletion: async (event) => { + const { locals } = event; + if (!locals.user) throw redirect(303, "/login"); + + const { db, tenant } = requireDbContext(locals); + const locale = locals.locale; + const user = locals.user; + + const formData = await event.request.formData(); + const password = String(formData.get("password") ?? ""); + const totpCode = String(formData.get("totp") ?? "") + .trim() + .replace(/\s/g, ""); + + const requestMetadata = getRequestMetadata(event); + + // ── step-up 재인증 (비밀번호 또는 TOTP) ───────────────────────────────── + // 세션 탈취 공격자가 정당 소유자의 계정을 삭제하지 못하도록 재인증을 강제한다. + // 비밀번호 크레덴셜이 있으면 비밀번호를, 없거나 TOTP 코드를 제출하면 TOTP 를 검증한다. + let stepUpOk = false; + + const pwCred = await findPasswordCredential(db, user.id); + if (password && pwCred?.secret) { + const ok = await verifyPassword(password, pwCred.secret); + stepUpOk = ok.valid; + } + + if (!stepUpOk && totpCode) { + const [totpCred] = await db + .select() + .from(credentials) + .where(and(eq(credentials.userId, user.id), eq(credentials.type, TOTP_CREDENTIAL_TYPE))) + .limit(1); + const config = getRuntimeConfig(event.platform); + if (totpCred?.secret && config.signingKeySecret) { + try { + const plainSecret = await tryWithSecrets(config.signingKeySecrets, (s) => decryptTotpSecret(totpCred.secret!, s, user.id)); + const lastUsedStep = totpCred.counter ?? undefined; + const matchedStep = await verifyTotp(totpCode, plainSecret, lastUsedStep); + if (matchedStep !== null) { + stepUpOk = true; + // v1 형식이면 v2 로 lazy migration + 재사용 방지용 counter 갱신. + let nextSecret = totpCred.secret; + if (isLegacyTotpCiphertext(totpCred.secret)) { + try { + nextSecret = await encryptTotpSecret(plainSecret, config.signingKeySecret, user.id); + } catch { + nextSecret = totpCred.secret; + } + } + await db.update(credentials).set({ lastUsedAt: new Date(), counter: matchedStep, secret: nextSecret }).where(eq(credentials.id, totpCred.id)); + } + } catch { + stepUpOk = false; + } + } + } + + if (!stepUpOk) { + await recordAuditEvent(db, { + tenantId: tenant.id, + userId: user.id, + actorId: user.id, + kind: "user_deletion_stepup_failed", + outcome: "failure", + ip: requestMetadata.ip, + userAgent: requestMetadata.userAgent, + }); + return fail(401, { error: translate(locale, "account.danger_zone.err_reauth") }); + } + + // ── 마지막 활성 관리자 자기삭제 차단 ───────────────────────────────────── + const lastAdminBlock = await assertNotLastAdmin(db, tenant.id, user.id); + if (lastAdminBlock) { + return fail(400, { error: translate(locale, "account.danger_zone.err_last_admin") }); + } + + // ── 소프트 삭제 전환: status=deletion_pending + 유예 30일 예약 ─────────── + const scheduledAt = new Date(Date.now() + DELETION_GRACE_MS); + await db + .update(users) + .set({ status: "deletion_pending", deletionScheduledAt: scheduledAt, updatedAt: new Date() }) + .where(and(eq(users.id, user.id), eq(users.tenantId, tenant.id))); + + // 전 세션 + refresh token 즉시 폐기(로그아웃). 복구는 유예 내 재로그인으로만 가능. + await revokeAllUserSessions(db, user.id); + await revokeAllUserRefreshTokens(db, user.id); + + await recordAuditEvent(db, { + tenantId: tenant.id, + userId: user.id, + actorId: user.id, + kind: "user_deletion_requested", + outcome: "success", + ip: requestMetadata.ip, + userAgent: requestMetadata.userAgent, + detail: { scheduledAt: scheduledAt.toISOString() }, + }); + + // 탈퇴 접수 알림 메일(best-effort). 세션 만료 전, 쿠키 삭제 이전에 발사한다. + dispatchSecurityAlert({ to: user.email, locale: user.locale, kind: "account_deletion_requested", platform: event.platform }); + + // 로그아웃: 세션 쿠키 삭제 후 로그인으로. + clearSessionCookie(event.cookies, event.url); + throw redirect(303, "/login?deletionRequested=1"); + }, +}; diff --git a/src/routes/account/danger-zone/+page.svelte b/src/routes/account/danger-zone/+page.svelte new file mode 100644 index 0000000..26a3188 --- /dev/null +++ b/src/routes/account/danger-zone/+page.svelte @@ -0,0 +1,85 @@ + + +
+
+ + +
+

{t("account.danger_zone.title")}

+

{t("account.danger_zone.subtitle")}

+ + {#if formError} +
+ {formError} +
+ {/if} + +
+

{t("account.danger_zone.warning_title")}

+

{t("account.danger_zone.warning_grace", { days: data.graceDays })}

+

{t("account.danger_zone.warning_recovery")}

+
+ +
+ {#if data.hasPassword} +
+ + +
+ {/if} + + {#if data.hasTotp} +
+ + +
+ {/if} + + + + +
+
+
+
diff --git a/src/routes/account/mfa/+page.server.ts b/src/routes/account/mfa/+page.server.ts index 7615839..ab34582 100644 --- a/src/routes/account/mfa/+page.server.ts +++ b/src/routes/account/mfa/+page.server.ts @@ -4,9 +4,11 @@ import type { Actions, PageServerLoad } from "./$types"; import { requireDbContext } from "$lib/server/auth/guards"; import { getRuntimeConfig } from "$lib/server/auth/runtime"; import { generateTotpSecret, buildOtpAuthUri, verifyTotp, encryptTotpSecret, decryptTotpSecret, generateBackupCodes, hashBackupCode } from "$lib/server/auth/totp"; +import { tryWithSecrets, tryWithSecretsNullable } from "$lib/server/crypto/keys"; import { TOTP_CREDENTIAL_TYPE, BACKUP_CODE_CREDENTIAL_TYPE } from "$lib/server/auth/constants"; import { credentials } from "$lib/server/db/schema"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit"; +import { dispatchSecurityAlert } from "$lib/server/security-notify"; const TOTP_SETUP_COOKIE = "idp_totp_setup"; const TOTP_SETUP_TTL_MS = 10 * 60 * 1000; // 10분 @@ -82,7 +84,7 @@ export const load: PageServerLoad = async ({ locals, cookies, platform, url }) = if (!totpCred && config.signingKeySecret) { const setupToken = cookies.get(TOTP_SETUP_COOKIE); if (setupToken) { - const secret = await verifySetupToken(setupToken, config.signingKeySecret); + const secret = await tryWithSecretsNullable(config.signingKeySecrets, (s) => verifySetupToken(setupToken, s)); if (secret) { const issuer = config.issuerUrl ? new URL(config.issuerUrl).hostname : "IdP"; pendingUri = buildOtpAuthUri(secret, locals.user.email, issuer); @@ -150,7 +152,7 @@ export const actions: Actions = { }); } - const plainSecret = await verifySetupToken(setupToken, config.signingKeySecret); + const plainSecret = await tryWithSecretsNullable(config.signingKeySecrets, (s) => verifySetupToken(setupToken, s)); if (!plainSecret) { cookies.delete(TOTP_SETUP_COOKIE, { path: "/" }); return fail(400, { @@ -224,6 +226,8 @@ export const actions: Actions = { userAgent: requestMetadata.userAgent, }); + dispatchSecurityAlert({ to: locals.user.email, locale: locals.user.locale, kind: "mfa_enrolled", platform: event.platform }); + return { confirm: true, backupCodes: codes }; }, @@ -258,7 +262,7 @@ export const actions: Actions = { return fail(400, { delete: true, error: "TOTP 인증기가 등록되어 있지 않습니다." }); } - const plainSecret = await decryptTotpSecret(totpCred.secret, config.signingKeySecret, locals.user.id); + const plainSecret = await tryWithSecrets(config.signingKeySecrets, (s) => decryptTotpSecret(totpCred.secret!, s, locals.user!.id)); const matchedStep = await verifyTotp(code, plainSecret); if (matchedStep === null) { return fail(400, { delete: true, error: "인증 코드가 올바르지 않습니다." }); @@ -279,6 +283,8 @@ export const actions: Actions = { userAgent: requestMetadata.userAgent, }); + dispatchSecurityAlert({ to: locals.user.email, locale: locals.user.locale, kind: "mfa_disabled", platform: event.platform }); + return { deleted: true }; }, @@ -314,7 +320,7 @@ export const actions: Actions = { return fail(400, { regenerate: true, error: "TOTP 인증기가 등록되어 있지 않습니다." }); } - const plainSecret = await decryptTotpSecret(totpCred.secret, config.signingKeySecret, locals.user.id); + const plainSecret = await tryWithSecrets(config.signingKeySecrets, (s) => decryptTotpSecret(totpCred.secret!, s, locals.user!.id)); const matchedStep = await verifyTotp(code, plainSecret); if (matchedStep === null) { return fail(400, { regenerate: true, error: "인증 코드가 올바르지 않습니다." }); @@ -347,6 +353,8 @@ export const actions: Actions = { userAgent: requestMetadata.userAgent, }); + dispatchSecurityAlert({ to: locals.user.email, locale: locals.user.locale, kind: "backup_codes_regenerated", platform: event.platform }); + return { regenerate: true, backupCodes: codes }; }, }; diff --git a/src/routes/account/passkeys/+page.server.ts b/src/routes/account/passkeys/+page.server.ts index 2a38c7d..20dd761 100644 --- a/src/routes/account/passkeys/+page.server.ts +++ b/src/routes/account/passkeys/+page.server.ts @@ -3,6 +3,7 @@ import { eq, and } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; import { requireDbContext } from "$lib/server/auth/guards"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; +import { dispatchSecurityAlert } from "$lib/server/security-notify"; import { credentials } from "$lib/server/db/schema"; import { WEBAUTHN_CREDENTIAL_TYPE } from "$lib/server/auth/constants"; import { findPasswordCredential } from "$lib/server/auth/users"; @@ -90,6 +91,8 @@ export const actions: Actions = { userAgent: requestMetadata.userAgent, }); + dispatchSecurityAlert({ to: locals.user.email, locale: locals.user.locale, kind: "passkey_removed", platform: event.platform }); + return { deleted: true }; }, }; diff --git a/src/routes/account/profile/+page.server.ts b/src/routes/account/profile/+page.server.ts index 1d137ef..2c51b1a 100644 --- a/src/routes/account/profile/+page.server.ts +++ b/src/routes/account/profile/+page.server.ts @@ -1,9 +1,17 @@ import { fail, redirect } from "@sveltejs/kit"; -import { and, eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; import { requireDbContext } from "$lib/server/auth/guards"; import { users } from "$lib/server/db/schema"; import { getUserMembership } from "$lib/server/org/membership"; +import { issueEmailVerification } from "$lib/server/auth/email-verification"; +import { issueEmailChange } from "$lib/server/auth/email-change"; +import { findPasswordCredential } from "$lib/server/auth/users"; +import { verifyPassword } from "$lib/server/auth/password"; +import { dispatchSecurityAlert } from "$lib/server/security-notify"; +import { checkRateLimit } from "$lib/server/ratelimit"; +import { translate } from "$lib/i18n/server"; +import { normalizeLocale } from "$lib/i18n/core"; export const load: PageServerLoad = async ({ locals }) => { if (!locals.user) throw redirect(303, "/login"); @@ -23,12 +31,16 @@ export const load: PageServerLoad = async ({ locals }) => { bio: locals.user.bio, birthdate: locals.user.birthdate, }, + email: locals.user.email, + emailVerified: !!locals.user.emailVerifiedAt, + // F3: 확인 대기 중인 새 이메일(있으면). UI 통합은 별도(profile.svelte 는 다른 에이전트 범위). + pendingEmail: locals.user.pendingEmail, membership, }; }; export const actions: Actions = { - default: async ({ locals, request }) => { + save: async ({ locals, request }) => { if (!locals.user) throw redirect(303, "/login"); const { db, tenant } = requireDbContext(locals); @@ -44,7 +56,7 @@ export const actions: Actions = { // birthdate 형식 검증 (YYYY-MM-DD) if (birthdate && !/^\d{4}-\d{2}-\d{2}$/.test(birthdate)) { - return fail(400, { error: "생년월일 형식이 올바르지 않습니다. (YYYY-MM-DD)" }); + return fail(400, { error: translate(locals.locale, "profile.err_birthdate_format") }); } await db @@ -64,4 +76,90 @@ export const actions: Actions = { return { success: true }; }, + + // 이메일 인증 메일 재발송. 이미 인증됐으면 no-op. rate-limit(기존 인프라 재사용). + resendVerification: async (event) => { + const { locals } = event; + if (!locals.user) throw redirect(303, "/login"); + const { db, rateLimitStore } = requireDbContext(locals); + const locale = locals.locale; + + // 이미 인증된 계정은 조용히 no-op(성공 응답). + if (locals.user.emailVerifiedAt) { + return { resent: true }; + } + + const rl = await checkRateLimit(rateLimitStore, `resend-verification:${locals.user.id}`, { windowMs: 60 * 60 * 1000, limit: 5 }); + if (!rl.allowed) { + return fail(429, { resendError: translate(locale, "errors.rate_limit", { minutes: Math.ceil(rl.retryAfterMs / 60000) }) }); + } + + // 수신자(=본인) locale 우선. users.locale 없으면 요청 locale 로 폴백. + await issueEmailVerification(db, locals.user.id, locals.user.email, normalizeLocale(locals.user.locale ?? locale), event.platform); + return { resent: true }; + }, + + // F3: 이메일 주소 변경 요청. 현 비밀번호 재인증 → 형식·중복 검증 → pendingEmail 저장 + + // 변경 토큰 발급(새 주소로 확인 메일) + 기존 주소로 "변경 시도" 보안 알림. 실제 교체는 + // confirm-email-change 라우트에서 토큰 확인 시 일어난다. + changeEmail: async (event) => { + const { locals } = event; + if (!locals.user) throw redirect(303, "/login"); + const { db, tenant, rateLimitStore } = requireDbContext(locals); + const locale = locals.locale; + const user = locals.user; + + const fd = await event.request.formData(); + const newEmail = String(fd.get("newEmail") ?? "") + .trim() + .toLowerCase(); + const password = String(fd.get("password") ?? ""); + + // 토큰 제출/재인증 브루트포스 방어 — 계정 단위 rate-limit. + const rl = await checkRateLimit(rateLimitStore, `change-email:${user.id}`, { windowMs: 60 * 60 * 1000, limit: 5 }); + if (!rl.allowed) { + return fail(429, { changeEmailError: translate(locale, "errors.rate_limit", { minutes: Math.ceil(rl.retryAfterMs / 60000) }) }); + } + + // ── 현 비밀번호 재인증(step-up) — 세션 탈취 공격자가 이메일을 바꿔치기하지 못하게. ── + const pwCred = await findPasswordCredential(db, user.id); + if (!pwCred?.secret) { + return fail(400, { changeEmailError: translate(locale, "profile.err_email_change_no_password") }); + } + const pw = await verifyPassword(password, pwCred.secret); + if (!pw.valid) { + return fail(400, { changeEmailError: translate(locale, "profile.err_email_change_password") }); + } + + // ── 새 이메일 형식·동일·중복 검증(signup 과 동일 정규식). ── + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { + return fail(400, { changeEmailError: translate(locale, "profile.err_email_change_format") }); + } + if (newEmail === user.email.toLowerCase()) { + return fail(400, { changeEmailError: translate(locale, "profile.err_email_change_same") }); + } + const [taken] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.tenantId, tenant.id), eq(users.email, newEmail), ne(users.id, user.id))) + .limit(1); + if (taken) { + return fail(409, { changeEmailError: translate(locale, "profile.err_email_change_taken") }); + } + + const now = new Date(); + // pendingEmail 대기 상태 기록(감사/UI 용). 실제 email 교체는 confirm 시점. + await db + .update(users) + .set({ pendingEmail: newEmail, pendingEmailRequestedAt: now, updatedAt: now }) + .where(and(eq(users.id, user.id), eq(users.tenantId, tenant.id))); + + // 새 주소로 확인 링크 메일(수신자 locale 우선, best-effort 격리). + await issueEmailChange(db, user.id, newEmail, normalizeLocale(user.locale ?? locale), event.platform); + + // 기존 주소로 "변경 시도됨" 보안 알림(best-effort). 탈취 시 정당 소유자가 인지·차단하도록. + dispatchSecurityAlert({ to: user.email, locale: user.locale, kind: "email_change_requested", when: now, platform: event.platform }); + + return { changeEmailSent: true, pendingEmail: newEmail }; + }, }; diff --git a/src/routes/account/profile/+page.svelte b/src/routes/account/profile/+page.svelte index 3bbf653..c45bc82 100644 --- a/src/routes/account/profile/+page.svelte +++ b/src/routes/account/profile/+page.svelte @@ -1,12 +1,31 @@ + +
+
+ + +
+

{t("account.sessions.title")}

+

{t("account.sessions.subtitle")}

+ + {#if formError} +
+ {formError} +
+ {/if} + + {#if revoked} +
+ {t("account.sessions.revoked_notice")} +
+ {/if} + + {#if revokedOthers} +
+ {t("account.sessions.revoked_others_notice")} +
+ {/if} + + {#if data.sessions.length > 0} +
+ {#each data.sessions as s (s.id)} + {@const isCurrent = s.id === data.currentSessionId} +
+
+
+ {#if isCurrent} + + {t("account.sessions.current_badge")} + + {/if} +

+ {t("account.sessions.device_label")}: {s.userAgent ?? t("account.sessions.unknown")} +

+

+ {t("account.sessions.ip_label")}: {s.ip ?? t("account.sessions.unknown")} +

+

+ {t("account.sessions.last_seen_label")}: {formatWhen(s.lastSeenAt)} +

+

+ {t("account.sessions.created_label")}: {formatWhen(s.createdAt)} +

+
+ +
+ + +
+
+
+ {/each} +
+ {:else} +
+

{t("account.sessions.empty")}

+
+ {/if} + + {#if otherCount > 0} +
+ +
+ {/if} +
+
+
diff --git a/src/routes/admin/ldap-providers/+page.server.ts b/src/routes/admin/ldap-providers/+page.server.ts index 7810703..ea78a11 100644 --- a/src/routes/admin/ldap-providers/+page.server.ts +++ b/src/routes/admin/ldap-providers/+page.server.ts @@ -2,12 +2,14 @@ import { fail } from "@sveltejs/kit"; import { and, desc, eq } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; import { requireAdminContext } from "$lib/server/auth/guards"; +import { adminError, requireFormId } from "$lib/server/admin/errors"; import { getRequestMetadata, recordAuditEvent } from "$lib/server/audit"; import { getRuntimeConfig } from "$lib/server/auth/runtime"; import { encryptSecret } from "$lib/server/crypto/keys"; import { identityProviders } from "$lib/server/db/schema"; import type { LdapProviderConfig } from "$lib/server/ldap/types"; import { validateLdapHost, validateLdapPort } from "$lib/server/validation"; +import type { Locale } from "$lib/i18n/core"; function buildConfig(fd: FormData): LdapProviderConfig { const port = parseInt(String(fd.get("port") ?? "389"), 10); @@ -54,13 +56,13 @@ function buildConfig(fd: FormData): LdapProviderConfig { // 평문 그대로 저장하던 silent fallback 을 제거. 운영 환경 (signingKeySecret 항상 // 존재) 에서 정상 동작, dev 환경에서 secret 미설정 시 admin 에게 명시적 에러로 // 알려 평문 LDAP 자격증명이 DB 에 박히는 사고 차단. -async function encryptBindPassword(config: LdapProviderConfig, signingKeySecret: string | undefined): Promise { +async function encryptBindPassword(config: LdapProviderConfig, signingKeySecret: string | undefined, locale: Locale): Promise { if (!config.bindPassword) { // 새 bindPassword 입력이 없으면 그대로 통과 (기존 enc 만 보존됨) return config; } if (!signingKeySecret) { - throw new Error("IDP_SIGNING_KEY_SECRET 이 설정되어야 LDAP bindPassword 를 저장할 수 있습니다."); + throw new Error(adminError(locale, "ldap_signing_key_secret_required")); } const enc = await encryptSecret(config.bindPassword, signingKeySecret, "idp-ldap-bind-password-v1"); // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -83,6 +85,7 @@ export const load: PageServerLoad = async ({ locals }) => { export const actions: Actions = { create: async (event) => { const { db, tenant } = requireAdminContext(event.locals); + const locale = event.locals.locale; const fd = await event.request.formData(); const name = String(fd.get("name") ?? "").trim(); @@ -90,25 +93,25 @@ export const actions: Actions = { const hasBind = String(fd.get("bindDN") ?? "").trim(); const hasPattern = String(fd.get("userDnPattern") ?? "").trim(); - if (!name) return fail(400, { create: true, error: "이름은 필수입니다." }); - if (!host) return fail(400, { create: true, error: "LDAP 호스트는 필수입니다." }); + if (!name) return fail(400, { create: true, error: adminError(locale, "name_required") }); + if (!host) return fail(400, { create: true, error: adminError(locale, "ldap_host_required") }); if (!hasBind && !hasPattern) return fail(400, { create: true, - error: "Admin Bind DN 또는 유저 DN 패턴 중 하나는 필수입니다.", + error: adminError(locale, "ldap_bind_or_userdn_required"), }); const hostV = validateLdapHost(host); - if (!hostV.ok) return fail(400, { create: true, error: hostV.reason }); + if (!hostV.ok) return fail(400, { create: true, error: adminError(locale, hostV.reason.key, hostV.reason.params) }); const port = parseInt(String(fd.get("port") ?? "389"), 10); const portV = validateLdapPort(isNaN(port) ? 389 : port); - if (!portV.ok) return fail(400, { create: true, error: portV.reason }); + if (!portV.ok) return fail(400, { create: true, error: adminError(locale, portV.reason.key, portV.reason.params) }); const { signingKeySecret } = getRuntimeConfig(event.platform); let config: LdapProviderConfig; try { - config = await encryptBindPassword(buildConfig(fd), signingKeySecret); + config = await encryptBindPassword(buildConfig(fd), signingKeySecret, locale); } catch (e) { return fail(503, { create: true, error: (e as Error).message }); } @@ -137,27 +140,28 @@ export const actions: Actions = { update: async (event) => { const { db, tenant } = requireAdminContext(event.locals); + const locale = event.locals.locale; const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); const name = String(fd.get("name") ?? "").trim(); const enabled = fd.get("enabled") === "true"; - if (!id || !name) return fail(400, { error: "잘못된 요청입니다." }); + if (!id || !name) return fail(400, { error: adminError(locale, "invalid_request") }); const host = String(fd.get("host") ?? "").trim(); if (host) { const hostV = validateLdapHost(host); - if (!hostV.ok) return fail(400, { error: hostV.reason }); + if (!hostV.ok) return fail(400, { error: adminError(locale, hostV.reason.key, hostV.reason.params) }); } const port = parseInt(String(fd.get("port") ?? "389"), 10); const portV = validateLdapPort(isNaN(port) ? 389 : port); - if (!portV.ok) return fail(400, { error: portV.reason }); + if (!portV.ok) return fail(400, { error: adminError(locale, portV.reason.key, portV.reason.params) }); const { signingKeySecret } = getRuntimeConfig(event.platform); let config: LdapProviderConfig; try { - config = await encryptBindPassword(buildConfig(fd), signingKeySecret); + config = await encryptBindPassword(buildConfig(fd), signingKeySecret, locale); } catch (e) { return fail(503, { error: (e as Error).message }); } @@ -172,10 +176,12 @@ export const actions: Actions = { delete: async (event) => { const { db, tenant } = requireAdminContext(event.locals); + const locale = event.locals.locale; const fd = await event.request.formData(); - const id = String(fd.get("id") ?? ""); - if (!id) return fail(400, { error: "잘못된 요청입니다." }); + const idr = requireFormId(fd, locale); + if (!idr.ok) return idr.failure; + const id = idr.id; await db.delete(identityProviders).where(and(eq(identityProviders.id, id), eq(identityProviders.tenantId, tenant.id))); diff --git a/src/routes/admin/login/+page.server.ts b/src/routes/admin/login/+page.server.ts index 6bcd4d1..0d91e92 100644 --- a/src/routes/admin/login/+page.server.ts +++ b/src/routes/admin/login/+page.server.ts @@ -6,6 +6,8 @@ import { authenticateLocalUser, hasTotpCredential, normalizeUsername } from "$li import { createMfaPendingToken, MFA_PENDING_COOKIE } from "$lib/server/auth/mfa"; import { getRuntimeConfig } from "$lib/server/auth/runtime"; import { checkRateLimit } from "$lib/server/ratelimit"; +import { translate } from "$lib/i18n/server"; +import { adminError } from "$lib/server/admin/errors"; function sanitizeRedirectTarget(target: string | null): string | null { if (!target) return null; @@ -39,12 +41,13 @@ export const actions: Actions = { const username = normalizeUsername(String(formData.get("username") ?? "")); const password = String(formData.get("password") ?? ""); const redirectTo = sanitizeRedirectTarget(String(formData.get("redirectTo") ?? "")); + const locale = event.locals.locale; if (!username || !password) { return fail(400, { username, redirectTo, - error: "아이디와 비밀번호를 입력해 주세요.", + error: adminError(locale, "login_missing_credentials"), }); } @@ -52,21 +55,21 @@ export const actions: Actions = { return fail(503, { username, redirectTo, - error: event.locals.runtimeError ?? 'D1 binding "DB" 가 준비되지 않았습니다. Wrangler preview/dev 환경에서 실행해 주세요.', + error: event.locals.runtimeError ?? translate(locale, "errors.db_not_ready"), }); } - const { db, tenant } = requireDbContext(event.locals); + const { db, tenant, rateLimitStore } = requireDbContext(event.locals); const requestMetadata = getRequestMetadata(event); // 레이트 리밋: IP당 10회/15분 const rlKey = `admin-login:${requestMetadata.ipKey}`; - const rl = await checkRateLimit(db, rlKey, { windowMs: 15 * 60 * 1000, limit: 10 }); + const rl = await checkRateLimit(rateLimitStore, rlKey, { windowMs: 15 * 60 * 1000, limit: 10 }); if (!rl.allowed) { return fail(429, { username, redirectTo, - error: `로그인 시도가 너무 많습니다. ${Math.ceil(rl.retryAfterMs / 60000)}분 후 다시 시도해 주세요.`, + error: adminError(locale, "login_rate_limit", { minutes: Math.ceil(rl.retryAfterMs / 60000) }), }); } @@ -85,7 +88,7 @@ export const actions: Actions = { return fail(400, { username, redirectTo, - error: "아이디 또는 비밀번호가 올바르지 않습니다.", + error: adminError(locale, "login_invalid_credentials"), }); } @@ -105,7 +108,7 @@ export const actions: Actions = { return fail(403, { username, redirectTo, - error: "관리자 권한이 없는 계정입니다.", + error: adminError(locale, "login_not_admin"), }); } @@ -114,7 +117,7 @@ export const actions: Actions = { return fail(503, { username, redirectTo, - error: "MFA 설정 오류: IDP_SIGNING_KEY_SECRET 이 설정되지 않았습니다.", + error: adminError(locale, "login_mfa_config"), }); } @@ -132,7 +135,7 @@ export const actions: Actions = { return fail(403, { username, redirectTo, - error: "관리자 계정은 MFA(OTP) 설정이 필수입니다. 먼저 MFA를 등록해 주세요.", + error: adminError(locale, "login_mfa_required"), }); } diff --git a/src/routes/admin/oidc-clients/+page.server.ts b/src/routes/admin/oidc-clients/+page.server.ts index 70ca5a3..cae7500 100644 --- a/src/routes/admin/oidc-clients/+page.server.ts +++ b/src/routes/admin/oidc-clients/+page.server.ts @@ -7,6 +7,8 @@ import { oidcClients } from "$lib/server/db/schema"; import { hashClientSecret } from "$lib/server/oidc/client"; import { ensureCsrfToken, isValidCsrf } from "$lib/server/auth/csrf"; import { isLoopbackHost } from "$lib/server/validation"; +import { adminError, requireFormId } from "$lib/server/admin/errors"; +import type { Locale } from "$lib/i18n/core"; function generateClientId(): string { return crypto.randomUUID().replace(/-/g, "").slice(0, 20); @@ -30,36 +32,36 @@ const ALLOWED_OIDC_SCOPES = ["openid", "profile", "email", "address", "phone", " * @param value 검증할 URL 문자열 * @param opts.allowCustomScheme 모바일 등 커스텀 scheme 허용 여부 (redirect_uri 한정) */ -function validateClientUri(value: string, opts: { allowCustomScheme?: boolean } = {}): { ok: true } | { ok: false; reason: string } { +function validateClientUri(value: string, locale: Locale, opts: { allowCustomScheme?: boolean } = {}): { ok: true } | { ok: false; reason: string } { let parsed: URL; try { parsed = new URL(value); } catch { - return { ok: false, reason: `URL 형식이 올바르지 않습니다: ${value}` }; + return { ok: false, reason: adminError(locale, "url_invalid_format", { value }) }; } // RFC 6749 §3.1.2 — fragment 포함 redirect URI 금지 if (parsed.hash && parsed.hash.length > 0) { - return { ok: false, reason: `fragment(#) 가 포함된 URI 는 허용되지 않습니다: ${value}` }; + return { ok: false, reason: adminError(locale, "url_fragment_forbidden", { value }) }; } const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); const blocked = ["javascript", "data", "file", "vbscript", "blob"]; if (blocked.includes(scheme)) { - return { ok: false, reason: `${scheme}: 스킴은 허용되지 않습니다.` }; + return { ok: false, reason: adminError(locale, "scheme_forbidden", { scheme }) }; } if (scheme === "https") return { ok: true }; if (scheme === "http") { if (isLoopbackHost(parsed.hostname)) return { ok: true }; - return { ok: false, reason: `http URL 은 localhost/127.0.0.1 만 허용됩니다: ${value}` }; + return { ok: false, reason: adminError(locale, "http_localhost_only", { value }) }; } if (opts.allowCustomScheme) { // 커스텀 scheme (모바일 redirect 등): RFC 3986 scheme 형식 if (/^[a-z][a-z0-9+.-]*$/i.test(scheme)) return { ok: true }; - return { ok: false, reason: `커스텀 scheme 형식이 올바르지 않습니다: ${value}` }; + return { ok: false, reason: adminError(locale, "custom_scheme_invalid", { value }) }; } - return { ok: false, reason: `허용되지 않는 scheme 입니다 (${scheme}): ${value}` }; + return { ok: false, reason: adminError(locale, "scheme_not_allowed", { scheme, value }) }; } function splitUriList(raw: string): string[] { @@ -69,23 +71,23 @@ function splitUriList(raw: string): string[] { .filter(Boolean); } -function validateUriList(raw: string, label: string, opts: { allowCustomScheme?: boolean } = {}): { ok: true; json: string } | { ok: false; reason: string } { +function validateUriList(raw: string, label: string, locale: Locale, opts: { allowCustomScheme?: boolean } = {}): { ok: true; json: string } | { ok: false; reason: string } { const list = splitUriList(raw); for (const v of list) { - const r = validateClientUri(v, opts); + const r = validateClientUri(v, locale, opts); if (!r.ok) return { ok: false, reason: `${label}: ${r.reason}` }; } return { ok: true, json: JSON.stringify(list) }; } -function validateSingleUri(value: string, label: string): { ok: true } | { ok: false; reason: string } { +function validateSingleUri(value: string, label: string, locale: Locale): { ok: true } | { ok: false; reason: string } { if (!value) return { ok: true }; - const r = validateClientUri(value); + const r = validateClientUri(value, locale); if (!r.ok) return { ok: false, reason: `${label}: ${r.reason}` }; return { ok: true }; } -function normalizeScopes(raw: string): { ok: true; value: string } | { ok: false; reason: string } { +function normalizeScopes(raw: string, locale: Locale): { ok: true; value: string } | { ok: false; reason: string } { const tokens = raw .split(/\s+/) .map((s) => s.trim()) @@ -94,7 +96,7 @@ function normalizeScopes(raw: string): { ok: true; value: string } | { ok: false const out: string[] = []; for (const t of tokens) { if (!(ALLOWED_OIDC_SCOPES as readonly string[]).includes(t)) { - return { ok: false, reason: `허용되지 않는 scope 입니다: ${t}` }; + return { ok: false, reason: adminError(locale, "scope_not_allowed", { scope: t }) }; } if (!seen.has(t)) { seen.add(t); @@ -102,7 +104,7 @@ function normalizeScopes(raw: string): { ok: true; value: string } | { ok: false } } if (!seen.has("openid")) { - return { ok: false, reason: "scope 에는 'openid' 가 포함되어야 합니다." }; + return { ok: false, reason: adminError(locale, "scope_openid_required") }; } return { ok: true, value: out.join(" ") }; } @@ -140,9 +142,10 @@ export const actions: Actions = { create: async (event) => { const { locals } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); - if (!isValidCsrf(event.cookies, fd)) return fail(403, { create: true, error: "CSRF 검증에 실패했습니다. 페이지를 새로 고친 뒤 다시 시도해 주세요." }); + if (!isValidCsrf(event.cookies, fd)) return fail(403, { create: true, error: adminError(locale, "csrf_failed") }); const name = String(fd.get("name") ?? "").trim(); const redirectUrisRaw = String(fd.get("redirectUris") ?? "").trim(); const postLogoutUrisRaw = String(fd.get("postLogoutRedirectUris") ?? "").trim(); @@ -153,7 +156,7 @@ export const actions: Actions = { const scopesRaw = String(fd.get("scopes") ?? "openid").trim(); const tokenMethodRaw = String(fd.get("tokenEndpointAuthMethod") ?? "client_secret_basic"); if (!(ALLOWED_TOKEN_AUTH_METHODS as readonly string[]).includes(tokenMethodRaw)) { - return fail(400, { create: true, error: "tokenEndpointAuthMethod 값이 올바르지 않습니다." }); + return fail(400, { create: true, error: adminError(locale, "invalid_token_auth_method") }); } const tokenMethod = tokenMethodRaw as TokenAuthMethod; // public client(none)는 PKCE 필수 @@ -161,20 +164,20 @@ export const actions: Actions = { // ctrls H-OIDC-4: 와일드카드 redirect_uri 매칭은 명시적 opt-in. const allowWildcardRedirectUri = fd.get("allowWildcardRedirectUri") === "true"; - if (!name) return fail(400, { create: true, error: "이름은 필수입니다." }); - if (!redirectUrisRaw) return fail(400, { create: true, error: "Redirect URI 는 필수입니다." }); + if (!name) return fail(400, { create: true, error: adminError(locale, "name_required") }); + if (!redirectUrisRaw) return fail(400, { create: true, error: adminError(locale, "redirect_uri_required") }); // URL 검증 - const redirectV = validateUriList(redirectUrisRaw, "Redirect URI", { allowCustomScheme: true }); + const redirectV = validateUriList(redirectUrisRaw, "Redirect URI", locale, { allowCustomScheme: true }); if (!redirectV.ok) return fail(400, { create: true, error: redirectV.reason }); - const postLogoutV = postLogoutUrisRaw ? validateUriList(postLogoutUrisRaw, "Post-Logout Redirect URI") : null; + const postLogoutV = postLogoutUrisRaw ? validateUriList(postLogoutUrisRaw, "Post-Logout Redirect URI", locale) : null; if (postLogoutV && !postLogoutV.ok) return fail(400, { create: true, error: postLogoutV.reason }); - const frontV = validateSingleUri(frontchannelLogoutUri, "Frontchannel Logout URI"); + const frontV = validateSingleUri(frontchannelLogoutUri, "Frontchannel Logout URI", locale); if (!frontV.ok) return fail(400, { create: true, error: frontV.reason }); - const backV = validateSingleUri(backchannelLogoutUri, "Backchannel Logout URI"); + const backV = validateSingleUri(backchannelLogoutUri, "Backchannel Logout URI", locale); if (!backV.ok) return fail(400, { create: true, error: backV.reason }); - const scopesV = normalizeScopes(scopesRaw); + const scopesV = normalizeScopes(scopesRaw, locale); if (!scopesV.ok) return fail(400, { create: true, error: scopesV.reason }); const clientId = generateClientId(); @@ -219,9 +222,10 @@ export const actions: Actions = { update: async (event) => { const { locals } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); - if (!isValidCsrf(event.cookies, fd)) return fail(403, { error: "CSRF 검증에 실패했습니다. 페이지를 새로 고친 뒤 다시 시도해 주세요." }); + if (!isValidCsrf(event.cookies, fd)) return fail(403, { error: adminError(locale, "csrf_failed") }); const id = String(fd.get("id") ?? ""); const name = String(fd.get("name") ?? "").trim(); const redirectUrisRaw = String(fd.get("redirectUris") ?? "").trim(); @@ -233,18 +237,18 @@ export const actions: Actions = { const scopesRaw = String(fd.get("scopes") ?? "openid").trim(); const enabled = fd.get("enabled") === "true"; - if (!id || !name) return fail(400, { error: "잘못된 요청입니다." }); - if (!redirectUrisRaw) return fail(400, { error: "Redirect URI 는 필수입니다." }); + if (!id || !name) return fail(400, { error: adminError(locale, "invalid_request") }); + if (!redirectUrisRaw) return fail(400, { error: adminError(locale, "redirect_uri_required") }); - const redirectV = validateUriList(redirectUrisRaw, "Redirect URI", { allowCustomScheme: true }); + const redirectV = validateUriList(redirectUrisRaw, "Redirect URI", locale, { allowCustomScheme: true }); if (!redirectV.ok) return fail(400, { error: redirectV.reason }); - const postLogoutV = postLogoutUrisRaw ? validateUriList(postLogoutUrisRaw, "Post-Logout Redirect URI") : null; + const postLogoutV = postLogoutUrisRaw ? validateUriList(postLogoutUrisRaw, "Post-Logout Redirect URI", locale) : null; if (postLogoutV && !postLogoutV.ok) return fail(400, { error: postLogoutV.reason }); - const frontV = validateSingleUri(frontchannelLogoutUri, "Frontchannel Logout URI"); + const frontV = validateSingleUri(frontchannelLogoutUri, "Frontchannel Logout URI", locale); if (!frontV.ok) return fail(400, { error: frontV.reason }); - const backV = validateSingleUri(backchannelLogoutUri, "Backchannel Logout URI"); + const backV = validateSingleUri(backchannelLogoutUri, "Backchannel Logout URI", locale); if (!backV.ok) return fail(400, { error: backV.reason }); - const scopesV = normalizeScopes(scopesRaw); + const scopesV = normalizeScopes(scopesRaw, locale); if (!scopesV.ok) return fail(400, { error: scopesV.reason }); // public client(none)는 PKCE를 수정 시에도 강제 유지 @@ -293,11 +297,13 @@ export const actions: Actions = { regenerateSecret: async (event) => { const { locals } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); - if (!isValidCsrf(event.cookies, fd)) return fail(403, { error: "CSRF 검증에 실패했습니다. 페이지를 새로 고친 뒤 다시 시도해 주세요." }); - const id = String(fd.get("id") ?? ""); - if (!id) return fail(400, { error: "잘못된 요청입니다." }); + if (!isValidCsrf(event.cookies, fd)) return fail(403, { error: adminError(locale, "csrf_failed") }); + const idr = requireFormId(fd, locale); + if (!idr.ok) return idr.failure; + const id = idr.id; const newSecret = generateClientSecret(); const newSecretHashed = await hashClientSecret(newSecret); @@ -324,11 +330,13 @@ export const actions: Actions = { delete: async (event) => { const { locals } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); - if (!isValidCsrf(event.cookies, fd)) return fail(403, { error: "CSRF 검증에 실패했습니다. 페이지를 새로 고친 뒤 다시 시도해 주세요." }); - const id = String(fd.get("id") ?? ""); - if (!id) return fail(400, { error: "잘못된 요청입니다." }); + if (!isValidCsrf(event.cookies, fd)) return fail(403, { error: adminError(locale, "csrf_failed") }); + const idr = requireFormId(fd, locale); + if (!idr.ok) return idr.failure; + const id = idr.id; await db.delete(oidcClients).where(and(eq(oidcClients.id, id), eq(oidcClients.tenantId, tenant.id))); diff --git a/src/routes/admin/oidc-clients/[id]/+page.server.ts b/src/routes/admin/oidc-clients/[id]/+page.server.ts index 5a4f42d..4e5939e 100644 --- a/src/routes/admin/oidc-clients/[id]/+page.server.ts +++ b/src/routes/admin/oidc-clients/[id]/+page.server.ts @@ -5,6 +5,8 @@ import { requireAdminContext } from "$lib/server/auth/guards"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; import type { DB } from "$lib/server/db"; import { oidcClients, serviceRoles } from "$lib/server/db/schema"; +import { adminError, requireFormId } from "$lib/server/admin/errors"; +import { ORGANIZATION_CLAIM_FIELDS, type OrganizationClaimConfig } from "$lib/server/oidc/claims"; const ROLE_KEY_RE = /^[A-Za-z0-9_.-]{1,64}$/; @@ -16,7 +18,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { .from(oidcClients) .where(and(eq(oidcClients.id, params.id), eq(oidcClients.tenantId, tenant.id))) .limit(1); - if (!client) error(404, "클라이언트를 찾을 수 없습니다."); + if (!client) error(404, adminError(locals.locale, "client_not_found")); const roles = await db .select() @@ -40,6 +42,7 @@ export const actions: Actions = { addRole: async (event) => { const { locals, params } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); const key = String(fd.get("key") ?? "").trim(); @@ -48,11 +51,11 @@ export const actions: Actions = { const isDefault = fd.get("isDefault") === "true"; const displayOrder = Number(fd.get("displayOrder") ?? "0") | 0; - if (!ROLE_KEY_RE.test(key)) return fail(400, { error: "key 는 영숫자/._- 만 허용 (1~64자)." }); - if (!label) return fail(400, { error: "label 은 필수입니다." }); + if (!ROLE_KEY_RE.test(key)) return fail(400, { error: adminError(locale, "invalid_role_key") }); + if (!label) return fail(400, { error: adminError(locale, "label_required") }); const c = await clientForTenant(db, tenant.id, params.id); - if (!c) return fail(404, { error: "클라이언트를 찾을 수 없습니다." }); + if (!c) return fail(404, { error: adminError(locale, "client_not_found") }); try { await db.insert(serviceRoles).values({ @@ -68,7 +71,7 @@ export const actions: Actions = { }); } catch { // unique (serviceType, serviceRefId, key) - return fail(409, { error: "이미 존재하는 role key 입니다." }); + return fail(409, { error: adminError(locale, "role_key_exists") }); } const meta = getRequestMetadata(event); @@ -89,6 +92,7 @@ export const actions: Actions = { updateRole: async (event) => { const { locals, params } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); const id = String(fd.get("roleId") ?? ""); @@ -97,7 +101,7 @@ export const actions: Actions = { const isDefault = fd.get("isDefault") === "true"; const displayOrder = Number(fd.get("displayOrder") ?? "0") | 0; - if (!id || !label) return fail(400, { error: "필수 항목 누락." }); + if (!id || !label) return fail(400, { error: adminError(locale, "required_field_missing") }); await db .update(serviceRoles) @@ -107,12 +111,55 @@ export const actions: Actions = { return { updated: true }; }, + // organization scope 클레임의 클라이언트별 노출 토글 저장. + // 네 필드가 모두 켜져 있으면 null(=미설정=전량 노출, 하위호환)로 저장해 DB 를 깨끗이 유지하고, + // 하나라도 꺼져 있으면 명시적 JSON config 를 저장한다. token/userinfo 양쪽이 동일 config 를 적용한다. + updateOrganizationClaims: async (event) => { + const { locals, params } = event; + const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; + const fd = await event.request.formData(); + + const c = await clientForTenant(db, tenant.id, params.id); + if (!c) return fail(404, { error: adminError(locale, "client_not_found") }); + + const config: OrganizationClaimConfig = {}; + let allEnabled = true; + for (const field of ORGANIZATION_CLAIM_FIELDS) { + const enabled = fd.get(field) === "true"; + config[field] = enabled; + if (!enabled) allEnabled = false; + } + + const value = allEnabled ? null : JSON.stringify(config); + await db + .update(oidcClients) + .set({ organizationClaimConfig: value, updatedAt: new Date() }) + .where(and(eq(oidcClients.id, c.id), eq(oidcClients.tenantId, tenant.id))); + + const meta = getRequestMetadata(event); + await recordAuditEvent(db, { + tenantId: tenant.id, + actorId: locals.user!.id, + spOrClientId: params.id, + kind: "oidc_client_updated", + outcome: "success", + ip: meta.ip, + userAgent: meta.userAgent, + detail: { organizationClaimConfig: value }, + }); + + return { organizationClaimsUpdated: true }; + }, + deleteRole: async (event) => { const { locals, params } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); - const id = String(fd.get("roleId") ?? ""); - if (!id) return fail(400, { error: "잘못된 요청." }); + const idr = requireFormId(fd, locale, { field: "roleId" }); + if (!idr.ok) return idr.failure; + const id = idr.id; await db.delete(serviceRoles).where(and(eq(serviceRoles.id, id), eq(serviceRoles.tenantId, tenant.id), eq(serviceRoles.serviceType, "oidc"), eq(serviceRoles.serviceRefId, params.id))); diff --git a/src/routes/admin/oidc-clients/[id]/+page.svelte b/src/routes/admin/oidc-clients/[id]/+page.svelte index e530e46..20c94a6 100644 --- a/src/routes/admin/oidc-clients/[id]/+page.svelte +++ b/src/routes/admin/oidc-clients/[id]/+page.svelte @@ -9,6 +9,28 @@ const { data, form } = $props<{ data: PageData; form?: ActionData }>(); const err = $derived((form as { error?: string } | null)?.error ?? null); let editingId = $state(null); + +// organization 클레임 노출 토글 — config null/미설정이면 전량 노출(하위호환)이므로 전부 체크. +const ORG_CLAIM_FIELDS = ["department", "team", "position", "jobTitle"] as const; +type OrgClaimField = (typeof ORG_CLAIM_FIELDS)[number]; + +function parseOrgClaimConfig(raw: string | null): Record { + const result: Record = { department: true, team: true, position: true, jobTitle: true }; + if (!raw) return result; // null=미설정=전량 노출 + try { + const parsed = JSON.parse(raw) as Record; + for (const f of ORG_CLAIM_FIELDS) { + if (typeof parsed[f] === "boolean") result[f] = parsed[f] as boolean; + } + } catch { + /* 파싱 실패 시 전량 노출 폴백 */ + } + return result; +} + +// 서버 저장값에서 파생한 체크박스 상태(writable $derived). 체크박스 bind 로 로컬 편집이 +// 가능하고, 저장 후 load 재실행으로 data.client 가 바뀌면 자동으로 재계산된다. +let orgClaims = $derived(parseOrgClaimConfig((data.client as { organizationClaimConfig: string | null }).organizationClaimConfig));
@@ -112,4 +134,32 @@ let editingId = $state(null);
+ + +
+
+

{t("oidc.org_claims.title")}

+
+

{t("oidc.org_claims.desc")}

+ + {#if (form as { organizationClaimsUpdated?: boolean } | null)?.organizationClaimsUpdated} +
{t("oidc.org_claims.saved")}
+ {/if} + +
+ {#each ORG_CLAIM_FIELDS as field (field)} + + {/each} +
+

{t("oidc.org_claims.all_note")}

+ +
+
+
diff --git a/src/routes/admin/saml-sps/+page.server.ts b/src/routes/admin/saml-sps/+page.server.ts index 6b5cbc3..a15f087 100644 --- a/src/routes/admin/saml-sps/+page.server.ts +++ b/src/routes/admin/saml-sps/+page.server.ts @@ -2,6 +2,7 @@ import { fail } from "@sveltejs/kit"; import { desc, eq, and } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; import { requireAdminContext } from "$lib/server/auth/guards"; +import { adminError, requireFormId } from "$lib/server/admin/errors"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; import { samlSps } from "$lib/server/db/schema"; import { validateSamlUrl } from "$lib/server/validation"; @@ -59,6 +60,7 @@ export const actions: Actions = { create: async (event) => { const { locals } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); const name = String(fd.get("name") ?? "").trim(); @@ -75,19 +77,19 @@ export const actions: Actions = { const encryptAssertion = fd.get("encryptAssertion") === "true"; const allowedAttributes = parseAllowedAttributes(String(fd.get("allowedAttributes") ?? "")); - if (!name) return fail(400, { create: true, error: "이름은 필수입니다." }); - if (!entityId) return fail(400, { create: true, error: "Entity ID 는 필수입니다." }); - if (!acsUrl) return fail(400, { create: true, error: "ACS URL 은 필수입니다." }); + if (!name) return fail(400, { create: true, error: adminError(locale, "name_required") }); + if (!entityId) return fail(400, { create: true, error: adminError(locale, "entity_id_required") }); + if (!acsUrl) return fail(400, { create: true, error: adminError(locale, "acs_url_required") }); // Assertion 암호화는 SP 공개키가 있어야 가능하다. - if (encryptAssertion && !cert) return fail(400, { create: true, error: "Assertion 암호화를 사용하려면 SP 인증서가 필요합니다." }); + if (encryptAssertion && !cert) return fail(400, { create: true, error: adminError(locale, "sp_cert_required_for_encryption") }); const acsV = validateSamlUrl(acsUrl, "ACS URL"); - if (!acsV.ok) return fail(400, { create: true, error: acsV.reason }); + if (!acsV.ok) return fail(400, { create: true, error: adminError(locale, acsV.reason.key, acsV.reason.params) }); const sloV = validateSamlUrl(sloUrl, "SLO URL"); - if (!sloV.ok) return fail(400, { create: true, error: sloV.reason }); + if (!sloV.ok) return fail(400, { create: true, error: adminError(locale, sloV.reason.key, sloV.reason.params) }); if (!(ALLOWED_NAMEID_FORMATS as readonly string[]).includes(nameIdFormat)) { - return fail(400, { create: true, error: "허용되지 않는 NameID Format 입니다." }); + return fail(400, { create: true, error: adminError(locale, "nameid_format_forbidden") }); } // 중복 Entity ID 확인 @@ -96,7 +98,7 @@ export const actions: Actions = { .from(samlSps) .where(and(eq(samlSps.tenantId, tenant.id), eq(samlSps.entityId, entityId))) .limit(1); - if (existing) return fail(409, { create: true, error: "이미 등록된 Entity ID 입니다." }); + if (existing) return fail(409, { create: true, error: adminError(locale, "entity_id_taken") }); await db.insert(samlSps).values({ id: crypto.randomUUID(), @@ -133,6 +135,7 @@ export const actions: Actions = { update: async (event) => { const { locals } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); @@ -150,17 +153,17 @@ export const actions: Actions = { const enabled = fd.get("enabled") === "true"; const allowedAttributes = parseAllowedAttributes(String(fd.get("allowedAttributes") ?? "")); - if (!id || !name || !acsUrl) return fail(400, { error: "필수 항목이 누락되었습니다." }); + if (!id || !name || !acsUrl) return fail(400, { error: adminError(locale, "required_field_missing") }); // Assertion 암호화는 SP 공개키가 있어야 가능하다. - if (encryptAssertion && !cert) return fail(400, { error: "Assertion 암호화를 사용하려면 SP 인증서가 필요합니다." }); + if (encryptAssertion && !cert) return fail(400, { error: adminError(locale, "sp_cert_required_for_encryption") }); const acsV = validateSamlUrl(acsUrl, "ACS URL"); - if (!acsV.ok) return fail(400, { error: acsV.reason }); + if (!acsV.ok) return fail(400, { error: adminError(locale, acsV.reason.key, acsV.reason.params) }); const sloV = validateSamlUrl(sloUrl, "SLO URL"); - if (!sloV.ok) return fail(400, { error: sloV.reason }); + if (!sloV.ok) return fail(400, { error: adminError(locale, sloV.reason.key, sloV.reason.params) }); if (nameIdFormat && !(ALLOWED_NAMEID_FORMATS as readonly string[]).includes(nameIdFormat)) { - return fail(400, { error: "허용되지 않는 NameID Format 입니다." }); + return fail(400, { error: adminError(locale, "nameid_format_forbidden") }); } // ctrls H-SAML-4: 보안 설정 변경을 audit 로그에 정확히 기록. @@ -225,10 +228,12 @@ export const actions: Actions = { delete: async (event) => { const { locals } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); - const id = String(fd.get("id") ?? ""); - if (!id) return fail(400, { error: "잘못된 요청입니다." }); + const idr = requireFormId(fd, locale); + if (!idr.ok) return idr.failure; + const id = idr.id; await db.delete(samlSps).where(and(eq(samlSps.id, id), eq(samlSps.tenantId, tenant.id))); diff --git a/src/routes/admin/saml-sps/[id]/+page.server.ts b/src/routes/admin/saml-sps/[id]/+page.server.ts index fd5efd4..eb61340 100644 --- a/src/routes/admin/saml-sps/[id]/+page.server.ts +++ b/src/routes/admin/saml-sps/[id]/+page.server.ts @@ -2,6 +2,7 @@ import { error, fail } from "@sveltejs/kit"; import { and, asc, eq } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; import { requireAdminContext } from "$lib/server/auth/guards"; +import { adminError } from "$lib/server/admin/errors"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; import type { DB } from "$lib/server/db"; import { samlSps, serviceRoles } from "$lib/server/db/schema"; @@ -16,7 +17,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { .from(samlSps) .where(and(eq(samlSps.id, params.id), eq(samlSps.tenantId, tenant.id))) .limit(1); - if (!sp) error(404, "SP 를 찾을 수 없습니다."); + if (!sp) error(404, adminError(locals.locale, "saml_sp_not_found")); const roles = await db .select() @@ -48,11 +49,11 @@ export const actions: Actions = { const isDefault = fd.get("isDefault") === "true"; const displayOrder = Number(fd.get("displayOrder") ?? "0") | 0; - if (!ROLE_KEY_RE.test(key)) return fail(400, { error: "key 는 영숫자/._- 만 허용 (1~64자)." }); - if (!label) return fail(400, { error: "label 은 필수입니다." }); + if (!ROLE_KEY_RE.test(key)) return fail(400, { error: adminError(locals.locale, "invalid_role_key") }); + if (!label) return fail(400, { error: adminError(locals.locale, "label_required") }); const s = await spForTenant(db, tenant.id, params.id); - if (!s) return fail(404, { error: "SP 를 찾을 수 없습니다." }); + if (!s) return fail(404, { error: adminError(locals.locale, "saml_sp_not_found") }); try { await db.insert(serviceRoles).values({ @@ -67,7 +68,7 @@ export const actions: Actions = { displayOrder, }); } catch { - return fail(409, { error: "이미 존재하는 role key 입니다." }); + return fail(409, { error: adminError(locals.locale, "role_key_exists") }); } const meta = getRequestMetadata(event); @@ -96,7 +97,7 @@ export const actions: Actions = { const isDefault = fd.get("isDefault") === "true"; const displayOrder = Number(fd.get("displayOrder") ?? "0") | 0; - if (!id || !label) return fail(400, { error: "필수 항목 누락." }); + if (!id || !label) return fail(400, { error: adminError(locals.locale, "required_field_missing") }); await db .update(serviceRoles) @@ -111,7 +112,7 @@ export const actions: Actions = { const { db, tenant } = requireAdminContext(locals); const fd = await event.request.formData(); const id = String(fd.get("roleId") ?? ""); - if (!id) return fail(400, { error: "잘못된 요청." }); + if (!id) return fail(400, { error: adminError(locals.locale, "invalid_request") }); await db.delete(serviceRoles).where(and(eq(serviceRoles.id, id), eq(serviceRoles.tenantId, tenant.id), eq(serviceRoles.serviceType, "saml"), eq(serviceRoles.serviceRefId, params.id))); diff --git a/src/routes/admin/signing-keys/+page.server.ts b/src/routes/admin/signing-keys/+page.server.ts index ef687aa..0a5b520 100644 --- a/src/routes/admin/signing-keys/+page.server.ts +++ b/src/routes/admin/signing-keys/+page.server.ts @@ -1,12 +1,14 @@ import { fail } from "@sveltejs/kit"; import { desc, eq, and, isNull } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; -import { type DB, DB_DIALECT } from "$lib/server/db"; +import type { DB } from "$lib/server/db"; +import { runAtomic } from "$lib/server/db/atomic"; 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"; +import { generateRsaSigningKey, wrapPrivateKey, generateSelfSignedCert, invalidateSigningKeyCache } from "$lib/server/crypto/keys"; +import { adminError } from "$lib/server/admin/errors"; export const load: PageServerLoad = async ({ locals }) => { const { db, tenant } = requireAdminContext(locals); @@ -44,10 +46,11 @@ export const actions: Actions = { rotate: async (event) => { const { locals, platform } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const config = getRuntimeConfig(platform); if (!config.signingKeySecret) { - return fail(503, { error: "IDP_SIGNING_KEY_SECRET 이 설정되지 않았습니다." }); + return fail(503, { error: adminError(locale, "signing_key_secret_missing") }); } const cn = config.issuerUrl ? new URL(config.issuerUrl).hostname : "idp.local"; @@ -60,8 +63,7 @@ export const actions: Actions = { const now = new Date(); // 두 작업을 원자적으로 실행: (1) 기존 활성 키 비활성화, (2) 새 활성 키 INSERT. - // - d1: db.batch (D1 은 interactive transaction 미지원). - // - postgres/mysql: interactive transaction. + // runAtomic 이 d1/sqlite=batch, postgres/mysql=transaction 분기를 흡수한다. // d1/postgres 는 partial unique index (tenantId WHERE active) 가 동시 rotate 의 // 두 번째 INSERT 를 UNIQUE 위반으로 막아 단일 active invariant 를 DB 레벨에서 강제한다. // MySQL 은 partial unique index 를 지원하지 않으므로 이 트랜잭션이 최선의 보호막이다. @@ -83,23 +85,16 @@ export const actions: Actions = { }); try { - if (DB_DIALECT === "d1" || DB_DIALECT === "sqlite") { - // d1 / libSQL 은 원자적 batch 지원 (정규 DB 타입이 활성 방언일 때만 노출되므로 캐스팅). - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).batch([buildDeactivate(db), buildInsert(db)]); - } else { - // postgres / mysql: interactive transaction. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).transaction(async (tx: Pick) => { - await buildDeactivate(tx); - await buildInsert(tx); - }); - } + await runAtomic(db, [buildDeactivate, buildInsert]); } catch { // UNIQUE 위반 (동시 rotate) 또는 기타 DB 에러 → 409 - return fail(409, { error: "다른 rotate 작업이 진행 중이거나 DB 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." }); + return fail(409, { error: adminError(locale, "rotate_conflict") }); } + // 서명키/JWKS 단기 캐시 무효화 — 같은 isolate 에서 새 활성 키를 즉시 반영. + // (cross-isolate 는 캐시 TTL 만료로 수렴한다 — crypto/keys.ts 주석 참고.) + invalidateSigningKeyCache(tenant.id); + const requestMetadata = getRequestMetadata(event); await recordAuditEvent(db, { tenantId: tenant.id, diff --git a/src/routes/admin/skins/+page.server.ts b/src/routes/admin/skins/+page.server.ts index a8b7b7e..3bf8d85 100644 --- a/src/routes/admin/skins/+page.server.ts +++ b/src/routes/admin/skins/+page.server.ts @@ -2,6 +2,7 @@ import { fail } from "@sveltejs/kit"; import { desc, eq, and } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; import { requireAdminContext } from "$lib/server/auth/guards"; +import { adminError } from "$lib/server/admin/errors"; import { clientSkins, oidcClients, samlSps } from "$lib/server/db/schema"; import { invalidateSkinCache } from "$lib/server/skin/resolver"; import { isLinkLocalHost, isLoopbackHost } from "$lib/server/validation"; @@ -13,17 +14,17 @@ function validateSkinFetchUrl(raw: string): { ok: true; url: URL } | { ok: false try { url = new URL(raw); } catch { - return { ok: false, reason: "유효한 URL을 입력해 주세요." }; + return { ok: false, reason: "invalid_url" }; } if (url.protocol !== "https:") { - return { ok: false, reason: "https URL만 허용됩니다." }; + return { ok: false, reason: "https_only" }; } const host = url.hostname.toLowerCase(); if (isLoopbackHost(host)) { - return { ok: false, reason: "loopback 주소는 사용할 수 없습니다." }; + return { ok: false, reason: "loopback_forbidden" }; } if (/^127\./.test(host) || isLinkLocalHost(host)) { - return { ok: false, reason: "내부망/메타데이터 주소는 사용할 수 없습니다." }; + return { ok: false, reason: "internal_addr_forbidden" }; } return { ok: true, url }; } @@ -43,6 +44,7 @@ export const load: PageServerLoad = async ({ locals }) => { export const actions: Actions = { create: async ({ request, locals }) => { const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await request.formData(); const clientType = fd.get("clientType") as "oidc" | "saml"; @@ -53,19 +55,19 @@ export const actions: Actions = { const cacheTtlSeconds = Number(fd.get("cacheTtlSeconds") ?? 3600); if (!clientType || !clientRefId || !fetchUrl) { - return fail(400, { create: true, error: "필수 항목을 입력해 주세요." }); + return fail(400, { create: true, error: adminError(locale, "required_fields") }); } if (clientType !== "oidc" && clientType !== "saml") { - return fail(400, { create: true, error: "clientType이 올바르지 않습니다." }); + return fail(400, { create: true, error: adminError(locale, "invalid_client_type") }); } const v = validateSkinFetchUrl(fetchUrl); - if (!v.ok) return fail(400, { create: true, error: v.reason }); + if (!v.ok) return fail(400, { create: true, error: adminError(locale, v.reason) }); const ttl = isNaN(cacheTtlSeconds) ? 3600 : cacheTtlSeconds; - if (ttl < 0) return fail(400, { create: true, error: "cacheTtlSeconds 는 0 이상이어야 합니다." }); + if (ttl < 0) return fail(400, { create: true, error: adminError(locale, "cache_ttl_negative") }); if (ttl > MAX_SKIN_CACHE_TTL_SECONDS) { - return fail(400, { create: true, error: `cacheTtlSeconds 는 ${MAX_SKIN_CACHE_TTL_SECONDS} 초(1일) 를 초과할 수 없습니다.` }); + return fail(400, { create: true, error: adminError(locale, "cache_ttl_max", { max: MAX_SKIN_CACHE_TTL_SECONDS }) }); } try { @@ -80,7 +82,7 @@ export const actions: Actions = { enabled: true, }); } catch { - return fail(409, { create: true, error: "이미 동일한 클라이언트/스킨 타입 설정이 있습니다." }); + return fail(409, { create: true, error: adminError(locale, "skin_config_exists") }); } return { created: true }; @@ -88,6 +90,7 @@ export const actions: Actions = { delete: async ({ request, locals, platform }) => { const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await request.formData(); const id = String(fd.get("id") ?? ""); @@ -97,7 +100,7 @@ export const actions: Actions = { .where(and(eq(clientSkins.id, id), eq(clientSkins.tenantId, tenant.id))) .limit(1); - if (!skin) return fail(404, { error: "스킨을 찾을 수 없습니다." }); + if (!skin) return fail(404, { error: adminError(locale, "skin_not_found") }); await invalidateSkinCache(platform, tenant.id, skin.clientType, skin.clientRefId, skin.skinType); await db.delete(clientSkins).where(eq(clientSkins.id, id)); @@ -107,6 +110,7 @@ export const actions: Actions = { toggleEnabled: async ({ request, locals }) => { const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await request.formData(); const id = String(fd.get("id") ?? ""); @@ -116,7 +120,7 @@ export const actions: Actions = { .where(and(eq(clientSkins.id, id), eq(clientSkins.tenantId, tenant.id))) .limit(1); - if (!skin) return fail(404, { error: "스킨을 찾을 수 없습니다." }); + if (!skin) return fail(404, { error: adminError(locale, "skin_not_found") }); await db.update(clientSkins).set({ enabled: !skin.enabled }).where(eq(clientSkins.id, id)); @@ -125,6 +129,7 @@ export const actions: Actions = { invalidateCache: async ({ request, locals, platform }) => { const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await request.formData(); const id = String(fd.get("id") ?? ""); @@ -134,7 +139,7 @@ export const actions: Actions = { .where(and(eq(clientSkins.id, id), eq(clientSkins.tenantId, tenant.id))) .limit(1); - if (!skin) return fail(404, { error: "스킨을 찾을 수 없습니다." }); + if (!skin) return fail(404, { error: adminError(locale, "skin_not_found") }); await invalidateSkinCache(platform, tenant.id, skin.clientType, skin.clientRefId, skin.skinType); @@ -143,21 +148,22 @@ export const actions: Actions = { update: async ({ request, locals, platform }) => { const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await request.formData(); const id = String(fd.get("id") ?? "").trim(); const fetchUrl = String(fd.get("fetchUrl") ?? "").trim(); const fetchSecret = String(fd.get("fetchSecret") ?? "").trim() || null; const cacheTtlSeconds = Number(fd.get("cacheTtlSeconds") ?? 3600); - if (!fetchUrl) return fail(400, { update: true, updateId: id, error: "URL을 입력해 주세요." }); + if (!fetchUrl) return fail(400, { update: true, updateId: id, error: adminError(locale, "url_required") }); const v = validateSkinFetchUrl(fetchUrl); - if (!v.ok) return fail(400, { update: true, updateId: id, error: v.reason }); + if (!v.ok) return fail(400, { update: true, updateId: id, error: adminError(locale, v.reason) }); const ttl = isNaN(cacheTtlSeconds) ? 3600 : cacheTtlSeconds; - if (ttl < 0) return fail(400, { update: true, updateId: id, error: "cacheTtlSeconds 는 0 이상이어야 합니다." }); + if (ttl < 0) return fail(400, { update: true, updateId: id, error: adminError(locale, "cache_ttl_negative") }); if (ttl > MAX_SKIN_CACHE_TTL_SECONDS) { - return fail(400, { update: true, updateId: id, error: `cacheTtlSeconds 는 ${MAX_SKIN_CACHE_TTL_SECONDS} 초(1일) 를 초과할 수 없습니다.` }); + return fail(400, { update: true, updateId: id, error: adminError(locale, "cache_ttl_max", { max: MAX_SKIN_CACHE_TTL_SECONDS }) }); } const [skin] = await db @@ -166,7 +172,7 @@ export const actions: Actions = { .where(and(eq(clientSkins.id, id), eq(clientSkins.tenantId, tenant.id))) .limit(1); - if (!skin) return fail(404, { update: true, updateId: id, error: "스킨을 찾을 수 없습니다." }); + if (!skin) return fail(404, { update: true, updateId: id, error: adminError(locale, "skin_not_found") }); await db .update(clientSkins) diff --git a/src/routes/admin/skins/+page.svelte b/src/routes/admin/skins/+page.svelte index c56aedb..82bef77 100644 --- a/src/routes/admin/skins/+page.svelte +++ b/src/routes/admin/skins/+page.svelte @@ -273,5 +273,8 @@ function clientLabel(clientType: string, clientRefId: string): string {

{{IDP_FORM_ACTION}} — {t("skins.placeholder_form_action")}

{{IDP_REDIRECT_TO}} — {t("skins.placeholder_redirect_to")}

{{IDP_SKIN_HINT}} — {t("skins.placeholder_skin_hint")}

+

{{IDP_REGISTERED}} — {t("skins.placeholder_registered")}

+

{{IDP_PASSWORD_RESET}} — {t("skins.placeholder_password_reset")}

+

{{IDP_FLASH_MSG}} — {t("skins.placeholder_flash_msg")}

diff --git a/src/routes/admin/skins/guide/+page.svelte b/src/routes/admin/skins/guide/+page.svelte index dece0e6..46dcf4c 100644 --- a/src/routes/admin/skins/guide/+page.svelte +++ b/src/routes/admin/skins/guide/+page.svelte @@ -51,7 +51,7 @@ import { t } from "$lib/i18n.svelte"; - {#each [{ key: "IDP_FORM_ACTION", desc: t("skins.placeholder_form_action") }, { key: "IDP_REDIRECT_TO", desc: t("skins.placeholder_redirect_to") }, { key: "IDP_SKIN_HINT", desc: t("skins.placeholder_skin_hint") }] as row (row.key)} + {#each [{ key: "IDP_FORM_ACTION", desc: t("skins.placeholder_form_action") }, { key: "IDP_REDIRECT_TO", desc: t("skins.placeholder_redirect_to") }, { key: "IDP_SKIN_HINT", desc: t("skins.placeholder_skin_hint") }, { key: "IDP_REGISTERED", desc: t("skins.placeholder_registered") }, { key: "IDP_PASSWORD_RESET", desc: t("skins.placeholder_password_reset") }, { key: "IDP_FLASH_MSG", desc: t("skins.placeholder_flash_msg") }] as row (row.key)} {{{row.key}}} diff --git a/src/routes/admin/users/+page.server.ts b/src/routes/admin/users/+page.server.ts index 44c2184..2e1d175 100644 --- a/src/routes/admin/users/+page.server.ts +++ b/src/routes/admin/users/+page.server.ts @@ -1,13 +1,17 @@ import { fail } from "@sveltejs/kit"; -import { desc, eq, and, lt, or, sql } from "drizzle-orm"; +import { desc, eq, and, lt, gt, or, sql, inArray, isNull } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; import { requireAdminContext, assertNotLastAdmin } from "$lib/server/auth/guards"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; -import { users, credentials } from "$lib/server/db/schema"; +import { dispatchSecurityAlert } from "$lib/server/security-notify"; +import { users, credentials, inviteTokens } from "$lib/server/db/schema"; import { hashPassword } from "$lib/server/auth/password"; import { normalizeEmail, normalizeUsername } from "$lib/server/auth/users"; import { PASSWORD_CREDENTIAL_TYPE } from "$lib/server/auth/constants"; import { revokeAllUserSessions } from "$lib/server/auth/session"; +import { adminError, requireFormId } from "$lib/server/admin/errors"; +import { runAtomic } from "$lib/server/db/atomic"; +import { issueInvite } from "$lib/server/auth/invite"; const PAGE_SIZE = 50; @@ -57,7 +61,37 @@ export const load: PageServerLoad = async ({ locals, url }) => { const rows = hasMore ? rowsPlusOne.slice(0, PAGE_SIZE) : rowsPlusOne; const nextCursor = hasMore && rows.length > 0 ? rows[rows.length - 1].createdAt.getTime() : null; - return { users: rows, search, pageSize: PAGE_SIZE, nextCursor }; + // "초대중" 배지 판정: 실제 미사용·미만료 invite_token 을 보유한 사용자만. + // + // 과거 판정(emailVerifiedAt NULL AND password credential 부재)은 LDAP/OIDC/SAML/패스키 전용 + // 계정도 만족한다(연합 프로비저닝은 emailVerifiedAt NULL + password credential 없이 계정 생성). + // 그래서 연합 사용자 전원이 "초대중"으로 오표시됐다. 진짜 초대 대기 계정은 accept-invite 로 + // 소비될(usedAt 세팅) 유효 토큰을 갖는다 — accept-invite 서버의 유효성 판정(isNull(usedAt) + + // 미만료)과 동일 기준으로 이번 페이지 사용자 id 범위만 배치 조회(N+1 없음)한다. + const pageUserIds = rows.map((r) => r.id); + const pendingInviteUserIds = new Set(); + if (pageUserIds.length > 0) { + const now = new Date(); + const inviteRows = await db + .select({ userId: inviteTokens.userId }) + .from(inviteTokens) + .where(and(inArray(inviteTokens.userId, pageUserIds), isNull(inviteTokens.usedAt), gt(inviteTokens.expiresAt, now))); + for (const t of inviteRows) pendingInviteUserIds.add(t.userId); + } + + const usersOut = rows.map((r) => ({ + id: r.id, + username: r.username, + email: r.email, + displayName: r.displayName, + role: r.role, + status: r.status, + createdAt: r.createdAt, + // 초대 대기 상태(유효한 미사용 초대 토큰 보유) 여부. + pendingInvite: pendingInviteUserIds.has(r.id), + })); + + return { users: usersOut, search, pageSize: PAGE_SIZE, nextCursor }; }; export const actions: Actions = { @@ -65,6 +99,7 @@ export const actions: Actions = { create: async (event) => { const { locals } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); const email = normalizeEmail(String(fd.get("email") ?? "")); @@ -74,13 +109,13 @@ export const actions: Actions = { const password = String(fd.get("password") ?? ""); if (!email || !password) { - return fail(400, { create: true, error: "이메일과 비밀번호는 필수입니다." }); + return fail(400, { create: true, error: adminError(locale, "email_password_required") }); } if (password.length < 8) { - return fail(400, { create: true, error: "비밀번호는 8자 이상이어야 합니다." }); + return fail(400, { create: true, error: adminError(locale, "password_min_length") }); } if (!["admin", "user"].includes(role)) { - return fail(400, { create: true, error: "역할이 올바르지 않습니다." }); + return fail(400, { create: true, error: adminError(locale, "invalid_role") }); } // 중복 확인 @@ -90,7 +125,7 @@ export const actions: Actions = { .where(and(eq(users.tenantId, tenant.id), eq(users.email, email))) .limit(1); if (existing) { - return fail(409, { create: true, error: "이미 사용 중인 이메일입니다." }); + return fail(409, { create: true, error: adminError(locale, "email_taken") }); } const [existingUsername] = await db @@ -99,7 +134,7 @@ export const actions: Actions = { .where(and(eq(users.tenantId, tenant.id), eq(users.username, username))) .limit(1); if (existingUsername) { - return fail(409, { create: true, error: "이미 사용 중인 아이디입니다." }); + return fail(409, { create: true, error: adminError(locale, "username_taken") }); } const userId = crypto.randomUUID(); @@ -137,22 +172,99 @@ export const actions: Actions = { return { create: true }; }, + // ── 사용자 초대 ──────────────────────────────────────────────────────────── + // 비밀번호 없이 계정을 선생성(credentials row 생략, emailVerifiedAt NULL, status active)한 뒤 + // 초대 토큰+메일을 발급한다. 초대 링크 수락 시 최초 비밀번호가 설정된다(accept-invite). + invite: async (event) => { + const { locals } = event; + const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; + + const fd = await event.request.formData(); + const email = normalizeEmail(String(fd.get("email") ?? "")); + const username = normalizeUsername(String(fd.get("username") ?? "")) || email.split("@")[0]; + const displayName = String(fd.get("displayName") ?? "").trim(); + const role = String(fd.get("role") ?? "user") as "admin" | "user"; + + if (!email) { + return fail(400, { invite: true, error: adminError(locale, "email_required") }); + } + if (!["admin", "user"].includes(role)) { + return fail(400, { invite: true, error: adminError(locale, "invalid_role") }); + } + + // 중복 확인 — create 액션과 동일 규약(이메일/아이디 중복 차단). + const [existing] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.tenantId, tenant.id), eq(users.email, email))) + .limit(1); + if (existing) { + return fail(409, { invite: true, error: adminError(locale, "email_taken") }); + } + + const [existingUsername] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.tenantId, tenant.id), eq(users.username, username))) + .limit(1); + if (existingUsername) { + return fail(409, { invite: true, error: adminError(locale, "username_taken") }); + } + + const userId = crypto.randomUUID(); + // users insert 를 runAtomic 로 감싼다 — 지금은 단일 write 지만, 초대 계정에 후속 row(예: 기본 + // 프로필/그룹)를 원자적으로 함께 넣도록 확장할 때 create 액션의 비원자 패턴을 답습하지 않기 위함. + await runAtomic(db, [ + (h) => + h.insert(users).values({ + id: userId, + tenantId: tenant.id, + email, + username, + displayName: displayName || null, + role, + status: "active", + // emailVerifiedAt 미설정(NULL) — 초대 링크 수락 시 세팅된다. + }), + ]); + + // 초대 토큰 발급 + 메일(best-effort, 완전 격리). 발송 실패가 초대 응답을 실패시키지 않는다. + // 초대 대상은 신규 계정(locale 미보유)이므로 관리자 요청 locale 로 발송한다. + await issueInvite(db, userId, email, locale, event.platform); + + const requestMetadata = getRequestMetadata(event); + await recordAuditEvent(db, { + tenantId: tenant.id, + userId, + actorId: locals.user!.id, + kind: "user_invited", + outcome: "success", + ip: requestMetadata.ip, + userAgent: requestMetadata.userAgent, + detail: { email, role }, + }); + + return { invite: true }; + }, + // ── 상태 변경 ───────────────────────────────────────────────────────────── updateStatus: async (event) => { const { locals } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); const status = String(fd.get("status") ?? "") as "active" | "disabled" | "locked"; if (!id || !["active", "disabled", "locked"].includes(status)) { - return fail(400, { error: "잘못된 요청입니다." }); + return fail(400, { error: adminError(locale, "invalid_request") }); } // 자기 자신 비활성화 방지 if (id === locals.user!.id && status !== "active") { - return fail(400, { error: "자기 자신의 상태를 변경할 수 없습니다." }); + return fail(400, { error: adminError(locale, "cannot_change_own_status") }); } // 마지막 활성 관리자 보호 — disable/locked 로 전환 시 검사 @@ -161,6 +273,13 @@ export const actions: Actions = { if (lastAdminFail) return lastAdminFail; } + // 알림 대상 파악용 — 대상 유저의 이메일/locale(같은 테넌트 범위). + const [target] = await db + .select({ email: users.email, locale: users.locale }) + .from(users) + .where(and(eq(users.id, id), eq(users.tenantId, tenant.id))) + .limit(1); + await db .update(users) .set({ status, updatedAt: new Date() }) @@ -183,6 +302,13 @@ export const actions: Actions = { detail: { status }, }); + // 보안 알림(best-effort, 완전 격리) — 계정 잠금/비활성 전환 시. + if (status === "locked") { + dispatchSecurityAlert({ to: target?.email, locale: target?.locale, kind: "account_locked", platform: event.platform }); + } else if (status === "disabled") { + dispatchSecurityAlert({ to: target?.email, locale: target?.locale, kind: "account_disabled", platform: event.platform }); + } + return { updateStatus: true }; }, @@ -190,18 +316,19 @@ export const actions: Actions = { updateRole: async (event) => { const { locals } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); const role = String(fd.get("role") ?? "") as "admin" | "user"; if (!id || !["admin", "user"].includes(role)) { - return fail(400, { error: "잘못된 요청입니다." }); + return fail(400, { error: adminError(locale, "invalid_request") }); } // 자기 자신 역할 변경 방지 if (id === locals.user!.id) { - return fail(400, { error: "자기 자신의 역할을 변경할 수 없습니다." }); + return fail(400, { error: adminError(locale, "cannot_change_own_role") }); } // admin → user 강등 시에만 last-admin 검사 (승격은 항상 허용) @@ -237,25 +364,26 @@ export const actions: Actions = { resetPassword: async (event) => { const { locals } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); const id = String(fd.get("id") ?? ""); const newPassword = String(fd.get("newPassword") ?? ""); if (!id || !newPassword) { - return fail(400, { resetPassword: true, error: "비밀번호를 입력해 주세요." }); + return fail(400, { resetPassword: true, error: adminError(locale, "password_required") }); } if (newPassword.length < 8) { - return fail(400, { resetPassword: true, error: "비밀번호는 8자 이상이어야 합니다." }); + return fail(400, { resetPassword: true, error: adminError(locale, "password_min_length") }); } // 대상 유저가 같은 테넌트인지 확인 const [target] = await db - .select({ id: users.id }) + .select({ id: users.id, email: users.email, locale: users.locale }) .from(users) .where(and(eq(users.id, id), eq(users.tenantId, tenant.id))) .limit(1); - if (!target) return fail(404, { resetPassword: true, error: "사용자를 찾을 수 없습니다." }); + if (!target) return fail(404, { resetPassword: true, error: adminError(locale, "user_not_found") }); const hashed = await hashPassword(newPassword); const [existing] = await db @@ -290,6 +418,9 @@ export const actions: Actions = { userAgent: requestMetadata.userAgent, }); + // 보안 알림(best-effort, 완전 격리) — 관리자가 비밀번호를 초기화함. + dispatchSecurityAlert({ to: target.email, locale: target.locale, kind: "password_reset_by_admin", platform: event.platform }); + return { resetPassword: true }; }, @@ -297,14 +428,15 @@ export const actions: Actions = { delete: async (event) => { const { locals } = event; const { db, tenant } = requireAdminContext(locals); + const locale = locals.locale; const fd = await event.request.formData(); - const id = String(fd.get("id") ?? ""); - - if (!id) return fail(400, { error: "잘못된 요청입니다." }); + const idr = requireFormId(fd, locale); + if (!idr.ok) return idr.failure; + const id = idr.id; if (id === locals.user!.id) { - return fail(400, { error: "자기 자신을 삭제할 수 없습니다." }); + return fail(400, { error: adminError(locale, "cannot_delete_self") }); } // 마지막 활성 관리자 보호 — 삭제도 동일하게 검사 diff --git a/src/routes/admin/users/+page.svelte b/src/routes/admin/users/+page.svelte index c7730cf..bb19b3a 100644 --- a/src/routes/admin/users/+page.svelte +++ b/src/routes/admin/users/+page.svelte @@ -12,10 +12,12 @@ const dateFormatter = new Intl.DateTimeFormat("ko-KR", { }); let showCreate = $state(false); +let showInvite = $state(false); let resetPasswordUserId = $state(null); const formErr = $derived((form as { error?: string } | null)?.error ?? null); const createErr = $derived((form as { create?: boolean; error?: string } | null)?.create ? formErr : null); +const inviteErr = $derived((form as { invite?: boolean; error?: string } | null)?.invite ? formErr : null); const resetErr = $derived((form as { resetPassword?: boolean; error?: string } | null)?.resetPassword ? formErr : null); const STATUS_COLOR: Record = { @@ -35,11 +37,84 @@ const STATUS_NEXT: Record = {

{t("users.title")}

- +
+ + +
+ {#if showInvite} +
+

{t("users.invite_title")}

+

{t("users.invite_hint")}

+ + {#if inviteErr} +
+ {inviteErr} +
+ {/if} + +
{ + return ({ result, update }) => { + update(); + if (result.type === "success") showInvite = false; + }; + }} + class="grid grid-cols-1 gap-3 sm:grid-cols-2"> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ {/if} + {#if showCreate}

{t("users.create_title")}

@@ -100,7 +175,7 @@ const STATUS_NEXT: Record = {
{/if} - {#if formErr && !createErr && !resetErr} + {#if formErr && !createErr && !inviteErr && !resetErr}
{formErr}
@@ -164,6 +239,11 @@ const STATUS_NEXT: Record = { {t(`users.status_${user.status}`)} + {#if user.pendingInvite} + + {t("users.badge_pending_invite")} + + {/if} {#if STATUS_NEXT[user.status]}
diff --git a/src/routes/admin/users/[id]/+page.server.ts b/src/routes/admin/users/[id]/+page.server.ts index 75fea81..6dcc234 100644 --- a/src/routes/admin/users/[id]/+page.server.ts +++ b/src/routes/admin/users/[id]/+page.server.ts @@ -1,11 +1,13 @@ -import { fail, error } from "@sveltejs/kit"; +import { error } from "@sveltejs/kit"; import { and, asc, eq, isNull } from "drizzle-orm"; import type { Actions, PageServerLoad } from "./$types"; -import { requireAdminContext, assertNotLastAdmin, assertUserInTenant } from "$lib/server/auth/guards"; -import { revokeAllUserSessions } from "$lib/server/auth/session"; -import { revokeAllUserRefreshTokens } from "$lib/server/oidc/refresh"; -import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index"; +import { requireAdminContext } from "$lib/server/auth/guards"; import { departments, oidcClients, parts, positions, samlSps, serviceRoles, teams, userDepartments, userParts, userServiceAssignments, userTeams, users } from "$lib/server/db/schema"; +import { updateProfile } from "$lib/server/admin/user-actions/profile"; +import { addDept, removeDept, addTeam, removeTeam, addPart, removePart } from "$lib/server/admin/user-actions/org"; +import { addAssignment, revokeAssignment, updateAssignmentExpiry } from "$lib/server/admin/user-actions/service"; +import { forceLogout } from "$lib/server/admin/user-actions/security"; +import { adminError } from "$lib/server/admin/errors"; export const load: PageServerLoad = async ({ locals, params }) => { const { db, tenant } = requireAdminContext(locals); @@ -18,7 +20,7 @@ export const load: PageServerLoad = async ({ locals, params }) => { .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))) .limit(1); - if (!user) error(404, "사용자를 찾을 수 없습니다."); + if (!user) error(404, adminError(locals.locale, "user_not_found")); // 아래 조회는 서로 독립적이므로 병렬 실행한다. 순차 await 워터폴을 제거해 // 관리자 상세 페이지 로드 지연을 대폭 단축한다. @@ -162,516 +164,18 @@ export const load: PageServerLoad = async ({ locals, params }) => { }; }; +// 액션은 도메인별 모듈로 분리돼 있고(순수 이동), 여기서는 이름-계약만 조립한다. +// profile: 프로필/역할/상태 · org: 부서/팀/파트 소속 · service: 서비스 권한 · security: 강제 로그아웃 export const actions: Actions = { - // 프로필 수정 - updateProfile: async (event) => { - const { locals, params, request } = event; - const { db, tenant } = requireAdminContext(locals); - const fd = await request.formData(); - const userId = params.id; - - const rawRole = String(fd.get("role") ?? "user"); - const rawStatus = String(fd.get("status") ?? "active"); - - if (rawRole !== "admin" && rawRole !== "user") { - return fail(400, { error: "잘못된 role 값입니다." }); - } - if (rawStatus !== "active" && rawStatus !== "disabled" && rawStatus !== "locked") { - return fail(400, { error: "잘못된 status 값입니다." }); - } - - const role = rawRole as "admin" | "user"; - const status = rawStatus as "active" | "disabled" | "locked"; - - // ctrls C-13: cross-tenant IDOR 차단. params.id 가 본 tenant user 인지 명시 검증. - const tenantCheck = await assertUserInTenant(db, tenant.id, userId); - if (!tenantCheck.ok) return tenantCheck.error; - - // 변경 전 role/status 캡처 — 자기-자신 가드, race 가드, role 변경 감지 모두에 사용 - const [before] = await db - .select({ role: users.role, status: users.status }) - .from(users) - .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))) - .limit(1); - - // ctrls C-12: 자기 자신의 role/status 변경은 무조건 차단. - // 값이 현재와 같더라도 폼 안에서 admin 이 자기 권한을 손대는 흐름 자체를 - // 차단해야 race 우회 가능성을 없앤다 (다른 admin 에게 요청해야 함). - // 또한 폼이 전송한 role/status 를 무시하고 DB 현재 값을 그대로 유지한다. - let effectiveRole = role; - let effectiveStatus = status; - if (userId === locals.user!.id) { - if (before && (before.role !== role || before.status !== status)) { - return fail(400, { error: "자기 자신의 role/status 는 변경할 수 없습니다. 다른 관리자에게 요청해 주세요." }); - } - effectiveRole = before?.role ?? role; - effectiveStatus = before?.status ?? status; - } - - // 마지막 활성 관리자 보호 — admin 강등 또는 active 해제 시 사전 검사 - const isAdminRemoval = effectiveRole === "user" || effectiveStatus !== "active"; - if (isAdminRemoval) { - const lastAdminFail = await assertNotLastAdmin(db, tenant.id, userId); - if (lastAdminFail) return lastAdminFail; - } - - const displayName = String(fd.get("displayName") ?? "").trim() || null; - const givenName = String(fd.get("givenName") ?? "").trim() || null; - const familyName = String(fd.get("familyName") ?? "").trim() || null; - const phoneNumber = String(fd.get("phoneNumber") ?? "").trim() || null; - const bio = String(fd.get("bio") ?? "").trim() || null; - const birthdate = String(fd.get("birthdate") ?? "").trim() || null; - const locale = String(fd.get("locale") ?? "ko-KR").trim(); - const zoneinfo = String(fd.get("zoneinfo") ?? "Asia/Seoul").trim(); - // 주소 (OIDC address 클레임 구성요소). 빈 값은 null 로 저장. - const addressStreet = String(fd.get("addressStreet") ?? "").trim() || null; - const addressLocality = String(fd.get("addressLocality") ?? "").trim() || null; - const addressRegion = String(fd.get("addressRegion") ?? "").trim() || null; - const addressPostalCode = String(fd.get("addressPostalCode") ?? "").trim() || null; - const addressCountry = String(fd.get("addressCountry") ?? "").trim() || null; - - await db - .update(users) - .set({ - displayName, - givenName, - familyName, - phoneNumber, - bio, - birthdate, - locale, - zoneinfo, - addressStreet, - addressLocality, - addressRegion, - addressPostalCode, - addressCountry, - role: effectiveRole, - status: effectiveStatus, - updatedAt: new Date(), - }) - .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))); - - // ctrls C-12: race 가드 — UPDATE 직후 invariant 재확인. - // 두 admin 이 동시에 서로 강등하면 사전 assertNotLastAdmin 가 둘 다 - // 통과해 0 admin 상태가 될 수 있다. UPDATE 직후 활성 admin 카운트를 - // 다시 세고 0 이면 본 UPDATE 의 role/status 만 즉시 되돌린다. - if (isAdminRemoval) { - const remaining = await db - .select({ id: users.id }) - .from(users) - .where(and(eq(users.tenantId, tenant.id), eq(users.role, "admin"), eq(users.status, "active"))) - .limit(1); - if (remaining.length === 0 && before) { - await db - .update(users) - .set({ role: before.role, status: before.status, updatedAt: new Date() }) - .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))); - return fail(409, { error: "동시에 다른 관리자도 강등된 것으로 보입니다. 변경을 취소했습니다. 다시 시도해 주세요." }); - } - } - - // role 변경 시 기존 세션 + OIDC refresh token 전부 파기 — 이전 권한 캐시 차단 - if (before && before.role !== effectiveRole) { - await revokeAllUserSessions(db, userId); - await revokeAllUserRefreshTokens(db, userId); - } - - const meta = getRequestMetadata(event); - await recordAuditEvent(db, { - tenantId: tenant.id, - userId, - actorId: locals.user!.id, - kind: "user_profile_updated", - outcome: "success", - ip: meta.ip, - userAgent: meta.userAgent, - detail: { role: effectiveRole, status: effectiveStatus }, - }); - - return { updated: true }; - }, - - // 부서 소속 추가 - addDept: async (event) => { - const { locals, params, request } = event; - const { db, tenant } = requireAdminContext(locals); - const fd = await request.formData(); - const userId = params.id; - const departmentId = String(fd.get("departmentId") ?? ""); - const positionId = String(fd.get("positionId") ?? "").trim() || null; - const jobTitle = String(fd.get("jobTitle") ?? "").trim() || null; - const isPrimary = fd.get("isPrimary") === "true"; - - if (!departmentId) return fail(400, { error: "부서를 선택해 주세요." }); - - const [targetUser] = await db - .select({ id: users.id }) - .from(users) - .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))) - .limit(1); - if (!targetUser) return fail(404, { error: "사용자를 찾을 수 없습니다." }); - - const [dept] = await db - .select({ id: departments.id }) - .from(departments) - .where(and(eq(departments.id, departmentId), eq(departments.tenantId, tenant.id))) - .limit(1); - if (!dept) return fail(404, { error: "부서를 찾을 수 없습니다." }); - - if (positionId) { - const [pos] = await db - .select({ id: positions.id }) - .from(positions) - .where(and(eq(positions.id, positionId), eq(positions.tenantId, tenant.id))) - .limit(1); - if (!pos) return fail(404, { error: "직책을 찾을 수 없습니다." }); - } - - const membershipId = crypto.randomUUID(); - await db.insert(userDepartments).values({ id: membershipId, tenantId: tenant.id, userId, departmentId, positionId, jobTitle, isPrimary }); - - const meta = getRequestMetadata(event); - await recordAuditEvent(db, { - tenantId: tenant.id, - userId, - actorId: locals.user!.id, - kind: "membership_change", - outcome: "success", - ip: meta.ip, - userAgent: meta.userAgent, - detail: { membershipId, action: "add_dept", departmentId, positionId, isPrimary }, - }); - - return { addedDept: true }; - }, - - // 부서 소속 제거 (endedAt 설정) - removeDept: async (event) => { - const { locals, params, request } = event; - const { db, tenant } = requireAdminContext(locals); - const fd = await request.formData(); - const membershipId = String(fd.get("membershipId") ?? ""); - if (!membershipId) return fail(400, { error: "잘못된 요청입니다." }); - - // IDOR 방어: membershipId가 본 페이지의 userId 소유인지도 확인 - const result = await db - .update(userDepartments) - .set({ endedAt: new Date() }) - .where(and(eq(userDepartments.id, membershipId), eq(userDepartments.userId, params.id), eq(userDepartments.tenantId, tenant.id))); - - const meta = getRequestMetadata(event); - await recordAuditEvent(db, { - tenantId: tenant.id, - userId: params.id, - actorId: locals.user!.id, - kind: "membership_change", - outcome: "success", - ip: meta.ip, - userAgent: meta.userAgent, - detail: { membershipId, action: "remove_dept" }, - }); - - void result; - return { removedDept: true }; - }, - - // 팀 소속 추가 - addTeam: async (event) => { - const { locals, params, request } = event; - const { db, tenant } = requireAdminContext(locals); - const fd = await request.formData(); - const userId = params.id; - const teamId = String(fd.get("teamId") ?? ""); - const jobTitle = String(fd.get("jobTitle") ?? "").trim() || null; - const isPrimary = fd.get("isPrimary") === "true"; - - if (!teamId) return fail(400, { error: "팀을 선택해 주세요." }); - - const [targetUser] = await db - .select({ id: users.id }) - .from(users) - .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))) - .limit(1); - if (!targetUser) return fail(404, { error: "사용자를 찾을 수 없습니다." }); - - const [team] = await db - .select({ id: teams.id }) - .from(teams) - .where(and(eq(teams.id, teamId), eq(teams.tenantId, tenant.id))) - .limit(1); - if (!team) return fail(404, { error: "팀을 찾을 수 없습니다." }); - - const membershipId = crypto.randomUUID(); - await db.insert(userTeams).values({ id: membershipId, tenantId: tenant.id, userId, teamId, jobTitle, isPrimary }); - - const meta = getRequestMetadata(event); - await recordAuditEvent(db, { - tenantId: tenant.id, - userId, - actorId: locals.user!.id, - kind: "membership_change", - outcome: "success", - ip: meta.ip, - userAgent: meta.userAgent, - detail: { membershipId, action: "add_team", teamId, isPrimary }, - }); - - return { addedTeam: true }; - }, - - // 팀 소속 제거 - removeTeam: async (event) => { - const { locals, params, request } = event; - const { db, tenant } = requireAdminContext(locals); - const fd = await request.formData(); - const membershipId = String(fd.get("membershipId") ?? ""); - if (!membershipId) return fail(400, { error: "잘못된 요청입니다." }); - - await db - .update(userTeams) - .set({ endedAt: new Date() }) - .where(and(eq(userTeams.id, membershipId), eq(userTeams.userId, params.id), eq(userTeams.tenantId, tenant.id))); - - const meta = getRequestMetadata(event); - await recordAuditEvent(db, { - tenantId: tenant.id, - userId: params.id, - actorId: locals.user!.id, - kind: "membership_change", - outcome: "success", - ip: meta.ip, - userAgent: meta.userAgent, - detail: { membershipId, action: "remove_team" }, - }); - - return { removedTeam: true }; - }, - - // 파트 소속 추가 - addPart: async (event) => { - const { locals, params, request } = event; - const { db, tenant } = requireAdminContext(locals); - const fd = await request.formData(); - const userId = params.id; - const partId = String(fd.get("partId") ?? ""); - const jobTitle = String(fd.get("jobTitle") ?? "").trim() || null; - const isPrimary = fd.get("isPrimary") === "true"; - - if (!partId) return fail(400, { error: "파트를 선택해 주세요." }); - - const [targetUser] = await db - .select({ id: users.id }) - .from(users) - .where(and(eq(users.id, userId), eq(users.tenantId, tenant.id))) - .limit(1); - if (!targetUser) return fail(404, { error: "사용자를 찾을 수 없습니다." }); - - const [part] = await db - .select({ id: parts.id }) - .from(parts) - .where(and(eq(parts.id, partId), eq(parts.tenantId, tenant.id))) - .limit(1); - if (!part) return fail(404, { error: "파트를 찾을 수 없습니다." }); - - const membershipId = crypto.randomUUID(); - await db.insert(userParts).values({ id: membershipId, tenantId: tenant.id, userId, partId, jobTitle, isPrimary }); - - const meta = getRequestMetadata(event); - await recordAuditEvent(db, { - tenantId: tenant.id, - userId, - actorId: locals.user!.id, - kind: "membership_change", - outcome: "success", - ip: meta.ip, - userAgent: meta.userAgent, - detail: { membershipId, action: "add_part", partId, isPrimary }, - }); - - return { addedPart: true }; - }, - - // 파트 소속 제거 - removePart: async (event) => { - const { locals, params, request } = event; - const { db, tenant } = requireAdminContext(locals); - const fd = await request.formData(); - const membershipId = String(fd.get("membershipId") ?? ""); - if (!membershipId) return fail(400, { error: "잘못된 요청입니다." }); - - await db - .update(userParts) - .set({ endedAt: new Date() }) - .where(and(eq(userParts.id, membershipId), eq(userParts.userId, params.id), eq(userParts.tenantId, tenant.id))); - - const meta = getRequestMetadata(event); - await recordAuditEvent(db, { - tenantId: tenant.id, - userId: params.id, - actorId: locals.user!.id, - kind: "membership_change", - outcome: "success", - ip: meta.ip, - userAgent: meta.userAgent, - detail: { membershipId, action: "remove_part" }, - }); - - return { removedPart: true }; - }, - - // ── 서비스 권한 부여 ────────────────────────────────────────────────────── - addAssignment: async (event) => { - const { locals, params, request } = event; - const { db, tenant } = requireAdminContext(locals); - const fd = await request.formData(); - const userId = params.id; - - // ctrls C-13: 다른 tenant 의 userId 가 본 tenant 의 권한 row 로 박혀 들어가는 - // cross-tenant IDOR 차단. 기존엔 service ref 만 tenant 검증하고 userId 는 - // 검증 없이 INSERT 했음. - const tenantCheck = await assertUserInTenant(db, tenant.id, userId); - if (!tenantCheck.ok) return tenantCheck.error; - - // form 의 service 필드는 "oidc:" 또는 "saml:" 형태. - const serviceRaw = String(fd.get("service") ?? ""); - const colonIdx = serviceRaw.indexOf(":"); - if (colonIdx <= 0) return fail(400, { error: "서비스를 선택해 주세요." }); - const serviceType = serviceRaw.slice(0, colonIdx); - const serviceRefId = serviceRaw.slice(colonIdx + 1); - if (serviceType !== "oidc" && serviceType !== "saml") return fail(400, { error: "잘못된 서비스 종류입니다." }); - if (!serviceRefId) return fail(400, { error: "잘못된 서비스 ID 입니다." }); - - const serviceRoleIdRaw = String(fd.get("serviceRoleId") ?? "").trim(); - const serviceRoleId = serviceRoleIdRaw || null; - const expiresAtRaw = String(fd.get("expiresAt") ?? "").trim(); - const attributesJsonRaw = String(fd.get("attributesJson") ?? "").trim(); - - // service ref 가 우리 테넌트의 활성 서비스인지 검증 - if (serviceType === "oidc") { - const [c] = await db - .select({ id: oidcClients.id }) - .from(oidcClients) - .where(and(eq(oidcClients.id, serviceRefId), eq(oidcClients.tenantId, tenant.id))) - .limit(1); - if (!c) return fail(404, { error: "OIDC 클라이언트를 찾을 수 없습니다." }); - } else { - const [s] = await db - .select({ id: samlSps.id }) - .from(samlSps) - .where(and(eq(samlSps.id, serviceRefId), eq(samlSps.tenantId, tenant.id))) - .limit(1); - if (!s) return fail(404, { error: "SAML SP 를 찾을 수 없습니다." }); - } - - // role 이 지정됐다면, 같은 service 의 role 인지 검증 - if (serviceRoleId) { - const [r] = await db - .select({ id: serviceRoles.id }) - .from(serviceRoles) - .where(and(eq(serviceRoles.id, serviceRoleId), eq(serviceRoles.tenantId, tenant.id), eq(serviceRoles.serviceType, serviceType), eq(serviceRoles.serviceRefId, serviceRefId))) - .limit(1); - if (!r) return fail(400, { error: "선택한 role 이 해당 서비스에 속하지 않습니다." }); - } - - let expiresAt: Date | null = null; - if (expiresAtRaw) { - const d = new Date(expiresAtRaw); - if (Number.isNaN(d.getTime())) return fail(400, { error: "만료일 형식이 올바르지 않습니다." }); - expiresAt = d; - } - - let attributesJson: string | null = null; - if (attributesJsonRaw) { - try { - const parsed = JSON.parse(attributesJsonRaw) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return fail(400, { error: "attributesJson 은 JSON object 여야 합니다." }); - } - attributesJson = JSON.stringify(parsed); - } catch { - return fail(400, { error: "attributesJson 파싱 실패." }); - } - } - - try { - await db.insert(userServiceAssignments).values({ - id: crypto.randomUUID(), - tenantId: tenant.id, - userId, - serviceType, - serviceRefId, - serviceRoleId, - attributesJson, - grantedBy: locals.user!.id, - expiresAt, - }); - } catch { - // unique (tenantId, userId, serviceType, serviceRefId) - return fail(409, { error: "이미 해당 서비스에 매핑이 존재합니다. 먼저 삭제해 주세요." }); - } - - const meta = getRequestMetadata(event); - await recordAuditEvent(db, { - tenantId: tenant.id, - userId, - actorId: locals.user!.id, - spOrClientId: serviceRefId, - kind: "service_assignment_granted", - outcome: "success", - ip: meta.ip, - userAgent: meta.userAgent, - detail: { serviceType, serviceRefId, serviceRoleId, expiresAt }, - }); - - return { addedAssignment: true }; - }, - - revokeAssignment: async (event) => { - const { locals, params, request } = event; - const { db, tenant } = requireAdminContext(locals); - const fd = await request.formData(); - const assignmentId = String(fd.get("assignmentId") ?? ""); - if (!assignmentId) return fail(400, { error: "잘못된 요청." }); - - // IDOR 가드: 본 페이지 user 의 assignment 만 영향 - await db.delete(userServiceAssignments).where(and(eq(userServiceAssignments.id, assignmentId), eq(userServiceAssignments.userId, params.id), eq(userServiceAssignments.tenantId, tenant.id))); - - const meta = getRequestMetadata(event); - await recordAuditEvent(db, { - tenantId: tenant.id, - userId: params.id, - actorId: locals.user!.id, - kind: "service_assignment_revoked", - outcome: "success", - ip: meta.ip, - userAgent: meta.userAgent, - detail: { assignmentId }, - }); - - return { revokedAssignment: true }; - }, - - updateAssignmentExpiry: async (event) => { - const { locals, params, request } = event; - const { db, tenant } = requireAdminContext(locals); - const fd = await request.formData(); - const assignmentId = String(fd.get("assignmentId") ?? ""); - if (!assignmentId) return fail(400, { error: "잘못된 요청." }); - - const expiresAtRaw = String(fd.get("expiresAt") ?? "").trim(); - let expiresAt: Date | null = null; - if (expiresAtRaw) { - const d = new Date(expiresAtRaw); - if (Number.isNaN(d.getTime())) return fail(400, { error: "만료일 형식이 올바르지 않습니다." }); - expiresAt = d; - } - - await db - .update(userServiceAssignments) - .set({ expiresAt }) - .where(and(eq(userServiceAssignments.id, assignmentId), eq(userServiceAssignments.userId, params.id), eq(userServiceAssignments.tenantId, tenant.id))); - - return { updatedExpiry: true }; - }, + updateProfile, + addDept, + removeDept, + addTeam, + removeTeam, + addPart, + removePart, + addAssignment, + revokeAssignment, + updateAssignmentExpiry, + forceLogout, }; diff --git a/src/routes/admin/users/[id]/+page.svelte b/src/routes/admin/users/[id]/+page.svelte index b652232..8985a37 100644 --- a/src/routes/admin/users/[id]/+page.svelte +++ b/src/routes/admin/users/[id]/+page.svelte @@ -8,6 +8,7 @@ const { data, form } = $props<{ data: PageData; form?: ActionData }>(); const err = $derived((form as { error?: string } | null)?.error ?? null); const updated = $derived((form as { updated?: boolean } | null)?.updated ?? false); +const forcedLogout = $derived((form as { forcedLogout?: boolean } | null)?.forcedLogout ?? false); const dateFormatter = new Intl.DateTimeFormat("ko-KR", { dateStyle: "medium" }); @@ -441,4 +442,21 @@ const TIMEZONE_OPTIONS = [
+ + +
+

{t("user_detail.danger_zone")}

+

{t("user_detail.force_logout_desc")}

+ {#if forcedLogout} +
{t("user_detail.force_logout_done")}
+ {/if} +
+ +
+
diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts index f362707..600a106 100644 --- a/src/routes/api/health/+server.ts +++ b/src/routes/api/health/+server.ts @@ -1,22 +1,29 @@ import { json } from "@sveltejs/kit"; import type { RequestHandler } from "./$types"; +import { tenants } from "$lib/server/db/schema"; /** - * 헬스체크 엔드포인트 (liveness + 얕은 readiness). + * 헬스체크 엔드포인트 (liveness + readiness). * * hooks.server.ts 는 이 경로에서 auth baseline 조회를 건너뛴다(부하 최소화). - * DB 바인딩이 초기화됐는지만 얕게 보고한다. 200 = 프로세스 살아있음. + * + * - DB 바인딩이 없으면 즉시 503(unavailable). + * - 바인딩이 있으면 경량 쿼리(tenants LIMIT 1)로 실제 연결 가능 여부까지 확인한다. + * 쿼리 실패(DB 다운/네트워크 등) 시 503 을 반환해 로드밸런서/오케스트레이터가 + * 해당 인스턴스로 트래픽을 보내지 않게 한다(과거엔 DB 다운에도 항상 200 이었다). + * - 정상이면 200 { status: "ok", db: "ready" }. */ -export const GET: RequestHandler = ({ locals }) => { - const dbReady = Boolean(locals.db); - return json( - { - status: "ok", - db: dbReady ? "ready" : "unavailable", - }, - { - status: 200, - headers: { "Cache-Control": "no-store" }, - }, - ); +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.db) { + return json({ status: "error", db: "unavailable" }, { status: 503, headers: { "Cache-Control": "no-store" } }); + } + + try { + // 경량 readiness 프로브 — 항상 존재하는 tenants 테이블을 한 행만 조회해 연결을 확인한다. + await locals.db.select({ id: tenants.id }).from(tenants).limit(1); + } catch { + return json({ status: "error", db: "unavailable" }, { status: 503, headers: { "Cache-Control": "no-store" } }); + } + + return json({ status: "ok", db: "ready" }, { status: 200, headers: { "Cache-Control": "no-store" } }); }; diff --git a/src/routes/api/totp/enroll/confirm/+server.ts b/src/routes/api/totp/enroll/confirm/+server.ts index 04ac5c8..88c3332 100644 --- a/src/routes/api/totp/enroll/confirm/+server.ts +++ b/src/routes/api/totp/enroll/confirm/+server.ts @@ -6,8 +6,10 @@ import { requireDbContext } from "$lib/server/auth/guards"; import { encryptTotpSecret, generateBackupCodes, hashBackupCode, verifyTotp } from "$lib/server/auth/totp"; import { checkRateLimit } from "$lib/server/ratelimit"; import { credentials, users } from "$lib/server/db/schema"; -import { type DB, DB_DIALECT } from "$lib/server/db"; +import type { DB } from "$lib/server/db"; +import { runAtomic } from "$lib/server/db/atomic"; import { isUniqueViolation } from "$lib/server/db/errors"; +import { translate } from "$lib/i18n/server"; /** * Phase 7.3 — TOTP enrollment confirm. @@ -16,27 +18,27 @@ import { isUniqueViolation } from "$lib/server/db/errors"; */ export const POST: RequestHandler = async ({ request, locals }) => { requireServiceToken(request, locals.runtimeConfig); - const { db } = requireDbContext(locals); + const { db, rateLimitStore } = requireDbContext(locals); const config = locals.runtimeConfig; if (!config.signingKeySecret) { - throw error(503, "IDP_SIGNING_KEY_SECRET 미설정"); + throw error(503, translate(locals.locale, "totp.errors.signing_key_not_set")); } const body = (await request.json().catch(() => null)) as { userId?: string; secret?: string; code?: string; label?: string } | null; const userId = body?.userId?.trim(); const secret = body?.secret?.trim(); const code = body?.code?.replace(/\s/g, ""); - const label = body?.label?.trim() || "TOTP 인증기 (dispatcher)"; + const label = body?.label?.trim() || translate(locals.locale, "totp.default_authenticator_label"); if (!userId || !secret || !code) { throw error(400, "userId, secret, code required"); } // ctrls C3: enrollment 코드 브루트포스 방어 (사용자당 5분 창 10회). - const rl = await checkRateLimit(db, `totp-enroll-confirm:${userId}`, { windowMs: 5 * 60 * 1000, limit: 10 }); + const rl = await checkRateLimit(rateLimitStore, `totp-enroll-confirm:${userId}`, { windowMs: 5 * 60 * 1000, limit: 10 }); if (!rl.allowed) { - throw error(429, "TOTP 등록 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요."); + throw error(429, translate(locals.locale, "totp.errors.enroll_rate_limited")); } const [u] = await db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1); @@ -65,7 +67,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { userId, type: BACKUP_CODE_CREDENTIAL_TYPE, secret: await hashBackupCode(c), - label: "백업 코드 (dispatcher)", + label: translate(locals.locale, "totp.default_backup_code_label"), })), ); @@ -74,8 +76,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { // - totpOwnerId 에 userId 를 채워 credentials_totp_owner_uidx (unique) 가 // 사용자당 TOTP 1개를 DB 레벨에서 강제 → 동시 confirm 두 건 중 하나만 성공. // - 백업코드 등 다른 INSERT 는 totpOwnerId 미설정(NULL) 이므로 unique 검사 제외. - // - d1/sqlite: db.batch (interactive transaction 미지원). - // - postgres/mysql: interactive transaction. + // runAtomic 이 d1/sqlite=batch, postgres/mysql=transaction 분기를 흡수한다. const buildTotpInsert = (h: Pick) => h.insert(credentials).values({ id: crypto.randomUUID(), @@ -90,18 +91,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { const buildBackupInsert = (h: Pick) => h.insert(credentials).values(backupCodeRows); try { - if (DB_DIALECT === "d1" || DB_DIALECT === "sqlite") { - // d1 / libSQL 은 원자적 batch 지원 (정규 DB 타입이 활성 방언일 때만 노출되므로 캐스팅). - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).batch([buildTotpInsert(db), buildBackupInsert(db)]); - } else { - // postgres / mysql: interactive transaction. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).transaction(async (tx: Pick) => { - await buildTotpInsert(tx); - await buildBackupInsert(tx); - }); - } + await runAtomic(db, [buildTotpInsert, buildBackupInsert]); } catch (err) { // credentials_totp_owner_uidx UNIQUE 위반(동시 이중 등록)일 때만 409 로 매핑한다. // 사전 SELECT 를 통과한 두 동시 요청 중 두 번째가 여기서 안전하게 거부된다. diff --git a/src/routes/api/totp/verify/+server.ts b/src/routes/api/totp/verify/+server.ts index c0d1431..91042ac 100644 --- a/src/routes/api/totp/verify/+server.ts +++ b/src/routes/api/totp/verify/+server.ts @@ -4,8 +4,10 @@ import { requireServiceToken } from "$lib/server/auth/service-token"; import { TOTP_CREDENTIAL_TYPE } from "$lib/server/auth/constants"; import { requireDbContext } from "$lib/server/auth/guards"; import { decryptTotpSecret, verifyTotp } from "$lib/server/auth/totp"; +import { tryWithSecrets } from "$lib/server/crypto/keys"; import { checkRateLimit } from "$lib/server/ratelimit"; import { credentials } from "$lib/server/db/schema"; +import { translate } from "$lib/i18n/server"; /** * Phase 7.3 — TOTP step-up 검증. @@ -14,11 +16,11 @@ import { credentials } from "$lib/server/db/schema"; */ export const POST: RequestHandler = async ({ request, locals }) => { requireServiceToken(request, locals.runtimeConfig); - const { db } = requireDbContext(locals); + const { db, rateLimitStore } = requireDbContext(locals); const config = locals.runtimeConfig; if (!config.signingKeySecret) { - throw error(503, "IDP_SIGNING_KEY_SECRET 미설정"); + throw error(503, translate(locals.locale, "totp.errors.signing_key_not_set")); } const body = (await request.json().catch(() => null)) as { userId?: string; code?: string } | null; @@ -29,9 +31,9 @@ export const POST: RequestHandler = async ({ request, locals }) => { // ctrls C3: TOTP 브루트포스 방어. service-token 경계 안이지만 dispatcher 침해 시 // 6자리 코드를 무제한 시도해 MFA 를 우회할 수 있으므로 사용자당 시도를 제한한다. // (5분 창에 10회 — webauthn-verify 와 동일 강도.) - const rl = await checkRateLimit(db, `totp-verify:${userId}`, { windowMs: 5 * 60 * 1000, limit: 10 }); + const rl = await checkRateLimit(rateLimitStore, `totp-verify:${userId}`, { windowMs: 5 * 60 * 1000, limit: 10 }); if (!rl.allowed) { - throw error(429, "TOTP 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요."); + throw error(429, translate(locals.locale, "totp.errors.verify_rate_limited")); } const [cred] = await db @@ -41,7 +43,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { .limit(1); if (!cred || !cred.secret) throw error(404, "TOTP not enrolled"); - const plain = await decryptTotpSecret(cred.secret, config.signingKeySecret, userId); + const plain = await tryWithSecrets(config.signingKeySecrets, (s) => decryptTotpSecret(cred.secret!, s, userId)); // ctrls C3: counter 컬럼을 마지막 사용 스텝으로 활용해 코드 재사용을 거부한다 // (웹 (auth)/mfa 경로와 동일 정책). const lastUsedStep = cred.counter ?? undefined; diff --git a/src/routes/api/users/lookup/+server.ts b/src/routes/api/users/lookup/+server.ts index 5b9b834..4397b49 100644 --- a/src/routes/api/users/lookup/+server.ts +++ b/src/routes/api/users/lookup/+server.ts @@ -11,8 +11,12 @@ import { DEFAULT_TENANT_SLUG } from "$lib/server/auth/constants"; * 신뢰된 다른 서비스 (예: stardust dispatcher) 가 username/email → users.id (uuid) * 매핑이 필요할 때 호출. Bearer service-token 으로 보호. * - * Query 파라미터 (셋 중 하나): - * - `?id=` : 직접 user id (tenant 무관) + * 모든 조회는 tenant 스코프로 강제된다. `tenant` 슬러그 미지정 시 default 테넌트로 + * 폴백하며(username/email 경로와 동일), id 경로도 예외 없이 해당 tenant 소속만 조회한다. + * 전역 dispatcher service-token 이 임의 테넌트 사용자 레코드를 조회하는 것을 막는다. + * + * Query 파라미터 (셋 중 하나 + 선택적 tenant): + * - `?id=&tenant=` : user id (해당 tenant 소속일 때만) * - `?username=&tenant=` : tenant 슬러그 생략 시 default * - `?email=&tenant=` : email 매칭 (lowercase 비교) * @@ -32,15 +36,19 @@ export const GET: RequestHandler = async ({ request, url, locals }) => { throw error(400, "one of id / username / email required"); } + const [tenant] = await db.select({ id: tenants.id }).from(tenants).where(eq(tenants.slug, tenantSlug)).limit(1); + if (!tenant) throw error(404, `tenant not found: ${tenantSlug}`); + if (id) { - const [row] = await db.select().from(users).where(eq(users.id, id)).limit(1); + const [row] = await db + .select() + .from(users) + .where(and(eq(users.tenantId, tenant.id), eq(users.id, id))) + .limit(1); if (!row) throw error(404, "user not found"); return json(shape(row)); } - const [tenant] = await db.select({ id: tenants.id }).from(tenants).where(eq(tenants.slug, tenantSlug)).limit(1); - if (!tenant) throw error(404, `tenant not found: ${tenantSlug}`); - if (username) { const [row] = await db .select() diff --git a/src/routes/api/webauthn/authenticate/options/+server.ts b/src/routes/api/webauthn/authenticate/options/+server.ts index d82f6d8..fdade7b 100644 --- a/src/routes/api/webauthn/authenticate/options/+server.ts +++ b/src/routes/api/webauthn/authenticate/options/+server.ts @@ -4,25 +4,26 @@ import { requireDbContext } from "$lib/server/auth/guards"; import { buildAuthenticationOptions, getWebAuthnConfig, saveChallenge } from "$lib/server/auth/webauthn"; import { checkRateLimit } from "$lib/server/ratelimit"; import { getRequestMetadata } from "$lib/server/audit"; +import { translate } from "$lib/i18n/server"; export const POST: RequestHandler = async (event) => { const { url, locals, request } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant, rateLimitStore } = requireDbContext(locals); const { rpID, origin } = getWebAuthnConfig(url); // Origin 검증 — 외부 사이트가 challenge 를 강탈/누적시키지 못하게. const reqOrigin = request.headers.get("origin"); if (reqOrigin && reqOrigin !== origin) { - throw error(403, "유효하지 않은 출처입니다."); + throw error(403, translate(locals.locale, "webauthn.errors.invalid_origin")); } // ctrls H-AUTH-2: rate-limit. 익명 호출자가 무제한으로 challenge 를 생성해 // webauthn_challenges 테이블을 채우는 (D1 storage exhaustion / latency 증가) // 면적을 차단. tenant + IP 키, 5분에 30회. const meta = getRequestMetadata(event); - const rl = await checkRateLimit(db, `webauthn-options:${tenant.id}:${meta.ipKey}`, { windowMs: 5 * 60 * 1000, limit: 30 }); + const rl = await checkRateLimit(rateLimitStore, `webauthn-options:${tenant.id}:${meta.ipKey}`, { windowMs: 5 * 60 * 1000, limit: 30 }); if (!rl.allowed) { - throw error(429, `요청이 너무 많습니다. ${Math.ceil(rl.retryAfterMs / 1000)}초 후 다시 시도해 주세요.`); + throw error(429, translate(locals.locale, "webauthn.errors.rate_limited_retry", { seconds: Math.ceil(rl.retryAfterMs / 1000) })); } const options = await buildAuthenticationOptions(rpID); diff --git a/src/routes/api/webauthn/authenticate/verify/+server.ts b/src/routes/api/webauthn/authenticate/verify/+server.ts index 2d36472..eb25202 100644 --- a/src/routes/api/webauthn/authenticate/verify/+server.ts +++ b/src/routes/api/webauthn/authenticate/verify/+server.ts @@ -10,6 +10,7 @@ import { checkRateLimit } from "$lib/server/ratelimit"; import { verifyPasskeyAuthentication, consumeChallenge, getWebAuthnConfig } from "$lib/server/auth/webauthn"; import type { AuthenticationResponseJSON } from "$lib/server/auth/webauthn"; import { users } from "$lib/server/db/schema"; +import { translate } from "$lib/i18n/server"; function extractChallengeFromClientData(clientDataJSONb64u: string): string | null { try { @@ -42,33 +43,33 @@ export const POST: RequestHandler = async (event) => { })() : null; if (reqOrigin && reqOrigin !== origin) { - throw error(403, "유효하지 않은 출처입니다."); + throw error(403, translate(locals.locale, "webauthn.errors.invalid_origin")); } if (!reqOrigin && refOrigin && refOrigin !== origin) { - throw error(403, "유효하지 않은 출처입니다."); + throw error(403, translate(locals.locale, "webauthn.errors.invalid_origin")); } const body = (await request.json()) as AuthenticationResponseJSON & { _redirectTo?: string }; - const { db, tenant } = requireDbContext(locals); + const { db, tenant, rateLimitStore } = requireDbContext(locals); // cf-connecting-ip 전용(H-ADMIN-3) + IPv6 /64 정규화(C6). 위조 가능한 x-forwarded-for 는 쓰지 않는다. const { ipKey } = getRequestMetadata(event); // 레이트 리밋: tenant + ip 기반 (body.id 는 클라이언트가 임의 변조 가능하므로 키로 부적합). const rlKey = `webauthn-verify:${tenant.id}:${ipKey}`; - const rl = await checkRateLimit(db, rlKey, { windowMs: 5 * 60 * 1000, limit: 10 }); + const rl = await checkRateLimit(rateLimitStore, rlKey, { windowMs: 5 * 60 * 1000, limit: 10 }); if (!rl.allowed) { - throw error(429, "인증 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요."); + throw error(429, translate(locals.locale, "webauthn.errors.auth_rate_limited")); } // 1회용 challenge 소진 (DB 기반) const clientChallenge = body.response?.clientDataJSON ? extractChallengeFromClientData(body.response.clientDataJSON) : null; if (!clientChallenge) { - throw error(400, "인증 세션이 유효하지 않습니다."); + throw error(400, translate(locals.locale, "webauthn.errors.auth_session_invalid")); } const challengeOk = await consumeChallenge(db, tenant.id, clientChallenge); if (!challengeOk) { - throw error(400, "인증 세션이 만료되었거나 이미 사용되었습니다."); + throw error(400, translate(locals.locale, "webauthn.errors.auth_session_expired")); } const result = await verifyPasskeyAuthentication(db, body, clientChallenge, rpID, origin, tenant.id); @@ -83,18 +84,18 @@ export const POST: RequestHandler = async (event) => { userAgent: requestMetadata.userAgent, detail: { method: "webauthn" }, }); - throw error(400, "패스키 인증에 실패했습니다."); + throw error(400, translate(locals.locale, "webauthn.errors.auth_failed")); } // 사용자 조회 const [user] = await db.select().from(users).where(eq(users.id, result.userId)).limit(1); if (!user || user.status !== "active") { - throw error(403, "비활성화된 계정입니다."); + throw error(403, translate(locals.locale, "webauthn.errors.account_disabled")); } if (user.tenantId !== tenant.id) { - throw error(403, "접근 권한이 없습니다."); + throw error(403, translate(locals.locale, "webauthn.errors.access_denied")); } const requestMetadata = getRequestMetadata(event); diff --git a/src/routes/api/webauthn/passkey-client/+server.ts b/src/routes/api/webauthn/passkey-client/+server.ts index 10b17f2..4e96d12 100644 --- a/src/routes/api/webauthn/passkey-client/+server.ts +++ b/src/routes/api/webauthn/passkey-client/+server.ts @@ -1,6 +1,11 @@ import type { RequestHandler } from "@sveltejs/kit"; +import { translate } from "$lib/i18n/server"; +import type { Locale } from "$lib/i18n/core"; -const script = ` +// 클라이언트로 내려가는 스크립트는 서버에서 locale 별 사용자 문구를 주입해 생성한다. +// 사용자 대면 문자열(취소/실패)은 translate 로 해석한 뒤 JSON.stringify 로 안전하게 임베드한다. +function buildScript(cancelledMsg: string, failedMsg: string): string { + return ` (function(){ function b64uToBuf(b64u){ var b64=b64u.replace(/-/g,'+').replace(/_/g,'/'); @@ -42,7 +47,7 @@ const script = ` opts.challenge=b64uToBuf(opts.challenge); if(opts.allowCredentials)opts.allowCredentials=opts.allowCredentials.map(function(c){return Object.assign({},c,{id:b64uToBuf(c.id)});}); var assertion=await navigator.credentials.get({publicKey:opts}); - if(!assertion)throw new Error('패스키 인증이 취소되었습니다.'); + if(!assertion)throw new Error(${JSON.stringify(cancelledMsg)}); var body={ id:assertion.id,rawId:bufToB64u(assertion.rawId),type:assertion.type, response:{ @@ -66,7 +71,7 @@ const script = ` window.location.href=dest; }catch(e){ btn.disabled=false;btn.textContent=orig; - showError(e&&e.message?e.message:'패스키 인증에 실패했습니다.'); + showError(e&&e.message?e.message:${JSON.stringify(failedMsg)}); } } // ctrls H-FRONT-2: redirectTo 를 더 이상 DOM input 에서 읽지 않는다. @@ -102,11 +107,17 @@ const script = ` } })(); `; +} -export const GET: RequestHandler = () => - new Response(script, { +export const GET: RequestHandler = ({ locals }) => { + const locale: Locale = locals.locale; + const script = buildScript(translate(locale, "webauthn.errors.auth_cancelled"), translate(locale, "webauthn.errors.auth_failed")); + return new Response(script, { headers: { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "public, max-age=3600", + // locale 별로 사용자 문구가 달라지므로 캐시 키를 locale 결정 입력(쿠키/Accept-Language)으로 분리. + Vary: "Cookie, Accept-Language", }, }); +}; diff --git a/src/routes/api/webauthn/register/options/+server.ts b/src/routes/api/webauthn/register/options/+server.ts index a419151..3c4ec04 100644 --- a/src/routes/api/webauthn/register/options/+server.ts +++ b/src/routes/api/webauthn/register/options/+server.ts @@ -3,16 +3,17 @@ import type { RequestHandler } from "./$types"; import { requireDbContext } from "$lib/server/auth/guards"; import { getRuntimeConfig } from "$lib/server/auth/runtime"; import { buildRegistrationOptions, createChallengeCookie, getWebAuthnConfig, WEBAUTHN_CHALLENGE_COOKIE } from "$lib/server/auth/webauthn"; +import { translate } from "$lib/i18n/server"; export const POST: RequestHandler = async (event) => { const { locals, cookies, url, platform } = event; if (!locals.user) { - throw error(401, "로그인이 필요합니다."); + throw error(401, translate(locals.locale, "webauthn.errors.login_required")); } const config = getRuntimeConfig(platform); if (!config.signingKeySecret) { - throw error(503, "IDP_SIGNING_KEY_SECRET 이 설정되지 않았습니다."); + throw error(503, translate(locals.locale, "webauthn.errors.signing_key_not_configured")); } const { db } = requireDbContext(locals); diff --git a/src/routes/api/webauthn/register/verify/+server.ts b/src/routes/api/webauthn/register/verify/+server.ts index fc33c38..83fb889 100644 --- a/src/routes/api/webauthn/register/verify/+server.ts +++ b/src/routes/api/webauthn/register/verify/+server.ts @@ -3,9 +3,12 @@ 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/index"; +import { dispatchSecurityAlert } from "$lib/server/security-notify"; import { checkRateLimit } from "$lib/server/ratelimit"; import { verifyChallengeCookie, verifyRegistrationResponse, savePasskey, getWebAuthnConfig, WEBAUTHN_CHALLENGE_COOKIE } from "$lib/server/auth/webauthn"; import type { RegistrationResponseJSON } from "$lib/server/auth/webauthn"; +import { tryWithSecretsNullable } from "$lib/server/crypto/keys"; +import { translate } from "$lib/i18n/server"; /** * 패스키 라벨에서 제어문자/BIDI override 등 위험 코드포인트를 제거한다. @@ -29,7 +32,7 @@ function sanitizePasskeyLabel(label: string): string { export const POST: RequestHandler = async (event) => { const { locals, cookies, request, url, platform } = event; if (!locals.user) { - throw error(401, "로그인이 필요합니다."); + throw error(401, translate(locals.locale, "webauthn.errors.login_required")); } const { rpID, origin } = getWebAuthnConfig(url); @@ -37,34 +40,34 @@ export const POST: RequestHandler = async (event) => { // Origin 검증 const reqOrigin = request.headers.get("origin"); if (reqOrigin && reqOrigin !== origin) { - throw error(403, "유효하지 않은 출처입니다."); + throw error(403, translate(locals.locale, "webauthn.errors.invalid_origin")); } const config = getRuntimeConfig(platform); - if (!config.signingKeySecret) { - throw error(503, "IDP_SIGNING_KEY_SECRET 이 설정되지 않았습니다."); + if (config.signingKeySecrets.length === 0) { + throw error(503, translate(locals.locale, "webauthn.errors.signing_key_not_configured")); } const cookieValue = cookies.get(WEBAUTHN_CHALLENGE_COOKIE); if (!cookieValue) { - throw error(400, "등록 세션이 만료되었습니다. 다시 시도해 주세요."); + throw error(400, translate(locals.locale, "webauthn.errors.register_session_expired")); } - const payload = await verifyChallengeCookie(cookieValue, config.signingKeySecret, "register"); + const payload = await tryWithSecretsNullable(config.signingKeySecrets, (s) => verifyChallengeCookie(cookieValue, s, "register")); if (!payload || payload.userId !== locals.user.id) { cookies.delete(WEBAUTHN_CHALLENGE_COOKIE, { path: "/" }); - throw error(400, "등록 세션이 유효하지 않습니다. 다시 시도해 주세요."); + throw error(400, translate(locals.locale, "webauthn.errors.register_session_invalid")); } const body = (await request.json()) as RegistrationResponseJSON & { label?: string }; const label = typeof body.label === "string" ? sanitizePasskeyLabel(body.label) : ""; - const { db: dbForRl, tenant: tenantForRl } = requireDbContext(locals); + const { tenant: tenantForRl, rateLimitStore } = requireDbContext(locals); // cf-connecting-ip 전용(H-ADMIN-3) + IPv6 /64 정규화(C6). 위조 가능한 x-forwarded-for 는 쓰지 않는다. const { ipKey } = getRequestMetadata(event); - const rl = await checkRateLimit(dbForRl, `webauthn-register-verify:${tenantForRl.id}:${ipKey}`, { windowMs: 5 * 60 * 1000, limit: 10 }); + const rl = await checkRateLimit(rateLimitStore, `webauthn-register-verify:${tenantForRl.id}:${ipKey}`, { windowMs: 5 * 60 * 1000, limit: 10 }); if (!rl.allowed) { - throw error(429, "등록 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요."); + throw error(429, translate(locals.locale, "webauthn.errors.register_rate_limited")); } const verification = await verifyRegistrationResponse({ @@ -77,7 +80,7 @@ export const POST: RequestHandler = async (event) => { cookies.delete(WEBAUTHN_CHALLENGE_COOKIE, { path: "/" }); if (!verification.verified || !verification.registrationInfo) { - throw error(400, "패스키 등록 검증에 실패했습니다."); + throw error(400, translate(locals.locale, "webauthn.errors.register_verify_failed")); } const { db, tenant } = requireDbContext(locals); @@ -94,5 +97,7 @@ export const POST: RequestHandler = async (event) => { userAgent: requestMetadata.userAgent, }); + dispatchSecurityAlert({ to: locals.user.email, locale: locals.user.locale, kind: "passkey_added", platform }); + return json({ ok: true }); }; diff --git a/src/routes/oidc/authorize/+server.ts b/src/routes/oidc/authorize/+server.ts index 543c346..18a14a2 100644 --- a/src/routes/oidc/authorize/+server.ts +++ b/src/routes/oidc/authorize/+server.ts @@ -8,6 +8,7 @@ import { checkRateLimit } from "$lib/server/ratelimit"; import { hasServiceAccess } from "$lib/server/access/service-permissions"; import { verifyIdToken } from "$lib/server/crypto/keys"; import { resolveIssuerUrl } from "$lib/server/auth/runtime"; +import { translate } from "$lib/i18n/server"; /** redirect_uri 가 확정된 이후에만 사용. 그 전 오류는 throw error() 로 직접 응답. */ function authRedirectError(redirectUri: string, errorCode: string, description: string, state?: string | null): never { @@ -20,16 +21,16 @@ function authRedirectError(redirectUri: string, errorCode: string, description: export const GET: RequestHandler = async (event) => { const { locals, url } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant, rateLimitStore } = requireDbContext(locals); // IP당 60회/분 제한 — grant INSERT DoS 방지 const { ip, ipKey } = getRequestMetadata(event); - const rl = await checkRateLimit(db, `oidc-authorize:${ipKey}`, { + const rl = await checkRateLimit(rateLimitStore, `oidc-authorize:${ipKey}`, { windowMs: 60 * 1000, limit: 60, }); if (!rl.allowed) { - throw error(429, "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요."); + throw error(429, translate(locals.locale, "oidc.errors.rate_limited")); } const clientId = url.searchParams.get("client_id"); @@ -43,19 +44,19 @@ export const GET: RequestHandler = async (event) => { // client_id / redirect_uri 가 없으면 redirect 불가 → 직접 오류 응답 if (!clientId || !redirectUri) { - throw error(400, "client_id 와 redirect_uri 는 필수입니다."); + throw error(400, translate(locals.locale, "oidc.errors.client_id_redirect_uri_required")); } if (responseType !== "code") { - throw error(400, "response_type=code 만 지원합니다."); + throw error(400, translate(locals.locale, "oidc.errors.response_type_unsupported")); } const client = await findOidcClient(db, tenant.id, clientId); if (!client) { - throw error(401, "등록되지 않은 client_id 입니다."); + throw error(401, translate(locals.locale, "oidc.errors.unknown_client")); } if (!isAllowedRedirectUri(client, redirectUri)) { - throw error(400, "redirect_uri 가 등록된 값과 일치하지 않습니다."); + throw error(400, translate(locals.locale, "oidc.errors.redirect_uri_mismatch")); } // PKCE 검증 @@ -75,7 +76,7 @@ export const GET: RequestHandler = async (event) => { userAgent: getRequestMetadata(event).userAgent, detail: { error: "invalid_request", reason: "pkce_required" }, }); - authRedirectError(redirectUri, "invalid_request", "PKCE code_challenge 가 필요합니다.", state); + authRedirectError(redirectUri, "invalid_request", translate(locals.locale, "oidc.errors.pkce_required"), state); } if (codeChallenge !== null && !hasChallenge) { await recordAuditEvent(db, { @@ -87,7 +88,7 @@ export const GET: RequestHandler = async (event) => { userAgent: getRequestMetadata(event).userAgent, detail: { error: "invalid_request", reason: "empty_code_challenge" }, }); - authRedirectError(redirectUri, "invalid_request", "code_challenge 가 비어 있습니다.", state); + authRedirectError(redirectUri, "invalid_request", translate(locals.locale, "oidc.errors.empty_code_challenge"), state); } if (hasChallenge && codeChallengeMethod !== "S256") { await recordAuditEvent(db, { @@ -99,7 +100,7 @@ export const GET: RequestHandler = async (event) => { userAgent: getRequestMetadata(event).userAgent, detail: { error: "invalid_request", reason: "invalid_code_challenge_method", method: codeChallengeMethod }, }); - authRedirectError(redirectUri, "invalid_request", "code_challenge_method=S256 만 지원합니다.", state); + authRedirectError(redirectUri, "invalid_request", translate(locals.locale, "oidc.errors.code_challenge_method_unsupported"), state); } // scope 검증 @@ -114,7 +115,7 @@ export const GET: RequestHandler = async (event) => { userAgent: getRequestMetadata(event).userAgent, detail: { error: "invalid_scope", requestedScope: scope }, }); - authRedirectError(redirectUri, "invalid_scope", "openid scope 가 필요합니다.", state); + authRedirectError(redirectUri, "invalid_scope", translate(locals.locale, "oidc.errors.openid_scope_required"), state); } const grantedScope = grantedScopes.join(" "); @@ -123,7 +124,7 @@ export const GET: RequestHandler = async (event) => { const promptNone = prompts.has("none"); // prompt=none 은 다른 값과 함께 올 수 없다. if (promptNone && prompts.size > 1) { - authRedirectError(redirectUri, "invalid_request", "prompt=none 은 다른 prompt 값과 함께 쓸 수 없습니다.", state); + authRedirectError(redirectUri, "invalid_request", translate(locals.locale, "oidc.errors.prompt_none_conflict"), state); } const maxAgeRaw = url.searchParams.get("max_age"); @@ -131,7 +132,7 @@ export const GET: RequestHandler = async (event) => { if (maxAgeRaw !== null) { const n = Number.parseInt(maxAgeRaw, 10); if (!Number.isFinite(n) || n < 0) { - authRedirectError(redirectUri, "invalid_request", "max_age 는 음이 아닌 정수여야 합니다.", state); + authRedirectError(redirectUri, "invalid_request", translate(locals.locale, "oidc.errors.max_age_invalid"), state); } maxAge = n; } @@ -172,7 +173,7 @@ export const GET: RequestHandler = async (event) => { userAgent: getRequestMetadata(event).userAgent, detail: { error: "login_required", reason: reauthRequired ? "reauth_required" : "not_authenticated" }, }); - authRedirectError(redirectUri, "login_required", "사용자 상호작용 없이 인증을 완료할 수 없습니다.", state); + authRedirectError(redirectUri, "login_required", translate(locals.locale, "oidc.errors.login_required"), state); } if (needsInteraction) { @@ -187,7 +188,7 @@ export const GET: RequestHandler = async (event) => { // 여기 도달 시 로그인 상태가 보장된다 (타입 좁히기용 방어 체크). if (!locals.user || !locals.session) { - throw error(500, "인증 게이트 이후 세션이 없습니다."); + throw error(500, translate(locals.locale, "oidc.errors.session_missing_after_gate")); } // 서비스 권한 게이트 (기본 deny). 매핑 없으면 SSO 거부. @@ -209,7 +210,7 @@ export const GET: RequestHandler = async (event) => { userAgent: getRequestMetadata(event).userAgent, detail: { error: "access_denied", reason: "no_service_assignment" }, }); - authRedirectError(redirectUri, "access_denied", "이 서비스에 대한 권한이 없습니다.", state); + authRedirectError(redirectUri, "access_denied", translate(locals.locale, "oidc.errors.service_access_denied"), state); } // authorization code 발급 diff --git a/src/routes/oidc/end-session/+server.ts b/src/routes/oidc/end-session/+server.ts index 4bcf2cc..c545ce2 100644 --- a/src/routes/oidc/end-session/+server.ts +++ b/src/routes/oidc/end-session/+server.ts @@ -7,6 +7,8 @@ import { getActiveSigningKey, verifyIdToken } from "$lib/server/crypto/keys"; import { getOidcBackchannelTargets, getOidcFrontchannelTargets, sendOneBackchannelLogout } from "$lib/server/oidc/logout"; import { matchesRedirectUri } from "$lib/server/oidc/client"; import { resolveIssuerUrl } from "$lib/server/auth/runtime"; +import { translate } from "$lib/i18n/server"; +import type { Locale } from "$lib/i18n/core"; function htmlEscape(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); @@ -25,7 +27,7 @@ function isSafeRedirectScheme(url: string): boolean { } } -function renderFrontchannelLogoutHtml(iframeUris: string[], redirectTo: string): string { +function renderFrontchannelLogoutHtml(iframeUris: string[], redirectTo: string, locale: Locale): string { // ctrls H-OIDC-6: iframe sandbox 강화. // - sandbox="" (모든 권한 제거) → RP iframe 안에서 script/popup/topnav 전부 차단. // 기존 allow-scripts 는 RP frontchannel logout 표준 (script 로 RP 측 세션 정리) @@ -37,11 +39,11 @@ function renderFrontchannelLogoutHtml(iframeUris: string[], redirectTo: string): const safeRedirect = isSafeRedirectScheme(redirectTo) ? redirectTo : "/"; // CSP 는 hash 모드이므로 inline JS 를 피하고 meta refresh 를 사용한다. return ( - `` + + `` + `Logging out...` + `` + `` + - `

로그아웃 중...

` + + `

${htmlEscape(translate(locale, "oidc.errors.logging_out"))}

` + iframes + `` ); @@ -84,7 +86,7 @@ export const GET: RequestHandler = async (event) => { // ctrls M-10: id_token_hint 가 없으면 거부 (CSRF / drive-by logout 방지) if (!idTokenHint) { - return new Response(JSON.stringify({ error: "invalid_request", error_description: "id_token_hint 가 필요합니다." }), { + return new Response(JSON.stringify({ error: "invalid_request", error_description: translate(locals.locale, "oidc.errors.id_token_hint_required") }), { status: 400, headers: { "Content-Type": "application/json" }, }); @@ -117,7 +119,7 @@ export const GET: RequestHandler = async (event) => { const aud = claims.aud; const audMatches = typeof aud === "string" ? aud === clientId : Array.isArray(aud) ? aud.includes(clientId) : false; if (!audMatches) { - return new Response(JSON.stringify({ error: "invalid_id_token_hint", error_description: "aud mismatch" }), { + return new Response(JSON.stringify({ error: "invalid_id_token_hint", error_description: translate(locals.locale, "oidc.errors.aud_mismatch") }), { status: 400, headers: { "Content-Type": "application/json" }, }); @@ -166,13 +168,13 @@ async function executeLogout(event: Parameters[0], postLogoutRed const userId = locals.user.id; const issuerUrl = resolveIssuerUrl(locals.runtimeConfig, url.origin); - const signingKeySecret = locals.runtimeConfig.signingKeySecret; + const signingKeySecrets = locals.runtimeConfig.signingKeySecrets; const bcTargets = await getOidcBackchannelTargets(db, tenantId, sessionId); const fcTargets = await getOidcFrontchannelTargets(db, tenantId, sessionId, idpSessionId, issuerUrl); - if (bcTargets.length > 0 && signingKeySecret) { - const signingKey = await getActiveSigningKey(db, tenantId, signingKeySecret); + if (bcTargets.length > 0 && signingKeySecrets.length > 0) { + const signingKey = await getActiveSigningKey(db, tenantId, signingKeySecrets); if (signingKey) { const bcPromises = bcTargets.map((t) => sendOneBackchannelLogout(t, userId, idpSessionId, issuerUrl, signingKey.privateKey, signingKey.kid).catch(() => undefined)); const wait = platform?.ctx?.waitUntil?.bind(platform.ctx); @@ -192,6 +194,7 @@ async function executeLogout(event: Parameters[0], postLogoutRed const html = renderFrontchannelLogoutHtml( fcTargets.map((t) => t.uri), redirectTo, + locals.locale, ); return new Response(html, { status: 200, @@ -224,7 +227,7 @@ export const POST: RequestHandler = async (event) => { } }; if (!sameOrigin(origin) && !sameOrigin(referer)) { - return new Response(JSON.stringify({ error: "invalid_request", error_description: "cross-origin POST 차단" }), { + return new Response(JSON.stringify({ error: "invalid_request", error_description: translate(locals.locale, "oidc.errors.cross_origin_post_blocked") }), { status: 403, headers: { "Content-Type": "application/json" }, }); @@ -259,7 +262,7 @@ export const POST: RequestHandler = async (event) => { const aud = claims.aud; const audMatches = typeof aud === "string" ? aud === clientId : Array.isArray(aud) ? aud.includes(clientId) : false; if (!audMatches) { - return new Response(JSON.stringify({ error: "invalid_id_token_hint", error_description: "aud mismatch" }), { + return new Response(JSON.stringify({ error: "invalid_id_token_hint", error_description: translate(locals.locale, "oidc.errors.aud_mismatch") }), { status: 400, headers: { "Content-Type": "application/json" }, }); diff --git a/src/routes/oidc/introspect/+server.ts b/src/routes/oidc/introspect/+server.ts index b71efc8..9898251 100644 --- a/src/routes/oidc/introspect/+server.ts +++ b/src/routes/oidc/introspect/+server.ts @@ -16,7 +16,8 @@ import { getRequestMetadata } from "$lib/server/audit"; import { checkRateLimit } from "$lib/server/ratelimit"; import { authenticateOidcClient } from "$lib/server/oidc/client"; import { findActiveRefreshToken } from "$lib/server/oidc/refresh"; -import { verifyAccessToken } from "$lib/server/crypto/keys"; +import { verifyAccessToken, tryWithSecretsNullable } from "$lib/server/crypto/keys"; +import { translate } from "$lib/i18n/server"; function errorResponse(code: string, description: string, status: number): Response { return new Response(JSON.stringify({ error: code, error_description: description }), { @@ -33,13 +34,13 @@ function inactive(): Response { export const POST: RequestHandler = async (event) => { const { locals, request } = event; - const { db, tenant } = requireDbContext(locals); - const { signingKeySecret } = locals.runtimeConfig; + const { db, tenant, rateLimitStore } = requireDbContext(locals); + const { signingKeySecrets } = locals.runtimeConfig; const { ipKey } = getRequestMetadata(event); - const rl = await checkRateLimit(db, `oidc-introspect:${ipKey}`, { windowMs: 60 * 1000, limit: 60 }); + const rl = await checkRateLimit(rateLimitStore, `oidc-introspect:${ipKey}`, { windowMs: 60 * 1000, limit: 60 }); if (!rl.allowed) { - return new Response(JSON.stringify({ error: "rate_limit_exceeded", error_description: "요청이 너무 많습니다." }), { + return new Response(JSON.stringify({ error: "rate_limit_exceeded", error_description: translate(locals.locale, "oidc.errors.rate_limited_short") }), { status: 429, headers: { "Content-Type": "application/json", "Retry-After": String(Math.ceil(rl.retryAfterMs / 1000)) }, }); @@ -50,14 +51,14 @@ export const POST: RequestHandler = async (event) => { if (!auth.ok) return errorResponse(auth.code, auth.description, auth.status); const token = String(body.get("token") ?? ""); - if (!token) return errorResponse("invalid_request", "token 파라미터가 필요합니다.", 400); + if (!token) return errorResponse("invalid_request", translate(locals.locale, "oidc.errors.token_param_required"), 400); const hint = String(body.get("token_type_hint") ?? ""); const clientId = auth.client.clientId; // 1) access token 시도 (refresh_token 힌트가 명시된 경우는 건너뜀). - if (hint !== "refresh_token" && signingKeySecret) { - const claims = await verifyAccessToken(token, signingKeySecret, tenant.id, clientId); + if (hint !== "refresh_token" && signingKeySecrets.length > 0) { + const claims = await tryWithSecretsNullable(signingKeySecrets, (s) => verifyAccessToken(token, s, tenant.id, clientId)); if (claims) { return json( { diff --git a/src/routes/oidc/revoke/+server.ts b/src/routes/oidc/revoke/+server.ts index 52a7ab8..6e7b733 100644 --- a/src/routes/oidc/revoke/+server.ts +++ b/src/routes/oidc/revoke/+server.ts @@ -14,6 +14,7 @@ import { getRequestMetadata } from "$lib/server/audit"; import { checkRateLimit } from "$lib/server/ratelimit"; import { authenticateOidcClient } from "$lib/server/oidc/client"; import { revokeRefreshTokenByValue } from "$lib/server/oidc/refresh"; +import { translate } from "$lib/i18n/server"; function errorResponse(code: string, description: string, status: number): Response { return new Response(JSON.stringify({ error: code, error_description: description }), { @@ -24,12 +25,12 @@ function errorResponse(code: string, description: string, status: number): Respo export const POST: RequestHandler = async (event) => { const { locals, request } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant, rateLimitStore } = requireDbContext(locals); const { ipKey } = getRequestMetadata(event); - const rl = await checkRateLimit(db, `oidc-revoke:${ipKey}`, { windowMs: 60 * 1000, limit: 60 }); + const rl = await checkRateLimit(rateLimitStore, `oidc-revoke:${ipKey}`, { windowMs: 60 * 1000, limit: 60 }); if (!rl.allowed) { - return new Response(JSON.stringify({ error: "rate_limit_exceeded", error_description: "요청이 너무 많습니다." }), { + return new Response(JSON.stringify({ error: "rate_limit_exceeded", error_description: translate(locals.locale, "oidc.errors.rate_limited_short") }), { status: 429, headers: { "Content-Type": "application/json", "Retry-After": String(Math.ceil(rl.retryAfterMs / 1000)) }, }); @@ -41,7 +42,7 @@ export const POST: RequestHandler = async (event) => { const token = String(body.get("token") ?? ""); // RFC 7009 §2.1: token 이 없으면 invalid_request. 그 외(무효/타 클라이언트 토큰)는 200. - if (!token) return errorResponse("invalid_request", "token 파라미터가 필요합니다.", 400); + if (!token) return errorResponse("invalid_request", translate(locals.locale, "oidc.errors.token_param_required"), 400); const hint = String(body.get("token_type_hint") ?? ""); // access_token 힌트가 명시된 경우 refresh 폐기를 건너뛴다(무의미한 조회 회피). diff --git a/src/routes/oidc/token/+server.ts b/src/routes/oidc/token/+server.ts index 192174d..1a921c8 100644 --- a/src/routes/oidc/token/+server.ts +++ b/src/routes/oidc/token/+server.ts @@ -7,7 +7,7 @@ import { findActiveUserById } from "$lib/server/auth/users"; import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit"; import { checkRateLimit } from "$lib/server/ratelimit"; import { findOidcClient, isValidClientSecret, parseBasicAuth } from "$lib/server/oidc/client"; -import { buildAddressClaim } from "$lib/server/oidc/claims"; +import { buildAddressClaim, buildOrganizationClaims, parseOrganizationClaimConfig } from "$lib/server/oidc/claims"; import { findAndConsumeGrant } from "$lib/server/oidc/grant"; import { verifyPkce } from "$lib/server/oidc/pkce"; import { issueRefreshToken, rotateRefreshToken, revokeRefreshTokenFamily } from "$lib/server/oidc/refresh"; @@ -42,6 +42,28 @@ function clientAllowsGrant(client: OidcClientRecord, grantType: string): boolean .includes(grantType); } +interface GrantSessionCheck { + row: typeof sessions.$inferSelect | null; + revoked: boolean; + expired: boolean; +} + +/** + * grant/refresh 에 연결된 IdP 세션을 조회하고 폐기(revoked)·만료(expired) 여부를 판정한다. + * authorization_code / refresh_token 두 grant 경로에서 공통으로 사용한다. + * 세션 row 가 없으면(예: onDelete set null 이전에 삭제) revoked/expired 모두 false 로 두어 + * 호출부의 기존 관례(row 부재 시 거부하지 않음)를 보존한다. + */ +async function checkGrantSession(db: DB, sessionId: string): Promise { + const [row] = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1); + const session = row ?? null; + return { + row: session, + revoked: Boolean(session?.revokedAt), + expired: Boolean(session && session.expiresAt.getTime() <= Date.now()), + }; +} + interface BuildTokenParams { db: DB; tenantId: string; @@ -51,6 +73,7 @@ interface BuildTokenParams { user: NonNullable>>; clientId: string; // 공개 client_id (aud/azp/access token 용) clientDbId: string; // oidcClients.id PK (서비스 권한 조회용) + organizationClaimConfig: string | null; // oidcClients.organizationClaimConfig(JSON text). null=전량 노출. scope: string; sessionId: string | null; nonce: string | null; @@ -123,11 +146,17 @@ async function buildTokens(params: BuildTokenParams): Promise<{ idToken: string; } } - // groups scope — 활성 조직 멤버십을 code(없으면 name) 문자열 배열로 매핑. - // userinfo 응답과 동일 로직(membershipToGroups)을 공유한다. 표준 claim 이므로 assignment 머지 이후에 설정. - if (scopes.has("groups")) { + // groups / organization scope — 활성 조직 멤버십 기반. userinfo 응답과 동일 로직 + // (membershipToGroups / buildOrganizationClaims)을 공유한다. 표준 claim 이므로 assignment + // 머지 이후에 설정. 두 scope 모두 요청돼도 멤버십은 한 번만 조회한다. + if (scopes.has("groups") || scopes.has("organization")) { const membership = await getUserMembership(db, user.id); - idTokenPayload.groups = membershipToGroups(membership); + if (scopes.has("groups")) { + idTokenPayload.groups = membershipToGroups(membership); + } + if (scopes.has("organization")) { + Object.assign(idTokenPayload, buildOrganizationClaims(membership, parseOrganizationClaimConfig(params.organizationClaimConfig))); + } } const idToken = await signJwt(idTokenPayload, signingKey.privateKey, signingKey.kid); @@ -161,12 +190,13 @@ function tokenResponse(body: Record): Response { export const POST: RequestHandler = async (event) => { const { locals, request, url } = event; - const { db, tenant } = requireDbContext(locals); - const { signingKeySecret } = locals.runtimeConfig; + const { db, tenant, rateLimitStore } = requireDbContext(locals); + // signingKeySecret = current (발급 전용), signingKeySecrets = 복호 fallback 용. + const { signingKeySecret, signingKeySecrets } = locals.runtimeConfig; // 레이트 리밋: IP당 30회/분 const { ip, ipKey, userAgent } = getRequestMetadata(event); - const rl = await checkRateLimit(db, `token:${ipKey}`, { windowMs: 60 * 1000, limit: 30 }); + const rl = await checkRateLimit(rateLimitStore, `token:${ipKey}`, { windowMs: 60 * 1000, limit: 30 }); if (!rl.allowed) { return new Response( JSON.stringify({ @@ -257,7 +287,7 @@ export const POST: RequestHandler = async (event) => { // per-client 레이트 리밋: 인증 성공한 클라이언트당 60회/분. // IP 기반 리밋과 별개로, 자격증명을 아는 남용 클라이언트를 격리한다. - const clientRl = await checkRateLimit(db, `token-client:${clientId}`, { windowMs: 60 * 1000, limit: 60 }); + const clientRl = await checkRateLimit(rateLimitStore, `token-client:${clientId}`, { windowMs: 60 * 1000, limit: 60 }); if (!clientRl.allowed) { await recordTokenFailure(clientId, "rate_limit_exceeded", "client 요청이 너무 많습니다"); return new Response( @@ -280,7 +310,7 @@ export const POST: RequestHandler = async (event) => { return tokenError("unauthorized_client", "이 클라이언트에 허용되지 않은 grant_type 입니다."); } - const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecrets); if (!signingKey) { return tokenError("server_error", "활성 서명 키를 찾을 수 없습니다.", 503); } @@ -307,9 +337,10 @@ export const POST: RequestHandler = async (event) => { // 브라우저 세션보다 오래 살아야 하기 때문. (로그아웃 시 refresh token 은 별도 폐기됨.) let sessionRow: typeof sessions.$inferSelect | null = null; if (record.sessionId) { - const [s] = await db.select().from(sessions).where(eq(sessions.id, record.sessionId)).limit(1); - sessionRow = s ?? null; - if (sessionRow?.revokedAt) { + const check = await checkGrantSession(db, record.sessionId); + sessionRow = check.row; + // 명시적 폐기(revoked)만 거부한다 — 세션의 자연 만료(expired)는 위 주석대로 거부하지 않는다. + if (check.revoked) { // 방금 회전된 토큰을 포함해 family 를 폐기하고 거부. await revokeRefreshTokenFamily(db, tenant.id, record.userId, clientId); await recordTokenFailure(clientId, "invalid_grant", "연결된 세션이 로그아웃됨"); @@ -348,6 +379,7 @@ export const POST: RequestHandler = async (event) => { user, clientId, clientDbId: client.id, + organizationClaimConfig: client.organizationClaimConfig, scope: effectiveScope, sessionId: record.sessionId, nonce: null, // refresh 로 재발급되는 id_token 에는 nonce 를 넣지 않는다 (원 요청 전용) @@ -413,6 +445,17 @@ export const POST: RequestHandler = async (event) => { } } + // 연결된 IdP 세션이 로그아웃(폐기)됐거나 만료됐으면 거부한다. + // 로그아웃 후에도 5분 TTL 안의 미소진 code 로 토큰이 발급되는 것을 막는다. + // sessionId 가 null 인 grant(세션 삭제로 set null 된 경우 등)는 검사를 건너뛰어 기존 동작을 보존한다. + if (grant.sessionId) { + const sessionCheck = await checkGrantSession(db, grant.sessionId); + if (sessionCheck.revoked || sessionCheck.expired) { + await recordTokenFailure(clientId, "invalid_grant", sessionCheck.revoked ? "연결된 세션이 로그아웃됨" : "연결된 세션이 만료됨"); + return tokenError("invalid_grant", "로그아웃되었거나 만료된 세션입니다. 다시 로그인해 주세요."); + } + } + const user = await findActiveUserById(db, grant.userId); if (!user) { await recordTokenFailure(clientId, "invalid_grant", "사용자 조회 실패"); @@ -429,6 +472,7 @@ export const POST: RequestHandler = async (event) => { user, clientId, clientDbId: client.id, + organizationClaimConfig: client.organizationClaimConfig, scope: grant.scope, sessionId: grant.sessionId, nonce: grant.nonce, diff --git a/src/routes/oidc/userinfo/+server.ts b/src/routes/oidc/userinfo/+server.ts index f71536b..a051bc4 100644 --- a/src/routes/oidc/userinfo/+server.ts +++ b/src/routes/oidc/userinfo/+server.ts @@ -3,10 +3,11 @@ import type { RequestHandler } from "./$types"; import { and, eq } from "drizzle-orm"; import { requireDbContext } from "$lib/server/auth/guards"; import { oidcClients, users } from "$lib/server/db/schema"; -import { verifyAccessToken } from "$lib/server/crypto/keys"; +import { verifyAccessToken, tryWithSecretsNullable } from "$lib/server/crypto/keys"; import { getUserMembership, membershipToGroups } from "$lib/server/org/membership"; import { getActiveAssignment, parseAssignmentAttributes } from "$lib/server/access/service-permissions"; -import { buildAddressClaim } from "$lib/server/oidc/claims"; +import { buildAddressClaim, buildOrganizationClaims, parseOrganizationClaimConfig } from "$lib/server/oidc/claims"; +import { translate } from "$lib/i18n/server"; const RESERVED_USERINFO_CLAIMS = new Set(["sub", "iss", "aud", "iat", "exp", "auth_time"]); @@ -22,37 +23,37 @@ function bearerError(code: string, description: string): Response { async function handleUserinfo(locals: App.Locals, request: Request): Promise { const { db, tenant } = requireDbContext(locals); - const { signingKeySecret } = locals.runtimeConfig; + const { signingKeySecrets } = locals.runtimeConfig; - if (!signingKeySecret) { - return new Response("IDP_SIGNING_KEY_SECRET 미설정", { status: 503 }); + if (signingKeySecrets.length === 0) { + return new Response(translate(locals.locale, "oidc.errors.signing_key_not_set"), { status: 503 }); } const authHeader = request.headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) { - return bearerError("invalid_token", "Bearer 토큰이 필요합니다."); + return bearerError("invalid_token", translate(locals.locale, "oidc.errors.bearer_token_required")); } const token = authHeader.slice(7); - const claims = await verifyAccessToken(token, signingKeySecret, tenant.id); + const claims = await tryWithSecretsNullable(signingKeySecrets, (s) => verifyAccessToken(token, s, tenant.id)); if (!claims) { - return bearerError("invalid_token", "유효하지 않거나 만료된 액세스 토큰입니다."); + return bearerError("invalid_token", translate(locals.locale, "oidc.errors.access_token_invalid")); } // ctrls H-OIDC-2: aud 강제. claims.aud (있을 경우) 가 claims.clientId 와 일치해야 한다. // 또한 발급 클라이언트가 현재도 등록되어 있고 enabled 인지 확인 — 비활성/삭제된 // 클라이언트의 토큰은 더 이상 userinfo 접근 불가. if (claims.aud && claims.aud !== claims.clientId) { - return bearerError("invalid_token", "토큰의 aud 와 client 가 일치하지 않습니다."); + return bearerError("invalid_token", translate(locals.locale, "oidc.errors.token_aud_mismatch")); } const [issuingClient] = await db - .select({ id: oidcClients.id }) + .select({ id: oidcClients.id, organizationClaimConfig: oidcClients.organizationClaimConfig }) .from(oidcClients) .where(and(eq(oidcClients.tenantId, tenant.id), eq(oidcClients.clientId, claims.clientId), eq(oidcClients.enabled, true))) .limit(1); if (!issuingClient) { - return bearerError("invalid_token", "발급 client 가 더 이상 유효하지 않습니다."); + return bearerError("invalid_token", translate(locals.locale, "oidc.errors.issuing_client_invalid")); } const [user] = await db @@ -62,7 +63,7 @@ async function handleUserinfo(locals: App.Locals, request: Request): Promise ({ - id: d.id, - name: d.name, - code: d.code, - is_primary: d.isPrimary, - job_title: d.jobTitle, - position: d.position - ? { - id: d.position.id, - name: d.position.name, - code: d.position.code, - level: d.position.level, - } - : null, - })); - response.team = membership.teams.map((t) => ({ - id: t.id, - name: t.name, - code: t.code, - department: t.departmentName, - is_primary: t.isPrimary, - job_title: t.jobTitle, - })); - response.position = membership.primaryPosition?.name ?? null; - response.job_title = membership.primaryJobTitle ?? null; + Object.assign(response, buildOrganizationClaims(membership, parseOrganizationClaimConfig(issuingClient.organizationClaimConfig))); } } diff --git a/src/routes/saml/metadata/+server.ts b/src/routes/saml/metadata/+server.ts index 11d39f6..700f82a 100644 --- a/src/routes/saml/metadata/+server.ts +++ b/src/routes/saml/metadata/+server.ts @@ -4,13 +4,14 @@ import { requireDbContext } from "$lib/server/auth/guards"; import { getRuntimeConfig } from "$lib/server/auth/runtime"; import { generateIdpMetadataXml } from "$lib/server/saml/metadata"; import { findSp } from "$lib/server/saml/sp"; +import { translate } from "$lib/i18n/server"; export const GET: RequestHandler = async ({ locals, platform, url }) => { const { db, tenant } = requireDbContext(locals); const config = getRuntimeConfig(platform); if (!config.issuerUrl) { - throw error(503, "IDP_ISSUER_URL 미설정"); + throw error(503, translate(locals.locale, "saml.errors.issuer_not_set")); } // SP 컨텍스트가 주어지면(`?sp=`) 해당 SP의 wantAuthnRequestsSigned 를 광고에 반영. diff --git a/src/routes/saml/slo/+server.ts b/src/routes/saml/slo/+server.ts index cf533f0..292d9eb 100644 --- a/src/routes/saml/slo/+server.ts +++ b/src/routes/saml/slo/+server.ts @@ -26,6 +26,7 @@ import { revokeRefreshTokensForSession } from "$lib/server/oidc/refresh"; import { buildSamlLogoutRequest, buildSamlLogoutResponse, buildSamlSloRedirectUrl, collectPendingSpData, parseSamlLogoutRequest, type PendingSpData } from "$lib/server/saml/slo"; import { verifySamlRedirectSignature } from "$lib/server/saml/parse-authn-request"; import { resolveIssuerUrl } from "$lib/server/auth/runtime"; +import { translate } from "$lib/i18n/server"; const SLO_STATE_TTL_MS = 10 * 60 * 1000; // 10 분 const SLO_REQUEST_ID_TTL_MS = 10 * 60 * 1000; // LogoutRequest replay 가드 보존 기간 @@ -43,13 +44,13 @@ function parsePendingSpData(json: string): PendingSpData[] { async function fireOidcBackchannelLogout(event: RequestEvent, idpSession: typeof sessions.$inferSelect): Promise { const { locals, platform, url } = event; const { db, tenant } = requireDbContext(locals); - const signingKeySecret = locals.runtimeConfig.signingKeySecret; - if (!signingKeySecret) return; + const signingKeySecrets = locals.runtimeConfig.signingKeySecrets; + if (signingKeySecrets.length === 0) return; const bcTargets = await getOidcBackchannelTargets(db, tenant.id, idpSession.id); if (bcTargets.length === 0) return; - const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecrets); if (!signingKey) return; const issuerUrl = resolveIssuerUrl(locals.runtimeConfig, url.origin); @@ -72,7 +73,7 @@ async function redirectToNextSp(event: RequestEvent, stateId: string, remaining: const { db, tenant } = requireDbContext(locals); if (remaining.length === 0) { - throw error(500, "체인에 남은 SP 가 없습니다"); + throw error(500, translate(locals.locale, "saml.errors.slo_no_remaining_sp")); } const next = remaining[0]; @@ -84,13 +85,13 @@ async function redirectToNextSp(event: RequestEvent, stateId: string, remaining: .set({ pendingSpDataJson: JSON.stringify(rest) }) .where(eq(samlSloStates.id, stateId)); - const signingKeySecret = locals.runtimeConfig.signingKeySecret; - if (!signingKeySecret) { - throw error(500, "서명 키가 설정되지 않아 SLO 체인을 계속 진행할 수 없습니다"); + const signingKeySecrets = locals.runtimeConfig.signingKeySecrets; + if (signingKeySecrets.length === 0) { + throw error(500, translate(locals.locale, "saml.errors.slo_signing_key_not_configured")); } - const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecrets); if (!signingKey) { - throw error(500, "활성 서명 키가 없습니다"); + throw error(500, translate(locals.locale, "saml.errors.slo_active_signing_key_missing")); } const issuerUrl = resolveIssuerUrl(locals.runtimeConfig, url.origin); @@ -169,7 +170,7 @@ export const GET: RequestHandler = async (event) => { .where(and(eq(samlSloStates.id, relayState), eq(samlSloStates.tenantId, tenant.id), gt(samlSloStates.expiresAt, new Date()))) .limit(1); if (!state) { - throw error(400, "Invalid or expired SLO state"); + throw error(400, translate(locals.locale, "saml.errors.slo_state_invalid")); } // ctrls C-9: SP-initiated 흐름의 LogoutResponse 는 반드시 initiatingSp 의 cert @@ -183,15 +184,15 @@ export const GET: RequestHandler = async (event) => { .where(and(eq(samlSps.tenantId, tenant.id), eq(samlSps.entityId, state.initiatingSpEntityId))) .limit(1); if (!initiatingSp?.cert) { - throw error(400, "initiating SP 인증서가 등록되지 않아 LogoutResponse 를 검증할 수 없습니다."); + throw error(400, translate(locals.locale, "saml.errors.slo_initiating_sp_cert_missing")); } if (!url.searchParams.has("Signature")) { - throw error(400, "LogoutResponse 는 반드시 서명되어야 합니다."); + throw error(400, translate(locals.locale, "saml.errors.slo_logout_response_must_be_signed")); } const rawQuery = url.search.replace(/^\?/, ""); const valid = await verifySamlRedirectSignature(rawQuery, initiatingSp.cert); if (!valid) { - throw error(400, "LogoutResponse 서명 검증 실패"); + throw error(400, translate(locals.locale, "saml.errors.slo_logout_response_sig_invalid")); } } @@ -209,9 +210,9 @@ export const GET: RequestHandler = async (event) => { // SP-initiated 였다면 최초 SP 로 LogoutResponse 를 돌려준다. if (state.initiatorSloUrl && state.inResponseTo) { - const signingKeySecret = locals.runtimeConfig.signingKeySecret; - if (signingKeySecret) { - const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + const signingKeySecrets = locals.runtimeConfig.signingKeySecrets; + if (signingKeySecrets.length > 0) { + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecrets); if (signingKey) { const issuerUrl = resolveIssuerUrl(locals.runtimeConfig, url.origin); const responseXml = buildSamlLogoutResponse({ @@ -244,7 +245,7 @@ export const GET: RequestHandler = async (event) => { .where(and(eq(samlSloStates.id, stateParam), eq(samlSloStates.tenantId, tenant.id), gt(samlSloStates.expiresAt, new Date()))) .limit(1); if (!state) { - throw error(400, "Invalid or expired SLO state"); + throw error(400, translate(locals.locale, "saml.errors.slo_state_invalid")); } const pending = parsePendingSpData(state.pendingSpDataJson); @@ -266,7 +267,7 @@ export const GET: RequestHandler = async (event) => { // 파서가 DOCTYPE/ENTITY 차단 + onErrorStopParsing + IssueInstant skew(±5분) 를 강제한다. parsed = await parseSamlLogoutRequest(samlRequest); } catch { - throw error(400, "Invalid SAMLRequest"); + throw error(400, translate(locals.locale, "saml.errors.slo_invalid_saml_request")); } // Destination 검증: SP 가 명시했으면 IdP 의 SLO endpoint 와 정확히 일치해야 한다. @@ -274,7 +275,7 @@ export const GET: RequestHandler = async (event) => { if (parsed.destination) { const expectedDestination = `${cfgIssuer.replace(/\/+$/, "")}/saml/slo`; if (parsed.destination !== expectedDestination) { - throw error(400, "LogoutRequest Destination 이 IdP 의 SLO endpoint 와 일치하지 않습니다."); + throw error(400, translate(locals.locale, "saml.errors.slo_destination_mismatch")); } } @@ -285,7 +286,7 @@ export const GET: RequestHandler = async (event) => { .where(and(eq(samlSps.tenantId, tenant.id), eq(samlSps.entityId, parsed.issuer), eq(samlSps.enabled, true))) .limit(1); if (!sp) { - throw error(400, "Unknown SAML SP"); + throw error(400, translate(locals.locale, "saml.errors.slo_unknown_sp")); } // ctrls C-8: 모든 SP-initiated LogoutRequest 는 SP cert 로 서명 검증되어야 @@ -293,15 +294,15 @@ export const GET: RequestHandler = async (event) => { // 위조한 LogoutRequest 로 임의 사용자 강제 로그아웃 + SLO 체인을 트리거할 수 // 있다. Signature 파라미터 누락도 동일하게 거부. if (!sp.cert) { - throw error(400, "SP 인증서가 등록되지 않아 SLO 요청을 검증할 수 없습니다."); + throw error(400, translate(locals.locale, "saml.errors.slo_sp_cert_missing")); } if (!url.searchParams.has("Signature")) { - throw error(400, "LogoutRequest 는 반드시 서명되어야 합니다."); + throw error(400, translate(locals.locale, "saml.errors.slo_logout_request_must_be_signed")); } const rawQuery = url.search.replace(/^\?/, ""); const valid = await verifySamlRedirectSignature(rawQuery, sp.cert); if (!valid) { - throw error(400, "Invalid SAMLRequest signature"); + throw error(400, translate(locals.locale, "saml.errors.slo_saml_request_sig_invalid")); } // Replay 가드: 서명 검증 성공 후 LogoutRequest ID 를 1회용으로 소비한다. @@ -315,7 +316,7 @@ export const GET: RequestHandler = async (event) => { .where(and(eq(samlAuthnRequestIds.tenantId, tenant.id), eq(samlAuthnRequestIds.requestId, parsed.id), gt(samlAuthnRequestIds.expiresAt, now))) .limit(1); if (seen) { - throw error(400, "LogoutRequest ID 가 이미 사용되었습니다 (replay)"); + throw error(400, translate(locals.locale, "saml.errors.slo_logout_request_replay")); } try { await db.insert(samlAuthnRequestIds).values({ @@ -326,7 +327,7 @@ export const GET: RequestHandler = async (event) => { }); } catch { // unique 충돌 → replay 와 동일 처리 - throw error(400, "LogoutRequest ID 가 이미 사용되었습니다 (replay)"); + throw error(400, translate(locals.locale, "saml.errors.slo_logout_request_replay")); } } @@ -393,11 +394,11 @@ export const GET: RequestHandler = async (event) => { if (!sp.sloUrl) { throw redirect(302, "/"); } - const signingKeySecret = locals.runtimeConfig.signingKeySecret; - if (!signingKeySecret) { + const signingKeySecrets = locals.runtimeConfig.signingKeySecrets; + if (signingKeySecrets.length === 0) { throw redirect(302, "/"); } - const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecrets); if (!signingKey) { throw redirect(302, "/"); } @@ -422,7 +423,7 @@ export const GET: RequestHandler = async (event) => { // 남은 SP 가 있으면: samlSloState 생성 후 첫 SP 로 체인 시작 if (!idpSession) { // 이론적으로 pending.length > 0 이려면 idpSession 이 있었어야 한다. - throw error(500, "SLO 체인 초기화 실패"); + throw error(500, translate(locals.locale, "saml.errors.slo_chain_init_failed")); } const stateId = crypto.randomUUID(); diff --git a/src/routes/saml/sso/+server.ts b/src/routes/saml/sso/+server.ts index bfe9160..cad1c1e 100644 --- a/src/routes/saml/sso/+server.ts +++ b/src/routes/saml/sso/+server.ts @@ -32,6 +32,8 @@ import { buildSignedSamlErrorResponse, buildSignedSamlResponse } from "$lib/serv import { findSp, recordSamlSession, type SamlSpRecord } from "$lib/server/saml/sp"; import { getUserMembership } from "$lib/server/org/membership"; import { getActiveAssignment, parseAssignmentAttributes } from "$lib/server/access/service-permissions"; +import { translate } from "$lib/i18n/server"; +import type { Locale } from "$lib/i18n/core"; const SAML_AUTHN_REQUEST_TTL_MS = 10 * 60 * 1000; // 10분 @@ -40,10 +42,10 @@ function htmlEscape(s: string): string { } /** ACS 로 SAMLResponse 를 실어 보내는 HTTP-POST auto-submit 폼 응답. */ -function renderAutoSubmitForm(acsUrl: string, samlResponseB64: string, relayState: string | null): Response { +function renderAutoSubmitForm(acsUrl: string, samlResponseB64: string, relayState: string | null, locale: Locale): Response { const relayStateInput = relayState ? `` : ""; const html = - `SSO 리다이렉트 중...` + + `${htmlEscape(translate(locale, "saml.errors.sso_redirecting"))}` + `` + `
` + `${relayStateInput}` + @@ -60,6 +62,7 @@ async function buildAndRenderSamlError(params: { certPem: string; privateKey: CryptoKey; relayState: string | null; + locale: Locale; }): Promise { const errorB64 = await buildSignedSamlErrorResponse({ inResponseTo: params.inResponseTo ?? "", @@ -69,7 +72,7 @@ async function buildAndRenderSamlError(params: { certPem: params.certPem, privateKey: params.privateKey, }); - return renderAutoSubmitForm(params.acsUrl, errorB64, params.relayState); + return renderAutoSubmitForm(params.acsUrl, errorB64, params.relayState, params.locale); } interface GateAndIssueParams { @@ -121,7 +124,7 @@ async function gateAndIssueSamlAssertion(event: Parameters[0], p userAgent: meta.userAgent, detail: { error: "access_denied", reason: "no_service_assignment" }, }); - throw error(403, "이 SP에 대한 권한이 없습니다."); + throw error(403, translate(event.locals.locale, "saml.errors.access_denied")); } // Replay 가드 (SP-initiated 한정). Assertion 발급 직전에 동일 AuthnRequest ID 의 @@ -134,7 +137,7 @@ async function gateAndIssueSamlAssertion(event: Parameters[0], p .where(and(eq(samlAuthnRequestIds.tenantId, tenant.id), eq(samlAuthnRequestIds.requestId, p.inResponseTo), gt(samlAuthnRequestIds.expiresAt, now))) .limit(1); if (seen) { - throw error(400, "AuthnRequest ID 가 이미 사용되었습니다 (replay)"); + throw error(400, translate(event.locals.locale, "saml.errors.authn_request_replay")); } try { await db.insert(samlAuthnRequestIds).values({ @@ -145,7 +148,7 @@ async function gateAndIssueSamlAssertion(event: Parameters[0], p }); } catch { // unique constraint 충돌 → replay 와 동일하게 거부 - throw error(400, "AuthnRequest ID 가 이미 사용되었습니다 (replay)"); + throw error(400, translate(event.locals.locale, "saml.errors.authn_request_replay")); } } @@ -265,7 +268,7 @@ async function gateAndIssueSamlAssertion(event: Parameters[0], p detail: { spEntityId: sp.entityId, nameId, initiatedBy: p.inResponseTo ? "sp" : "idp" }, }); - return renderAutoSubmitForm(p.acsUrl, samlResponseB64, p.relayState); + return renderAutoSubmitForm(p.acsUrl, samlResponseB64, p.relayState, event.locals.locale); } interface ProcessAuthnRequestParams { @@ -303,6 +306,7 @@ async function processSpInitiatedAuthnRequest(event: Parameters[ certPem: p.certPem, privateKey: p.privateKey, relayState: authnRequest.relayState, + locale: event.locals.locale, }); } @@ -352,6 +356,7 @@ async function processSpInitiatedAuthnRequest(event: Parameters[ certPem: p.certPem, privateKey: p.privateKey, relayState: authnRequest.relayState, + locale: event.locals.locale, }); } // 첫 시도: 재인증(MFA 포함)을 강제한다. @@ -382,13 +387,13 @@ async function processSpInitiatedAuthnRequest(event: Parameters[ * 로그인된 사용자가 `?sp=` 로 SP 를 지정하면, 대응되는 AuthnRequest 없이 * IdP 가 먼저 Assertion 을 SP 의 등록된 ACS 로 밀어 준다. InResponseTo 없음. */ -async function handleIdpInitiated(event: Parameters[0], ctx: { db: DB; tenant: Tenant; issuerUrl: string; signingKeySecret: string; spEntityId: string }): Promise { +async function handleIdpInitiated(event: Parameters[0], ctx: { db: DB; tenant: Tenant; issuerUrl: string; signingKeySecrets: string[]; spEntityId: string }): Promise { const { locals, url } = event; const { db, tenant } = ctx; const sp = await findSp(db, tenant.id, ctx.spEntityId); if (!sp) { - throw error(403, `등록되지 않은 SP 입니다: ${ctx.spEntityId}`); + throw error(403, translate(locals.locale, "saml.errors.unknown_sp", { entityId: ctx.spEntityId })); } // 미로그인 시 로그인 페이지로 (로그인 후 동일 IdP-initiated URL 로 복귀) @@ -399,9 +404,9 @@ async function handleIdpInitiated(event: Parameters[0], ctx: { d throw redirect(302, loginUrl.toString()); } - const signingKey = await getActiveSigningKey(db, tenant.id, ctx.signingKeySecret); + const signingKey = await getActiveSigningKey(db, tenant.id, ctx.signingKeySecrets); if (!signingKey || !signingKey.certPem) { - throw error(503, "서명 키가 없습니다. 서버를 재시작하여 키를 생성하세요."); + throw error(503, translate(locals.locale, "saml.errors.signing_key_missing")); } const relayState = url.searchParams.get("RelayState"); @@ -427,37 +432,37 @@ async function handleIdpInitiated(event: Parameters[0], ctx: { d */ async function ssoPreflight(event: Parameters[0]) { const { locals, platform } = event; - const { db, tenant } = requireDbContext(locals); + const { db, tenant, rateLimitStore } = requireDbContext(locals); const config = getRuntimeConfig(platform); const { ipKey } = getRequestMetadata(event); - const rl = await checkRateLimit(db, `saml-sso:${ipKey}`, { windowMs: 60 * 1000, limit: 30 }); + const rl = await checkRateLimit(rateLimitStore, `saml-sso:${ipKey}`, { windowMs: 60 * 1000, limit: 30 }); if (!rl.allowed) { - throw error(429, "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요."); + throw error(429, translate(locals.locale, "saml.errors.rate_limited")); } - if (!config.issuerUrl) throw error(503, "IDP_ISSUER_URL 미설정"); - if (!config.signingKeySecret) throw error(503, "IDP_SIGNING_KEY_SECRET 미설정"); + if (!config.issuerUrl) throw error(503, translate(locals.locale, "saml.errors.issuer_not_set")); + if (config.signingKeySecrets.length === 0) throw error(503, translate(locals.locale, "saml.errors.signing_key_not_set")); - return { db, tenant, issuerUrl: config.issuerUrl, signingKeySecret: config.signingKeySecret }; + return { db, tenant, issuerUrl: config.issuerUrl, signingKeySecrets: config.signingKeySecrets }; } /** AuthnRequest Destination 이 IdP SSO endpoint 와 일치하는지 검증 (명시된 경우만). */ -function assertDestination(authnRequest: ParsedAuthnRequest, issuerUrl: string): void { +function assertDestination(authnRequest: ParsedAuthnRequest, issuerUrl: string, locale: Locale): void { if (authnRequest.destination) { const expectedDestination = `${issuerUrl.replace(/\/+$/, "")}/saml/sso`; if (authnRequest.destination !== expectedDestination) { - throw error(400, "AuthnRequest Destination 이 IdP 의 SSO endpoint 와 일치하지 않습니다."); + throw error(400, translate(locale, "saml.errors.destination_mismatch")); } } } /** AuthnRequest 의 ACS URL 이 등록된 SP ACS 와 일치하는지 검증하고 최종 ACS 를 반환. */ -function resolveAcsUrl(authnRequest: ParsedAuthnRequest, sp: SamlSpRecord): string { +function resolveAcsUrl(authnRequest: ParsedAuthnRequest, sp: SamlSpRecord, locale: Locale): string { // AuthnRequest 에 ACS 가 명시된 경우 반드시 등록된 SP ACS 와 일치해야 한다. // 다른 URL 을 허용하면 공격자가 서명된 Assertion 을 자신의 서버로 가로챌 수 있다. if (authnRequest.acsUrl && authnRequest.acsUrl !== sp.acsUrl) { - throw error(400, "AuthnRequest의 ACS URL이 등록된 SP ACS URL과 일치하지 않습니다."); + throw error(400, translate(locale, "saml.errors.acs_url_mismatch")); } return sp.acsUrl; } @@ -469,7 +474,7 @@ function resolveAcsUrl(authnRequest: ParsedAuthnRequest, sp: SamlSpRecord): stri */ export const GET: RequestHandler = async (event) => { const { url } = event; - const { db, tenant, issuerUrl, signingKeySecret } = await ssoPreflight(event); + const { db, tenant, issuerUrl, signingKeySecrets } = await ssoPreflight(event); const samlRequestB64 = url.searchParams.get("SAMLRequest"); const relayState = url.searchParams.get("RelayState"); @@ -478,9 +483,9 @@ export const GET: RequestHandler = async (event) => { if (!samlRequestB64) { const spParam = url.searchParams.get("sp"); if (spParam) { - return await handleIdpInitiated(event, { db, tenant, issuerUrl, signingKeySecret, spEntityId: spParam }); + return await handleIdpInitiated(event, { db, tenant, issuerUrl, signingKeySecrets, spEntityId: spParam }); } - throw error(400, "SAMLRequest 파라미터가 없습니다."); + throw error(400, translate(event.locals.locale, "saml.errors.saml_request_missing")); } // ── SP-initiated / HTTP-Redirect 바인딩 ──────────────────────────────────── @@ -488,14 +493,14 @@ export const GET: RequestHandler = async (event) => { try { authnRequest = await parseAuthnRequest(samlRequestB64, relayState); } catch { - throw error(400, "SAMLRequest 파싱 실패"); + throw error(400, translate(event.locals.locale, "saml.errors.saml_request_parse_failed")); } - assertDestination(authnRequest, issuerUrl); + assertDestination(authnRequest, issuerUrl, event.locals.locale); const sp = await findSp(db, tenant.id, authnRequest.issuer); if (!sp) { - throw error(403, `등록되지 않은 SP 입니다: ${authnRequest.issuer}`); + throw error(403, translate(event.locals.locale, "saml.errors.unknown_sp", { entityId: authnRequest.issuer })); } // AuthnRequest 서명 검증: SP 가 서명을 요구하거나 Signature 파라미터가 있는 경우. @@ -503,20 +508,20 @@ export const GET: RequestHandler = async (event) => { const hasSig = url.searchParams.has("Signature"); if (sp.wantAuthnRequestsSigned || hasSig) { if (!sp.cert) { - throw error(400, "SP 인증서가 등록되지 않아 AuthnRequest 서명을 검증할 수 없습니다."); + throw error(400, translate(event.locals.locale, "saml.errors.sp_cert_missing_for_authn_sig")); } const rawQuery = url.search.slice(1); const sigValid = await verifySamlRedirectSignature(rawQuery, sp.cert); if (!sigValid) { - throw error(400, "AuthnRequest 서명 검증에 실패했습니다."); + throw error(400, translate(event.locals.locale, "saml.errors.authn_signature_invalid")); } } - const acsUrl = resolveAcsUrl(authnRequest, sp); + const acsUrl = resolveAcsUrl(authnRequest, sp, event.locals.locale); - const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecrets); if (!signingKey || !signingKey.certPem) { - throw error(503, "서명 키가 없습니다. 서버를 재시작하여 키를 생성하세요."); + throw error(503, translate(event.locals.locale, "saml.errors.signing_key_missing")); } return await processSpInitiatedAuthnRequest(event, { @@ -538,14 +543,14 @@ export const GET: RequestHandler = async (event) => { */ export const POST: RequestHandler = async (event) => { const { url } = event; - const { db, tenant, issuerUrl, signingKeySecret } = await ssoPreflight(event); + const { db, tenant, issuerUrl, signingKeySecrets } = await ssoPreflight(event); const form = await event.request.formData(); const samlRequestB64 = typeof form.get("SAMLRequest") === "string" ? (form.get("SAMLRequest") as string) : null; const relayState = typeof form.get("RelayState") === "string" ? (form.get("RelayState") as string) : null; if (!samlRequestB64) { - throw error(400, "SAMLRequest 파라미터가 없습니다."); + throw error(400, translate(event.locals.locale, "saml.errors.saml_request_missing")); } // HTTP-POST 바인딩: base64(XML), deflate 없음. 서명 검증·resume 재인코딩에 재사용하도록 @@ -558,21 +563,21 @@ export const POST: RequestHandler = async (event) => { for (let i = 0; i < raw.length; i++) bin[i] = raw.charCodeAt(i); xml = new TextDecoder().decode(bin); } catch { - throw error(400, "SAMLRequest 파싱 실패"); + throw error(400, translate(event.locals.locale, "saml.errors.saml_request_parse_failed")); } let authnRequest: ParsedAuthnRequest; try { authnRequest = await parseAuthnRequestPost(samlRequestB64, relayState); } catch { - throw error(400, "SAMLRequest 파싱 실패"); + throw error(400, translate(event.locals.locale, "saml.errors.saml_request_parse_failed")); } - assertDestination(authnRequest, issuerUrl); + assertDestination(authnRequest, issuerUrl, event.locals.locale); const sp = await findSp(db, tenant.id, authnRequest.issuer); if (!sp) { - throw error(403, `등록되지 않은 SP 입니다: ${authnRequest.issuer}`); + throw error(403, translate(event.locals.locale, "saml.errors.unknown_sp", { entityId: authnRequest.issuer })); } // ── 서명 검증 (HTTP-POST 바인딩) ─────────────────────────────────────────── @@ -583,19 +588,19 @@ export const POST: RequestHandler = async (event) => { if (sp.wantAuthnRequestsSigned || authnRequest.hasSignature) { if (!sp.cert) { // 검증에 쓸 SP 인증서가 없으면 서명을 검증할 방법이 없다 → 거부. - throw error(400, "SP 인증서가 등록되지 않아 AuthnRequest 서명을 검증할 수 없습니다."); + throw error(400, translate(event.locals.locale, "saml.errors.sp_cert_missing_for_authn_sig")); } const sigValid = await verifyEnvelopedXmlSignature(xml, sp.cert); if (!sigValid) { - throw error(400, "AuthnRequest 서명 검증에 실패했습니다."); + throw error(400, translate(event.locals.locale, "saml.errors.authn_signature_invalid")); } } - const acsUrl = resolveAcsUrl(authnRequest, sp); + const acsUrl = resolveAcsUrl(authnRequest, sp, event.locals.locale); - const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecrets); if (!signingKey || !signingKey.certPem) { - throw error(503, "서명 키가 없습니다. 서버를 재시작하여 키를 생성하세요."); + throw error(503, translate(event.locals.locale, "saml.errors.signing_key_missing")); } // 로그인/재인증 후 복귀 URL: POST body 는 GET 리다이렉트로 보존되지 않으므로, 동일 diff --git a/test/integration/account-deletion.test.ts b/test/integration/account-deletion.test.ts new file mode 100644 index 0000000..1df725f --- /dev/null +++ b/test/integration/account-deletion.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { actions as loginActions } from "../../src/routes/(auth)/login/+page.server"; +import { runExpiredDataGc } from "../../src/lib/server/db/gc"; +import { users } from "../../src/lib/server/db/schema"; +import { openMemoryDb, seedTenantAndSigningKey, seedUser, makeEvent, catchRedirect, TEST_ISSUER_URL, type MemoryDb } from "./harness"; +import type { Tenant, User } from "../../src/lib/server/db/schema"; + +// Phase 8: 셀프서비스 계정 삭제(deletion_pending) → 유예 내 로그인 복구, 유예 경과 시 GC 하드삭제. +// 삭제 신청 자체는 세션+step-up 재인증이 얽혀 있어, DB 상태(deletion_pending)를 직접 구성하고 +// 실제 login 액션(복구 분기)과 실제 GC 하드삭제 조건을 검증한다(가짜 통과 없이 실 로직 구동). + +let mem: MemoryDb; +let tenant: Tenant; + +beforeEach(async () => { + mem = await openMemoryDb(); + tenant = await seedTenantAndSigningKey(mem); +}); + +afterEach(() => mem.close()); + +async function statusOf(userId: string): Promise { + const [u] = await mem.db.select().from(users).where(eq(users.id, userId)).limit(1); + return u?.status; +} + +describe("Phase 8 — 계정 삭제 신청/복구/GC", () => { + it("유예 기간 내 deletion_pending 계정은 로그인(recover=1)으로 active 복구된다", async () => { + const user: User = await seedUser(mem.db, { + tenantId: tenant.id, + email: "carol@test.example", + username: "carol", + password: "carol-password", + status: "deletion_pending", + deletionScheduledAt: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000), // 유예 내(20일 후 예정) + }); + + // 1단계: recover 없이 로그인 → 복구 확인 프롬프트 반환(세션 생성/복구 없음) + const step1 = (await loginActions.default( + makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/login`, + form: { username: "carol", password: "carol-password" }, + locals: { db: mem.db, tenant, env: mem.env }, + }), + )) as { recovery?: boolean }; + expect(step1.recovery).toBe(true); + expect(await statusOf(user.id)).toBe("deletion_pending"); // 아직 복구 안 됨 + + // 2단계: recover=1 로 재제출 → 복구 후 정상 로그인(리다이렉트) + const redirect = await catchRedirect(() => + loginActions.default( + makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/login`, + form: { username: "carol", password: "carol-password", recover: "1" }, + locals: { db: mem.db, tenant, env: mem.env }, + }), + ), + ); + expect(redirect.status).toBe(303); + expect(await statusOf(user.id)).toBe("active"); + const [restored] = await mem.db.select().from(users).where(eq(users.id, user.id)).limit(1); + expect(restored.deletionScheduledAt).toBeNull(); + }); + + it("유예 경과한 deletion_pending 계정은 복구가 거부되고(400), GC 가 하드 삭제한다", async () => { + const elapsed: User = await seedUser(mem.db, { + tenantId: tenant.id, + email: "dave@test.example", + username: "dave", + password: "dave-password", + status: "deletion_pending", + deletionScheduledAt: new Date(Date.now() - 1000), // 유예 경과(과거) + }); + + // 로그인 복구 시도 → 유예 경과로 거부(400) + const res = (await loginActions.default( + makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/login`, + form: { username: "dave", password: "dave-password", recover: "1" }, + locals: { db: mem.db, tenant, env: mem.env }, + }), + )) as { status?: number }; + expect(res.status).toBe(400); + expect(await statusOf(elapsed.id)).toBe("deletion_pending"); // 여전히 존재 + + // GC 실행 → 유예 경과 deletion_pending 만 하드 삭제 + const result = await runExpiredDataGc(mem.db); + const usersGc = result.tables.find((t) => t.table === "users"); + expect(usersGc?.ok).toBe(true); + expect(await statusOf(elapsed.id)).toBeUndefined(); // 하드 삭제됨 + }); + + it("GC 는 활성 계정과 유예 미경과 계정을 삭제하지 않는다(보수적 조건)", async () => { + const active = await seedUser(mem.db, { tenantId: tenant.id, email: "erin@test.example", username: "erin", status: "active" }); + const pendingFuture = await seedUser(mem.db, { + tenantId: tenant.id, + email: "frank@test.example", + username: "frank", + status: "deletion_pending", + deletionScheduledAt: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), + }); + + await runExpiredDataGc(mem.db); + + expect(await statusOf(active.id)).toBe("active"); + expect(await statusOf(pendingFuture.id)).toBe("deletion_pending"); + }); +}); diff --git a/test/integration/harness.ts b/test/integration/harness.ts new file mode 100644 index 0000000..054d3cb --- /dev/null +++ b/test/integration/harness.ts @@ -0,0 +1,592 @@ +/** + * Phase 11 통합 테스트 하네스. + * + * 목적: mock 이 아닌 **실제 DB**(libSQL `:memory:`) 위에 **실제 drizzle 스키마 마이그레이션**을 + * 적용하고, 실제 서버 모듈(auth/oidc/route 핸들러)을 직접 구동해 풀플로우를 검증한다. + * + * 핵심 구성: + * 1. openMemoryDb(): libSQL `:memory:` 클라이언트 + drizzle 인스턴스를 만들고 + * `drizzle/sqlite/*.sql` 을 journal 순서대로 적용한다(scripts/lib/db.ts 의 executeMultiple 패턴). + * 2. makeEvent(): 라우트 핸들러(+server.ts / +page.server.ts)가 기대하는 RequestEvent/locals 를 + * 최소 구성으로 조립한다(db·tenant·session·user·runtimeConfig·platform·cookies·locale). + * 3. 시드 유틸: 테넌트/서명키/유저/OIDC 클라이언트/서비스 할당/세션. + * + * 격리: 케이스마다 새 인메모리 DB(= 새 tenantId)를 만든다. 서명키/JWKS/baseline 은 + * globalThis 캐시를 쓰므로 tenantId 가 달라 케이스 간 오염이 없다(추가로 서명키 캐시는 + * invalidate 유틸을 노출한다). + */ + +import "reflect-metadata"; +import { fileURLToPath } from "node:url"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createClient, type Client } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import { eq } from "drizzle-orm"; +import * as x509 from "@peculiar/x509"; +import { X509Certificate } from "@peculiar/x509"; +import type { RequestEvent } from "@sveltejs/kit"; +import type { DB } from "$lib/server/db"; +import { + credentials, + identityProviders, + oidcClients, + samlSps, + sessions, + tenants, + userServiceAssignments, + users, + type IdentityProvider, + type SamlSp, + type Session, + type Tenant, + type User, +} from "$lib/server/db/schema"; +import { ensureDefaultTenant, ensureSigningKey } from "$lib/server/auth/bootstrap"; +import { getRuntimeConfig, type RuntimeConfig } from "$lib/server/auth/runtime"; +import { hashPassword } from "$lib/server/auth/password"; +import { hashClientSecret } from "$lib/server/oidc/client"; +import { createSessionRecord } from "$lib/server/auth/session"; +import { b64uEncode, getActiveSigningKey } from "$lib/server/crypto/keys"; +import { DbRateLimitStore } from "$lib/server/ratelimit"; +import { ensureXmlEngine, xmldsigjs, XMLSerializer } from "$lib/server/saml/xml-setup"; + +// ── 테스트용 마스터 시크릿 / issuer ──────────────────────────────────────────────── +// 실 서명·암호화 경로가 이 값을 사용한다. platform.env 로 주입해 getRuntimeConfig 가 읽는다. +export const TEST_SIGNING_SECRET = "test-signing-secret-current-abcdefghijklmnopqrstuvwxyz-0123456789"; +export const TEST_SIGNING_SECRET_PREVIOUS = "test-signing-secret-previous-ABCDEFGHIJKLMNOPQRSTUVWXYZ-9876543210"; +export const TEST_ISSUER_URL = "https://idp.test.example"; + +const projectRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const migrationsDir = join(projectRoot, "drizzle", "sqlite"); + +interface JournalEntry { + idx: number; + tag: string; +} + +/** _journal.json 을 읽어 마이그레이션 태그를 idx 오름차순으로 반환한다. */ +function migrationTags(): string[] { + const journalPath = join(migrationsDir, "meta", "_journal.json"); + const journal = JSON.parse(readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] }; + return [...journal.entries].sort((a, b) => a.idx - b.idx).map((e) => e.tag); +} + +export interface MemoryDb { + db: DB; + client: Client; + /** platform.env 로 넘길 환경. getRuntimeConfig 가 읽는다. */ + env: Record; + close(): void; +} + +/** + * libSQL `:memory:` DB 를 만들고 실제 마이그레이션(SQL)을 순서대로 적용한다. + * executeMultiple 은 `;` 로 구분된 여러 문장을 한 번에 실행한다(마이그레이션 파일 통째 적용). + */ +export async function openMemoryDb(envOverrides: Record = {}): Promise { + const client = createClient({ url: ":memory:" }); + for (const tag of migrationTags()) { + const sqlText = readFileSync(join(migrationsDir, `${tag}.sql`), "utf-8"); + await client.executeMultiple(sqlText); + } + // 앱 런타임과 동일하게 FK 제약을 활성화한다(마이그레이션 DDL 적용 이후). + await client.execute("PRAGMA foreign_keys = ON"); + + const db = drizzle(client, { schema: { tenants, users, credentials, sessions, oidcClients, userServiceAssignments } }) as unknown as DB; + + const env: Record = { + IDP_ISSUER_URL: TEST_ISSUER_URL, + IDP_SIGNING_KEY_SECRET: TEST_SIGNING_SECRET, + IDP_DEFAULT_TENANT_NAME: "Test Tenant", + ...envOverrides, + }; + + return { + db, + client, + env, + close() { + client.close(); + }, + }; +} + +// ── 테스트용 platform / RequestEvent ──────────────────────────────────────────────── + +/** getRuntimeConfig 가 읽는 최소 platform. ctx 는 없음(=Node 경로, waitUntil 미노출). */ +export function makePlatform(env: Record): App.Platform { + return { env } as unknown as App.Platform; +} + +/** Map 기반 최소 Cookies 스텁. 라우트가 set/get/delete 하는 세션·MFA 쿠키를 담는다. */ +export function makeCookies(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + return { + get: (name: string) => store.get(name), + getAll: () => Array.from(store.entries()).map(([name, value]) => ({ name, value })), + set: (name: string, value: string) => { + store.set(name, value); + }, + delete: (name: string) => { + store.delete(name); + }, + serialize: () => "", + _store: store, + }; +} + +export interface MakeEventOptions { + method?: string; + url?: string; + headers?: Record; + /** application/x-www-form-urlencoded 폼 본문(POST 액션/토큰 엔드포인트용). */ + form?: Record; + locals: { + db: DB; + tenant: Tenant | null; + user?: User | null; + session?: Session | null; + locale?: string; + env: Record; + }; + cookies?: ReturnType; +} + +/** + * 실제 라우트 핸들러가 소비하는 RequestEvent 를 조립한다. + * 핸들러가 실제로 접근하는 필드(request/url/locals/platform/cookies/getClientAddress)만 채운다. + * + * 반환 타입은 `RequestEvent` 로 둔다. 각 라우트 핸들러는 `./$types` 로 + * RouteId/RouteParams 가 좁혀진 `RequestEvent` 등을 파라미터로 + * 받는데, `never` 는 모든 리터럴에 대입 가능하므로 어떤 라우트 핸들러에도 그대로 넘길 수 있다. + */ +export function makeEvent(opts: MakeEventOptions): RequestEvent { + const method = opts.method ?? "GET"; + const url = new URL(opts.url ?? "https://idp.test.example/"); + const headers = new Headers(opts.headers ?? {}); + + let body: BodyInit | undefined; + if (opts.form) { + const params = new URLSearchParams(opts.form); + body = params; + if (!headers.has("content-type")) headers.set("content-type", "application/x-www-form-urlencoded"); + } + const request = new Request(url.toString(), { method, headers, body }); + + const platform = makePlatform(opts.locals.env); + const runtimeConfig: RuntimeConfig = getRuntimeConfig(platform); + const cookies = opts.cookies ?? makeCookies(); + + const locals: App.Locals = { + db: opts.locals.db, + // 테스트 DB(요청별 격리)로 DbRateLimitStore 를 주입 — 프로덕션 Workers 경로와 동일. + rateLimitStore: new DbRateLimitStore(opts.locals.db), + tenant: opts.locals.tenant, + user: opts.locals.user ?? null, + session: opts.locals.session ?? null, + runtimeConfig, + runtimeError: null, + locale: (opts.locals.locale ?? "ko") as App.Locals["locale"], + }; + + return { + request, + url, + locals, + platform, + cookies, + params: {}, + route: { id: null }, + getClientAddress: () => "127.0.0.1", + setHeaders: () => {}, + isDataRequest: false, + isSubRequest: false, + fetch: globalThis.fetch, + } as unknown as RequestEvent; +} + +// ── 시드 유틸 ──────────────────────────────────────────────────────────────────── + +/** 기본 테넌트 + 활성 서명키(실 RSA 키·cert)를 생성한다. */ +export async function seedTenantAndSigningKey(mem: MemoryDb): Promise { + const platform = makePlatform(mem.env); + const config = getRuntimeConfig(platform); + const tenant = await ensureDefaultTenant(mem.db, platform); + await ensureSigningKey(mem.db, tenant, config.signingKeySecrets, config.issuerUrl); + return tenant; +} + +export interface SeedUserOptions { + tenantId: string; + email: string; + username?: string | null; + password?: string; + role?: "admin" | "user"; + status?: "active" | "disabled" | "locked" | "deletion_pending"; + emailVerifiedAt?: Date | null; + displayName?: string; + deletionScheduledAt?: Date | null; +} + +/** 유저를 생성하고, password 가 주어지면 password credential 도 함께 생성한다. */ +export async function seedUser(db: DB, opts: SeedUserOptions): Promise { + const id = crypto.randomUUID(); + await db.insert(users).values({ + id, + tenantId: opts.tenantId, + email: opts.email.toLowerCase(), + username: opts.username === undefined ? opts.email.split("@")[0] : opts.username, + displayName: opts.displayName ?? "Test User", + role: opts.role ?? "user", + status: opts.status ?? "active", + emailVerifiedAt: opts.emailVerifiedAt === undefined ? new Date() : opts.emailVerifiedAt, + deletionScheduledAt: opts.deletionScheduledAt ?? null, + }); + + if (opts.password) { + await db.insert(credentials).values({ + id: crypto.randomUUID(), + userId: id, + type: "password", + secret: await hashPassword(opts.password), + label: "비밀번호", + }); + } + + const [row] = await db.select().from(users).where(eq(users.id, id)).limit(1); + return row!; +} + +export interface SeedOidcClientOptions { + tenantId: string; + clientId: string; + secret?: string; + redirectUris: string[]; + scopes?: string; + grantTypes?: string; + tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post" | "none"; + requirePkce?: boolean; +} + +export async function seedOidcClient(db: DB, opts: SeedOidcClientOptions): Promise { + const id = crypto.randomUUID(); + await db.insert(oidcClients).values({ + id, + tenantId: opts.tenantId, + clientId: opts.clientId, + clientSecretHash: opts.secret ? await hashClientSecret(opts.secret) : null, + name: opts.clientId, + redirectUris: JSON.stringify(opts.redirectUris), + scopes: opts.scopes ?? "openid profile email offline_access", + grantTypes: opts.grantTypes ?? "authorization_code,refresh_token", + tokenEndpointAuthMethod: opts.tokenEndpointAuthMethod ?? "client_secret_basic", + requirePkce: opts.requirePkce ?? true, + enabled: true, + }); + const [row] = await db.select().from(oidcClients).where(eq(oidcClients.id, id)).limit(1); + return row!; +} + +/** 유저에게 서비스(OIDC/SAML) 접근 권한을 부여한다(기본 deny 를 통과시키기 위함). */ +export async function seedServiceAssignment(db: DB, args: { tenantId: string; userId: string; serviceType: "oidc" | "saml"; serviceRefId: string }): Promise { + await db.insert(userServiceAssignments).values({ + id: crypto.randomUUID(), + tenantId: args.tenantId, + userId: args.userId, + serviceType: args.serviceType, + serviceRefId: args.serviceRefId, + }); +} + +/** 실제 createSessionRecord 로 세션을 만들고 raw 토큰 + Session row 를 돌려준다. */ +export async function seedSession(db: DB, args: { tenantId: string; userId: string }): Promise<{ session: Session; sessionToken: string }> { + const { sessionToken, sessionId } = await createSessionRecord(db, { + tenantId: args.tenantId, + userId: args.userId, + amr: ["pwd"], + acr: "urn:mace:incommon:iap:silver", + }); + const [session] = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1); + return { session: session!, sessionToken }; +} + +// ── 헬퍼: PKCE / redirect 파싱 ───────────────────────────────────────────────────── + +/** S256 code_challenge 를 계산한다(실 라우트/pkce.ts 와 동일 알고리즘). */ +export async function pkceChallengeS256(verifier: string): Promise { + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + return b64uEncode(hash); +} + +/** throw 된 SvelteKit redirect 를 잡아 Location 을 반환한다. redirect 가 아니면 재throw. */ +export async function catchRedirect(fn: () => unknown): Promise<{ status: number; location: string }> { + try { + await fn(); + } catch (e) { + const r = e as { status?: number; location?: string }; + if (typeof r.status === "number" && typeof r.location === "string") { + return { status: r.status, location: r.location }; + } + throw e; + } + throw new Error("redirect 가 발생하지 않았습니다."); +} + +/** throw 된 SvelteKit error() 를 잡아 { status, body } 를 반환한다. error 가 아니면 재throw. */ +export async function catchError(fn: () => unknown): Promise<{ status: number; body: unknown }> { + try { + await fn(); + } catch (e) { + const r = e as { status?: number; body?: unknown }; + if (typeof r.status === "number" && "body" in (e as object)) { + return { status: r.status, body: r.body }; + } + throw e; + } + throw new Error("error() 가 발생하지 않았습니다."); +} + +// ── 쿠키 체이닝 헬퍼 ─────────────────────────────────────────────────────────────── +// 같은 브라우저(쿠키 저장소)로 여러 라우트를 연속 호출하는 흐름(login → mfa, POST/saml/sso → +// 로그인 후 재개)을 재현하기 위해, 하나의 makeCookies 인스턴스를 여러 makeEvent 에 재사용한다. + +/** 동일 쿠키 저장소를 여러 makeEvent 에 재사용하는 세션(브라우저) 헬퍼. */ +export function makeCookieJar(initial: Record = {}) { + const cookies = makeCookies(initial); + return { + cookies, + /** 현재 저장소에 담긴 쿠키명→값 스냅샷. */ + snapshot: () => Object.fromEntries(cookies._store.entries()) as Record, + /** 특정 쿠키 값 조회(없으면 undefined). */ + get: (name: string) => cookies.get(name), + /** 저장된 쿠키가 하나라도 있는지. */ + has: (name: string) => cookies._store.has(name), + }; +} + +// ── SAML 서명 fixture (saml-verify-xml-signature.test.ts 에서 승격·공용화) ───────────── + +const SAML_RSA_ALG: RsaHashedKeyGenParams = { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), +}; + +export interface KeyCert { + keys: CryptoKeyPair; + certPem: string; + certB64: string; +} + +/** 자체서명 RSA 키/인증서 쌍을 만든다(SP AuthnRequest 서명·SP cert 등록용). */ +export async function makeKeyCert(cn: string): Promise { + x509.cryptoProvider.set(crypto as Crypto); + const keys = (await crypto.subtle.generateKey(SAML_RSA_ALG, true, ["sign", "verify"])) as CryptoKeyPair; + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: "01", + name: `CN=${cn}`, + notBefore: new Date("2020-01-01T00:00:00Z"), + notAfter: new Date("2035-01-01T00:00:00Z"), + signingAlgorithm: SAML_RSA_ALG, + keys, + }); + const certPem = cert.toString("pem"); + const certB64 = certPem + .replace(/-----BEGIN CERTIFICATE-----/, "") + .replace(/-----END CERTIFICATE-----/, "") + .replace(/\s+/g, ""); + return { keys, certPem, certB64 }; +} + +export interface SignAuthnRequestOptions { + id: string; + kc: KeyCert; + issuer: string; + destination: string; + acsUrl: string; + /** true 이면 enveloped ds:Signature 를 붙인다. false 면 서명 없는 XML 을 반환한다. */ + sign?: boolean; + forceAuthn?: boolean; +} + +/** + * SP-initiated AuthnRequest XML 을 만든다. sign=true 면 SP 개인키로 enveloped ds:Signature 를 + * 바로 뒤에 삽입한다(response.ts 서명 생성과 동일 방식). IssueInstant 는 현재 + * 시각(±5분 skew 검증 통과)을 쓴다. + */ +export async function buildAuthnRequestXml(opts: SignAuthnRequestOptions): Promise { + ensureXmlEngine(); + const issueInstant = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + const forceAuthnAttr = opts.forceAuthn ? ` ForceAuthn="true"` : ""; + const fullXml = + `` + + `${opts.issuer}` + + ``; + + if (!opts.sign) return fullXml; + + const doc = xmldsigjs.Parse(fullXml); + const rootEl = doc.documentElement as Element & { setIdAttribute?: (name: string, flag: boolean) => void }; + rootEl.setIdAttribute?.("ID", true); + + const signedXml = new xmldsigjs.SignedXml(); + signedXml.XmlSignature.SignedInfo.CanonicalizationMethod.Algorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + await signedXml.Sign({ name: "RSASSA-PKCS1-v1_5" }, opts.kc.keys.privateKey, doc, { + x509: [opts.kc.certB64], + references: [{ uri: `#${opts.id}`, hash: "SHA-256", transforms: ["enveloped", "exc-c14n"] }], + }); + + const sigNode = signedXml.XmlSignature.GetXml(); + if (sigNode) { + const issuerEls = rootEl.getElementsByTagNameNS("urn:oasis:names:tc:SAML:2.0:assertion", "Issuer"); + const issuerEl = issuerEls[0]; + if (issuerEl?.nextSibling) { + rootEl.insertBefore(sigNode, issuerEl.nextSibling); + } else { + rootEl.appendChild(sigNode); + } + } + return xmldsigjs.Stringify(doc).replace(/^<\?xml[^?]*\?>\s*/i, ""); +} + +/** HTTP-POST 바인딩용 SAMLRequest 파라미터 값(base64(XML), deflate 없음)을 만든다. */ +export function encodePostBindingSamlRequest(xml: string): string { + const bytes = new TextEncoder().encode(xml); + const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join(""); + return btoa(binary); +} + +/** base64(SAMLResponse) 를 디코드해 XML 문자열로 돌려준다(auto-submit 폼 검증용). */ +export function decodeSamlResponse(b64: string): string { + const raw = atob(b64); + const bin = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) bin[i] = raw.charCodeAt(i); + return new TextDecoder().decode(bin); +} + +/** + * SAML Response XML 에서 서명된 을 자족적 문서로 추출한다. + * (직렬화 검사·디버깅용. 서명 재검증은 verifyAssertionSignatureInResponse 를 쓴다.) + */ +export function extractSignedAssertionXml(responseXml: string): string | null { + ensureXmlEngine(); + const doc = xmldsigjs.Parse(responseXml); + const assertionEls = doc.getElementsByTagNameNS("urn:oasis:names:tc:SAML:2.0:assertion", "Assertion"); + const assertionEl = assertionEls[0]; + if (!assertionEl) return null; + assertionEl.setAttribute("xmlns:saml", "urn:oasis:names:tc:SAML:2.0:assertion"); + assertionEl.setAttribute("xmlns:xs", "http://www.w3.org/2001/XMLSchema"); + assertionEl.setAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"); + const serializer = new XMLSerializer(); + return serializer.serializeToString(assertionEl as unknown as Parameters[0]); +} + +const XMLDSIG_NS = "http://www.w3.org/2000/09/xmldsig#"; +const SAML_ASSERTION_NS = "urn:oasis:names:tc:SAML:2.0:assertion"; + +/** + * SAML Response XML 안의 enveloped 서명을 주어진 인증서 공개키로 **in-place** + * 검증한다. Response 문서 컨텍스트 안에서 검증하므로(추출/재직렬화 없이) 서명 시점과 동일한 + * exc-c14n 네임스페이스 컨텍스트를 본다 — SP 가 실제로 수행하는 검증과 동일한 방식. + * 서명 불일치·변조·형식 위반 시 false 를 반환한다. + */ +export async function verifyAssertionSignatureInResponse(responseXml: string, certPem: string): Promise { + try { + if (!certPem) return false; + ensureXmlEngine(); + const doc = xmldsigjs.Parse(responseXml); + const assertionEl = doc.getElementsByTagNameNS(SAML_ASSERTION_NS, "Assertion")[0] as (Element & { setIdAttribute?: (n: string, f: boolean) => void }) | undefined; + if (!assertionEl) return false; + assertionEl.setIdAttribute?.("ID", true); + const sigEl = assertionEl.getElementsByTagNameNS(XMLDSIG_NS, "Signature")[0]; + if (!sigEl || sigEl.parentNode !== assertionEl) return false; + const cert = new X509Certificate(certPem); + const publicKey = await crypto.subtle.importKey("spki", cert.publicKey.rawData, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["verify"]); + const signedXml = new xmldsigjs.SignedXml(doc); + signedXml.LoadXml(sigEl); + return await signedXml.Verify(publicKey); + } catch { + return false; + } +} + +// ── SAML SP / LDAP provider 시드 ────────────────────────────────────────────────── + +export interface SeedSamlSpOptions { + tenantId: string; + entityId: string; + acsUrl: string; + name?: string; + cert?: string | null; + nameIdFormat?: string; + signResponse?: boolean; + signAssertion?: boolean; + encryptAssertion?: boolean; + wantAuthnRequestsSigned?: boolean; + allowedAttributes?: string[] | null; + attributeMappingJson?: string | null; + enabled?: boolean; +} + +/** SAML SP(Service Provider) 레코드를 삽입한다. */ +export async function seedSamlSp(db: DB, opts: SeedSamlSpOptions): Promise { + const id = crypto.randomUUID(); + await db.insert(samlSps).values({ + id, + tenantId: opts.tenantId, + entityId: opts.entityId, + name: opts.name ?? opts.entityId, + acsUrl: opts.acsUrl, + cert: opts.cert ?? null, + nameIdFormat: opts.nameIdFormat ?? "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + signResponse: opts.signResponse ?? true, + signAssertion: opts.signAssertion ?? true, + encryptAssertion: opts.encryptAssertion ?? false, + wantAuthnRequestsSigned: opts.wantAuthnRequestsSigned ?? false, + allowedAttributes: opts.allowedAttributes === undefined ? null : opts.allowedAttributes ? JSON.stringify(opts.allowedAttributes) : null, + attributeMappingJson: opts.attributeMappingJson ?? null, + enabled: opts.enabled ?? true, + }); + const [row] = await db.select().from(samlSps).where(eq(samlSps.id, id)).limit(1); + return row!; +} + +export interface SeedIdentityProviderOptions { + tenantId: string; + kind?: "ldap" | "oidc" | "saml" | "oauth2"; + name?: string; + /** config_json 에 직렬화될 provider 설정(LDAP 는 LdapProviderConfig). */ + config?: Record; + enabled?: boolean; +} + +/** identity_providers 레코드(기본 LDAP)를 삽입한다. */ +export async function seedIdentityProvider(db: DB, opts: SeedIdentityProviderOptions): Promise { + const id = crypto.randomUUID(); + await db.insert(identityProviders).values({ + id, + tenantId: opts.tenantId, + kind: opts.kind ?? "ldap", + name: opts.name ?? "Test LDAP", + configJson: opts.config ? JSON.stringify(opts.config) : null, + enabled: opts.enabled ?? true, + }); + const [row] = await db.select().from(identityProviders).where(eq(identityProviders.id, id)).limit(1); + return row!; +} + +/** 현재 활성 IdP 서명키의 인증서(PEM)를 반환한다(SAML Response 서명 검증용). */ +export async function getIdpSigningCertPem(mem: MemoryDb, tenantId: string): Promise { + const config = getRuntimeConfig(makePlatform(mem.env)); + const key = await getActiveSigningKey(mem.db, tenantId, config.signingKeySecrets); + if (!key?.certPem) throw new Error("활성 IdP 서명키 인증서가 없습니다."); + return key.certPem; +} diff --git a/test/integration/invite-accept.test.ts b/test/integration/invite-accept.test.ts new file mode 100644 index 0000000..a929159 --- /dev/null +++ b/test/integration/invite-accept.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { actions as acceptInviteActions } from "../../src/routes/(auth)/accept-invite/+page.server"; +import { generateToken } from "../../src/lib/server/email"; +import { authenticateLocalUser } from "../../src/lib/server/auth/users"; +import { credentials, inviteTokens, users } from "../../src/lib/server/db/schema"; +import { openMemoryDb, seedTenantAndSigningKey, seedUser, makeEvent, TEST_ISSUER_URL, type MemoryDb } from "./harness"; +import type { Tenant, User } from "../../src/lib/server/db/schema"; + +// Phase 7: 초대 수락 → credential(비밀번호) 생성 + emailVerifiedAt 세팅 + 토큰 소진 → 로그인 가능. +// 실제 accept-invite default 액션과 authenticateLocalUser 를 실 DB 로 검증한다. + +let mem: MemoryDb; +let tenant: Tenant; +let invitee: User; + +beforeEach(async () => { + mem = await openMemoryDb(); + tenant = await seedTenantAndSigningKey(mem); + // 초대 대상: 비밀번호 credential 없음 + 이메일 미인증 상태로 생성 + invitee = await seedUser(mem.db, { + tenantId: tenant.id, + email: "invitee@test.example", + username: "invitee", + emailVerifiedAt: null, + }); +}); + +afterEach(() => mem.close()); + +/** 실제 issueInvite 와 동일한 방식(generateToken → inviteTokens insert)으로 유효 초대 토큰을 만든다. */ +async function makeInvite(userId: string): Promise { + const { token, tokenHash } = await generateToken(); + await mem.db.insert(inviteTokens).values({ userId, tokenHash, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }); + return token; +} + +describe("Phase 7 — 초대 수락", () => { + it("초대 수락이 password credential 생성 + emailVerifiedAt 세팅 + 토큰 소진을 원자적으로 수행하고, 이후 로그인 가능하다", async () => { + const token = await makeInvite(invitee.id); + + const event = makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/accept-invite`, + form: { token, password: "new-strong-password", confirmPassword: "new-strong-password" }, + locals: { db: mem.db, tenant, env: mem.env }, + }); + const result = await acceptInviteActions.default(event); + expect(result).toEqual({ accepted: true }); + + // password credential 생성 확인 + const cred = await mem.db.select().from(credentials).where(eq(credentials.userId, invitee.id)); + expect(cred.length).toBe(1); + expect(cred[0].type).toBe("password"); + expect(cred[0].secret).toBeTruthy(); + + // emailVerifiedAt 세팅 확인(초대 클릭 = 이메일 소유 증명) + const [u] = await mem.db.select().from(users).where(eq(users.id, invitee.id)).limit(1); + expect(u.emailVerifiedAt).not.toBeNull(); + + // 토큰 소진 확인 + const [tok] = await mem.db.select().from(inviteTokens).where(eq(inviteTokens.userId, invitee.id)).limit(1); + expect(tok.usedAt).not.toBeNull(); + + // 실제 로컬 인증으로 로그인 가능(설정한 비밀번호로 통과) + const authed = await authenticateLocalUser(mem.db, tenant.id, "invitee", "new-strong-password"); + expect(authed?.id).toBe(invitee.id); + }); + + it("비밀번호 확인 불일치는 400 으로 거부하고 credential 을 만들지 않는다", async () => { + const token = await makeInvite(invitee.id); + const event = makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/accept-invite`, + form: { token, password: "abcdefgh", confirmPassword: "different" }, + locals: { db: mem.db, tenant, env: mem.env }, + }); + const result = (await acceptInviteActions.default(event)) as { status?: number }; + expect(result.status).toBe(400); + const cred = await mem.db.select().from(credentials).where(eq(credentials.userId, invitee.id)); + expect(cred.length).toBe(0); + }); + + it("소진된 토큰은 재사용할 수 없다", async () => { + const token = await makeInvite(invitee.id); + const mkEvent = () => + makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/accept-invite`, + form: { token, password: "first-password-1", confirmPassword: "first-password-1" }, + locals: { db: mem.db, tenant, env: mem.env }, + }); + expect(await acceptInviteActions.default(mkEvent())).toEqual({ accepted: true }); + // 두 번째 제출: 토큰이 이미 소진됨 → invalid_link 400 + const second = (await acceptInviteActions.default(mkEvent())) as { status?: number }; + expect(second.status).toBe(400); + }); +}); diff --git a/test/integration/ldap-login.test.ts b/test/integration/ldap-login.test.ts new file mode 100644 index 0000000..858fba2 --- /dev/null +++ b/test/integration/ldap-login.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { eq, and } from "drizzle-orm"; +import { provisionLdapUser } from "../../src/lib/server/ldap/provision"; +import { identities, users } from "../../src/lib/server/db/schema"; +import { openMemoryDb, seedTenantAndSigningKey, seedUser, seedIdentityProvider, makeEvent, makeCookieJar, catchRedirect, TEST_ISSUER_URL, type MemoryDb } from "./harness"; +import type { Tenant } from "../../src/lib/server/db/schema"; + +// LDAP 로그인 분기를 실 DB + 실 login 라우트로 검증한다. 실제 LDAP 서버는 띄우지 않고, +// 네트워크 계층(ldap/client)만 최소 모킹한다 — authenticateLdap(필터/DN 조합·검증)과 +// provisionLdapUser(JIT), login 액션 분기는 모두 실제 코드가 구동된다. + +const LDAP_USER = "ldapuser"; +const LDAP_PW = "ldap-correct-pw"; +const LDAP_DN = "uid=ldapuser,dc=example,dc=com"; + +// ldap/client 모킹: 지정한 DN+비밀번호만 bind 성공, 속성은 고정 디렉터리 엔트리 반환. +vi.mock("../../src/lib/server/ldap/client", () => ({ + ldapBind: async (_config: unknown, dn: string, password: string): Promise => { + if (dn === "uid=ldapuser,dc=example,dc=com" && password === "ldap-correct-pw") return; + throw new Error("LDAP bind 실패: invalid credentials"); + }, + ldapSearchDn: async (): Promise => null, + ldapFetchEntry: async (): Promise> => ({ + mail: "ldapuser@corp.example", + cn: "LDAP User", + givenName: "Ldap", + sn: "User", + }), +})); + +let mem: MemoryDb; +let tenant: Tenant; + +beforeEach(async () => { + mem = await openMemoryDb(); + tenant = await seedTenantAndSigningKey(mem); + // Pattern 방식 LDAP provider (bindDN 없음 → userDnPattern 으로 DN 조합). + await seedIdentityProvider(mem.db, { + tenantId: tenant.id, + kind: "ldap", + config: { + host: "ldap.example.com", + port: 389, + baseDN: "dc=example,dc=com", + userDnPattern: "uid={username},dc=example,dc=com", + tlsMode: "none", + }, + enabled: true, + }); +}); + +afterEach(() => { + mem.close(); + vi.clearAllMocks(); +}); + +function loginEvent(cookies: ReturnType["cookies"], form: Record) { + return makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/login`, + form, + locals: { db: mem.db, tenant, env: mem.env }, + cookies, + }); +} + +describe("LDAP 로그인(JIT 프로비저닝)", () => { + it("올바른 LDAP 자격증명은 신규 유저를 JIT 프로비저닝하고 세션을 발급한다", async () => { + const { actions: loginActions } = await import("../../src/routes/(auth)/login/+page.server"); + const jar = makeCookieJar(); + + const redirect = await catchRedirect(() => loginActions.default(loginEvent(jar.cookies, { username: LDAP_USER, password: LDAP_PW }))); + expect(redirect.status).toBe(303); + expect(jar.has("idp_session")).toBe(true); + + // JIT 로 생성된 유저 + LDAP identity 연결 확인. + const [u] = await mem.db + .select() + .from(users) + .where(and(eq(users.tenantId, tenant.id), eq(users.email, "ldapuser@corp.example"))) + .limit(1); + expect(u).toBeTruthy(); + expect(u.role).toBe("user"); + expect(u.status).toBe("active"); + + const idRows = await mem.db.select().from(identities).where(eq(identities.userId, u.id)); + expect(idRows.length).toBe(1); + expect(idRows[0].subject).toBe(LDAP_DN); + }); + + it("잘못된 LDAP 비밀번호는 로컬 인증으로도 실패해 400 으로 거부한다(유저 미생성)", async () => { + const { actions: loginActions } = await import("../../src/routes/(auth)/login/+page.server"); + const jar = makeCookieJar(); + + const res = (await loginActions.default(loginEvent(jar.cookies, { username: LDAP_USER, password: "wrong-pw" }))) as { status?: number }; + expect(res.status).toBe(400); + expect(jar.has("idp_session")).toBe(false); + // 실패 시 유저가 만들어지면 안 된다. + const rows = await mem.db.select().from(users).where(eq(users.email, "ldapuser@corp.example")); + expect(rows.length).toBe(0); + }); + + it("provisionLdapUser: 동일 이메일의 기존 로컬 계정이 있으면 자동 연결을 거부한다(계정 탈취 방지)", async () => { + // 관리자 로컬 계정이 이미 corp 이메일을 점유. + await seedUser(mem.db, { tenantId: tenant.id, email: "ldapuser@corp.example", username: "existing", role: "admin", status: "active" }); + await expect( + provisionLdapUser(mem.db, tenant.id, "provider-1", { + dn: LDAP_DN, + username: LDAP_USER, + email: "ldapuser@corp.example", + displayName: "LDAP User", + givenName: "Ldap", + familyName: "User", + }), + ).rejects.toThrow(); + }); +}); diff --git a/test/integration/login-mfa.test.ts b/test/integration/login-mfa.test.ts new file mode 100644 index 0000000..43c81f8 --- /dev/null +++ b/test/integration/login-mfa.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { eq, and } from "drizzle-orm"; +import { actions as loginActions } from "../../src/routes/(auth)/login/+page.server"; +import { actions as mfaActions } from "../../src/routes/(auth)/mfa/+page.server"; +import { credentials, sessions } from "../../src/lib/server/db/schema"; +import { encryptTotpSecret, generateTotpCode, generateTotpSecret, hashBackupCode } from "../../src/lib/server/auth/totp"; +import { TOTP_CREDENTIAL_TYPE, BACKUP_CODE_CREDENTIAL_TYPE } from "../../src/lib/server/auth/constants"; +import { openMemoryDb, seedTenantAndSigningKey, seedUser, makeEvent, makeCookieJar, catchRedirect, TEST_ISSUER_URL, TEST_SIGNING_SECRET, type MemoryDb } from "./harness"; +import type { Tenant, User } from "../../src/lib/server/db/schema"; + +// 로그인(비밀번호) → idp_mfa_pending 발급 → MFA(TOTP/백업코드) → idp_session 발급을, +// 같은 쿠키(브라우저) 인스턴스로 체이닝해 실 DB + 실 라우트 액션으로 검증한다. + +const MFA_PENDING_COOKIE = "idp_mfa_pending"; +const SESSION_COOKIE = "idp_session"; +const PASSWORD = "mfa-user-strong-password"; + +let mem: MemoryDb; +let tenant: Tenant; +let user: User; +let totpSecret: string; + +beforeEach(async () => { + mem = await openMemoryDb(); + tenant = await seedTenantAndSigningKey(mem); + user = await seedUser(mem.db, { + tenantId: tenant.id, + email: "mfauser@test.example", + username: "mfauser", + password: PASSWORD, + displayName: "MFA User", + }); + // TOTP credential 등록(현행 v2 암호문 — signingKeySecret + userId 바인딩). + totpSecret = generateTotpSecret(); + await mem.db.insert(credentials).values({ + id: crypto.randomUUID(), + userId: user.id, + type: TOTP_CREDENTIAL_TYPE, + secret: await encryptTotpSecret(totpSecret, TEST_SIGNING_SECRET, user.id), + label: "authenticator", + }); +}); + +afterEach(() => mem.close()); + +/** 비밀번호 로그인 액션을 실행한다(옵션 쿠키 재사용). */ +function loginEvent(cookies: ReturnType["cookies"], form: Record) { + return makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/login`, + form, + locals: { db: mem.db, tenant, env: mem.env }, + cookies, + }); +} + +function mfaEvent(cookies: ReturnType["cookies"], form: Record) { + return makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/mfa`, + form, + locals: { db: mem.db, tenant, env: mem.env }, + cookies, + }); +} + +describe("로그인 → MFA 체이닝", () => { + it("비밀번호 로그인이 MFA 대기 쿠키를 발급하고, 올바른 TOTP 로 세션이 발급된다(동일 쿠키 체이닝)", async () => { + const jar = makeCookieJar(); + + // 1) 비밀번호 로그인 → /mfa 로 리다이렉트 + idp_mfa_pending 쿠키 발급(세션 미발급) + const step1 = await catchRedirect(() => loginActions.default(loginEvent(jar.cookies, { username: "mfauser", password: PASSWORD }))); + expect(step1.status).toBe(303); + expect(step1.location).toBe("/mfa"); + expect(jar.has(MFA_PENDING_COOKIE)).toBe(true); + expect(jar.has(SESSION_COOKIE)).toBe(false); + // 아직 세션 row 없음 + expect((await mem.db.select().from(sessions).where(eq(sessions.userId, user.id))).length).toBe(0); + + // 2) 같은 쿠키로 MFA(TOTP) 제출 → idp_session 발급 + MFA 대기 쿠키 소거 + const code = await generateTotpCode(totpSecret); + const step2 = await catchRedirect(() => mfaActions.default(mfaEvent(jar.cookies, { code }))); + expect(step2.status).toBe(303); + expect(step2.location).toBe("/"); // 일반 사용자 기본 랜딩 + expect(jar.has(SESSION_COOKIE)).toBe(true); + expect(jar.has(MFA_PENDING_COOKIE)).toBe(false); + + // 세션 row 가 생성되고 amr 에 pwd+otp 가 기록되어야 한다. + const rows = await mem.db.select().from(sessions).where(eq(sessions.userId, user.id)); + expect(rows.length).toBe(1); + expect(rows[0].amr).toContain("totp"); + expect(rows[0].amr).toContain("pwd"); + }); + + it("잘못된 TOTP 코드는 400 으로 거부하고 세션을 발급하지 않는다", async () => { + const jar = makeCookieJar(); + await catchRedirect(() => loginActions.default(loginEvent(jar.cookies, { username: "mfauser", password: PASSWORD }))); + + // 유효 코드와 다른(그리고 현재/인접 스텝과 겹치지 않는) 코드 사용. + const valid = await generateTotpCode(totpSecret); + const wrong = valid === "000000" ? "999999" : "000000"; + const res = (await mfaActions.default(mfaEvent(jar.cookies, { code: wrong }))) as { status?: number; data?: unknown }; + expect(res.status).toBe(400); + expect(jar.has(SESSION_COOKIE)).toBe(false); + expect((await mem.db.select().from(sessions).where(eq(sessions.userId, user.id))).length).toBe(0); + }); + + it("백업 코드로도 MFA 를 통과해 세션이 발급된다", async () => { + // 미사용 백업코드 credential 하나 등록. + const backupCode = "ABCD2345"; + await mem.db.insert(credentials).values({ + id: crypto.randomUUID(), + userId: user.id, + type: BACKUP_CODE_CREDENTIAL_TYPE, + secret: await hashBackupCode(backupCode), + label: "backup", + }); + + const jar = makeCookieJar(); + await catchRedirect(() => loginActions.default(loginEvent(jar.cookies, { username: "mfauser", password: PASSWORD }))); + + const step2 = await catchRedirect(() => mfaActions.default(mfaEvent(jar.cookies, { code: backupCode, use_backup: "1" }))); + expect(step2.status).toBe(303); + expect(jar.has(SESSION_COOKIE)).toBe(true); + + // 사용된 백업코드는 소진(usedAt) 처리되어야 한다. + const [bc] = await mem.db + .select() + .from(credentials) + .where(and(eq(credentials.userId, user.id), eq(credentials.type, BACKUP_CODE_CREDENTIAL_TYPE))) + .limit(1); + expect(bc.usedAt).not.toBeNull(); + + // 세션 amr 에 백업코드(swk) 방식이 반영되어야 한다. + const rows = await mem.db.select().from(sessions).where(eq(sessions.userId, user.id)); + expect(rows.length).toBe(1); + expect(rows[0].amr).toContain("swk"); + }); + + // 11회 순차 로그인은 시도마다 scrypt 검증 + 실패 타이밍 균등화 지연이 누적되므로 기본 5s 로는 부족하다. + it("로그인 IP 레이트리밋: 임계(10회/15분) 초과 시 429 로 차단한다", { timeout: 30_000 }, async () => { + const jar = makeCookieJar(); + // 잘못된 비밀번호로 10회 시도(모두 400), 11회째는 IP 레이트리밋으로 429. + for (let i = 0; i < 10; i++) { + const r = (await loginActions.default(loginEvent(jar.cookies, { username: "mfauser", password: "wrong-password" }))) as { status?: number }; + expect(r.status).toBe(400); + } + const over = (await loginActions.default(loginEvent(jar.cookies, { username: "mfauser", password: "wrong-password" }))) as { status?: number }; + expect(over.status).toBe(429); + }); +}); diff --git a/test/integration/oidc-flow.test.ts b/test/integration/oidc-flow.test.ts new file mode 100644 index 0000000..0152872 --- /dev/null +++ b/test/integration/oidc-flow.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { GET as authorizeGET } from "../../src/routes/oidc/authorize/+server"; +import { POST as tokenPOST } from "../../src/routes/oidc/token/+server"; +import { GET as userinfoGET } from "../../src/routes/oidc/userinfo/+server"; +import { verifyIdToken } from "../../src/lib/server/crypto/keys"; +import { + openMemoryDb, + seedTenantAndSigningKey, + seedUser, + seedOidcClient, + seedServiceAssignment, + seedSession, + makeEvent, + pkceChallengeS256, + catchRedirect, + TEST_ISSUER_URL, + type MemoryDb, +} from "./harness"; +import type { Tenant, User, Session } from "../../src/lib/server/db/schema"; + +// OIDC Authorization Code + PKCE 풀플로우를 실 DB(libSQL :memory:) + 실 라우트 핸들러로 검증한다. +// authorize(PKCE) → code → token(id_token/access_token/refresh_token) → userinfo, 그리고 code 1회 소진. + +const CLIENT_ID = "test-web-client"; +const CLIENT_SECRET = "s3cr3t-client-secret-value-0123456789"; +const REDIRECT_URI = "https://app.test.example/callback"; + +let mem: MemoryDb; +let tenant: Tenant; +let user: User; +let session: Session; +let clientDbId: string; + +beforeEach(async () => { + mem = await openMemoryDb(); + tenant = await seedTenantAndSigningKey(mem); + user = await seedUser(mem.db, { + tenantId: tenant.id, + email: "alice@test.example", + username: "alice", + password: "correct horse battery staple", + displayName: "Alice Example", + }); + const client = await seedOidcClient(mem.db, { + tenantId: tenant.id, + clientId: CLIENT_ID, + secret: CLIENT_SECRET, + redirectUris: [REDIRECT_URI], + scopes: "openid profile email offline_access", + }); + clientDbId = client.id; + await seedServiceAssignment(mem.db, { tenantId: tenant.id, userId: user.id, serviceType: "oidc", serviceRefId: clientDbId }); + const seeded = await seedSession(mem.db, { tenantId: tenant.id, userId: user.id }); + session = seeded.session; +}); + +afterEach(() => { + mem.close(); +}); + +/** authorize 를 호출하고 발급된 authorization code 를 돌려준다. */ +async function runAuthorize(verifier: string, opts: { state?: string; nonce?: string } = {}): Promise { + const challenge = await pkceChallengeS256(verifier); + const params = new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: "code", + scope: "openid profile email offline_access", + code_challenge: challenge, + code_challenge_method: "S256", + }); + if (opts.state) params.set("state", opts.state); + if (opts.nonce) params.set("nonce", opts.nonce); + + const event = makeEvent({ + method: "GET", + url: `${TEST_ISSUER_URL}/oidc/authorize?${params.toString()}`, + locals: { db: mem.db, tenant, user, session, env: mem.env }, + }); + + const { status, location } = await catchRedirect(() => authorizeGET(event)); + expect(status).toBe(302); + const dest = new URL(location); + expect(`${dest.origin}${dest.pathname}`).toBe(REDIRECT_URI); + const code = dest.searchParams.get("code"); + expect(code).toBeTruthy(); + if (opts.state) expect(dest.searchParams.get("state")).toBe(opts.state); + return code!; +} + +async function runToken(form: Record): Promise { + const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"); + return (await tokenPOST( + makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/oidc/token`, + headers: { authorization: `Basic ${basic}` }, + form, + locals: { db: mem.db, tenant, env: mem.env }, + }), + )) as Response; +} + +describe("OIDC 풀플로우 (authorize → token → userinfo)", () => { + it("PKCE 인가코드 교환으로 id_token/access_token/refresh_token 을 발급하고 검증한다", async () => { + const verifier = "pkce-verifier-abcdefghijklmnopqrstuvwxyz-0123456789-ABCDEFG"; + const code = await runAuthorize(verifier, { state: "xyz-state", nonce: "nonce-123" }); + + const res = await runToken({ + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.token_type).toBe("Bearer"); + expect(body.access_token).toBeTruthy(); + expect(body.id_token).toBeTruthy(); + // offline_access 요청 + refresh_token grant 허용 → refresh token 발급 + expect(body.refresh_token).toBeTruthy(); + + // 실 서명키로 id_token 서명 검증 + 클레임 확인 + const claims = await verifyIdToken(mem.db, tenant.id, body.id_token, { expectedIssuer: TEST_ISSUER_URL, expectedAud: CLIENT_ID }); + expect(claims).not.toBeNull(); + expect(claims!.sub).toBe(user.id); + expect(claims!.iss).toBe(TEST_ISSUER_URL); + expect(claims!.aud).toBe(CLIENT_ID); + expect(claims!.nonce).toBe("nonce-123"); + expect(claims!.email).toBe("alice@test.example"); + expect(claims!.sid).toBe(session.id); + + // access token 으로 userinfo 조회 + const uinfo = (await userinfoGET( + makeEvent({ + method: "GET", + url: `${TEST_ISSUER_URL}/oidc/userinfo`, + headers: { authorization: `Bearer ${body.access_token}` }, + locals: { db: mem.db, tenant, env: mem.env }, + }), + )) as Response; + expect(uinfo.status).toBe(200); + const ui = (await uinfo.json()) as Record; + expect(ui.sub).toBe(user.id); + expect(ui.email).toBe("alice@test.example"); + expect(ui.name).toBe("Alice Example"); + }); + + it("authorization code 는 1회만 소진된다(재사용 거부)", async () => { + const verifier = "verifier-second-case-zzzzzzzzzzzzzzzzzzzzzzzzz-000111222333"; + const code = await runAuthorize(verifier); + + const first = await runToken({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, code_verifier: verifier }); + expect(first.status).toBe(200); + + const second = await runToken({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, code_verifier: verifier }); + expect(second.status).toBe(400); + const err = (await second.json()) as Record; + expect(err.error).toBe("invalid_grant"); + }); + + it("PKCE code_verifier 불일치 시 거부한다", async () => { + const verifier = "verifier-correct-value-mismatch-case-4444555566667777"; + const code = await runAuthorize(verifier); + const res = await runToken({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, code_verifier: "wrong-verifier-value" }); + expect(res.status).toBe(400); + expect(((await res.json()) as Record).error).toBe("invalid_grant"); + }); + + it("서비스 접근 권한 매핑이 없으면 SSO 를 거부한다(기본 deny)", async () => { + // 권한 없는 별도 유저 + 세션으로 authorize 시도 + const other = await seedUser(mem.db, { tenantId: tenant.id, email: "mallory@test.example", username: "mallory", password: "pw" }); + const otherSession = (await seedSession(mem.db, { tenantId: tenant.id, userId: other.id })).session; + const challenge = await pkceChallengeS256("verifier-denied-user-8888999900001111aaaa"); + const params = new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: "code", + scope: "openid", + code_challenge: challenge, + code_challenge_method: "S256", + }); + const event = makeEvent({ + method: "GET", + url: `${TEST_ISSUER_URL}/oidc/authorize?${params.toString()}`, + locals: { db: mem.db, tenant, user: other, session: otherSession, env: mem.env }, + }); + const { location } = await catchRedirect(() => authorizeGET(event)); + const dest = new URL(location); + expect(dest.searchParams.get("error")).toBe("access_denied"); + expect(dest.searchParams.get("code")).toBeNull(); + }); +}); diff --git a/test/integration/p12-logic.test.ts b/test/integration/p12-logic.test.ts new file mode 100644 index 0000000..f7d0e45 --- /dev/null +++ b/test/integration/p12-logic.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { GET as authorizeGET } from "../../src/routes/oidc/authorize/+server"; +import { POST as tokenPOST } from "../../src/routes/oidc/token/+server"; +import { GET as userinfoGET } from "../../src/routes/oidc/userinfo/+server"; +import { GET as healthGET } from "../../src/routes/api/health/+server"; +import { actions as acceptInviteActions } from "../../src/routes/(auth)/accept-invite/+page.server"; +import { actions as confirmEmailChangeActions } from "../../src/routes/account/confirm-email-change/+page.server"; +import { assertNotLastAdmin } from "../../src/lib/server/auth/guards"; +import { verifyIdToken } from "../../src/lib/server/crypto/keys"; +import { generateToken } from "../../src/lib/server/email"; +import { credentials, departments, emailChangeTokens, inviteTokens, userDepartments, users } from "../../src/lib/server/db/schema"; +import { + openMemoryDb, + seedTenantAndSigningKey, + seedUser, + seedOidcClient, + seedServiceAssignment, + seedSession, + makeEvent, + pkceChallengeS256, + catchRedirect, + TEST_ISSUER_URL, + type MemoryDb, +} from "./harness"; +import type { Tenant } from "../../src/lib/server/db/schema"; + +let mem: MemoryDb; +let tenant: Tenant; + +beforeEach(async () => { + mem = await openMemoryDb(); + tenant = await seedTenantAndSigningKey(mem); +}); + +afterEach(() => mem.close()); + +// ── B2: organization scope 만 요청 시 id_token 과 userinfo 가 동일한 조직 클레임을 반환 ── +describe("B2 — organization scope 조직 클레임 (id_token ↔ userinfo 동일)", () => { + const CLIENT_ID = "org-client"; + const CLIENT_SECRET = "org-client-secret-0123456789abcdef"; + const REDIRECT_URI = "https://org-app.test.example/cb"; + + it("organization scope 만으로 발급한 id_token 의 조직 클레임이 userinfo 응답과 일치한다", async () => { + const user = await seedUser(mem.db, { tenantId: tenant.id, email: "org@test.example", username: "org", password: "pw-strong-123", displayName: "Org User" }); + const client = await seedOidcClient(mem.db, { + tenantId: tenant.id, + clientId: CLIENT_ID, + secret: CLIENT_SECRET, + redirectUris: [REDIRECT_URI], + scopes: "openid organization", + grantTypes: "authorization_code", + }); + await seedServiceAssignment(mem.db, { tenantId: tenant.id, userId: user.id, serviceType: "oidc", serviceRefId: client.id }); + const session = (await seedSession(mem.db, { tenantId: tenant.id, userId: user.id })).session; + + // 주소속 부서 시드. + const deptId = crypto.randomUUID(); + await mem.db.insert(departments).values({ id: deptId, tenantId: tenant.id, name: "플랫폼실", code: "PLT" }); + await mem.db.insert(userDepartments).values({ id: crypto.randomUUID(), tenantId: tenant.id, userId: user.id, departmentId: deptId, isPrimary: true, jobTitle: "실장" }); + + // authorize (PKCE) → code + const verifier = "org-verifier-abcdefghijklmnopqrstuvwxyz-0123456789"; + const challenge = await pkceChallengeS256(verifier); + const params = new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: "code", + scope: "openid organization", + code_challenge: challenge, + code_challenge_method: "S256", + }); + const authEvent = makeEvent({ method: "GET", url: `${TEST_ISSUER_URL}/oidc/authorize?${params.toString()}`, locals: { db: mem.db, tenant, user, session, env: mem.env } }); + const { location } = await catchRedirect(() => authorizeGET(authEvent)); + const code = new URL(location).searchParams.get("code")!; + expect(code).toBeTruthy(); + + // token → id_token / access_token + const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"); + const tokenRes = (await tokenPOST( + makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/oidc/token`, + headers: { authorization: `Basic ${basic}` }, + form: { grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, code_verifier: verifier }, + locals: { db: mem.db, tenant, env: mem.env }, + }), + )) as Response; + expect(tokenRes.status).toBe(200); + const body = (await tokenRes.json()) as Record; + + const claims = await verifyIdToken(mem.db, tenant.id, body.id_token, { expectedIssuer: TEST_ISSUER_URL, expectedAud: CLIENT_ID }); + expect(claims).not.toBeNull(); + + // userinfo (access token) + const uinfoRes = (await userinfoGET( + makeEvent({ method: "GET", url: `${TEST_ISSUER_URL}/oidc/userinfo`, headers: { authorization: `Bearer ${body.access_token}` }, locals: { db: mem.db, tenant, env: mem.env } }), + )) as Response; + expect(uinfoRes.status).toBe(200); + const ui = (await uinfoRes.json()) as Record; + + // 조직 클레임(department)이 양쪽에 존재하고 동일해야 한다. + expect(Array.isArray(claims!.department)).toBe(true); + expect((claims!.department as unknown[]).length).toBe(1); + expect(claims!.department).toEqual(ui.department); + expect((claims!.department as Array>)[0].name).toBe("플랫폼실"); + expect((claims!.department as Array>)[0].is_primary).toBe(true); + // job_title 도 동일. + expect(claims!.job_title).toBe(ui.job_title); + expect(claims!.job_title).toBe("실장"); + }); +}); + +// ── B3-a: accept-invite 는 disabled/deletion_pending 계정 수락을 거부한다 ── +describe("B3-a — accept-invite 는 비활성 계정 수락을 거부", () => { + async function makeInvite(userId: string): Promise { + const { token, tokenHash } = await generateToken(); + await mem.db.insert(inviteTokens).values({ userId, tokenHash, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }); + return token; + } + + for (const status of ["disabled", "deletion_pending"] as const) { + it(`status=${status} 계정의 유효 초대 토큰이라도 수락을 400 으로 거부하고 credential 을 만들지 않는다`, async () => { + const invitee = await seedUser(mem.db, { tenantId: tenant.id, email: `inv-${status}@test.example`, username: `inv-${status}`, emailVerifiedAt: null, status }); + const token = await makeInvite(invitee.id); + const res = (await acceptInviteActions.default( + makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/accept-invite`, + form: { token, password: "new-strong-password", confirmPassword: "new-strong-password" }, + locals: { db: mem.db, tenant, env: mem.env }, + }), + )) as { status?: number }; + expect(res.status).toBe(400); + const cred = await mem.db.select().from(credentials).where(eq(credentials.userId, invitee.id)); + expect(cred.length).toBe(0); + }); + } +}); + +// ── B3-b: assertNotLastAdmin 은 미수락 초대 admin 을 "로그인 가능 admin" 으로 세지 않는다 ── +describe("B3-b — assertNotLastAdmin 은 미수락 초대 admin 을 다른 관리자로 오인하지 않는다", () => { + it("사용 가능한 admin 이 하나뿐이고 나머지가 credential 없는 초대 admin 이면, 그 admin 변경을 차단한다", async () => { + // 로그인 가능한(비밀번호 있는) admin. + const usableAdmin = await seedUser(mem.db, { tenantId: tenant.id, email: "a1@test.example", username: "a1", password: "pw-strong-1", role: "admin", status: "active" }); + // 미수락 초대 admin — active 이지만 credential/identity 없음(로그인 불가). + await seedUser(mem.db, { tenantId: tenant.id, email: "a2@test.example", username: "a2", role: "admin", status: "active" }); + + const blocked = await assertNotLastAdmin(mem.db, tenant.id, usableAdmin.id); + // 다른 "사용 가능" admin 이 없으므로 last-admin 보호가 차단(fail)을 반환해야 한다. + expect(blocked).not.toBeNull(); + expect((blocked as { status?: number }).status).toBe(400); + }); + + it("사용 가능한 admin 이 둘 이상이면 변경을 허용(null)한다", async () => { + const a1 = await seedUser(mem.db, { tenantId: tenant.id, email: "b1@test.example", username: "b1", password: "pw-strong-1", role: "admin", status: "active" }); + await seedUser(mem.db, { tenantId: tenant.id, email: "b2@test.example", username: "b2", password: "pw-strong-2", role: "admin", status: "active" }); + const ok = await assertNotLastAdmin(mem.db, tenant.id, a1.id); + expect(ok).toBeNull(); + }); +}); + +// ── B4: health 는 DB 쿼리 실패 시 503 을 반환한다 ── +describe("B4 — health readiness", () => { + it("정상 DB 에서는 200 { db: ready } 를 반환한다", async () => { + const res = (await healthGET({ locals: { db: mem.db } } as unknown as Parameters[0])) as Response; + expect(res.status).toBe(200); + expect(((await res.json()) as { db: string }).db).toBe("ready"); + }); + + it("DB 바인딩이 없으면 503 unavailable 을 반환한다", async () => { + const res = (await healthGET({ locals: { db: null } } as unknown as Parameters[0])) as Response; + expect(res.status).toBe(503); + }); + + it("DB 쿼리가 실패(연결 끊김)하면 503 을 반환한다", async () => { + mem.close(); // libSQL 클라이언트를 닫아 이후 쿼리가 실패하도록 만든다. + const res = (await healthGET({ locals: { db: mem.db } } as unknown as Parameters[0])) as Response; + expect(res.status).toBe(503); + expect(((await res.json()) as { db: string }).db).toBe("unavailable"); + }); +}); + +// ── F3: 이메일 변경 confirm → users.email 교체 ── +describe("F3 — 이메일 변경 확인", () => { + async function makeChangeToken(userId: string, targetEmail: string): Promise { + const { token, tokenHash } = await generateToken(); + await mem.db.insert(emailChangeTokens).values({ userId, tokenHash, targetEmail, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }); + return token; + } + + it("유효한 변경 토큰 확인이 users.email 을 targetEmail 로 교체하고 토큰을 소진한다", async () => { + const user = await seedUser(mem.db, { tenantId: tenant.id, email: "old@test.example", username: "changer", emailVerifiedAt: null }); + const token = await makeChangeToken(user.id, "new@test.example"); + + const res = await confirmEmailChangeActions.default( + makeEvent({ method: "POST", url: `${TEST_ISSUER_URL}/account/confirm-email-change`, form: { token }, locals: { db: mem.db, tenant, user, env: mem.env } }), + ); + expect(res).toEqual({ changed: true }); + + const [u] = await mem.db.select().from(users).where(eq(users.id, user.id)).limit(1); + expect(u.email).toBe("new@test.example"); + expect(u.emailVerifiedAt).not.toBeNull(); + const [tok] = await mem.db.select().from(emailChangeTokens).where(eq(emailChangeTokens.userId, user.id)).limit(1); + expect(tok.usedAt).not.toBeNull(); + }); + + it("targetEmail 이 이미 다른 계정에 점유된 경우 409 로 거부하고 이메일을 바꾸지 않는다", async () => { + await seedUser(mem.db, { tenantId: tenant.id, email: "taken@test.example", username: "owner" }); + const user = await seedUser(mem.db, { tenantId: tenant.id, email: "mine@test.example", username: "mine" }); + const token = await makeChangeToken(user.id, "taken@test.example"); + + const res = (await confirmEmailChangeActions.default( + makeEvent({ method: "POST", url: `${TEST_ISSUER_URL}/account/confirm-email-change`, form: { token }, locals: { db: mem.db, tenant, user, env: mem.env } }), + )) as { status?: number }; + expect(res.status).toBe(409); + const [u] = await mem.db.select().from(users).where(eq(users.id, user.id)).limit(1); + expect(u.email).toBe("mine@test.example"); + }); +}); diff --git a/test/integration/saml-sso.test.ts b/test/integration/saml-sso.test.ts new file mode 100644 index 0000000..2cf2bfd --- /dev/null +++ b/test/integration/saml-sso.test.ts @@ -0,0 +1,185 @@ +import "reflect-metadata"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { POST as ssoPOST } from "../../src/routes/saml/sso/+server"; +import { samlSessions } from "../../src/lib/server/db/schema"; +import { + openMemoryDb, + seedTenantAndSigningKey, + seedUser, + seedSamlSp, + seedServiceAssignment, + seedSession, + makeEvent, + makeKeyCert, + buildAuthnRequestXml, + encodePostBindingSamlRequest, + decodeSamlResponse, + verifyAssertionSignatureInResponse, + getIdpSigningCertPem, + catchError, + TEST_ISSUER_URL, + type MemoryDb, + type KeyCert, +} from "./harness"; +import type { Tenant, User, Session, SamlSp } from "../../src/lib/server/db/schema"; + +// SAML SP-initiated HTTP-POST 바인딩을 실 DB(libSQL :memory:) + 실 /saml/sso POST 라우트로 검증한다. +// 서명된 AuthnRequest → 서비스 권한 게이트 → 서명된 SAML Response(ACS auto-submit 폼) 발급까지 풀플로우. + +const SP_ENTITY_ID = "https://sp.test.example"; +const SP_ACS_URL = "https://sp.test.example/acs"; +const SSO_DESTINATION = `${TEST_ISSUER_URL}/saml/sso`; + +let mem: MemoryDb; +let tenant: Tenant; +let user: User; +let session: Session; +let sp: SamlSp; +let spKc: KeyCert; + +beforeEach(async () => { + mem = await openMemoryDb(); + tenant = await seedTenantAndSigningKey(mem); + spKc = await makeKeyCert("Test SP"); + user = await seedUser(mem.db, { + tenantId: tenant.id, + email: "sam@test.example", + username: "sam", + password: "sam-password-strong", + displayName: "Sam Example", + }); + session = (await seedSession(mem.db, { tenantId: tenant.id, userId: user.id })).session; + sp = await seedSamlSp(mem.db, { + tenantId: tenant.id, + entityId: SP_ENTITY_ID, + acsUrl: SP_ACS_URL, + cert: spKc.certPem, + wantAuthnRequestsSigned: true, + // 서명 검증(단일 Assertion 서명)을 재검증하기 위해 Response 서명은 끈다. + signResponse: false, + }); +}); + +afterEach(() => mem.close()); + +/** 서명된 AuthnRequest 를 POST 바인딩으로 /saml/sso 에 제출한다. */ +async function postAuthnRequest(args: { id: string; loggedIn: boolean; assignUser: boolean; requestUser?: User; requestSession?: Session }): Promise { + const xml = await buildAuthnRequestXml({ + id: args.id, + kc: spKc, + issuer: SP_ENTITY_ID, + destination: SSO_DESTINATION, + acsUrl: SP_ACS_URL, + sign: true, + }); + const samlRequest = encodePostBindingSamlRequest(xml); + const u = args.requestUser ?? user; + const s = args.requestSession ?? session; + const event = makeEvent({ + method: "POST", + url: SSO_DESTINATION, + form: { SAMLRequest: samlRequest }, + locals: { + db: mem.db, + tenant, + user: args.loggedIn ? u : null, + session: args.loggedIn ? s : null, + env: mem.env, + }, + }); + return (await ssoPOST(event)) as Response; +} + +/** auto-submit 폼 HTML 에서 SAMLResponse hidden input 값을 추출한다. */ +function extractSamlResponseFromForm(html: string): string { + const m = html.match(/name="SAMLResponse" value="([^"]+)"/); + expect(m).not.toBeNull(); + return m![1]; +} + +describe("SAML SP-initiated POST 바인딩", () => { + it("로그인+권한 부여 상태에서 서명된 AuthnRequest 는 서명·audience·ACS 가 일치하는 SAML Response 를 ACS 로 발급한다", async () => { + await seedServiceAssignment(mem.db, { tenantId: tenant.id, userId: user.id, serviceType: "saml", serviceRefId: sp.id }); + + const res = await postAuthnRequest({ id: "_authnreq_ok", loggedIn: true, assignUser: true }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + + const html = await res.text(); + // ACS 로 auto-submit 하는 폼이어야 한다. + expect(html).toContain(`action="${SP_ACS_URL}"`); + + const responseXml = decodeSamlResponse(extractSamlResponseFromForm(html)); + // InResponseTo = AuthnRequest ID, Destination = ACS, Audience = SP entityId, NameID = 사용자 이메일. + expect(responseXml).toContain(`InResponseTo="_authnreq_ok"`); + expect(responseXml).toContain(`Destination="${SP_ACS_URL}"`); + expect(responseXml).toContain(`${SP_ENTITY_ID}`); + expect(responseXml).toContain(`>${user.email}`); + expect(responseXml).toContain("urn:oasis:names:tc:SAML:2.0:status:Success"); + + // IdP 서명키로 Assertion 서명을 실제 재검증한다(SP 가 하는 방식과 동일하게 문서 컨텍스트 내 검증). + const idpCert = await getIdpSigningCertPem(mem, tenant.id); + expect(await verifyAssertionSignatureInResponse(responseXml, idpCert)).toBe(true); + // 다른 인증서(SP 인증서)로는 검증되지 않아야 한다(서명이 IdP 키로 되어 있음). + expect(await verifyAssertionSignatureInResponse(responseXml, spKc.certPem)).toBe(false); + + // saml_sessions 기록이 남아야 한다. + const sessions = await mem.db.select().from(samlSessions).where(eq(samlSessions.userId, user.id)); + expect(sessions.length).toBe(1); + expect(sessions[0].spId).toBe(sp.id); + expect(sessions[0].nameId).toBe(user.email); + }); + + it("Assertion 서명 후 NameID 를 변조하면 서명 검증이 실패한다(서명이 실제로 내용을 커버)", async () => { + await seedServiceAssignment(mem.db, { tenantId: tenant.id, userId: user.id, serviceType: "saml", serviceRefId: sp.id }); + const res = await postAuthnRequest({ id: "_authnreq_tamper", loggedIn: true, assignUser: true }); + const responseXml = decodeSamlResponse(extractSamlResponseFromForm(await res.text())); + const tampered = responseXml.replace(`>${user.email}`, `>attacker@evil.example`); + expect(tampered).not.toBe(responseXml); + const idpCert = await getIdpSigningCertPem(mem, tenant.id); + // 원본은 검증 통과, 변조본은 다이제스트 불일치로 실패해야 한다. + expect(await verifyAssertionSignatureInResponse(responseXml, idpCert)).toBe(true); + expect(await verifyAssertionSignatureInResponse(tampered, idpCert)).toBe(false); + }); + + it("서비스 권한 매핑이 없는 SP 접근은 403 으로 거부된다(기본 deny)", async () => { + // seedServiceAssignment 를 하지 않음 → 게이트 실패. + const { status } = await catchError(() => postAuthnRequest({ id: "_authnreq_denied", loggedIn: true, assignUser: false })); + expect(status).toBe(403); + // Assertion 이 발급되지 않았으므로 saml_sessions 기록도 없어야 한다. + const sessions = await mem.db.select().from(samlSessions).where(eq(samlSessions.userId, user.id)); + expect(sessions.length).toBe(0); + }); + + it("동일 AuthnRequest ID 로 두 번째 Assertion 발급을 시도하면 replay 가드가 400 으로 거부한다", async () => { + await seedServiceAssignment(mem.db, { tenantId: tenant.id, userId: user.id, serviceType: "saml", serviceRefId: sp.id }); + + const first = await postAuthnRequest({ id: "_authnreq_replay", loggedIn: true, assignUser: true }); + expect(first.status).toBe(200); + + // 동일 AuthnRequest ID 재제출 → 이미 소비된 requestId → replay 거부. + const { status } = await catchError(() => postAuthnRequest({ id: "_authnreq_replay", loggedIn: true, assignUser: true })); + expect(status).toBe(400); + + // Assertion 은 최초 1회만 발급되어야 한다. + const sessions = await mem.db.select().from(samlSessions).where(eq(samlSessions.userId, user.id)); + expect(sessions.length).toBe(1); + }); + + it("미로그인 상태의 SP-initiated 요청은 /login 으로 리다이렉트한다(Assertion 미발급)", async () => { + await seedServiceAssignment(mem.db, { tenantId: tenant.id, userId: user.id, serviceType: "saml", serviceRefId: sp.id }); + // processSpInitiatedAuthnRequest 는 미로그인 시 redirect(302, /login...) 를 throw 한다. + let redirected: { status?: number; location?: string } | null = null; + try { + await postAuthnRequest({ id: "_authnreq_nologin", loggedIn: false, assignUser: true }); + } catch (e) { + redirected = e as { status?: number; location?: string }; + } + expect(redirected?.status).toBe(302); + expect(redirected?.location).toContain("/login"); + expect(redirected?.location).toContain("redirectTo="); + const sessions = await mem.db.select().from(samlSessions).where(eq(samlSessions.userId, user.id)); + expect(sessions.length).toBe(0); + }); +}); diff --git a/test/integration/secret-rotation.test.ts b/test/integration/secret-rotation.test.ts new file mode 100644 index 0000000..7f462eb --- /dev/null +++ b/test/integration/secret-rotation.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { ensureDefaultTenant } from "../../src/lib/server/auth/bootstrap"; +import { generateRsaSigningKey, wrapPrivateKey, getActiveSigningKey, invalidateSigningKeyCache, encryptSecret, decryptSecret, tryWithSecrets } from "../../src/lib/server/crypto/keys"; +import { signingKeys } from "../../src/lib/server/db/schema"; +import { openMemoryDb, makePlatform, TEST_SIGNING_SECRET, TEST_SIGNING_SECRET_PREVIOUS, type MemoryDb } from "./harness"; +import type { Tenant } from "../../src/lib/server/db/schema"; + +// Phase 9: 무중단 시크릿 회전. 발급/암호화는 항상 current 만 쓰되, 복호/검증은 current→previous +// 순차 fallback 한다. "previous 로 암호화된 데이터가 회전(current 승격) 후에도 복호되는지" 를 +// 실 DB 서명키 행 + 실제 getActiveSigningKey/tryWithSecrets 경로로 검증한다. + +let mem: MemoryDb; +let tenant: Tenant; + +beforeEach(async () => { + mem = await openMemoryDb(); + tenant = await ensureDefaultTenant(mem.db, makePlatform(mem.env)); +}); + +afterEach(() => { + invalidateSigningKeyCache(tenant.id); + mem.close(); +}); + +describe("Phase 9 — 시크릿 current/previous fallback", () => { + it("previous 로 래핑된 서명키를 current 로만은 복호 못 하고, [current, previous] fallback 으로 복호한다", async () => { + // 회전 이전 시점에 previous(=과거 current)로 private key 를 래핑해 저장했다고 가정. + const { kid, privateKey, publicJwk } = await generateRsaSigningKey(); + const privateJwkEncrypted = await wrapPrivateKey(privateKey, TEST_SIGNING_SECRET_PREVIOUS); + await mem.db.insert(signingKeys).values({ + id: crypto.randomUUID(), + tenantId: tenant.id, + kid, + use: "sig", + alg: "RS256", + publicJwk: JSON.stringify(publicJwk), + privateJwkEncrypted, + active: true, + }); + + // current(신규 시크릿) 단독으로는 unwrap 실패 → tryWithSecrets 가 마지막 에러를 throw. + invalidateSigningKeyCache(tenant.id); + await expect(getActiveSigningKey(mem.db, tenant.id, [TEST_SIGNING_SECRET])).rejects.toThrow(); + + // [current, previous] fallback 으로는 previous 로 성공 복호 → 활성 키 반환. + invalidateSigningKeyCache(tenant.id); + const key = await getActiveSigningKey(mem.db, tenant.id, [TEST_SIGNING_SECRET, TEST_SIGNING_SECRET_PREVIOUS]); + expect(key).not.toBeNull(); + expect(key!.kid).toBe(kid); + // 실제 복호된 privateKey 로 서명이 가능한지(RS256 sign)까지 확인. + const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key!.privateKey, new TextEncoder().encode("payload")); + expect(sig.byteLength).toBeGreaterThan(0); + }); + + it("previous 로 암호화한 일반 시크릿(LDAP bindPassword 형식)이 회전 후 [current, previous] 로 복호된다", async () => { + const plaintext = "ldap-bind-password-super-secret"; + const context = "idp-ldap-bind-password-v1"; + // 회전 전: previous 시크릿으로 암호화된 값이 DB(configJson)에 남아 있는 상태. + const encrypted = await encryptSecret(plaintext, TEST_SIGNING_SECRET_PREVIOUS, context); + + // 회전 후 current 단독 복호는 실패. + await expect(decryptSecret(encrypted, TEST_SIGNING_SECRET, context)).rejects.toThrow(); + + // current→previous fallback(실 로그인 경로와 동일 패턴)은 성공. + const decrypted = await tryWithSecrets([TEST_SIGNING_SECRET, TEST_SIGNING_SECRET_PREVIOUS], (s) => decryptSecret(encrypted, s, context)); + expect(decrypted).toBe(plaintext); + }); + + it("발급/암호화는 current 로만 수행한다 — current 로 래핑한 키는 current 단독으로 복호된다", async () => { + const { kid, privateKey, publicJwk } = await generateRsaSigningKey(); + const privateJwkEncrypted = await wrapPrivateKey(privateKey, TEST_SIGNING_SECRET); + await mem.db.insert(signingKeys).values({ + id: crypto.randomUUID(), + tenantId: tenant.id, + kid, + use: "sig", + alg: "RS256", + publicJwk: JSON.stringify(publicJwk), + privateJwkEncrypted, + active: true, + }); + invalidateSigningKeyCache(tenant.id); + const key = await getActiveSigningKey(mem.db, tenant.id, [TEST_SIGNING_SECRET]); + expect(key!.kid).toBe(kid); + }); +}); diff --git a/test/integration/session-refresh-cascade.test.ts b/test/integration/session-refresh-cascade.test.ts new file mode 100644 index 0000000..43d3365 --- /dev/null +++ b/test/integration/session-refresh-cascade.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { actions as sessionActions } from "../../src/routes/account/sessions/+page.server"; +import { issueRefreshToken, rotateRefreshToken } from "../../src/lib/server/oidc/refresh"; +import { oidcRefreshTokens, sessions } from "../../src/lib/server/db/schema"; +import { openMemoryDb, seedTenantAndSigningKey, seedUser, seedSession, makeEvent, TEST_ISSUER_URL, type MemoryDb } from "./harness"; +import type { Tenant, User, Session } from "../../src/lib/server/db/schema"; + +// Phase 6: 개별 세션 철회 → 그 세션에 묶인 OIDC refresh token 연쇄 폐기. +// 실제 account/sessions revoke 액션을 직접 호출해 검증한다. + +let mem: MemoryDb; +let tenant: Tenant; +let user: User; +let sessionA: Session; +let sessionB: Session; + +beforeEach(async () => { + mem = await openMemoryDb(); + tenant = await seedTenantAndSigningKey(mem); + user = await seedUser(mem.db, { tenantId: tenant.id, email: "bob@test.example", username: "bob", password: "pw" }); + sessionA = (await seedSession(mem.db, { tenantId: tenant.id, userId: user.id })).session; + sessionB = (await seedSession(mem.db, { tenantId: tenant.id, userId: user.id })).session; +}); + +afterEach(() => mem.close()); + +async function activeTokenCount(sessionId: string): Promise { + const rows = await mem.db.select().from(oidcRefreshTokens).where(eq(oidcRefreshTokens.sessionId, sessionId)); + return rows.filter((r) => !r.revokedAt).length; +} + +describe("Phase 6 — 세션 개별 철회 시 refresh token 연쇄 폐기", () => { + it("revoke 액션이 대상 세션의 refresh token 만 폐기하고, 폐기된 토큰은 회전이 거부된다", async () => { + // 두 세션 각각에 refresh token 발급 + const tokenA = await issueRefreshToken(mem.db, { tenantId: tenant.id, clientId: "c1", userId: user.id, sessionId: sessionA.id, scope: "openid offline_access" }); + await issueRefreshToken(mem.db, { tenantId: tenant.id, clientId: "c1", userId: user.id, sessionId: sessionB.id, scope: "openid offline_access" }); + + expect(await activeTokenCount(sessionA.id)).toBe(1); + expect(await activeTokenCount(sessionB.id)).toBe(1); + + // sessionA 를 개별 철회 (locals.session = sessionB 이므로 현재 세션이 아님 → JSON 반환) + const event = makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/account/sessions`, + form: { id: sessionA.id }, + locals: { db: mem.db, tenant, user, session: sessionB, env: mem.env }, + }); + const result = await sessionActions.revoke(event); + expect(result).toEqual({ revoked: true }); + + // sessionA 의 refresh token 은 연쇄 폐기, sessionB 는 살아있음 + expect(await activeTokenCount(sessionA.id)).toBe(0); + expect(await activeTokenCount(sessionB.id)).toBe(1); + + // 폐기된 토큰의 회전은 reuse 로 거부된다(연쇄 폐기 실효성 확인) + const rot = await rotateRefreshToken(mem.db, tenant.id, "c1", tokenA); + expect(rot.ok).toBe(false); + if (!rot.ok) expect(rot.reason).toBe("reuse"); + + // 세션 자체도 revoked 로 마킹됨 + const [sa] = await mem.db.select().from(sessions).where(eq(sessions.id, sessionA.id)).limit(1); + expect(sa.revokedAt).not.toBeNull(); + }); + + it("존재하지 않는 세션 id 는 404 로 거부한다(IDOR 방지 가드)", async () => { + const event = makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/account/sessions`, + form: { id: crypto.randomUUID() }, + locals: { db: mem.db, tenant, user, session: sessionB, env: mem.env }, + }); + const result = (await sessionActions.revoke(event)) as { status?: number }; + expect(result.status).toBe(404); + }); +}); diff --git a/test/integration/session-revoke-notify.test.ts b/test/integration/session-revoke-notify.test.ts new file mode 100644 index 0000000..b76b68c --- /dev/null +++ b/test/integration/session-revoke-notify.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { oidcRefreshTokens, sessions } from "../../src/lib/server/db/schema"; +import { openMemoryDb, seedTenantAndSigningKey, seedUser, seedSession, makeEvent, TEST_ISSUER_URL, type MemoryDb } from "./harness"; +import type { Tenant, User } from "../../src/lib/server/db/schema"; + +// 세션 철회 알림 경로: account/sessions 의 revoke 액션이 실제로 세션 + 연쇄 refresh 를 폐기하고 +// 보안 알림(dispatchSecurityAlert)을 호출하는지 검증한다. 알림 sink(security-notify)만 모킹해 +// 호출 여부/인자를 확인하고, 세션·refresh 폐기·감사로그는 실 DB 로 검증한다. +const dispatchMock = vi.fn(); +vi.mock("../../src/lib/server/security-notify", () => ({ + dispatchSecurityAlert: (...args: unknown[]) => dispatchMock(...args), +})); + +let mem: MemoryDb; +let tenant: Tenant; +let user: User; + +beforeEach(async () => { + mem = await openMemoryDb(); + tenant = await seedTenantAndSigningKey(mem); + user = await seedUser(mem.db, { tenantId: tenant.id, email: "revoker@test.example", username: "revoker", password: "pw-strong-1" }); + dispatchMock.mockClear(); +}); + +afterEach(() => mem.close()); + +describe("세션 철회 알림 경로 (account/sessions revoke)", () => { + it("다른 세션을 철회하면 세션 폐기 + refresh 연쇄 폐기 + 보안 알림(session_revoked) 이 일어난다", async () => { + const { actions } = await import("../../src/routes/account/sessions/+page.server"); + + // 현재 세션 + 철회 대상 세션 2개 생성. + const current = (await seedSession(mem.db, { tenantId: tenant.id, userId: user.id })).session; + const other = (await seedSession(mem.db, { tenantId: tenant.id, userId: user.id })).session; + + // 철회 대상 세션에 묶인 refresh token 하나 시드 → 연쇄 폐기 확인용. + const rtId = crypto.randomUUID(); + await mem.db.insert(oidcRefreshTokens).values({ + id: rtId, + tenantId: tenant.id, + clientId: "some-client", + userId: user.id, + sessionId: other.id, + tokenHash: "rt-hash-" + rtId, + scope: "openid offline_access", + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }); + + const event = makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/account/sessions?/revoke`, + form: { id: other.id }, + locals: { db: mem.db, tenant, user, session: current, env: mem.env }, + }); + const res = (await actions.revoke(event)) as { revoked?: boolean }; + expect(res.revoked).toBe(true); + + // 대상 세션은 revokedAt 이 채워지고, 현재 세션은 유지되어야 한다. + const [revoked] = await mem.db.select().from(sessions).where(eq(sessions.id, other.id)).limit(1); + expect(revoked.revokedAt).not.toBeNull(); + const [keep] = await mem.db.select().from(sessions).where(eq(sessions.id, current.id)).limit(1); + expect(keep.revokedAt).toBeNull(); + + // 연쇄 refresh token 폐기. + const [rt] = await mem.db.select().from(oidcRefreshTokens).where(eq(oidcRefreshTokens.id, rtId)).limit(1); + expect(rt.revokedAt).not.toBeNull(); + + // 보안 알림이 session_revoked 로 사용자 이메일에 발송 요청되어야 한다. + expect(dispatchMock).toHaveBeenCalledTimes(1); + const arg = dispatchMock.mock.calls[0][0] as { to?: string; kind?: string }; + expect(arg.kind).toBe("session_revoked"); + expect(arg.to).toBe(user.email); + }); + + it("존재하지 않는 세션 id 는 404 로 거부하고 알림을 보내지 않는다", async () => { + const { actions } = await import("../../src/routes/account/sessions/+page.server"); + const current = (await seedSession(mem.db, { tenantId: tenant.id, userId: user.id })).session; + + const event = makeEvent({ + method: "POST", + url: `${TEST_ISSUER_URL}/account/sessions?/revoke`, + form: { id: crypto.randomUUID() }, + locals: { db: mem.db, tenant, user, session: current, env: mem.env }, + }); + const res = (await actions.revoke(event)) as { status?: number }; + expect(res.status).toBe(404); + expect(dispatchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/test/stubs/app-environment.ts b/test/stubs/app-environment.ts new file mode 100644 index 0000000..fe4d364 --- /dev/null +++ b/test/stubs/app-environment.ts @@ -0,0 +1,16 @@ +// vitest 용 $app/environment 스텁. +// +// 통합 테스트는 실제 서버 모듈(hooks/bootstrap/runtime/route 핸들러)을 직접 import 해 +// 구동하는데, 이들이 SvelteKit 의 `$app/environment` 에서 `dev`/`building` 등을 읽는다. +// 순수 유닛 설정에는 이 alias 가 없어 해석이 깨지므로 최소 스텁을 제공한다. +// +// dev=false 로 둔다 — 이는 "프로덕션 경로" 를 그대로 구동한다는 뜻이다: +// - runtime.resolveIssuerUrl 은 issuerUrl 이 설정돼 있으면 그 값을 반환하고(테스트는 +// platform.env.IDP_ISSUER_URL 로 주입한다), 미설정 시에만 dev 분기를 탄다. +// - bootstrap.assertRequiredConfig 는 ensureAuthBaseline 경로에서만 동작하는데, 하네스는 +// ensureDefaultTenant/ensureSigningKey 를 직접 호출하므로 이 검증 경로를 타지 않는다. +// 따라서 dev=false 라도 통합 테스트는 필요한 값이 주입돼 있어 정상 구동된다. +export const dev = false; +export const building = false; +export const browser = false; +export const version = "test"; diff --git a/test/unit/admin-zod.test.ts b/test/unit/admin-zod.test.ts index 409e406..8e7bfa4 100644 --- a/test/unit/admin-zod.test.ts +++ b/test/unit/admin-zod.test.ts @@ -38,7 +38,7 @@ describe("teamCreateSchema", () => { it("name 이 비면 실패 + 친화적 메시지", () => { const res = teamCreateSchema.safeParse({ name: " " }); expect(res.success).toBe(false); - if (!res.success) expect(res.error.issues[0]?.message).toBe("팀명을 입력해 주세요."); + if (!res.success) expect(res.error.issues[0]?.message).toBe("admin.errors.team_name_required"); }); it("departmentId 가 지정되면 trim 값 유지", () => { @@ -51,7 +51,7 @@ describe("teamUpdateSchema", () => { it("id/name 없으면 실패 (잘못된 요청)", () => { const res = teamUpdateSchema.safeParse({ name: "팀", status: "active" }); expect(res.success).toBe(false); - if (!res.success) expect(res.error.issues[0]?.message).toBe("잘못된 요청입니다."); + if (!res.success) expect(res.error.issues[0]?.message).toBe("admin.errors.invalid_request"); }); it("status 는 active/inactive 만 허용 (잘못된 enum 거부)", () => { @@ -82,7 +82,7 @@ describe("positionCreateSchema / positionUpdateSchema (level 정수 coerce)", () it("level 이 숫자가 아니면 실패", () => { const res = positionCreateSchema.safeParse({ name: "과장", level: "abc" }); expect(res.success).toBe(false); - if (!res.success) expect(res.error.issues[0]?.message).toBe("레벨은 숫자여야 합니다."); + if (!res.success) expect(res.error.issues[0]?.message).toBe("admin.errors.level_must_be_number"); }); it("update 도 id/name 필수 + level coerce", () => { diff --git a/test/unit/crud-factory.test.ts b/test/unit/crud-factory.test.ts index 4c09dde..4c7d306 100644 --- a/test/unit/crud-factory.test.ts +++ b/test/unit/crud-factory.test.ts @@ -44,7 +44,7 @@ function makeEvent(db: unknown, form: Record): RequestEvent { const fd = new FormData(); for (const [k, v] of Object.entries(form)) fd.set(k, v); return { - locals: { db, tenant: { id: "tenant-1" }, user: { id: "user-1", role: "admin" } }, + locals: { db, tenant: { id: "tenant-1" }, user: { id: "user-1", role: "admin" }, locale: "ko" }, request: { formData: async () => fd, headers: new Headers() }, } as unknown as RequestEvent; } diff --git a/test/unit/crypto-keys.test.ts b/test/unit/crypto-keys.test.ts new file mode 100644 index 0000000..4385ac0 --- /dev/null +++ b/test/unit/crypto-keys.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import type { DB } from "$lib/server/db"; +import { + b64uEncode, + b64uDecode, + generateRsaSigningKey, + signJwt, + verifyIdToken, + generateAccessToken, + verifyAccessToken, + wrapPrivateKey, + unwrapPrivateKey, + encryptSecret, + decryptSecret, + tryWithSecrets, + tryWithSecretsNullable, + type AccessTokenClaims, +} from "$lib/server/crypto/keys"; + +// ── 독립 교차 검증 헬퍼 ──────────────────────────────────────────────────────── +// keys.ts 의 b64u 구현에 의존하지 않는 별도 base64url 디코더 (tautology 회피용). +function b64urlToBytes(s: string): Uint8Array { + const b64 = s.replace(/-/g, "+").replace(/_/g, "/"); + const pad = b64.length % 4 ? "=".repeat(4 - (b64.length % 4)) : ""; + const bin = atob(b64 + pad); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr; +} +function b64urlToString(s: string): string { + return new TextDecoder().decode(b64urlToBytes(s)); +} + +const nowSec = () => Math.floor(Date.now() / 1000); + +// verifyIdToken 은 db 파라미터로 공개 JWK 행을 조회한다. 실제 암호 검증 로직 +// (서명/typ/crit/events/exp/aud/iss) 을 그대로 태우기 위한 최소 쿼리빌더 스텁. +function fakeDb(publicJwk: JsonWebKey | null): DB { + const rows = publicJwk ? [{ publicJwk: JSON.stringify(publicJwk) }] : []; + return { + select: () => ({ from: () => ({ where: () => ({ limit: async () => rows }) }) }), + } as unknown as DB; +} + +describe("b64u 헬퍼", () => { + it("인코딩→디코딩 라운드트립 + 독립 디코더와 교차 일치", () => { + const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255, 42, 7]); + const encoded = b64uEncode(bytes); + // padding 없음 + url-safe 문자만 + expect(encoded).not.toMatch(/[+/=]/); + expect(Array.from(b64uDecode(encoded))).toEqual(Array.from(bytes)); + // 독립 디코더로도 동일 결과 + expect(Array.from(b64urlToBytes(encoded))).toEqual(Array.from(bytes)); + }); +}); + +describe("RS256 JWT (signJwt / verifyIdToken)", () => { + let key: Awaited>; + + beforeAll(async () => { + key = await generateRsaSigningKey(); + }); + + it("signJwt 결과를 WebCrypto 로 직접 검증 (독립 교차검증)", async () => { + const payload = { sub: "user-1", aud: "client-a", iss: "https://idp.example", exp: nowSec() + 3600, iat: nowSec() }; + const token = await signJwt(payload, key.privateKey, key.kid); + const [h, p, s] = token.split("."); + + // 헤더/페이로드를 독립 디코더로 파싱 + const header = JSON.parse(b64urlToString(h)) as Record; + expect(header.alg).toBe("RS256"); + expect(header.typ).toBe("JWT"); + expect(header.kid).toBe(key.kid); + expect(JSON.parse(b64urlToString(p))).toEqual(payload); + + // jose 없이 공개 JWK 를 import 해 서명을 직접 검증 + const pub = await crypto.subtle.importKey("jwk", key.publicJwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]); + const ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", pub, b64urlToBytes(s), new TextEncoder().encode(`${h}.${p}`)); + expect(ok).toBe(true); + }); + + it("custom typ 헤더가 반영된다", async () => { + const token = await signJwt({ sub: "x" }, key.privateKey, key.kid, { typ: "logout+jwt" }); + const header = JSON.parse(b64urlToString(token.split(".")[0])) as Record; + expect(header.typ).toBe("logout+jwt"); + }); + + it("유효 토큰: verifyIdToken 이 claims 반환 (aud/iss 일치)", async () => { + const payload = { sub: "user-1", aud: "client-a", iss: "https://idp.example", exp: nowSec() + 3600 }; + const token = await signJwt(payload, key.privateKey, key.kid); + const claims = await verifyIdToken(fakeDb(key.publicJwk), "tenant-1", token, { expectedAud: "client-a", expectedIssuer: "https://idp.example" }); + expect(claims).not.toBeNull(); + expect(claims!.sub).toBe("user-1"); + }); + + it("변조된 서명 거부", async () => { + const token = await signJwt({ sub: "u", aud: "client-a", exp: nowSec() + 3600 }, key.privateKey, key.kid); + const [h, p, s] = token.split("."); + // 서명 첫 문자 치환 — 마지막 base64url 문자는 미사용(패딩) 비트만 담을 수 있어 + // 같은 바이트로 디코드될 수 있으므로 유효 비트가 확실한 첫 문자를 바꾼다. + const tamperedChar = s[0] === "A" ? "B" : "A"; + const tampered = `${h}.${p}.${tamperedChar}${s.slice(1)}`; + expect(await verifyIdToken(fakeDb(key.publicJwk), "tenant-1", tampered)).toBeNull(); + }); + + it("잘못된 aud 거부", async () => { + const token = await signJwt({ sub: "u", aud: "client-a", exp: nowSec() + 3600 }, key.privateKey, key.kid); + expect(await verifyIdToken(fakeDb(key.publicJwk), "tenant-1", token, { expectedAud: "client-b" })).toBeNull(); + }); + + it("만료 토큰 거부 (ignoreExpiry 로는 통과)", async () => { + const token = await signJwt({ sub: "u", aud: "client-a", exp: nowSec() - 10 }, key.privateKey, key.kid); + expect(await verifyIdToken(fakeDb(key.publicJwk), "tenant-1", token, { expectedAud: "client-a" })).toBeNull(); + // 만료 무시 옵션에서는 서명 유효 → 통과 + const claims = await verifyIdToken(fakeDb(key.publicJwk), "tenant-1", token, { expectedAud: "client-a", ignoreExpiry: true }); + expect(claims).not.toBeNull(); + }); + + it("type-confusion 방어: 비-JWT typ / events claim 거부", async () => { + const logoutTyped = await signJwt({ sub: "u", exp: nowSec() + 3600 }, key.privateKey, key.kid, { typ: "logout+jwt" }); + expect(await verifyIdToken(fakeDb(key.publicJwk), "tenant-1", logoutTyped)).toBeNull(); + + const withEvents = await signJwt({ sub: "u", exp: nowSec() + 3600, events: { "http://schemas.openid.net/event/backchannel-logout": {} } }, key.privateKey, key.kid); + expect(await verifyIdToken(fakeDb(key.publicJwk), "tenant-1", withEvents)).toBeNull(); + }); + + it("kid 미매칭(행 없음) 거부", async () => { + const token = await signJwt({ sub: "u", exp: nowSec() + 3600 }, key.privateKey, key.kid); + expect(await verifyIdToken(fakeDb(null), "tenant-1", token)).toBeNull(); + }); + + it("잘못된 형식(파트 수 불일치) 거부", async () => { + expect(await verifyIdToken(fakeDb(key.publicJwk), "tenant-1", "not.a.jwt.token")).toBeNull(); + expect(await verifyIdToken(fakeDb(key.publicJwk), "tenant-1", "onlyone")).toBeNull(); + }); +}); + +describe("HMAC opaque access token (generate/verifyAccessToken)", () => { + const secret = "hmac-secret-abcdefghijklmnop-1234567890"; + const baseClaims = (over: Partial = {}): AccessTokenClaims => ({ + sub: "user-1", + tenantId: "tenant-1", + clientId: "client-a", + scope: "openid profile", + exp: nowSec() + 3600, + iat: nowSec(), + aud: "client-a", + iss: "https://idp.example", + ...over, + }); + + it("발급 → 검증 라운드트립", async () => { + const claims = baseClaims(); + const token = await generateAccessToken(claims, secret); + const verified = await verifyAccessToken(token, secret, "tenant-1", "client-a"); + expect(verified).toEqual(claims); + }); + + it("HMAC 서명을 WebCrypto 로 직접 검증 (독립 교차검증)", async () => { + const token = await generateAccessToken(baseClaims(), secret); + const [data, sig] = token.split("."); + const macKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); + const ok = await crypto.subtle.verify("HMAC", macKey, b64urlToBytes(sig), new TextEncoder().encode(data)); + expect(ok).toBe(true); + }); + + it("페이로드 변조 거부 (서명 미갱신)", async () => { + const token = await generateAccessToken(baseClaims(), secret); + const [, sig] = token.split("."); + const forged = { ...baseClaims(), sub: "attacker" }; + const forgedData = b64uEncode(new TextEncoder().encode(JSON.stringify(forged))); + expect(await verifyAccessToken(`${forgedData}.${sig}`, secret, "tenant-1")).toBeNull(); + }); + + it("서명 변조 거부", async () => { + const token = await generateAccessToken(baseClaims(), secret); + const [data, sig] = token.split("."); + // 첫 문자 치환 (마지막 문자는 미사용 비트만 담아 같은 바이트로 디코드될 수 있음). + const ch = sig[0] === "A" ? "B" : "A"; + expect(await verifyAccessToken(`${data}.${ch}${sig.slice(1)}`, secret, "tenant-1")).toBeNull(); + }); + + it("만료 거부", async () => { + const token = await generateAccessToken(baseClaims({ exp: nowSec() - 10 }), secret); + expect(await verifyAccessToken(token, secret, "tenant-1")).toBeNull(); + }); + + it("tenant 불일치 거부", async () => { + const token = await generateAccessToken(baseClaims(), secret); + expect(await verifyAccessToken(token, secret, "tenant-OTHER")).toBeNull(); + }); + + it("aud 불일치 거부", async () => { + const token = await generateAccessToken(baseClaims(), secret); + expect(await verifyAccessToken(token, secret, "tenant-1", "client-B")).toBeNull(); + }); + + it("잘못된 secret 거부", async () => { + const token = await generateAccessToken(baseClaims(), secret); + expect(await verifyAccessToken(token, "wrong-secret", "tenant-1")).toBeNull(); + }); +}); + +describe("private key 래핑 (wrapPrivateKey / unwrapPrivateKey)", () => { + let key: Awaited>; + const secret = "wrap-secret-0123456789"; + + beforeAll(async () => { + key = await generateRsaSigningKey(); + }); + + it("래핑 → 언래핑 라운드트립 (복원 키로 서명한 값이 원본 공개키로 검증됨)", async () => { + const wrapped = await wrapPrivateKey(key.privateKey, secret); + expect(wrapped.split(".")).toHaveLength(3); // salt.iv.ct + const unwrapped = await unwrapPrivateKey(wrapped, secret); + const msg = new TextEncoder().encode("payload-to-sign"); + const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", unwrapped, msg); + const ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key.publicKey, sig, msg); + expect(ok).toBe(true); + }); + + it("잘못된 secret 으로 언래핑 실패 (AES-GCM 인증 태그)", async () => { + const wrapped = await wrapPrivateKey(key.privateKey, secret); + await expect(unwrapPrivateKey(wrapped, "wrong-secret")).rejects.toThrow(); + }); + + it("잘못된 형식 거부", async () => { + await expect(unwrapPrivateKey("only.two", secret)).rejects.toThrow(/Invalid encrypted key format/); + }); +}); + +describe("secret 암호화 + HKDF 도메인 분리 (encryptSecret / decryptSecret)", () => { + const secret = "master-secret-abcdef-0123456789"; + + it("기본 context 라운드트립", async () => { + const enc = await encryptSecret("hello world", secret); + expect(enc.split(".")).toHaveLength(3); + expect(await decryptSecret(enc, secret)).toBe("hello world"); + }); + + it("커스텀 context 라운드트립", async () => { + const enc = await encryptSecret("payload", secret, "context-A"); + expect(await decryptSecret(enc, secret, "context-A")).toBe("payload"); + }); + + it("도메인 분리: 다른 context 로는 복호화 불가", async () => { + const enc = await encryptSecret("payload", secret, "context-A"); + // 같은 secret 이라도 HKDF info(context) 가 다르면 파생 키가 달라 복호 실패 + await expect(decryptSecret(enc, secret, "context-B")).rejects.toThrow(); + // 기본 context 로도 실패 (context-A 로 암호화했으므로) + await expect(decryptSecret(enc, secret)).rejects.toThrow(); + }); + + it("잘못된 masterSecret 으로 복호화 실패", async () => { + const enc = await encryptSecret("payload", secret, "context-A"); + await expect(decryptSecret(enc, "wrong-master", "context-A")).rejects.toThrow(); + }); + + it("잘못된 형식 거부", async () => { + await expect(decryptSecret("only.two", secret)).rejects.toThrow(/Invalid encrypted secret format/); + }); +}); + +// ── 무중단 회전 헬퍼 (tryWithSecrets / tryWithSecretsNullable) ────────────────── +describe("무중단 시크릿 회전 (tryWithSecrets)", () => { + const CURRENT = "new-master-secret-abcdef"; + const PREVIOUS = "old-master-secret-012345"; + + it("throwing 변형: current 로 복호 성공 시 previous 는 시도하지 않는다", async () => { + // current 로 암호화된 값 → [current, previous] 로 즉시 성공. + const enc = await encryptSecret("payload", CURRENT, "ctx"); + const tried: string[] = []; + const out = await tryWithSecrets([CURRENT, PREVIOUS], (s) => { + tried.push(s); + return decryptSecret(enc, s, "ctx"); + }); + expect(out).toBe("payload"); + expect(tried).toEqual([CURRENT]); // previous 미시도 + }); + + it("throwing 변형: current 실패 시 previous 로 fallback 복호", async () => { + // previous 로 암호화된 (회전 전) 값 → current 실패 후 previous 성공. + const encOld = await encryptSecret("legacy", PREVIOUS, "ctx"); + const tried: string[] = []; + const out = await tryWithSecrets([CURRENT, PREVIOUS], (s) => { + tried.push(s); + return decryptSecret(encOld, s, "ctx"); + }); + expect(out).toBe("legacy"); + expect(tried).toEqual([CURRENT, PREVIOUS]); + }); + + it("throwing 변형: 전부 실패 시 마지막 에러를 throw", async () => { + const encOther = await encryptSecret("x", "totally-different-secret", "ctx"); + await expect(tryWithSecrets([CURRENT, PREVIOUS], (s) => decryptSecret(encOther, s, "ctx"))).rejects.toThrow(); + }); + + it("throwing 변형: 빈 시크릿 배열은 즉시 throw (미설정 방어)", async () => { + await expect(tryWithSecrets([], async () => "x")).rejects.toThrow(); + }); + + it("nullable 변형: 최초 non-null 을 반환하고 이후 시크릿은 시도하지 않는다", async () => { + const claims: AccessTokenClaims = { sub: "u", tenantId: "t", clientId: "c", scope: "openid", iat: 0, exp: Math.floor(Date.now() / 1000) + 300 }; + const token = await generateAccessToken(claims, CURRENT); + const tried: string[] = []; + const out = await tryWithSecretsNullable([CURRENT, PREVIOUS], (s) => { + tried.push(s); + return verifyAccessToken(token, s, "t"); + }); + expect(out?.sub).toBe("u"); + expect(tried).toEqual([CURRENT]); + }); + + it("nullable 변형: current(null) → previous 로 fallback", async () => { + const claims: AccessTokenClaims = { sub: "u2", tenantId: "t", clientId: "c", scope: "openid", iat: 0, exp: Math.floor(Date.now() / 1000) + 300 }; + // previous 로 서명된 (회전 전 발급) 토큰. + const token = await generateAccessToken(claims, PREVIOUS); + const tried: string[] = []; + const out = await tryWithSecretsNullable([CURRENT, PREVIOUS], (s) => { + tried.push(s); + return verifyAccessToken(token, s, "t"); + }); + expect(out?.sub).toBe("u2"); + expect(tried).toEqual([CURRENT, PREVIOUS]); + }); + + it("nullable 변형: 전부 null 이면 null", async () => { + const claims: AccessTokenClaims = { sub: "u3", tenantId: "t", clientId: "c", scope: "openid", iat: 0, exp: Math.floor(Date.now() / 1000) + 300 }; + const token = await generateAccessToken(claims, "some-other-secret"); + const out = await tryWithSecretsNullable([CURRENT, PREVIOUS], (s) => verifyAccessToken(token, s, "t")); + expect(out).toBeNull(); + }); +}); diff --git a/test/unit/email-verification.test.ts b/test/unit/email-verification.test.ts new file mode 100644 index 0000000..c9392a6 --- /dev/null +++ b/test/unit/email-verification.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { env } from "$env/dynamic/private"; +import type { DB } from "$lib/server/db"; +import { generateToken, hashToken } from "$lib/server/email"; +import { issueEmailVerification, EMAIL_VERIFY_EXPIRY_MS } from "$lib/server/auth/email-verification"; + +// ── mock 설계 요약 ──────────────────────────────────────────────────────────── +// 대상 계약: +// generateToken() : CSPRNG 32바이트 → hex 토큰 + SHA-256 hex 해시. +// issueEmailVerification() : db.insert(emailVerificationTokens).values({userId,tokenHash,expiresAt}) +// (평문 토큰 미저장). 발송 실패/issuer 미설정을 격리. +// env 는 vitest 스텁($env/dynamic/private, 빈 가변 객체)을 공유하므로 테스트에서 +// IDP_ISSUER_URL 를 주입/제거해 분기를 제어한다. SMTP_* 미설정 → 실제 발송은 throw +// 하지만 issueEmailVerification 내부 .catch/try 로 격리되어 상위로 새지 않아야 한다. +function makeDb(onInsert?: () => void) { + const inserts: Record[] = []; + const db = { + insert: () => ({ + values: async (v: Record) => { + if (onInsert) onInsert(); + inserts.push(v); + }, + }), + }; + return { db: db as unknown as DB, inserts }; +} + +const HEX64 = /^[0-9a-f]{64}$/; + +// env($env/dynamic/private)는 선언상 필수 string 속성이라 delete가 막힌다. +// 동일 객체를 optional 뷰로만 캐스팅해(런타임 동작 동일) 키 제거를 허용한다. +const mutEnv = env as Record; + +beforeEach(() => { + // 발송 경로가 실제 네트워크를 타지 않도록 SMTP 미설정 유지(발송은 throw→격리 검증). + delete mutEnv.IDP_ISSUER_URL; + delete mutEnv.SMTP_HOSTNAME; + delete mutEnv.SMTP_PORTNUMB; + delete mutEnv.SMTP_USERNAME; + delete mutEnv.SMTP_PASSWORD; + vi.spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => { + vi.restoreAllMocks(); + delete mutEnv.IDP_ISSUER_URL; +}); + +describe("generateToken — CSPRNG 32바이트 / SHA-256 해시", () => { + it("token 은 64 hex(32바이트) URL-safe, tokenHash 는 token 의 SHA-256 hex", async () => { + const { token, tokenHash } = await generateToken(); + expect(HEX64.test(token)).toBe(true); // hex → URL-safe + expect(HEX64.test(tokenHash)).toBe(true); + expect(tokenHash).not.toBe(token); + // 해시 계약: tokenHash === SHA-256(token) + expect(tokenHash).toBe(await hashToken(token)); + }); + + it("호출마다 다른 토큰(CSPRNG 무작위성)", async () => { + const a = await generateToken(); + const b = await generateToken(); + expect(a.token).not.toBe(b.token); + expect(a.tokenHash).not.toBe(b.tokenHash); + }); +}); + +describe("issueEmailVerification — 토큰 저장/TTL/격리", () => { + it("issuer 설정 시 tokenHash(SHA-256)만 insert, 평문 토큰은 저장 안 함", async () => { + env.IDP_ISSUER_URL = "https://idp.example.com"; + const { db, inserts } = makeDb(); + const before = Date.now(); + await issueEmailVerification(db, "user-1", "u@example.com", "ko", undefined); + + expect(inserts.length).toBe(1); + const v = inserts[0]; + expect(v.userId).toBe("user-1"); + expect(HEX64.test(v.tokenHash as string)).toBe(true); // 해시만 저장 + expect(v.token).toBeUndefined(); // 평문 토큰 컬럼 없음 + // TTL 24h. + const exp = (v.expiresAt as Date).getTime(); + expect(exp).toBeGreaterThanOrEqual(before + EMAIL_VERIFY_EXPIRY_MS - 2000); + expect(exp).toBeLessThanOrEqual(Date.now() + EMAIL_VERIFY_EXPIRY_MS + 2000); + expect(EMAIL_VERIFY_EXPIRY_MS).toBe(24 * 60 * 60 * 1000); + }); + + it("발송 실패(SMTP 미설정 → send throw)가 상위로 새지 않고 격리된다", async () => { + env.IDP_ISSUER_URL = "https://idp.example.com"; + // SMTP_* 미설정이므로 sendEmailVerificationEmail 내부에서 throw 하지만 + // .catch/try 로 삼켜야 한다 — resolve 되어야 한다(토큰 insert 는 성공). + const { db, inserts } = makeDb(); + await expect(issueEmailVerification(db, "user-1", "u@example.com", "ko", undefined)).resolves.toBeUndefined(); + expect(inserts.length).toBe(1); + }); + + it("IDP_ISSUER_URL 미설정 시 발송/저장 스킵(insert 없음)", async () => { + // beforeEach 에서 IDP_ISSUER_URL 삭제됨. + const { db, inserts } = makeDb(); + await expect(issueEmailVerification(db, "user-1", "u@example.com", "ko", undefined)).resolves.toBeUndefined(); + expect(inserts.length).toBe(0); + }); + + it("토큰 발급/insert 예외도 격리되어 상위로 전파되지 않는다", async () => { + env.IDP_ISSUER_URL = "https://idp.example.com"; + const db = makeDb(() => { + throw new Error("db down"); + }).db; + await expect(issueEmailVerification(db, "user-1", "u@example.com", "ko", undefined)).resolves.toBeUndefined(); + }); +}); diff --git a/test/unit/gc.test.ts b/test/unit/gc.test.ts new file mode 100644 index 0000000..4001c07 --- /dev/null +++ b/test/unit/gc.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getTableName, type SQL } from "drizzle-orm"; +import { SQLiteSyncDialect } from "drizzle-orm/sqlite-core"; +import type { DB } from "$lib/server/db"; +import { runExpiredDataGc } from "$lib/server/db/gc"; +import { REFRESH_TOKEN_TTL_MS } from "$lib/server/oidc/refresh"; +import { SESSION_TTL_MS } from "$lib/server/auth/constants"; + +// ── 순수 로직 검증용 목(mock) ────────────────────────────────────────────── +// runExpiredDataGc 는 각 테이블에 대해 db.delete(table).where(cond) 만 호출한다. +// (직접 DELETE + import 된 purge 함수 모두 동일 shape.) mock 은 delete 대상 테이블명과 +// 전달된 where SQL 을 캡처하고, 선택적으로 특정 테이블에서 예외를 던져 에러 격리를 검증한다. +const dialect = new SQLiteSyncDialect(); +function render(where: SQL): { sql: string; params: unknown[] } { + const q = dialect.sqlToQuery(where); + return { sql: q.sql, params: q.params }; +} + +interface DeleteCapture { + table: string; + where: SQL; +} + +function makeDb(opts: { failFor?: string[] } = {}) { + const deletes: DeleteCapture[] = []; + const selects: DeleteCapture[] = []; + const db = { + delete: (table: unknown) => { + const name = getTableName(table as Parameters[0]); + return { + where: async (where: SQL) => { + if (opts.failFor?.includes(name)) throw new Error(`boom:${name}`); + deletes.push({ table: name, where }); + return { rowsAffected: 0 }; + }, + }; + }, + // users 배치 하드삭제(runUsersBatchDelete)는 select().from(users).where(cond).limit(n) 로 + // id 를 조회한 뒤 delete(users).where(inArray(...)) 한다. select 를 캡처하고, users 는 + // 한 배치(배치 크기보다 작음)만 반환해 루프가 정확히 1회 만에 종료되게 한다. + select: () => ({ + from: (table: unknown) => { + const name = getTableName(table as Parameters[0]); + const builder = { + _where: undefined as SQL | undefined, + where(where: SQL) { + this._where = where; + return this; + }, + async limit() { + if (opts.failFor?.includes(name)) throw new Error(`boom:${name}`); + if (this._where) selects.push({ table: name, where: this._where }); + return name === "users" ? [{ id: "u1" }] : []; + }, + }; + return builder; + }, + }), + } as unknown as DB; + return { db, deletes, selects }; +} + +/** 캡처된 delete/select 목록에서 테이블명으로 where 를 찾아 렌더링한다. */ +function whereFor(captures: DeleteCapture[], table: string): { sql: string; params: unknown[] } { + const cap = captures.find((d) => d.table === table); + if (!cap) throw new Error(`capture for ${table} 미실행`); + return render(cap.where); +} + +// runExpiredDataGc 는 진행 로그를 console 에 남긴다 — 테스트 출력 소음 억제. +let logSpy: ReturnType; +let errSpy: ReturnType; +beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); +}); +afterEach(() => { + logSpy.mockRestore(); + errSpy.mockRestore(); +}); + +const ALL_TABLES = [ + "oidc_refresh_tokens", + "webauthn_challenges", + "rate_limits", + "oidc_grants", + "password_reset_tokens", + "email_verification_tokens", + "email_change_tokens", + "invite_tokens", + "saml_slo_states", + "saml_authn_request_ids", + "saml_sessions", + "sessions", + "users", +]; + +describe("runExpiredDataGc — 보수적 만료 조건", () => { + it("모든 대상 테이블에 대해 정확히 한 번씩 DELETE 를 실행한다", async () => { + const { db, deletes } = makeDb(); + const result = await runExpiredDataGc(db); + expect(deletes.map((d) => d.table).sort()).toEqual([...ALL_TABLES].sort()); + expect(result.tables.filter((t) => t.ok).length).toBe(ALL_TABLES.length); + }); + + it("sessions: expiresAt 이 refresh TTL(30일) 유예를 넘긴 세션만 삭제한다", async () => { + const start = Date.now(); + const { db, deletes } = makeDb(); + await runExpiredDataGc(db); + const end = Date.now(); + + const { sql, params } = whereFor(deletes, "sessions"); + expect(sql).toContain('"sessions"."expires_at" < ?'); + // cutoff = now - 30일. (sqlite timestamp_ms 컬럼 → 파라미터는 epoch ms 숫자) + const cutoff = params[0] as number; + expect(typeof cutoff).toBe("number"); + expect(cutoff).toBeLessThanOrEqual(end - REFRESH_TOKEN_TTL_MS); + expect(cutoff).toBeGreaterThanOrEqual(start - REFRESH_TOKEN_TTL_MS - 5_000); + }); + + it("saml_authn_request_ids: 유예 없이 expiresAt 경과분만 삭제한다(replay 창 보존)", async () => { + const start = Date.now(); + const { db, deletes } = makeDb(); + await runExpiredDataGc(db); + const end = Date.now(); + + const { sql, params } = whereFor(deletes, "saml_authn_request_ids"); + expect(sql).toContain('"saml_authn_request_ids"."expires_at" < ?'); + const cutoff = params[0] as number; + // 유예가 없으므로 cutoff ≈ now (sessions 의 30일 소급과 대비). + expect(cutoff).toBeGreaterThanOrEqual(start - 5_000); + expect(cutoff).toBeLessThanOrEqual(end); + + // sessions cutoff 는 약 30일 더 과거여야 한다(유예 존재 대비 검증). + const sessCutoff = whereFor(deletes, "sessions").params[0] as number; + expect(cutoff - sessCutoff).toBeGreaterThanOrEqual(REFRESH_TOKEN_TTL_MS - 5_000); + }); + + it("email_verification_tokens: 유예 없이 expiresAt 경과분만 삭제한다", async () => { + const start = Date.now(); + const { db, deletes } = makeDb(); + await runExpiredDataGc(db); + const end = Date.now(); + + const { sql, params } = whereFor(deletes, "email_verification_tokens"); + expect(sql).toContain('"email_verification_tokens"."expires_at" < ?'); + const cutoff = params[0] as number; + expect(cutoff).toBeGreaterThanOrEqual(start - 5_000); + expect(cutoff).toBeLessThanOrEqual(end); + }); + + it("saml_sessions: 활성 세션(endedAt NULL)을 삭제하지 않고, notOnOrAfter/endedAt 유예 경과분만 삭제한다", async () => { + const start = Date.now(); + const { db, deletes } = makeDb(); + await runExpiredDataGc(db); + const end = Date.now(); + + const { sql, params } = whereFor(deletes, "saml_sessions"); + // 두 분기(OR): 만료창(notOnOrAfter) 또는 로그아웃(endedAt) 이 유예를 넘긴 경우. + expect(sql).toContain('"saml_sessions"."not_on_or_after" < ?'); + expect(sql).toContain('"saml_sessions"."ended_at" < ?'); + expect(sql).toContain(" or "); + // endedAt 은 오직 `<` 비교로만 쓰인다 → SQL 시맨틱상 NULL(활성) 은 절대 매칭되지 않는다. + // (is null / is not null 스윕이 없어야 활성 세션이 성급히 삭제되지 않는다.) + expect(sql).not.toContain('"saml_sessions"."ended_at" is'); + + // 두 분기 모두 IdP 세션 TTL(12h) 유예 cutoff = now - SESSION_TTL_MS. + const [c1, c2] = params as number[]; + expect(c1).toBe(c2); + expect(c1).toBeLessThanOrEqual(end - SESSION_TTL_MS); + expect(c1).toBeGreaterThanOrEqual(start - SESSION_TTL_MS - 5_000); + }); + + it("users: status=deletion_pending 이고 deletionScheduledAt 경과분만 (배치)조회·삭제한다(활성/미경과 보존)", async () => { + const start = Date.now(); + const { db, deletes, selects } = makeDb(); + await runExpiredDataGc(db); + const end = Date.now(); + + // 배치 하드삭제는 대상 id 를 select 로 먼저 조회한다 — 대상 조건(where)을 이 select 에서 검증. + const { sql, params } = whereFor(selects, "users"); + // 두 조건(AND): status = 'deletion_pending' 그리고 deletion_scheduled_at < now. + expect(sql).toContain('"users"."status" = ?'); + expect(sql).toContain('"users"."deletion_scheduled_at" < ?'); + expect(sql).toContain(" and "); + // deletionScheduledAt 은 오직 `<` 비교로만 쓰인다 → NULL(활성/일반 계정)은 절대 매칭되지 않는다. + expect(sql).not.toContain('"users"."deletion_scheduled_at" is'); + + const [statusParam, cutoff] = params as [string, number]; + expect(statusParam).toBe("deletion_pending"); + // 유예 없이 즉시(now) cutoff — 실제 30일 유예는 신청 시 deletionScheduledAt 에 반영되어 있으므로 + // GC 는 예정 시각 경과분만 지운다. + expect(cutoff).toBeGreaterThanOrEqual(start - 5_000); + expect(cutoff).toBeLessThanOrEqual(end); + + // 조회된 id 로 실제 삭제(inArray)가 배치 수행된다. + const delSql = whereFor(deletes, "users").sql; + expect(delSql).toContain('"users"."id" in'); + }); + + it("invite_tokens: 만료(expiresAt 경과) 또는 소진(usedAt 설정) 토큰을 삭제한다(유효 대기분 보존)", async () => { + const start = Date.now(); + const { db, deletes } = makeDb(); + await runExpiredDataGc(db); + const end = Date.now(); + + const { sql, params } = whereFor(deletes, "invite_tokens"); + // 두 분기(OR): 만료(expires_at <) 또는 소진(used_at is not null). + expect(sql).toContain('"invite_tokens"."expires_at" < ?'); + expect(sql).toContain('"invite_tokens"."used_at" is not null'); + expect(sql).toContain(" or "); + const cutoff = params[0] as number; + expect(cutoff).toBeGreaterThanOrEqual(start - 5_000); + expect(cutoff).toBeLessThanOrEqual(end); + }); + + it("oidc_grants / password_reset_tokens / saml_slo_states: expiresAt 경과분만 삭제한다", async () => { + const { db, deletes } = makeDb(); + await runExpiredDataGc(db); + for (const table of ["oidc_grants", "password_reset_tokens", "saml_slo_states"]) { + const { sql } = whereFor(deletes, table); + expect(sql, table).toContain(`"${table}"."expires_at" < ?`); + } + }); +}); + +describe("runExpiredDataGc — 테이블별 에러 격리", () => { + it("한 테이블의 실패가 다른 테이블의 purge/delete 를 막지 않는다", async () => { + // purge 함수 대상(oidc_refresh_tokens) 과 직접 delete 대상(saml_sessions) 을 동시에 실패시킨다. + const failing = ["oidc_refresh_tokens", "saml_sessions"]; + const { db, deletes } = makeDb({ failFor: failing }); + const result = await runExpiredDataGc(db); + + // 실패한 테이블은 결과에 ok:false + error 로 기록된다. + for (const t of failing) { + const entry = result.tables.find((r) => r.table === t); + expect(entry?.ok).toBe(false); + expect(entry?.error).toContain("boom"); + } + + // 나머지 테이블은 모두 정상 실행(캡처됨 + ok:true)돼야 한다. + const survivors = ALL_TABLES.filter((t) => !failing.includes(t)); + for (const t of survivors) { + expect( + deletes.some((d) => d.table === t), + `${t} 실행됨`, + ).toBe(true); + expect(result.tables.find((r) => r.table === t)?.ok).toBe(true); + } + + // 결과에는 여전히 모든 테이블 항목이 존재한다. + expect(result.tables.map((r) => r.table).sort()).toEqual([...ALL_TABLES].sort()); + }); +}); diff --git a/test/unit/oidc-claims.test.ts b/test/unit/oidc-claims.test.ts new file mode 100644 index 0000000..c434e84 --- /dev/null +++ b/test/unit/oidc-claims.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from "vitest"; +import { buildOrganizationClaims, parseOrganizationClaimConfig } from "$lib/server/oidc/claims"; +import type { UserMembership } from "$lib/server/org/membership"; + +/** 클레임 노출 검증용 고정 멤버십(주소속 부서 1 + 팀 1). */ +function fixtureMembership(): UserMembership { + return { + departments: [ + { + id: "dept-1", + name: "플랫폼실", + code: "PLTF", + isPrimary: true, + jobTitle: "리드", + position: { id: "pos-1", name: "책임", code: "L3", level: 3 }, + }, + ], + teams: [ + { + id: "team-1", + name: "인증팀", + code: "AUTH", + departmentName: "플랫폼실", + isPrimary: true, + jobTitle: "팀원", + }, + ], + parts: [], + primaryPosition: { id: "pos-1", name: "책임", code: "L3", level: 3 }, + primaryJobTitle: "리드", + }; +} + +/** 비어있는 멤버십(소속 없음). */ +function emptyMembership(): UserMembership { + return { + departments: [], + teams: [], + parts: [], + primaryPosition: null, + primaryJobTitle: null, + }; +} + +describe("buildOrganizationClaims", () => { + it("config null → 4필드(department/team/position/job_title) 전량 노출", () => { + const claims = buildOrganizationClaims(fixtureMembership(), null); + + expect(Object.keys(claims).sort()).toEqual(["department", "job_title", "position", "team"]); + expect(claims.department).toEqual([ + { + id: "dept-1", + name: "플랫폼실", + code: "PLTF", + is_primary: true, + job_title: "리드", + position: { id: "pos-1", name: "책임", code: "L3", level: 3 }, + }, + ]); + expect(claims.team).toEqual([ + { + id: "team-1", + name: "인증팀", + code: "AUTH", + department: "플랫폼실", + is_primary: true, + job_title: "팀원", + }, + ]); + expect(claims.position).toBe("책임"); + expect(claims.job_title).toBe("리드"); + }); + + it("config undefined → null 과 동일하게 전량 노출(하위호환)", () => { + const claims = buildOrganizationClaims(fixtureMembership()); + expect(Object.keys(claims).sort()).toEqual(["department", "job_title", "position", "team"]); + }); + + it("{ team: false } → team 키만 생략, 나머지 3필드 노출", () => { + const claims = buildOrganizationClaims(fixtureMembership(), { team: false }); + + expect("team" in claims).toBe(false); + expect(Object.keys(claims).sort()).toEqual(["department", "job_title", "position"]); + expect(claims.department).toHaveLength(1); + expect(claims.position).toBe("책임"); + expect(claims.job_title).toBe("리드"); + }); + + it("{ position: false, jobTitle: false } → 두 최상위 키 생략, department/team 노출", () => { + const claims = buildOrganizationClaims(fixtureMembership(), { position: false, jobTitle: false }); + + expect("position" in claims).toBe(false); + expect("job_title" in claims).toBe(false); + expect(Object.keys(claims).sort()).toEqual(["department", "team"]); + expect(claims.department).toHaveLength(1); + expect(claims.team).toHaveLength(1); + }); + + it("{ department: false } → department 키만 생략(jobTitle 토글과 job_title 은 독립)", () => { + const claims = buildOrganizationClaims(fixtureMembership(), { department: false }); + + expect("department" in claims).toBe(false); + expect(Object.keys(claims).sort()).toEqual(["job_title", "position", "team"]); + // jobTitle 토글은 최상위 job_title 클레임만 제어하며 department.job_title 과 무관. + expect(claims.job_title).toBe("리드"); + }); + + it("전부 false → 조직 클레임 없음(빈 객체)", () => { + const claims = buildOrganizationClaims(fixtureMembership(), { + department: false, + team: false, + position: false, + jobTitle: false, + }); + + expect(claims).toEqual({}); + expect(Object.keys(claims)).toHaveLength(0); + }); + + it("true 명시는 노출(false 만 생략을 유발)", () => { + const claims = buildOrganizationClaims(fixtureMembership(), { team: true, position: false }); + + expect("team" in claims).toBe(true); + expect("position" in claims).toBe(false); + }); + + it("멤버십이 비어있으면 노출 필드는 빈 배열/null 로 그대로 나온다", () => { + const claims = buildOrganizationClaims(emptyMembership(), null); + + expect(claims.department).toEqual([]); + expect(claims.team).toEqual([]); + expect(claims.position).toBeNull(); + expect(claims.job_title).toBeNull(); + }); + + it("멤버십이 비어있어도 config off 는 해당 키를 생략한다", () => { + const claims = buildOrganizationClaims(emptyMembership(), { department: false, team: false }); + + expect(Object.keys(claims).sort()).toEqual(["job_title", "position"]); + expect(claims.position).toBeNull(); + expect(claims.job_title).toBeNull(); + }); +}); + +describe("parseOrganizationClaimConfig", () => { + it("null/undefined/빈 문자열 → null(전량 노출 폴백)", () => { + expect(parseOrganizationClaimConfig(null)).toBeNull(); + expect(parseOrganizationClaimConfig(undefined)).toBeNull(); + expect(parseOrganizationClaimConfig("")).toBeNull(); + }); + + it("유효 JSON → 알려진 boolean 필드만 취한다", () => { + expect(parseOrganizationClaimConfig('{"team":false}')).toEqual({ team: false }); + expect(parseOrganizationClaimConfig('{"department":true,"team":false,"position":true,"jobTitle":false}')).toEqual({ + department: true, + team: false, + position: true, + jobTitle: false, + }); + }); + + it("boolean 이 아닌 값/미지 키는 무시한다(오염 방지)", () => { + expect(parseOrganizationClaimConfig('{"team":"nope","position":1,"jobTitle":false,"evil":true}')).toEqual({ + jobTitle: false, + }); + }); + + it("잘못된 JSON → null(안전 폴백 = 전량 노출)", () => { + expect(parseOrganizationClaimConfig("{not json}")).toBeNull(); + expect(parseOrganizationClaimConfig("undefined")).toBeNull(); + }); + + it("객체가 아닌 유효 JSON(배열/스칼라) → null", () => { + expect(parseOrganizationClaimConfig("null")).toBeNull(); + expect(parseOrganizationClaimConfig("42")).toBeNull(); + // 배열은 typeof object 이지만 알려진 필드가 없어 빈 config 로 정규화된다. + expect(parseOrganizationClaimConfig("[1,2,3]")).toEqual({}); + }); + + it("파싱 결과를 buildOrganizationClaims 에 넘기면 off 경로가 적용된다(왕복)", () => { + const config = parseOrganizationClaimConfig('{"position":false,"jobTitle":false}'); + const claims = buildOrganizationClaims(fixtureMembership(), config); + + expect(Object.keys(claims).sort()).toEqual(["department", "team"]); + }); +}); diff --git a/test/unit/oidc-grant.test.ts b/test/unit/oidc-grant.test.ts new file mode 100644 index 0000000..18fec18 --- /dev/null +++ b/test/unit/oidc-grant.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import type { DB } from "$lib/server/db"; +import { createGrant, findAndConsumeGrant, type CreateGrantParams } from "$lib/server/oidc/grant"; + +// ── mock 설계 요약 ──────────────────────────────────────────────────────────── +// grant.ts 는 활성 방언(테스트 = d1)에서 아래 두 호출 shape 만 쓴다: +// createGrant : db.insert(oidcGrants).values({...}) (await) +// findAndConsumeGrant : db.update(oidcGrants).set({usedAt}).where(..) (원자적 claim) +// .returning() (await → 소진된 row[]) +// 실제 계약과 어긋나지 않도록 정확히 이 체인만 mock 하고, insert payload 와 +// returning 결과를 캡처/주입한다. WHERE 가드(codeHash·tenant·client·isNull(usedAt)· +// gt(expiresAt))는 DB 가 강제하므로, mock 은 "claim 매칭 여부"를 returning 배열의 +// 존재/부재로 표현한다 — 만료·이미소진·해시불일치는 모두 매칭 실패(빈 배열)로 귀결된다. +function makeDb(returningRows: unknown[] = []) { + const inserts: Record[] = []; + const updateSets: Record[] = []; + const db = { + insert: () => ({ + values: async (v: Record) => { + inserts.push(v); + }, + }), + update: () => ({ + set: (v: Record) => { + updateSets.push(v); + return { + where: () => ({ + returning: async () => returningRows, + }), + }; + }, + }), + }; + return { db: db as unknown as DB, inserts, updateSets }; +} + +// grant.ts 의 sha256Base64Url 와 동일 산식 — codeHash 계약 검증용. +async function sha256Base64Url(input: string): Promise { + const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + let bin = ""; + for (const b of new Uint8Array(buf)) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function grantParams(overrides: Partial = {}): CreateGrantParams { + return { + tenantId: "t1", + clientId: "c1", + userId: "u1", + sessionId: "s1", + code: "raw-authorization-code-xyz", + codeChallenge: "chal", + codeChallengeMethod: "S256", + redirectUri: "https://rp.example/cb", + scope: "openid profile", + nonce: "n1", + state: "st1", + ...overrides, + }; +} + +describe("createGrant — codeHash 기반 저장(평문 code 미저장)", () => { + it("raw code 는 저장하지 않고 SHA-256 base64url 해시(codeHash)만 insert", async () => { + const { db, inserts } = makeDb(); + const params = grantParams(); + await createGrant(db, params); + + expect(inserts.length).toBe(1); + const v = inserts[0]; + // 평문 code 컬럼은 insert 페이로드에 존재하지 않아야 한다. + expect(v.code).toBeUndefined(); + // codeHash 는 raw code 의 SHA-256 base64url 과 정확히 일치. + expect(v.codeHash).toBe(await sha256Base64Url(params.code)); + expect(v.codeHash).not.toBe(params.code); + // 나머지 필드 passthrough + 서버 생성 값. + expect(typeof v.id).toBe("string"); + expect(v.tenantId).toBe("t1"); + expect(v.clientId).toBe("c1"); + expect(v.redirectUri).toBe("https://rp.example/cb"); + expect(v.expiresAt).toBeInstanceOf(Date); + }); + + it("expiresAt 는 now+5분(AUTH_CODE_TTL) 근방", async () => { + const { db, inserts } = makeDb(); + const before = Date.now(); + await createGrant(db, grantParams()); + const exp = (inserts[0].expiresAt as Date).getTime(); + expect(exp).toBeGreaterThanOrEqual(before + 5 * 60 * 1000 - 2000); + expect(exp).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000 + 2000); + }); +}); + +describe("findAndConsumeGrant — 원자적 1회 소진(d1 RETURNING)", () => { + it("claim 성공(RETURNING 이 row 반환) 시 grant 반환 + usedAt 세팅 UPDATE 발행", async () => { + const grant = { id: "g1", tenantId: "t1", clientId: "c1", scope: "openid" }; + const { db, updateSets } = makeDb([grant]); + + const res = await findAndConsumeGrant(db, "t1", "c1", "raw-authorization-code-xyz"); + expect(res).toBe(grant); + // 원자적 claim: usedAt 을 세팅하는 UPDATE 가 정확히 한 번 발행됐다. + expect(updateSets.length).toBe(1); + expect(updateSets[0].usedAt).toBeInstanceOf(Date); + }); + + it("만료 grant: WHERE 가드(gt expiresAt)로 claim 미매칭 → RETURNING 빈 배열 → null", async () => { + const { db, updateSets } = makeDb([]); // 매칭 row 없음 + const res = await findAndConsumeGrant(db, "t1", "c1", "expired-code"); + expect(res).toBeNull(); + // 소진 UPDATE 자체는 시도된다(원자적 claim). 매칭 0행이라 RETURNING 이 비어 null. + expect(updateSets.length).toBe(1); + }); + + it("이미 소진된 grant: WHERE 가드(isNull usedAt)로 claim 미매칭 → null", async () => { + const { db } = makeDb([]); + const res = await findAndConsumeGrant(db, "t1", "c1", "already-used-code"); + expect(res).toBeNull(); + }); + + it("해시 불일치(다른 code): 매칭 row 없음 → null", async () => { + const { db } = makeDb([]); + const res = await findAndConsumeGrant(db, "t1", "c1", "unknown-code"); + expect(res).toBeNull(); + }); +}); diff --git a/test/unit/oidc-refresh.test.ts b/test/unit/oidc-refresh.test.ts new file mode 100644 index 0000000..9608e72 --- /dev/null +++ b/test/unit/oidc-refresh.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "vitest"; +import type { DB } from "$lib/server/db"; +import { issueRefreshToken, revokeRefreshTokenFamily, rotateRefreshToken } from "$lib/server/oidc/refresh"; + +// ── mock 설계 요약 ──────────────────────────────────────────────────────────── +// refresh.ts 가 활성 방언(테스트 = d1)에서 쓰는 호출 shape: +// issueRefreshToken : db.insert(tbl).values({...}) (await) +// revokeRefreshTokenFamily: db.update(tbl).set({revokedAt}).where(..) (await) +// rotateRefreshToken : +// 1) db.select().from(tbl).where(..).limit(1) → [record?] +// 2) runAtomic(db,[claim,insert]) → d1 경로에서 db.batch([...]) +// claim = db.update(tbl).set({revokedAt,replacedById}).where(..).returning({id}) +// insert = db.insert(tbl).values({...}) +// batch 결과[0] 의 length>0 이면 claim 승자. +// +// 실제 계약 대응: +// - .where() 는 revokeFamily 처럼 직접 await 될 수도(→ thenable), rotate claim 처럼 +// .returning() 이 이어질 수도 있어 둘 다 지원한다. +// - .values()/.returning() 은 batch 에 수집될 때 __result 로 실행 결과를 노출하고, +// 직접 await 될 때(thenable)도 동작한다. batch 는 수집된 빌더의 __result 를 +// op 순서대로 돌려줘 runAtomic 계약(결과 배열=op 순서)을 재현한다. +interface MakeDbOpts { + selectResult?: unknown[]; + claimReturning?: Array<{ id: string }>; +} +function makeDb(opts: MakeDbOpts = {}) { + const inserts: Record[] = []; + const updateSets: Record[] = []; + let batchCalls = 0; + const selectResult = opts.selectResult ?? []; + const claimReturning = opts.claimReturning ?? []; + + const db = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => selectResult, + }), + }), + }), + insert: () => ({ + values: (v: Record) => { + inserts.push(v); + // batch 수집(__result) + 직접 await(thenable) 양쪽 지원. + return { __result: undefined, then: (res: (x: unknown) => void) => res(undefined) }; + }, + }), + update: () => ({ + set: (v: Record) => { + updateSets.push(v); + return { + where: () => ({ + // 직접 await(revokeFamily) 경로. + then: (res: (x: unknown) => void) => res(undefined), + // batch claim 경로. + returning: () => ({ __result: claimReturning, then: (res: (x: unknown) => void) => res(claimReturning) }), + }), + }; + }, + }), + batch: async (builders: Array<{ __result: unknown }>) => { + batchCalls += 1; + return builders.map((b) => b.__result); + }, + }; + return { + db: db as unknown as DB, + inserts, + updateSets, + get batchCalls() { + return batchCalls; + }, + }; +} + +async function sha256Hex(input: string): Promise { + const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join(""); +} + +const URLSAFE_B64 = /^[A-Za-z0-9_-]+$/; + +function activeRecord(overrides: Record = {}) { + return { + id: "rt-old", + tenantId: "t1", + clientId: "c1", + userId: "u1", + sessionId: "sess-1", + scope: "openid offline_access", + tokenHash: "oldhash", + revokedAt: null, + replacedById: null, + expiresAt: new Date(Date.now() + 60_000), + ...overrides, + }; +} + +describe("issueRefreshToken — raw 토큰 미저장(해시만 보관)", () => { + it("32바이트 URL-safe base64 토큰 반환 + SHA-256 해시만 insert", async () => { + const { db, inserts } = makeDb(); + const before = Date.now(); + const token = await issueRefreshToken(db, { tenantId: "t1", clientId: "c1", userId: "u1", sessionId: "sess-1", scope: "openid" }); + + expect(URLSAFE_B64.test(token)).toBe(true); + expect(token.length).toBe(43); // 32바이트 base64url(패딩 제거) + + expect(inserts.length).toBe(1); + const v = inserts[0]; + expect(v.token).toBeUndefined(); // 평문 토큰 컬럼 없음 + expect(v.tokenHash).toBe(await sha256Hex(token)); + expect(v.tokenHash).not.toBe(token); + expect(v.tenantId).toBe("t1"); + expect(v.clientId).toBe("c1"); + expect(v.userId).toBe("u1"); + expect(v.sessionId).toBe("sess-1"); + expect(v.scope).toBe("openid"); + expect(typeof v.id).toBe("string"); + const exp = (v.expiresAt as Date).getTime(); + expect(exp).toBeGreaterThanOrEqual(before + 30 * 24 * 60 * 60 * 1000 - 2000); + }); +}); + +describe("revokeRefreshTokenFamily", () => { + it("revokedAt 을 세팅하는 UPDATE 를 발행", async () => { + const { db, updateSets } = makeDb(); + await revokeRefreshTokenFamily(db, "t1", "u1", "c1"); + expect(updateSets.length).toBe(1); + expect(updateSets[0].revokedAt).toBeInstanceOf(Date); + }); +}); + +describe("rotateRefreshToken — 회전 성공 경로(runAtomic claim+insert)", () => { + it("claim 승자 → { ok:true } + 새 토큰 발급, 새 토큰 해시만 insert", async () => { + const record = activeRecord(); + const store = makeDb({ selectResult: [record], claimReturning: [{ id: "rt-new" }] }); + const { db, inserts, updateSets } = store; + + const res = await rotateRefreshToken(db, "t1", "c1", "presented-raw-token"); + expect(res.ok).toBe(true); + if (!res.ok) throw new Error("unreachable"); + expect(res.record).toBe(record); + expect(URLSAFE_B64.test(res.newToken)).toBe(true); + expect(res.newToken.length).toBe(43); + + // 원자적 batch 가 한 번 실행됐다. + expect(store.batchCalls).toBe(1); + + // claim UPDATE: revokedAt + replacedById 세팅(회전 링크). + expect(updateSets.length).toBe(1); + expect(updateSets[0].revokedAt).toBeInstanceOf(Date); + expect(updateSets[0].replacedById).toBeDefined(); + + // new 토큰은 해시로만 저장 — 평문 미저장, 원본 raw != 해시. + expect(inserts.length).toBe(1); + expect(inserts[0].token).toBeUndefined(); + expect(inserts[0].tokenHash).toBe(await sha256Hex(res.newToken)); + expect(inserts[0].userId).toBe("u1"); + }); +}); + +describe("rotateRefreshToken — 거부/재사용 경로", () => { + it("없는 토큰 → { ok:false, reason:'invalid_grant' }, batch/insert 없음", async () => { + const store = makeDb({ selectResult: [] }); + const res = await rotateRefreshToken(store.db, "t1", "c1", "nope"); + expect(res).toEqual({ ok: false, reason: "invalid_grant" }); + expect(store.batchCalls).toBe(0); + expect(store.inserts.length).toBe(0); + }); + + it("이미 revoke 된 토큰 재사용 → family 폐기 + { reason:'reuse' } (회전 없음)", async () => { + const record = activeRecord({ revokedAt: new Date(Date.now() - 1000) }); + const store = makeDb({ selectResult: [record] }); + const { updateSets } = store; + const res = await rotateRefreshToken(store.db, "t1", "c1", "reused"); + expect(res).toEqual({ ok: false, reason: "reuse" }); + // 회전 batch 는 실행되지 않고, family 폐기 UPDATE 만 발행. + expect(store.batchCalls).toBe(0); + expect(store.inserts.length).toBe(0); + expect(updateSets.length).toBe(1); + expect(updateSets[0].revokedAt).toBeInstanceOf(Date); + expect(updateSets[0].replacedById).toBeUndefined(); // family 폐기는 revokedAt 만 + }); + + it("revoked 이지만 미만료인 토큰도 재사용으로 감지(만료보다 revoke 우선 판정)", async () => { + // revokedAt 검사가 expiresAt 검사보다 먼저 — 미만료여도 reuse 로 귀결되어야 한다. + const record = activeRecord({ revokedAt: new Date(Date.now() - 1000), expiresAt: new Date(Date.now() + 60_000) }); + const store = makeDb({ selectResult: [record] }); + const res = await rotateRefreshToken(store.db, "t1", "c1", "reused-not-expired"); + expect(res).toEqual({ ok: false, reason: "reuse" }); + expect(store.batchCalls).toBe(0); + }); + + it("만료 토큰(미revoke) → { reason:'expired' }, family 폐기/회전 없음", async () => { + const record = activeRecord({ revokedAt: null, expiresAt: new Date(Date.now() - 1000) }); + const store = makeDb({ selectResult: [record] }); + const res = await rotateRefreshToken(store.db, "t1", "c1", "expired"); + expect(res).toEqual({ ok: false, reason: "expired" }); + expect(store.batchCalls).toBe(0); + expect(store.updateSets.length).toBe(0); + }); + + it("동시 회전 패자(claim 0행) → family 폐기 + { reason:'reuse' }", async () => { + // 유효 토큰이지만 claim UPDATE 가 0행(다른 요청이 먼저 회전) → 재사용으로 간주. + const record = activeRecord(); + const store = makeDb({ selectResult: [record], claimReturning: [] }); + const { updateSets } = store; + const res = await rotateRefreshToken(store.db, "t1", "c1", "concurrent-loser"); + expect(res).toEqual({ ok: false, reason: "reuse" }); + expect(store.batchCalls).toBe(1); // batch 는 실행됨(패자도 insert 시도) + expect(store.inserts.length).toBe(1); // 패자의 new 토큰도 삽입되지만 반환되지 않음 + // updateSets: [0]=claim(replacedById 有), [1]=family 폐기(revokedAt 만) + expect(updateSets.length).toBe(2); + expect(updateSets[0].replacedById).toBeDefined(); + expect(updateSets[1].replacedById).toBeUndefined(); + expect(updateSets[1].revokedAt).toBeInstanceOf(Date); + }); +}); diff --git a/test/unit/password.test.ts b/test/unit/password.test.ts new file mode 100644 index 0000000..e61e5fc --- /dev/null +++ b/test/unit/password.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from "vitest"; +import { hashEncoded, Config } from "@hicaru/argon2-pure.js"; +import { hashPassword, verifyPassword, timingSafeEqual } from "$lib/server/auth/password"; + +// 운영 scrypt 파라미터 (password.ts 와 동일해야 함) — 교차 검증용 상수. +const OP_N = 32768; +const OP_R = 8; +const OP_P = 3; + +function bytesFromB64(b64: string): Uint8Array { + return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); +} +function b64FromBytes(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)); +} + +// scrypt$N=..,r=..,p=..$saltB64$hashB64 레코드를 파싱한다. +function parseScryptRecord(record: string) { + const parts = record.split("$"); + expect(parts).toHaveLength(4); + expect(parts[0]).toBe("scrypt"); + const params: Record = {}; + for (const pair of parts[1].split(",")) { + const [k, v] = pair.split("="); + params[k] = Number(v); + } + return { params, salt: bytesFromB64(parts[2]), hash: bytesFromB64(parts[3]) }; +} + +describe("scrypt 해시 (hashPassword / verifyPassword)", () => { + it("해시 → 검증 라운드트립 (현행 파라미터는 rehash 없음)", async () => { + const record = await hashPassword("hunter2"); + const result = await verifyPassword("hunter2", record); + expect(result.valid).toBe(true); + expect(result.rehash).toBeUndefined(); + }); + + it("오답 거부", async () => { + const record = await hashPassword("hunter2"); + expect(await verifyPassword("wrong-password", record)).toEqual({ valid: false }); + }); + + it("레코드 형식: 운영 파라미터(N=32768,r=8,p=3), salt 16B, derived 32B", async () => { + const record = await hashPassword("pw"); + expect(record.startsWith("scrypt$N=32768,r=8,p=3$")).toBe(true); + const { params, salt, hash } = parseScryptRecord(record); + expect(params).toEqual({ N: OP_N, r: OP_R, p: OP_P }); + expect(salt).toHaveLength(16); + expect(hash).toHaveLength(32); + }); + + it("같은 비밀번호라도 salt 랜덤화로 매번 다른 해시", async () => { + const a = await hashPassword("same"); + const b = await hashPassword("same"); + expect(a).not.toBe(b); + // 그럼에도 둘 다 검증 통과 + expect((await verifyPassword("same", a)).valid).toBe(true); + expect((await verifyPassword("same", b)).valid).toBe(true); + }); + + it("손상된 scrypt 파라미터(N 비-2의거듭제곱) 거부", async () => { + const { salt, hash } = parseScryptRecord(await hashPassword("pw")); + const bad = `scrypt$N=1000,r=8,p=3$${b64FromBytes(salt)}$${b64FromBytes(hash)}`; + expect(await verifyPassword("pw", bad)).toEqual({ valid: false }); + }); + + it("과대 파라미터(N > 2^17) 거부 — 메모리 폭탄 방어", async () => { + const { salt, hash } = parseScryptRecord(await hashPassword("pw")); + const bad = `scrypt$N=262144,r=8,p=3$${b64FromBytes(salt)}$${b64FromBytes(hash)}`; + expect(await verifyPassword("pw", bad)).toEqual({ valid: false }); + }); +}); + +describe("상수시간 비교 (timingSafeEqual)", () => { + it("동일 바이트열 true, 상이 false, 길이 불일치 false", () => { + const a = new Uint8Array([1, 2, 3, 4]); + expect(timingSafeEqual(a, new Uint8Array([1, 2, 3, 4]))).toBe(true); + expect(timingSafeEqual(a, new Uint8Array([1, 2, 3, 5]))).toBe(false); + expect(timingSafeEqual(a, new Uint8Array([1, 2, 3]))).toBe(false); + }); +}); + +describe("레거시 PBKDF2 검증 → scrypt 업그레이드", () => { + async function makePbkdf2(password: string, iterations: number, digestLabel: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const keyMaterial = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), { name: "PBKDF2" }, false, ["deriveBits"]); + // verifyPbkdf2 는 항상 SHA-256 으로 재파생하므로 정답 해시는 SHA-256 기반. + const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: salt.buffer as ArrayBuffer, iterations }, keyMaterial, 256); + return `pbkdf2$${digestLabel}:${iterations}$${b64FromBytes(salt)}$${b64FromBytes(new Uint8Array(bits))}`; + } + + it("유효 pbkdf2(sha256, 100000) → valid + scrypt rehash", async () => { + const record = await makePbkdf2("legacy-pw", 100_000, "sha256"); + const result = await verifyPassword("legacy-pw", record); + expect(result.valid).toBe(true); + expect(result.rehash?.startsWith("scrypt$")).toBe(true); + }); + + it("오답 거부", async () => { + const record = await makePbkdf2("legacy-pw", 100_000, "sha256"); + expect(await verifyPassword("nope", record)).toEqual({ valid: false }); + }); + + it("iteration 하한(100000) 미만 거부", async () => { + const record = await makePbkdf2("legacy-pw", 99_999, "sha256"); + expect(await verifyPassword("legacy-pw", record)).toEqual({ valid: false }); + }); + + it("digest 불일치(sha512 라벨) 거부", async () => { + // 해시는 SHA-256 으로 만들었지만 라벨이 sha512 → digest 명시 일치 실패로 거부 + const record = await makePbkdf2("legacy-pw", 100_000, "sha512"); + expect(await verifyPassword("legacy-pw", record)).toEqual({ valid: false }); + }); +}); + +describe("레거시 argon2id 검증 → scrypt 업그레이드", () => { + function makeArgon2id(password: string): string { + // 테스트 속도용 저강도 파라미터. 검증 경로(verifyEncoded)만 확인하면 충분. + const cfg = new Config(); + cfg.memCost = 64; + cfg.timeCost = 1; + cfg.lanes = 1; + cfg.hashLength = 16; + const salt = new TextEncoder().encode("test-salt-16byte"); + return hashEncoded(new TextEncoder().encode(password), salt, cfg); + } + + it("유효 argon2id → valid + scrypt rehash", async () => { + const record = makeArgon2id("legacy-pw"); + expect(record.startsWith("$argon2id$")).toBe(true); + const result = await verifyPassword("legacy-pw", record); + expect(result.valid).toBe(true); + expect(result.rehash?.startsWith("scrypt$")).toBe(true); + }); + + it("오답 거부", async () => { + const record = makeArgon2id("legacy-pw"); + expect(await verifyPassword("nope", record)).toEqual({ valid: false }); + }); +}); + +describe("TIMING_DUMMY_HASH (users.ts, S1 타이밍 균등화 상수)", () => { + // users.ts 는 DB 의존이라 import 대상 외 — 상수 문자열만 복제해 형식을 교차 검증한다. + const TIMING_DUMMY_HASH = "scrypt$N=32768,r=8,p=3$laGnY6fbAMkDKdFTKRUGyg==$Jm6an31vv6UDMaa2dn2B2riImIX6qmwMUcc6BWcccg8="; + + it("운영 scrypt 파라미터와 일치하는 유효 레코드 (salt 16B, hash 32B)", () => { + const { params, salt, hash } = parseScryptRecord(TIMING_DUMMY_HASH); + expect(params).toEqual({ N: OP_N, r: OP_R, p: OP_P }); + expect(salt).toHaveLength(16); + expect(hash).toHaveLength(32); + }); + + it("verifyPassword 가 파싱/파생 가능한 레코드로 동작 (임의 입력은 불일치)", async () => { + // 실제 scrypt 파생 1회를 태우되(타이밍 균등화 목적) 결과는 valid:false 여야 한다. + const result = await verifyPassword("any-attacker-guess", TIMING_DUMMY_HASH); + expect(result).toEqual({ valid: false }); + }); +}); diff --git a/test/unit/ratelimit.test.ts b/test/unit/ratelimit.test.ts new file mode 100644 index 0000000..53186d6 --- /dev/null +++ b/test/unit/ratelimit.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { DB } from "$lib/server/db"; +import { checkRateLimit, peekRateLimit, DbRateLimitStore, MemoryRateLimitStore, type RateLimitCounts, type RateLimitStore } from "$lib/server/ratelimit/index"; + +// ── 설계 요약 ────────────────────────────────────────────────────────────────── +// 원자증가/조회는 RateLimitStore 뒤로 캡슐화됐고, checkRateLimit/peekRateLimit 은 두 버킷 +// 카운트에 슬라이딩 윈도우 감쇠 산식을 적용하는 순수 로직이다. 따라서: +// - checkRateLimit/peekRateLimit: store 를 목으로 주입해 산식만 검증(백엔드 무관 동일 결과). +// - DbRateLimitStore: 기존 mock DB 패턴으로 upsert shape/키 규약/expiresAt 검증. +// - MemoryRateLimitStore: 실제 인스턴스로 증가·슬라이딩·evict 를 검증. +// 시간은 vi.useFakeTimers 로 고정해 windowIndex·elapsed 를 결정론적으로 만든다. + +// now=90000, windowMs=60000 → windowIndex=1, windowStart=60000, elapsed=30000 (윈도우 절반) +const NOW = 90_000; +const WINDOW = 60_000; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW)); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +// increment/peek 가 항상 고정 카운트를 반환하는 목 store — 산식만 검증하기 위한 것. +function mockStore(counts: RateLimitCounts): RateLimitStore { + return { + increment: async () => counts, + peek: async () => counts, + }; +} + +describe("checkRateLimit — 한도 내/초과 (store 주입)", () => { + it("한도 내: allowed=true, remaining=limit-sliding, retryAfter=0", async () => { + // prev=0, current=3 → sliding = floor(0*0.5)+3 = 3 ≤ 5 + const res = await checkRateLimit(mockStore({ current: 3, prev: 0 }), "user:a", { windowMs: WINDOW, limit: 5 }); + expect(res.allowed).toBe(true); + expect(res.remaining).toBe(2); + expect(res.retryAfterMs).toBe(0); + }); + + it("초과: allowed=false, remaining=0, retryAfterMs=windowMs-elapsed", async () => { + // prev=0, current=6 → sliding = 6 > 5 + const res = await checkRateLimit(mockStore({ current: 6, prev: 0 }), "user:a", { windowMs: WINDOW, limit: 5 }); + expect(res.allowed).toBe(false); + expect(res.remaining).toBe(0); + // elapsed = 30000 → retryAfter = 60000-30000 = 30000 + expect(res.retryAfterMs).toBe(WINDOW - 30_000); + }); +}); + +describe("checkRateLimit — 슬라이딩 윈도우 감쇠 산식 (기존 동일성)", () => { + it("floor(prev*(1-elapsed/window)) + current 로 이전 버킷을 가중 감쇠", async () => { + // elapsed=30000, factor=1-0.5=0.5. prev=4, current=1 → floor(4*0.5)+1 = 3 ≤ 5 → 허용, remaining=2 + const res = await checkRateLimit(mockStore({ current: 1, prev: 4 }), "user:a", { windowMs: WINDOW, limit: 5 }); + expect(res.allowed).toBe(true); + expect(res.remaining).toBe(2); + }); + + it("감쇠된 prev 가중치로 한도를 넘으면 차단", async () => { + // prev=10, current=1, factor=0.5 → floor(5)+1 = 6 > 5 → 차단 + const res = await checkRateLimit(mockStore({ current: 1, prev: 10 }), "user:a", { windowMs: WINDOW, limit: 5 }); + expect(res.allowed).toBe(false); + expect(res.remaining).toBe(0); + }); + + it("elapsed 비율에 따라 감쇠 강도가 달라진다(윈도우 초반엔 prev 가중치 큼)", async () => { + // now=61000 → elapsed=1000. prev=5, current=1 → floor(5*(1-1000/60000))+1 = floor(4.916..)+1 = 5 ≤ 5 → 허용(경계) + vi.setSystemTime(new Date(61_000)); + const res = await checkRateLimit(mockStore({ current: 1, prev: 5 }), "user:a", { windowMs: WINDOW, limit: 5 }); + expect(res.allowed).toBe(true); + expect(res.remaining).toBe(0); + }); + + it("이전 버킷이 없으면(prev=0) sliding=current", async () => { + const res = await checkRateLimit(mockStore({ current: 5, prev: 0 }), "user:a", { windowMs: WINDOW, limit: 5 }); + expect(res.allowed).toBe(true); + expect(res.remaining).toBe(0); // 5-5 + }); +}); + +describe("peekRateLimit — 동일 산식, 증가 없음", () => { + it("peek 결과에 checkRateLimit 과 동일한 산식을 적용한다", async () => { + const res = await peekRateLimit(mockStore({ current: 6, prev: 0 }), "user:a", { windowMs: WINDOW, limit: 5 }); + expect(res.allowed).toBe(false); // 6 > 5 + expect(res.retryAfterMs).toBe(WINDOW - 30_000); + }); + + it("store.increment 가 아니라 store.peek 를 호출한다(증가 부작용 없음)", async () => { + const increment = vi.fn(); + const peek = vi.fn(async () => ({ current: 1, prev: 0 }) as RateLimitCounts); + const store: RateLimitStore = { increment, peek }; + await peekRateLimit(store, "user:a", { windowMs: WINDOW, limit: 5 }); + expect(peek).toHaveBeenCalledOnce(); + expect(increment).not.toHaveBeenCalled(); + }); +}); + +describe("단일 타임스탬프 시맨틱 — increment/peek/evaluate 가 동일 now 사용", () => { + it("checkRateLimit 은 store.increment 와 evaluate 에 동일한 now(=Date.now())를 전달한다", async () => { + let seenNow: number | undefined; + const store: RateLimitStore = { + increment: async (_k, _w, now) => { + seenNow = now; + return { current: 6, prev: 0 }; + }, + peek: async () => ({ current: 0, prev: 0 }), + }; + const res = await checkRateLimit(store, "user:a", { windowMs: WINDOW, limit: 5 }); + // store 가 받은 now 로 windowIndex=1, windowStart=60000, elapsed=30000 이 결정되고 + // evaluate 도 같은 now 를 써야 retryAfterMs = WINDOW-30000 이 나온다. + expect(seenNow).toBe(NOW); + expect(res.allowed).toBe(false); + expect(res.retryAfterMs).toBe(WINDOW - 30_000); + }); + + it("peekRateLimit 도 store.peek 와 evaluate 에 동일한 now 를 전달한다", async () => { + let seenNow: number | undefined; + const store: RateLimitStore = { + increment: async () => ({ current: 0, prev: 0 }), + peek: async (_k, _w, now) => { + seenNow = now; + return { current: 6, prev: 0 }; + }, + }; + const res = await peekRateLimit(store, "user:a", { windowMs: WINDOW, limit: 5 }); + expect(seenNow).toBe(NOW); + expect(res.retryAfterMs).toBe(WINDOW - 30_000); + }); + + it("윈도우 경계 직전/직후: 명시적 now 로 버킷 인덱스가 결정론적으로 갈린다", async () => { + const store = new MemoryRateLimitStore(); + // 경계 직전: now=119999 → windowIndex=1 → 키 k:1 + const before = await store.increment("k", WINDOW, 2 * WINDOW - 1); + expect(before).toEqual({ current: 1, prev: 0 }); + // 경계 직후: now=120000 → windowIndex=2 → 새 버킷 k:2 (직전 k:1 은 prev 로 노출) + const after = await store.increment("k", WINDOW, 2 * WINDOW); + expect(after).toEqual({ current: 1, prev: 1 }); + }); +}); + +// ── DbRateLimitStore (mock DB) ──────────────────────────────────────────────── +// 테스트 방언 = d1 → onConflictDoUpdate + RETURNING 경로. +// increment: insert(...).values(v).onConflictDoUpdate(...).returning() → [{count}] +// 그 뒤 이전 버킷 select 1회. +// peek: current select → prev select (증가 없음). +// select 결과는 호출 순서대로 selectRows 큐에서 소비한다. +interface MakeDbOpts { + currentCount: number; + selectRows?: Array>; +} +function makeDb(opts: MakeDbOpts) { + let insertValues: Record | undefined; + let selectCall = 0; + const selectRows = opts.selectRows ?? []; + const db = { + insert: () => ({ + values: (v: Record) => { + insertValues = v; + return { + onConflictDoUpdate: () => ({ + returning: async () => [{ count: opts.currentCount }], + }), + onDuplicateKeyUpdate: async () => undefined, + }; + }, + }), + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => selectRows[selectCall++] ?? [], + }), + }), + }), + }; + return { + db: db as unknown as DB, + get insertValues() { + return insertValues; + }, + }; +} + +describe("DbRateLimitStore — upsert shape / 키 규약 / 만료", () => { + it("increment: current 는 upsert RETURNING, prev 는 이전 버킷 select", async () => { + // prev 버킷 select → [{count:4}] + const store = makeDb({ currentCount: 3, selectRows: [[{ count: 4 }]] }); + const counts = await new DbRateLimitStore(store.db).increment("user:a", WINDOW); + expect(counts).toEqual({ current: 3, prev: 4 }); + }); + + it("increment: current 버킷 key=`${key}:${windowIndex}`, count=1, expiresAt=windowStart+windowMs*2", async () => { + const store = makeDb({ currentCount: 1, selectRows: [[]] }); + await new DbRateLimitStore(store.db).increment("user:a", WINDOW); + expect(store.insertValues?.key).toBe("user:a:1"); // floor(90000/60000)=1 + expect(store.insertValues?.count).toBe(1); + expect((store.insertValues?.expiresAt as Date).getTime()).toBe(60_000 + WINDOW * 2); // 180000 + }); + + it("increment: 이전 버킷 row 부재 → prev=0", async () => { + const store = makeDb({ currentCount: 5, selectRows: [[]] }); + const counts = await new DbRateLimitStore(store.db).increment("user:a", WINDOW); + expect(counts).toEqual({ current: 5, prev: 0 }); + }); + + it("peek: 증가 없이 current/prev 두 select 만 수행", async () => { + // 첫 select = current 버킷, 둘째 = prev 버킷 + const store = makeDb({ currentCount: 999, selectRows: [[{ count: 2 }], [{ count: 7 }]] }); + const counts = await new DbRateLimitStore(store.db).peek("user:a", WINDOW); + expect(counts).toEqual({ current: 2, prev: 7 }); + // insert(upsert)는 호출되지 않았어야 한다 → insertValues 미설정 + expect(store.insertValues).toBeUndefined(); + }); +}); + +// ── MemoryRateLimitStore ────────────────────────────────────────────────────── +describe("MemoryRateLimitStore — 증가/슬라이딩/evict", () => { + it("increment 는 현재 버킷을 원자 증가하고 prev 를 함께 반환", async () => { + const store = new MemoryRateLimitStore(); + expect(await store.increment("k", WINDOW)).toEqual({ current: 1, prev: 0 }); + expect(await store.increment("k", WINDOW)).toEqual({ current: 2, prev: 0 }); + expect(await store.increment("k", WINDOW)).toEqual({ current: 3, prev: 0 }); + }); + + it("이전 윈도우 버킷 카운트를 prev 로 노출한다", async () => { + const store = new MemoryRateLimitStore(); + // windowIndex 0 (t=30000) 에서 2회 증가 → k:0 count=2, expiresAt=120000 + vi.setSystemTime(new Date(30_000)); + await store.increment("k", WINDOW); + await store.increment("k", WINDOW); + // windowIndex 1 (t=90000) 에서 조회 → prev=k:0=2, current=k:1=0 + vi.setSystemTime(new Date(NOW)); + expect(await store.peek("k", WINDOW)).toEqual({ current: 0, prev: 2 }); + }); + + it("checkRateLimit 과 결합 시 DB 백엔드와 동일한 한도 판정을 낸다", async () => { + const store = new MemoryRateLimitStore(); + const opts = { windowMs: WINDOW, limit: 5 }; + // prev=0 이므로 sliding=current. 5회까지 허용, 6회째 초과. + for (let i = 1; i <= 5; i++) { + const r = await checkRateLimit(store, "user:a", opts); + expect(r.allowed).toBe(true); + expect(r.remaining).toBe(5 - i); + } + const sixth = await checkRateLimit(store, "user:a", opts); + expect(sixth.allowed).toBe(false); + expect(sixth.retryAfterMs).toBe(WINDOW - 30_000); + }); + + it("만료 버킷은 조회 시 0 으로 취급된다", async () => { + const store = new MemoryRateLimitStore(); + // t=90000: k:1 expiresAt = 60000 + 120000 = 180000 + await store.increment("k", WINDOW); + // t=200000 > 180000 → 만료 + vi.setSystemTime(new Date(200_000)); + expect(await store.peek("k", WINDOW)).toEqual({ current: 0, prev: 0 }); + }); + + it("evict: 만료 버킷은 스윕에서 실제로 제거되어 메모리가 누적되지 않는다", async () => { + const store = new MemoryRateLimitStore(); + // t=90000: 첫 sweep(lastSweep 0→90000, 빈 맵), k:1 저장(expiresAt 180000) + await store.increment("k", WINDOW); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((store as any).buckets.size).toBe(1); + // t=250000: sweep 간격(≥60s) 경과 + k:1 만료(180000<250000) → k 제거, other 만 남음 + vi.setSystemTime(new Date(250_000)); + await store.increment("other", WINDOW); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const buckets = (store as any).buckets as Map; + expect(buckets.size).toBe(1); + expect([...buckets.keys()][0]).toBe(`other:${Math.floor(250_000 / WINDOW)}`); + }); +}); diff --git a/test/unit/schema-parity.test.ts b/test/unit/schema-parity.test.ts index 3e35dee..71cc427 100644 --- a/test/unit/schema-parity.test.ts +++ b/test/unit/schema-parity.test.ts @@ -1,50 +1,268 @@ import { describe, it, expect } from "vitest"; -import { getTableColumns, getTableName, is, Table } from "drizzle-orm"; +import { is } from "drizzle-orm"; +import { SQLiteTable, getTableConfig as getSqliteTableConfig } from "drizzle-orm/sqlite-core"; +import { PgTable, getTableConfig as getPgTableConfig } from "drizzle-orm/pg-core"; +import { MySqlTable, getTableConfig as getMysqlTableConfig } from "drizzle-orm/mysql-core"; import * as sqliteSchema from "../../src/lib/server/db/schema.sqlite"; import * as pgSchema from "../../src/lib/server/db/schema.pg"; import * as mysqlSchema from "../../src/lib/server/db/schema.mysql"; /** - * E1: 3개 방언 스키마(schema.sqlite/pg/mysql)의 테이블·컬럼 집합이 동일한지 강제한다. - * 수작업 동기화 중 한 방언에만 컬럼을 추가/삭제/개명하는 drift 를 CI 에서 잡는다. - * (인덱스 parity 는 방언별 API 차이로 이 테스트 범위 밖 — 별도 수동 검토 필요.) + * E1: 3개 방언 스키마(schema.sqlite/pg/mysql)를 drizzle introspection 으로 정규화해 + * 다음 축의 parity 를 강제한다. 수작업 동기화 중 한 방언에만 생기는 drift 를 CI 에서 잡는다. + * + * 1. 테이블 집합 + * 2. 테이블별 컬럼 집합 + * 3. 컬럼별 nullable(notNull) 여부 + * 4. 컬럼별 타입 "계열"(type family) — 방언 간 정당한 물리 타입 차이는 아래 매핑 테이블로 + * 정규화해 비교하고, 정규화 불가능한 진짜 drift 만 실패시킨다. + * 5. 인덱스·unique 제약 — 인덱스 이름 + unique 여부 + 대상 컬럼 집합. 방언별 정당한 + * 차이는 INDEX_PARITY_EXCEPTIONS 로 명시 문서화하고, 목록에 없는 신규 drift 만 실패. */ -function tableColumnMap(schema: Record): Map { - const out = new Map(); + +// ── 타입 계열(type family) 매핑 테이블 ──────────────────────────────────────────── +// +// 방언 간 정당한 SQL 타입 차이를 하나의 "계열" 로 정규화한다. 키는 drizzle 의 columnType +// (방언별 컬럼 구현 클래스명), 값은 방언 독립 계열 문자열이다. +// +// 예) 타임스탬프 컬럼의 물리 타입은 방언마다 다르지만 모두 "date" 계열이다: +// sqlite integer(mode:timestamp_ms) / pg timestamp(3, tz) / mysql datetime(fsp:3) +// 불리언도 sqlite integer(mode:boolean) / pg boolean / mysql boolean(tinyint) → "boolean". +// 문자열은 sqlite/pg text 와 mysql varchar|text → "string"(mysql 은 인덱싱 위해 길이 필요). +// +// 이 표에 없는 columnType 이 등장하면(=미분류) 아래 컬럼 parity 검사에서 명시적으로 실패해 +// 새 타입을 의도적으로 계열 분류하도록 강제한다(조용한 오분류 방지). +const COLUMN_TYPE_FAMILY: Record = { + // string 계열 + SQLiteText: "string", + PgText: "string", + MySqlVarChar: "string", + MySqlText: "string", + // number 계열 + SQLiteInteger: "number", + PgInteger: "number", + MySqlInt: "number", + // boolean 계열 + SQLiteBoolean: "boolean", + PgBoolean: "boolean", + MySqlBoolean: "boolean", + // date/timestamp 계열 + SQLiteTimestamp: "date", + PgTimestamp: "date", + MySqlDateTime: "date", +}; + +// ── 인덱스 parity 예외 목록 ─────────────────────────────────────────────────────── +// +// 방언별로 정당하게 존재/부재가 갈리는 인덱스를 명시 문서화한다. 여기 등재된 (테이블, +// 인덱스명) 조합은 `missingIn` 에 나열된 방언에서 누락돼 있어도 drift 로 보지 않는다. +// 목록에 없는 신규 누락/추가/컬럼불일치만 실패한다. +interface IndexParityException { + table: string; + index: string; + /** 이 인덱스가 정당하게 누락된 방언들. */ + missingIn: Dialect[]; + reason: string; +} +const INDEX_PARITY_EXCEPTIONS: IndexParityException[] = [ + { + table: "signing_keys", + index: "signing_keys_tenant_one_active_uidx", + missingIn: ["mysql"], + // MySQL 은 partial unique index (WHERE active) 를 지원하지 않는다. sqlite/pg 는 부분 + // 유니크 인덱스로 "tenant 당 active signing key 1개" 불변식을 DB 레벨에서 강제하고, + // mysql 은 애플리케이션 레벨 트랜잭션으로 동일 불변식을 보장한다(schema.mysql.ts 주석). + reason: "MySQL 은 partial unique index 미지원 → 앱 레벨 트랜잭션으로 대체", + }, + { + table: "users", + index: "users_deletion_pending_idx", + missingIn: ["mysql"], + // sqlite/pg 는 부분 인덱스(WHERE status='deletion_pending')로 삭제 예정 계정만 색인한다. + // MySQL 은 부분 인덱스를 지원하지 않아 아래 users_deletion_gc_idx(복합)로 대체한다. + reason: "MySQL 은 partial index 미지원 → users_deletion_gc_idx 복합 인덱스로 대체", + }, + { + table: "users", + index: "users_deletion_gc_idx", + missingIn: ["sqlite", "pg"], + // MySQL 전용 복합 인덱스(status, deletionScheduledAt). sqlite/pg 는 부분 인덱스 + // users_deletion_pending_idx 로 동일 GC 조회를 더 좁게 색인하므로 이 인덱스가 없다. + reason: "sqlite/pg 는 부분 인덱스 users_deletion_pending_idx 사용 → 복합 인덱스 불필요", + }, +]; + +// ── introspection 정규화 ────────────────────────────────────────────────────────── + +type Dialect = "sqlite" | "pg" | "mysql"; + +interface ColumnInfo { + notNull: boolean; + /** 정규화된 타입 계열. 미분류 columnType 이면 `unmapped:`. */ + family: string; +} +interface IndexInfo { + unique: boolean; + /** 대상 컬럼명 정렬 목록(JSON 직렬화용). 표현식 인덱스 항목은 "". */ + columns: string[]; +} +interface TableInfo { + columns: Map; + indexes: Map; +} + +function familyOf(columnType: string): string { + return COLUMN_TYPE_FAMILY[columnType] ?? `unmapped:${columnType}`; +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +function normalizeTable(cfg: { columns: any[]; indexes: any[] }): TableInfo { + const columns = new Map(); + for (const c of cfg.columns) { + columns.set(c.name, { notNull: Boolean(c.notNull), family: familyOf(c.columnType) }); + } + const indexes = new Map(); + for (const idx of cfg.indexes) { + const conf = idx.config; + const cols = conf.columns.map((col: any) => col?.name ?? "").sort(); + indexes.set(conf.name, { unique: Boolean(conf.unique), columns: cols }); + } + return { columns, indexes }; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +function buildTableMap(schema: Record, dialect: Dialect): Map { + const out = new Map(); for (const value of Object.values(schema)) { - if (is(value, Table)) { - const cols = Object.values(getTableColumns(value)) - .map((c) => (c as { name: string }).name) - .sort(); - out.set(getTableName(value), cols); + if (dialect === "sqlite" && is(value, SQLiteTable)) { + const cfg = getSqliteTableConfig(value); + out.set(cfg.name, normalizeTable(cfg)); + } else if (dialect === "pg" && is(value, PgTable)) { + const cfg = getPgTableConfig(value); + out.set(cfg.name, normalizeTable(cfg)); + } else if (dialect === "mysql" && is(value, MySqlTable)) { + const cfg = getMysqlTableConfig(value); + out.set(cfg.name, normalizeTable(cfg)); } } return out; } -const sqlite = tableColumnMap(sqliteSchema as Record); -const pg = tableColumnMap(pgSchema as Record); -const mysql = tableColumnMap(mysqlSchema as Record); +const dialects: { name: Dialect; tables: Map }[] = [ + { name: "sqlite", tables: buildTableMap(sqliteSchema as Record, "sqlite") }, + { name: "pg", tables: buildTableMap(pgSchema as Record, "pg") }, + { name: "mysql", tables: buildTableMap(mysqlSchema as Record, "mysql") }, +]; + +// sqlite 를 기준 방언으로 삼는다(테이블/컬럼 집합 parity 는 아래에서 별도 강제). +const sqlite = dialects[0].tables; +const others = dialects.slice(1); + +function isAllowedIndexOmission(table: string, index: string, dialect: Dialect): boolean { + return INDEX_PARITY_EXCEPTIONS.some((e) => e.table === table && e.index === index && e.missingIn.includes(dialect)); +} describe("schema dialect parity (E1)", () => { it("세 방언의 테이블 집합이 동일", () => { const sqliteTables = [...sqlite.keys()].sort(); - const pgTables = [...pg.keys()].sort(); - const mysqlTables = [...mysql.keys()].sort(); - expect(pgTables).toEqual(sqliteTables); - expect(mysqlTables).toEqual(sqliteTables); + for (const { name, tables } of others) { + expect([...tables.keys()].sort(), `[${name}] 테이블 집합`).toEqual(sqliteTables); + } }); it("각 테이블의 컬럼 집합이 세 방언에서 동일", () => { const mismatches: string[] = []; - for (const [table, cols] of sqlite) { - const pgCols = pg.get(table); - const mysqlCols = mysql.get(table); - if (pgCols && JSON.stringify(pgCols) !== JSON.stringify(cols)) { - mismatches.push(`[pg] ${table}: sqlite=${cols.join(",")} vs pg=${pgCols.join(",")}`); + for (const [table, info] of sqlite) { + const sqliteCols = [...info.columns.keys()].sort(); + for (const { name, tables } of others) { + const cols = [...(tables.get(table)?.columns.keys() ?? [])].sort(); + if (JSON.stringify(cols) !== JSON.stringify(sqliteCols)) { + mismatches.push(`[${name}] ${table}: sqlite=${sqliteCols.join(",")} vs ${name}=${cols.join(",")}`); + } } - if (mysqlCols && JSON.stringify(mysqlCols) !== JSON.stringify(cols)) { - mismatches.push(`[mysql] ${table}: sqlite=${cols.join(",")} vs mysql=${mysqlCols.join(",")}`); + } + expect(mismatches).toEqual([]); + }); + + it("각 컬럼의 nullable(notNull) 여부가 세 방언에서 동일", () => { + const mismatches: string[] = []; + for (const [table, info] of sqlite) { + for (const [col, sc] of info.columns) { + for (const { name, tables } of others) { + const oc = tables.get(table)?.columns.get(col); + if (oc && oc.notNull !== sc.notNull) { + mismatches.push(`[${name}] ${table}.${col}: sqlite.notNull=${sc.notNull} vs ${name}.notNull=${oc.notNull}`); + } + } + } + } + expect(mismatches).toEqual([]); + }); + + it("모든 컬럼의 타입이 매핑 테이블에 분류돼 있다(미분류 columnType 없음)", () => { + const unmapped: string[] = []; + for (const { name, tables } of dialects) { + for (const [table, info] of tables) { + for (const [col, ci] of info.columns) { + if (ci.family.startsWith("unmapped:")) { + unmapped.push(`[${name}] ${table}.${col}: ${ci.family}`); + } + } + } + } + expect(unmapped).toEqual([]); + }); + + it("각 컬럼의 타입 계열(type family)이 세 방언에서 동일", () => { + const mismatches: string[] = []; + for (const [table, info] of sqlite) { + for (const [col, sc] of info.columns) { + for (const { name, tables } of others) { + const oc = tables.get(table)?.columns.get(col); + if (oc && oc.family !== sc.family) { + mismatches.push(`[${name}] ${table}.${col}: sqlite=${sc.family} vs ${name}=${oc.family}`); + } + } + } + } + expect(mismatches).toEqual([]); + }); + + it("인덱스·unique 제약이 세 방언에서 동일(예외 목록 제외)", () => { + const mismatches: string[] = []; + for (const table of sqlite.keys()) { + // 세 방언 중 이 테이블에 존재하는 모든 인덱스 이름의 합집합을 대상으로 비교. + const indexNames = new Set(); + for (const { tables } of dialects) { + for (const idxName of tables.get(table)?.indexes.keys() ?? []) indexNames.add(idxName); + } + + for (const idxName of indexNames) { + // 각 방언의 인덱스 시그니처(없으면 undefined) 수집. + const perDialect = dialects.map(({ name, tables }) => ({ + dialect: name, + idx: tables.get(table)?.indexes.get(idxName), + })); + + // (a) 누락 검사 — 예외 목록에 없는 누락은 drift. + for (const { dialect, idx } of perDialect) { + if (!idx && !isAllowedIndexOmission(table, idxName, dialect)) { + mismatches.push(`[${dialect}] ${table}.${idxName}: 인덱스 누락(예외 목록에 없음)`); + } + } + + // (b) 존재하는 방언들끼리 시그니처(unique + 컬럼 집합) 일치 검사. + const present = perDialect.filter((d) => d.idx); + if (present.length >= 2) { + const ref = present[0].idx!; + const refSig = JSON.stringify({ unique: ref.unique, columns: ref.columns }); + for (const { dialect, idx } of present.slice(1)) { + const sig = JSON.stringify({ unique: idx!.unique, columns: idx!.columns }); + if (sig !== refSig) { + mismatches.push(`[${dialect}] ${table}.${idxName}: ${present[0].dialect}=${refSig} vs ${dialect}=${sig}`); + } + } + } } } expect(mismatches).toEqual([]); diff --git a/test/unit/session.test.ts b/test/unit/session.test.ts new file mode 100644 index 0000000..74abfe2 --- /dev/null +++ b/test/unit/session.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from "vitest"; +import type { SQL } from "drizzle-orm"; +import { SQLiteSyncDialect } from "drizzle-orm/sqlite-core"; +import type { DB } from "$lib/server/db"; +import { createSessionRecord, getSessionContext, touchSession, revokeSession, revokeAllUserSessions, revokeOtherSessions } from "$lib/server/auth/session"; +import { SESSION_TTL_MS } from "$lib/server/auth/constants"; + +// ── 순수 로직 검증용 목(mock) ────────────────────────────────────────────── +// crud-factory.test.ts 패턴을 따른다: 실제 쿼리는 실행하지 않고 insert/update/select +// 체인에 전달된 값·where 조건만 캡처한다. where 는 drizzle SQL 객체이므로, +// SQLiteSyncDialect 로 파라미터화된 SQL 문자열로 렌더링해 컬럼·연산자·바인딩을 검증한다. +const dialect = new SQLiteSyncDialect(); +function render(where: SQL): { sql: string; params: unknown[] } { + const q = dialect.sqlToQuery(where); + return { sql: q.sql, params: q.params }; +} + +interface UpdateCapture { + set: Record; + where: SQL; +} +interface SelectCapture { + on?: SQL; + where?: SQL; +} + +function makeDb(selectResult: unknown[] = []) { + const inserts: Record[] = []; + const updates: UpdateCapture[] = []; + const selects: SelectCapture[] = []; + const db = { + insert: () => ({ + values: async (v: Record) => { + inserts.push(v); + }, + }), + update: () => ({ + set: (set: Record) => ({ + where: async (where: SQL) => { + updates.push({ set, where }); + }, + }), + }), + select: () => ({ + from: () => ({ + innerJoin: (_table: unknown, on: SQL) => ({ + where: (where: SQL) => ({ + limit: async () => { + selects.push({ on, where }); + return selectResult; + }, + }), + }), + }), + }), + } as unknown as DB; + return { db, inserts, updates, selects }; +} + +// 모듈 내부 hashSessionToken 과 동일한 SHA-256 → base64url 산식(검증용 독립 재구현). +function bytesToBase64Url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} +async function sha256Base64Url(token: string): Promise { + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); + return bytesToBase64Url(new Uint8Array(hash)); +} + +const BASE64URL_32BYTES = /^[A-Za-z0-9_-]{43}$/; + +describe("createSessionRecord", () => { + it("세션 토큰은 SHA-256 해시로만 저장하고 원문 토큰은 DB 에 남기지 않는다", async () => { + const { db, inserts } = makeDb(); + const before = Date.now(); + const { sessionToken, sessionId, expiresAt } = await createSessionRecord(db, { + tenantId: "t1", + userId: "u1", + amr: ["pwd", "totp"], + acr: "acr-mfa", + ip: "1.2.3.4", + userAgent: "ua", + }); + const after = Date.now(); + + expect(inserts.length).toBe(1); + const row = inserts[0]; + + // 저장된 idpSessionId 는 반환된 원문 토큰의 SHA-256(base64url) 해시여야 한다. + const expectedHash = await sha256Base64Url(sessionToken); + expect(row.idpSessionId).toBe(expectedHash); + // 원문 토큰 자체는 어떤 컬럼에도 저장되지 않는다. + expect(row.idpSessionId).not.toBe(sessionToken); + expect(Object.values(row)).not.toContain(sessionToken); + // 해시 형식(32바이트 base64url = 43자)·원문 토큰 형식 모두 확인. + expect(row.idpSessionId).toMatch(BASE64URL_32BYTES); + expect(sessionToken).toMatch(BASE64URL_32BYTES); + + // 나머지 필드 전파 확인. + expect(row.id).toBe(sessionId); + expect(row.tenantId).toBe("t1"); + expect(row.userId).toBe("u1"); + expect(row.amr).toBe("pwd totp"); // 배열 → 공백 결합 + expect(row.acr).toBe("acr-mfa"); + expect(row.ip).toBe("1.2.3.4"); + expect(row.userAgent).toBe("ua"); + expect(row.lastSeenAt).toBeInstanceOf(Date); + + // 만료 시각 = 생성 시각 + SESSION_TTL_MS (경계 여유 포함). + const exp = (expiresAt as Date).getTime(); + expect(exp).toBeGreaterThanOrEqual(before + SESSION_TTL_MS); + expect(exp).toBeLessThanOrEqual(after + SESSION_TTL_MS); + }); + + it("amr/acr 미지정 시 null 로 저장한다", async () => { + const { db, inserts } = makeDb(); + await createSessionRecord(db, { tenantId: "t1", userId: "u1" }); + expect(inserts[0].amr).toBeNull(); + expect(inserts[0].acr).toBeNull(); + expect(inserts[0].ip).toBeNull(); + expect(inserts[0].userAgent).toBeNull(); + }); +}); + +describe("getSessionContext", () => { + it("유효 세션 라운드트립 — 원문 토큰이 아닌 SHA-256 해시로 조회한다", async () => { + const rowResult = { session: { id: "s1" }, user: { id: "u1" } }; + const { db, selects } = makeDb([rowResult]); + const token = "raw-session-token"; + + const result = await getSessionContext(db, token); + expect(result).toBe(rowResult); + + // 조회 where 의 첫 바인딩은 원문 토큰이 아니라 그 SHA-256 해시여야 한다. + expect(selects.length).toBe(1); + const { sql, params } = render(selects[0].where!); + const expectedHash = await sha256Base64Url(token); + expect(sql).toContain('"sessions"."idp_session_id" = ?'); + expect(params[0]).toBe(expectedHash); + expect(params).not.toContain(token); + }); + + it("만료·폐기·비활성 세션을 DB 레벨 where 가드로 배제한다", async () => { + const { db, selects } = makeDb([]); + await getSessionContext(db, "tok"); + const { sql, params } = render(selects[0].where!); + + // 만료 세션 거부: expires_at > now + expect(sql).toContain('"sessions"."expires_at" > ?'); + // revoke 세션 거부: revoked_at IS NULL + expect(sql).toContain('"sessions"."revoked_at" is null'); + // 비활성 사용자 거부: users.status = 'active' + expect(sql).toContain('"users"."status" = ?'); + expect(params).toContain("active"); + }); + + it("매칭 세션이 없으면 null 을 반환한다", async () => { + const { db } = makeDb([]); + expect(await getSessionContext(db, "tok")).toBeNull(); + }); +}); + +describe("touchSession", () => { + it("lastSeenAt 만 갱신하고 세션 id 로 대상 지정한다", async () => { + const { db, updates } = makeDb(); + const ts = new Date("2026-01-01T00:00:00Z"); + await touchSession(db, "s1", ts); + expect(updates.length).toBe(1); + expect(updates[0].set).toEqual({ lastSeenAt: ts }); + const { sql, params } = render(updates[0].where); + expect(sql).toContain('"sessions"."id" = ?'); + expect(params).toEqual(["s1"]); + }); +}); + +describe("revokeSession", () => { + it("revokedAt 를 설정하고, 아직 폐기되지 않은 대상 세션만 갱신한다", async () => { + const { db, updates } = makeDb(); + const now = new Date("2026-02-02T00:00:00Z"); + await revokeSession(db, "idp-session-id-value", now); + expect(updates.length).toBe(1); + expect(updates[0].set).toEqual({ revokedAt: now }); + + const { sql, params } = render(updates[0].where); + expect(sql).toContain('"sessions"."idp_session_id" = ?'); + // 이미 폐기된 행은 재갱신하지 않는다(멱등). + expect(sql).toContain('"sessions"."revoked_at" is null'); + expect(params[0]).toBe("idp-session-id-value"); + }); +}); + +describe("revokeAllUserSessions", () => { + it("사용자의 모든 미폐기 세션을 revoke 한다(세션 제외 없음)", async () => { + const { db, updates } = makeDb(); + const now = new Date("2026-03-03T00:00:00Z"); + await revokeAllUserSessions(db, "u1", now); + expect(updates[0].set).toEqual({ revokedAt: now }); + + const { sql, params } = render(updates[0].where); + expect(sql).toContain('"sessions"."user_id" = ?'); + expect(sql).toContain('"sessions"."revoked_at" is null'); + // 특정 세션을 남기는 조건(id <>)이 없어야 전부 폐기된다. + expect(sql).not.toContain("<>"); + expect(params).toEqual(["u1"]); + }); +}); + +describe("revokeOtherSessions", () => { + it("keepSessionId 를 제외한 사용자의 미폐기 세션만 revoke 한다", async () => { + const { db, updates } = makeDb(); + const now = new Date("2026-04-04T00:00:00Z"); + await revokeOtherSessions(db, "u1", "keep-1", now); + expect(updates[0].set).toEqual({ revokedAt: now }); + + const { sql, params } = render(updates[0].where); + expect(sql).toContain('"sessions"."user_id" = ?'); + // 유지할 세션(keepSessionId)은 제외. + expect(sql).toContain('"sessions"."id" <> ?'); + // 이미 폐기된 세션은 건드리지 않는다. + expect(sql).toContain('"sessions"."revoked_at" is null'); + expect(params).toEqual(["u1", "keep-1"]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 91625f9..658c9ea 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,10 +17,20 @@ export default defineConfig({ "$db-active-driver": resolvePath("./src/lib/server/db/driver-sqlite.ts"), "$env/dynamic/private": resolvePath("./test/stubs/env-dynamic-private.ts"), "$env/static/private": resolvePath("./test/stubs/env-dynamic-private.ts"), + // 통합 테스트(test/integration)가 실 서버 모듈을 직접 구동할 때 필요한 $app/environment 스텁. + // 순수 유닛 테스트는 이 모듈을 import 하지 않으므로 영향이 없다. + "$app/environment": resolvePath("./test/stubs/app-environment.ts"), }, }, test: { environment: "node", include: ["test/**/*.test.ts", "src/**/*.test.ts"], + coverage: { + // @vitest/coverage-v8 기반. 게이트(threshold)는 강제하지 않고 리포트만 산출한다. + provider: "v8", + reporter: ["text", "html"], + reportsDirectory: "./coverage", + include: ["src/lib/**/*.ts"], + }, }, }); diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc index 094469f..9054235 100644 --- a/wrangler.example.jsonc +++ b/wrangler.example.jsonc @@ -22,6 +22,14 @@ "enabled": true, "head_sampling_rate": 1, }, + // ── 만료 데이터 GC ───────────────────────────────────────────────────────── + // 만료·소진 행(sessions/oidc_grants/refresh_tokens/webauthn_challenges/rate_limits/ + // password_reset_tokens/saml_* 등)은 별도 Cron 없이 정리된다: + // 요청의 ~1% 에서 ctx.waitUntil 로 GC 를 백그라운드 발사(src/lib/server/db/gc.ts). + // adapter-cloudflare 가 생성하는 _worker.js 는 fetch 핸들러만 export 하므로 Cron + // Trigger(scheduled 핸들러)를 커스텀 worker 엔트리 없이는 붙일 수 없어 확률적 GC 를 + // 택했다. 정기 Cron 이 필요하면 커스텀 엔트리로 scheduled() 를 추가하고 아래를 켜라: + // "triggers": { "crons": ["0 * * * *"] } // 매시 정각, scheduled()->runExpiredDataGc "vars": { // ── DB 방언 선택 (배포 단위, 하나만 사용) ────────────────────────────── // "d1"(기본) | "postgres" | "mysql".