diff --git a/.env.example b/.env.example index 4f507cc..e2ca6a6 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,10 @@ IDP_SIGNING_KEY_SECRET="your-very-long-random-secret-at-least-32-chars" # OIDC/SAML issuer URL (배포 도메인과 일치시킬 것) # 미설정 시 요청 origin으로 자동 대체 (로컬 개발 시 http://localhost:5173) IDP_ISSUER_URL="http://localhost:5173" + +SMTP_HOSTNAME="" +SMTP_PORTNUMB= +SMTP_USERNAME="" +SMTP_PASSWORD="" +SMTP_SENDMAIL="" +SMTP_ENC_TYPE="" \ No newline at end of file diff --git a/bun.lock b/bun.lock index 58f1cc1..b8ec12d 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "idp", "dependencies": { "@hicaru/argon2-pure.js": "^0.0.4", + "nodemailer": "^8.0.5", }, "devDependencies": { "@eslint/compat": "^2.0.4", @@ -18,6 +19,7 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", "@types/node": "^25", + "@types/nodemailer": "^8.0.0", "@types/qrcode": "^1.5.6", "@xmldom/xmldom": "^0.9.9", "@yrneh_jang/ldapjs": "^3.1.1", @@ -393,6 +395,8 @@ "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/nodemailer": ["@types/nodemailer@8.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -653,6 +657,8 @@ "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "nodemailer": ["nodemailer@8.0.5", "", {}, "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], diff --git a/package.json b/package.json index a2bef07..ead76f4 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", "@types/node": "^25", + "@types/nodemailer": "^8.0.0", "@types/qrcode": "^1.5.6", "@xmldom/xmldom": "^0.9.9", "@yrneh_jang/ldapjs": "^3.1.1", @@ -58,6 +59,7 @@ "xpath": "^0.0.34" }, "dependencies": { - "@hicaru/argon2-pure.js": "^0.0.4" + "@hicaru/argon2-pure.js": "^0.0.4", + "nodemailer": "^8.0.5" } } diff --git a/scripts/setup.ts b/scripts/setup.ts index 9c67090..45edc20 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -51,6 +51,8 @@ interface Args { noPreview: boolean; migrate?: boolean; migratePreview?: boolean; + r2BucketName?: string; + noR2: boolean; signingKey?: string; tenantName?: string; adminUsername?: string; @@ -65,6 +67,7 @@ interface Args { function parseArgs(argv: string[]): Args { const args: Args = { noPreview: false, + noR2: false, yes: false, help: false, }; @@ -99,6 +102,12 @@ function parseArgs(argv: string[]): Args { case "--no-migrate-preview": args.migratePreview = false; break; + case "--r2-bucket-name": + args.r2BucketName = argv[++i]; + break; + case "--no-r2": + args.noR2 = true; + break; case "--signing-key": args.signingKey = argv[++i]; break; @@ -148,6 +157,8 @@ ${cyan("옵션:")} --no-migrate 마이그레이션 건너뜀 --migrate-preview 프리뷰 DB 마이그레이션 자동 진행 --no-migrate-preview 프리뷰 DB 마이그레이션 건너뜀 + --r2-bucket-name R2 버킷 이름 (기본값: keystone-skin-cache) + --no-r2 R2 버킷 생성 건너뜀 --signing-key IDP_SIGNING_KEY_SECRET 값 --tenant-name 조직(테넌트) 이름 --admin-username 초기 관리자 아이디 @@ -588,6 +599,44 @@ async function step4_updateFiles(dbId: string, previewDbId: string | null, accou console.log(green(" ✓ .env 업데이트 완료")); } +async function step4b_r2Setup(args: Args) { + console.log(`\n${cyan("─── 4b. R2 버킷 설정 (스킨 캐시) ─────────────────────────────")}`); + + if (args.noR2) { + console.log(" R2 버킷 생성 건너뜀"); + return; + } + + const doCreate = args.yes || (await confirm("커스텀 스킨 캐시용 R2 버킷을 생성하시겠습니까?", true)); + if (!doCreate) { + console.log(" R2 버킷 생성 건너뜀"); + return; + } + + const bucketName = args.r2BucketName ?? (await ask("R2 버킷 이름", "keystone-skin-cache")); + + const result = await runWithSpinner(`R2 버킷 생성 중: ${bucketName}`, "wrangler", ["r2", "bucket", "create", bucketName]); + if (!result.success) { + const combined = result.stdout + result.stderr; + if (combined.includes("already exists")) { + console.log(yellow(` 버킷 '${bucketName}'이 이미 존재합니다. 기존 버킷을 사용합니다.`)); + } else { + console.error(red(` R2 버킷 생성 실패:\n${result.stderr}`)); + return; + } + } else { + console.log(green(` ✓ R2 버킷 '${bucketName}' 생성 완료`)); + } + + // wrangler.jsonc의 bucket_name 업데이트 + if (fs.existsSync(WRANGLER_JSONC)) { + let content = readFile(WRANGLER_JSONC); + content = replaceAll(content, "keystone-skin-cache", bucketName); + writeFile(WRANGLER_JSONC, content); + console.log(green(` ✓ wrangler.jsonc bucket_name 업데이트 완료`)); + } +} + // ─── Migration Conflict Detection ──────────────────────────────────────────── async function getExistingTables(dbName: string, env: Record): Promise { @@ -1049,6 +1098,9 @@ async function main() { // Step 4: Update files await step4_updateFiles(dbId, previewDbId, accountId); + // Step 4b: R2 bucket + await step4b_r2Setup(args); + // Step 5: Migration await step5_migrate(args, previewDbId !== null, dbName, previewDbName); diff --git a/src/app.d.ts b/src/app.d.ts index 850a2f7..04b831d 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,7 +3,7 @@ declare global { namespace App { interface Platform { - env: Env; + env: Env & { SKIN_CACHE?: R2Bucket }; ctx: ExecutionContext; caches: CacheStorage; cf?: IncomingRequestCfProperties; diff --git a/src/lib/i18n/ko.json b/src/lib/i18n/ko.json index cf3c9df..8385312 100644 --- a/src/lib/i18n/ko.json +++ b/src/lib/i18n/ko.json @@ -38,7 +38,9 @@ "login": { "username": "아이디", "password": "비밀번호", - "submit": "로그인" + "submit": "로그인", + "registered_success": "회원가입이 완료되었습니다. 로그인해 주세요.", + "password_reset_success": "비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요." }, "admin": { "title": "관리자 대시보드", @@ -54,7 +56,8 @@ "departments": "부서", "teams": "팀", "parts": "파트", - "ldap_providers": "LDAP 프로바이더" + "ldap_providers": "LDAP 프로바이더", + "skins": "로그인 스킨" }, "account": { "profile": "내 프로필", @@ -353,5 +356,104 @@ "poc": { "title": "Workers 환경 PoC", "subtitle": "킥오프 M0 사전 점검. 각 엔드포인트는 GET 요청 시 JSON 으로 결과를 반환." + }, + "signup": { + "title": "회원가입", + "subtitle": "계정을 만들어 시작하세요.", + "username_label": "아이디", + "email_label": "이메일", + "password_label": "비밀번호", + "confirm_password_label": "비밀번호 확인", + "submit": "가입하기", + "have_account": "이미 계정이 있으신가요?", + "not_available": "현재 이 서비스는 관리자를 통해서만 계정을 생성할 수 있습니다." + }, + "find_id": { + "title": "아이디 찾기", + "subtitle": "가입 시 사용한 이메일 주소를 입력하세요.", + "email_label": "이메일", + "submit": "아이디 찾기", + "result_found": "해당 이메일로 등록된 아이디입니다.", + "result_email_sent": "전체 아이디를 이메일로 발송했습니다.", + "result_not_found": "입력하신 이메일로 등록된 계정이 없습니다.", + "not_available": "아이디 찾기 기능은 현재 지원되지 않습니다. 관리자에게 문의해 주세요." + }, + "find_password": { + "title": "비밀번호 찾기", + "subtitle": "아이디와 이메일을 입력하면 재설정 링크를 보내드립니다.", + "username_label": "아이디", + "email_label": "이메일", + "submit": "재설정 링크 받기", + "result_sent": "입력하신 정보가 일치하면 비밀번호 재설정 링크를 이메일로 발송합니다.", + "not_available": "비밀번호 찾기 기능은 현재 지원되지 않습니다. 관리자에게 문의해 주세요." + }, + "reset_password": { + "title": "비밀번호 재설정", + "subtitle": "새로운 비밀번호를 입력해 주세요.", + "password_label": "새 비밀번호", + "confirm_label": "비밀번호 확인", + "submit": "비밀번호 변경", + "invalid_link": "링크가 만료되었거나 유효하지 않습니다." + }, + "skins": { + "title": "로그인 스킨", + "add_btn": "+ 스킨 추가", + "create_title": "새 로그인 스킨", + "created_success": "스킨이 추가되었습니다.", + "cache_invalidated": "캐시가 삭제되었습니다.", + "client_type_label": "클라이언트 타입", + "client_label": "클라이언트", + "fetch_url_label": "스킨 URL", + "fetch_secret_label": "인증 시크릿 (X-IDP-Token)", + "fetch_secret_placeholder": "없으면 비워두세요", + "cache_ttl_label": "캐시 TTL (초)", + "skin_type_label": "스킨 타입", + "skin_type_login": "로그인", + "skin_type_signup": "회원가입", + "skin_type_find_id": "아이디 찾기", + "skin_type_find_password": "비밀번호 찾기", + "col_client": "클라이언트", + "col_skin_type": "스킨 타입", + "col_fetch_url": "스킨 URL", + "col_ttl": "TTL", + "col_created": "생성", + "empty": "등록된 스킨이 없습니다.", + "delete_confirm": "스킨을 삭제하시겠습니까?", + "invalidate_cache": "캐시 삭제", + "guide_link": "가이드 보기", + "placeholder_guide_title": "치환자 안내", + "placeholder_form_action": "폼 action (비어 있으면 현재 URL로 POST)", + "placeholder_redirect_to": "로그인 후 리다이렉트 대상 URL", + "placeholder_skin_hint": "스킨 힌트 값 (hidden input에 활용)", + "guide_title": "커스텀 스킨 개발 가이드", + "guide_subtitle": "외부 URL에서 HTML을 가져와 로그인·회원가입 화면을 완전히 커스터마이즈할 수 있습니다.", + "guide_overview_title": "개요", + "guide_overview_desc": "IDP는 각 OIDC 클라이언트 / SAML SP별로 아래 4가지 페이지에 대한 커스텀 스킨 URL을 등록할 수 있습니다. 스킨 URL이 등록되어 있으면 해당 페이지 접근 시 IDP가 URL을 Fetch하여 HTML을 반환합니다. 등록이 없거나 Fetch 실패 시에는 기본 내장 UI가 표시됩니다.", + "guide_flow_title": "동작 흐름", + "guide_flow_1": "사용자가 OIDC/SAML 로그인 흐름을 시작하면 IDP가 로그인 페이지로 리다이렉트합니다.", + "guide_flow_2": "IDP는 요청한 클라이언트의 스킨 설정을 DB에서 조회합니다.", + "guide_flow_3": "스킨 URL이 있으면 R2 캐시를 확인하고, TTL이 만료되었거나 캐시가 없으면 스킨 서버에서 HTML을 Fetch합니다.", + "guide_flow_4": "IDP가 HTML 내 치환자({{IDP_FORM_ACTION}} 등)를 실제 값으로 교체한 뒤 브라우저에 반환합니다.", + "guide_flow_5": "사용자가 스킨 폼을 제출하면 IDP가 처리하고 클라이언트로 리다이렉트합니다.", + "guide_placeholders_title": "치환자 (Template Variables)", + "guide_placeholder_col_name": "치환자", + "guide_placeholder_col_desc": "설명", + "guide_auth_title": "X-IDP-Token 인증 헤더", + "guide_auth_desc": "스킨 URL에 인증 시크릿을 설정하면 IDP가 Fetch 요청 시 X-IDP-Token 헤더에 해당 값을 포함합니다. 스킨 서버는 이 헤더를 검증하여 IDP에서 온 요청인지 확인할 수 있습니다.", + "guide_example_title": "스킨 HTML 예시 (로그인)", + "guide_example_login_desc": "아래는 로그인 스킨의 최소 구현 예시입니다. {{IDP_FORM_ACTION}}과 hidden input 두 개만 포함하면 동작합니다.", + "guide_example_note_title": "주의사항", + "guide_example_note_1": "form의 method는 반드시 POST여야 합니다.", + "guide_example_note_2": "username과 password 필드명을 정확히 사용해야 합니다.", + "guide_example_note_3": "redirectTo, skinHint hidden input이 없으면 로그인 후 리다이렉트가 동작하지 않을 수 있습니다.", + "guide_cache_title": "캐시 동작", + "guide_cache_1": "스킨 HTML은 Cloudflare R2에 캐시되며, 설정한 TTL(초) 동안 재사용됩니다.", + "guide_cache_2": "TTL이 만료되면 다음 요청 시 스킨 서버에서 새로 Fetch하여 캐시를 갱신합니다.", + "guide_cache_3": "스킨 목록 페이지에서 '캐시 삭제' 버튼을 누르면 즉시 캐시를 무효화할 수 있습니다.", + "guide_setup_title": "등록 방법", + "guide_setup_1": "스킨 HTML을 호스팅할 서버(또는 CDN)를 준비하고 공개 URL을 확보합니다.", + "guide_setup_2": "인증이 필요한 경우 시크릿 값을 정하고 X-IDP-Token 헤더를 검증하는 로직을 서버에 추가합니다.", + "guide_setup_3": "로그인 스킨 관리 페이지에서 대상 클라이언트, 스킨 타입, URL, 시크릿, TTL을 입력하고 추가합니다.", + "guide_setup_4": "추가 후 토글로 활성화하면 해당 클라이언트의 로그인 흐름에서 커스텀 스킨이 적용됩니다." } } diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 6ba326a..1cde5ef 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -208,6 +208,10 @@ export const oidcClients = sqliteTable( name: text("name").notNull(), redirectUris: text("redirect_uris").notNull(), postLogoutRedirectUris: text("post_logout_redirect_uris"), + frontchannelLogoutUri: text("frontchannel_logout_uri"), + frontchannelLogoutSessionRequired: integer("frontchannel_logout_session_required", { mode: "boolean" }).notNull().default(false), + backchannelLogoutUri: text("backchannel_logout_uri"), + backchannelLogoutSessionRequired: integer("backchannel_logout_session_required", { mode: "boolean" }).notNull().default(false), scopes: text("scopes").notNull().default("openid"), grantTypes: text("grant_types").notNull().default("authorization_code,refresh_token"), responseTypes: text("response_types").notNull().default("code"), @@ -353,6 +357,38 @@ export const samlSessions = sqliteTable( (t) => [uniqueIndex("saml_sessions_session_index_uidx").on(t.sessionIndex), index("saml_sessions_tenant_sp_idx").on(t.tenantId, t.spId)], ); +/** + * SAML SLO 체인 상태. 여러 SP 를 순차적으로 로그아웃하기 위한 리다이렉트 체인을 + * DB 에 저장해 둔다. id 값이 RelayState 로 전달되어 체인 전반에 걸쳐 식별자 역할을 한다. + */ +export const samlSloStates = sqliteTable("saml_slo_states", { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + tenantId: text("tenant_id") + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + // sessions.id — FK 로 걸지 않는다 (체인 중간에 세션이 revoke 될 수 있음) + idpSessionRecordId: text("idp_session_record_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // SP-initiated SLO 일 때만 값이 있다. + initiatingSpEntityId: text("initiating_sp_entity_id"), + // 최초 SP 가 보낸 LogoutRequest ID (InResponseTo 에 사용) + inResponseTo: text("in_response_to"), + // 체인 종료 시 LogoutResponse 를 보낼 SP 의 SLO URL (SP-initiated) + initiatorSloUrl: text("initiator_slo_url"), + // 체인 종료 시 최종적으로 리다이렉트할 URI (예: "/login") + completionUri: text("completion_uri").notNull(), + // JSON array: [{spId, entityId, sloUrl, nameId, nameIdFormat, sessionIndex}] + pendingSpDataJson: text("pending_sp_data_json").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .notNull() + .default(sql`(unixepoch() * 1000)`), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), +}); + // ---------- Keys & Audit ---------- /** @@ -662,6 +698,54 @@ export const rateLimits = sqliteTable("rate_limits", { expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), }); +export const clientSkins = sqliteTable( + "client_skins", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + tenantId: text("tenant_id") + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + clientType: text("client_type", { enum: ["oidc", "saml"] }).notNull(), + clientRefId: text("client_ref_id").notNull(), + skinType: text("skin_type", { enum: ["login", "signup", "find_id", "find_password"] }) + .notNull() + .default("login"), + fetchUrl: text("fetch_url").notNull(), + 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" }) + .notNull() + .$defaultFn(() => new Date()), + }, + (t) => [uniqueIndex("client_skins_unique").on(t.tenantId, t.clientType, t.clientRefId, t.skinType)], +); + +// ---------- Password Reset ---------- + +export const passwordResetTokens = sqliteTable( + "password_reset_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("password_reset_tokens_user_idx").on(t.userId), uniqueIndex("password_reset_tokens_hash_uidx").on(t.tokenHash)], +); + +export type PasswordResetToken = typeof passwordResetTokens.$inferSelect; + export type User = typeof users.$inferSelect; export type Credential = typeof credentials.$inferSelect; export type Identity = typeof identities.$inferSelect; @@ -672,6 +756,7 @@ export type OidcGrant = typeof oidcGrants.$inferSelect; export type OidcRefreshToken = typeof oidcRefreshTokens.$inferSelect; export type SamlSp = typeof samlSps.$inferSelect; export type SamlSession = typeof samlSessions.$inferSelect; +export type SamlSloState = typeof samlSloStates.$inferSelect; export type SigningKey = typeof signingKeys.$inferSelect; export type AuditEvent = typeof auditEvents.$inferSelect; export type Position = typeof positions.$inferSelect; @@ -682,3 +767,4 @@ export type UserTeam = typeof userTeams.$inferSelect; export type Part = typeof parts.$inferSelect; export type UserPart = typeof userParts.$inferSelect; export type WebauthnChallenge = typeof webauthnChallenges.$inferSelect; +export type ClientSkin = typeof clientSkins.$inferSelect; diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts new file mode 100644 index 0000000..5d87bdf --- /dev/null +++ b/src/lib/server/email.ts @@ -0,0 +1,88 @@ +import nodemailer from "nodemailer"; +import { env } from "$env/dynamic/private"; + +function getSmtpConfig() { + const hostname = env.SMTP_HOSTNAME; + const port = env.SMTP_PORTNUMB; + const username = env.SMTP_USERNAME; + const password = env.SMTP_PASSWORD; + if (!hostname || !port || !username || !password) return null; + const enc = (env.SMTP_ENC_TYPE ?? "tls").toLowerCase(); + return { + hostname, + port: parseInt(port, 10), + username, + password, + secure: enc === "ssl" || parseInt(port, 10) === 465, + senderAddress: env.SMTP_SENDMAIL ?? username, + }; +} + +async function send(to: string, subject: string, html: string): Promise { + const smtp = getSmtpConfig(); + if (!smtp) throw new Error("SMTP 설정이 없습니다."); + + const transporter = nodemailer.createTransport({ + host: smtp.hostname, + port: smtp.port, + secure: smtp.secure, + auth: { user: smtp.username, pass: smtp.password }, + }); + transporter.setMaxListeners(20); + + try { + await transporter.sendMail({ from: smtp.senderAddress, to, subject, html }); + } finally { + transporter.close(); + } +} + +function baseHtml(title: string, body: string): string { + return ` + + +

${title}

+ ${body} +

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

+ +`; +} + +export async function sendFindIdEmail(to: string, username: string): Promise { + const html = baseHtml( + "아이디 확인", + `

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

+

${username}

`, + ); + await send(to, "아이디 안내", html); +} + +export async function sendPasswordResetEmail(to: string, resetUrl: string): Promise { + const html = baseHtml( + "비밀번호 재설정", + `

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

+

+ 비밀번호 재설정 +

`, + ); + await send(to, "비밀번호 재설정 안내", html); +} + +export async function generateToken(): Promise<{ token: string; tokenHash: string }> { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + const token = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); + const tokenHash = Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join(""); + return { token, tokenHash }; +} + +export async function hashToken(token: string): Promise { + const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); + return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join(""); +} + +export function maskUsername(username: string): string { + if (username.length <= 2) return username[0] + "*".repeat(username.length - 1); + const visible = Math.max(1, Math.ceil(username.length / 3)); + return username.slice(0, visible) + "*".repeat(username.length - visible); +} diff --git a/src/lib/server/oidc/logout.ts b/src/lib/server/oidc/logout.ts new file mode 100644 index 0000000..ef83aa1 --- /dev/null +++ b/src/lib/server/oidc/logout.ts @@ -0,0 +1,132 @@ +/** + * OIDC Single Logout helpers (Front-channel 1.0 + Back-channel 1.0). + * + * Back-channel (BC): + * - IdP POSTs signed logout_token (JWT) to clients' `backchannel_logout_uri`. + * - Target set = clients with an active grant or refresh-token bound to this IdP session. + * + * Front-channel (FC): + * - IdP renders `).join(""); + // CSP 는 hash 모드이므로 inline JS 를 피하고 meta refresh 를 사용한다. + return ( + `` + + `Logging out...` + + `` + + `` + + `

로그아웃 중...

` + + iframes + + `` + ); +} + +async function resolvePostLogoutRedirect(locals: App.Locals, postLogoutRedirectUri: string | null, clientId: string | null): Promise { + if (!postLogoutRedirectUri || !clientId || !locals.db || !locals.tenant) return "/"; + const [client] = await locals.db + .select({ postLogoutRedirectUris: oidcClients.postLogoutRedirectUris }) + .from(oidcClients) + .where(and(eq(oidcClients.tenantId, locals.tenant.id), eq(oidcClients.clientId, clientId), eq(oidcClients.enabled, true))) + .limit(1); + if (!client?.postLogoutRedirectUris) return "/"; + let allowed: string[]; + try { + allowed = JSON.parse(client.postLogoutRedirectUris) as string[]; + } catch { + allowed = []; + } + if (Array.isArray(allowed) && allowed.includes(postLogoutRedirectUri)) return postLogoutRedirectUri; + return "/"; +} + +export const GET: RequestHandler = async (event) => { + const { locals, url, cookies, platform } = event; const postLogoutRedirectUri = url.searchParams.get("post_logout_redirect_uri"); const clientId = url.searchParams.get("client_id"); const idTokenHint = url.searchParams.get("id_token_hint"); - // id_token_hint 제공 시 서명 검증 및 sub 일치 확인 if (idTokenHint && locals.db && locals.tenant) { const claims = await verifyIdToken(locals.db, locals.tenant.id, idTokenHint); if (!claims) { return new Response(JSON.stringify({ error: "invalid_id_token_hint" }), { status: 400, headers: { "Content-Type": "application/json" }, - }) as unknown as never; + }); } - // 현재 세션 사용자와 불일치 시 거부 if (locals.user && claims.sub !== locals.user.id) { return new Response(JSON.stringify({ error: "id_token_hint_mismatch" }), { status: 400, headers: { "Content-Type": "application/json" }, - }) as unknown as never; + }); } } - // IdP 세션 폐기 - if (locals.session && locals.db) { - await revokeSession(locals.db, locals.session.idpSessionId); - clearSessionCookie(cookies, url); - } + // 세션이 있으면 BC/FC 로그아웃 타깃을 수집·발송한 뒤 세션을 폐기한다. + // (세션 폐기 전에 sessionId / idpSessionId / userId 를 캡처해야 함.) + if (locals.session && locals.user && locals.db && locals.tenant) { + const db = locals.db; + const tenantId = locals.tenant.id; + const sessionId = locals.session.id; + const idpSessionId = locals.session.idpSessionId; + const userId = locals.user.id; - // post_logout_redirect_uri 검증 후 리다이렉트 - if (postLogoutRedirectUri && clientId && locals.db && locals.tenant) { - const [client] = await locals.db - .select({ postLogoutRedirectUris: oidcClients.postLogoutRedirectUris }) - .from(oidcClients) - .where(and(eq(oidcClients.tenantId, locals.tenant.id), eq(oidcClients.clientId, clientId), eq(oidcClients.enabled, true))) - .limit(1); - - if (client?.postLogoutRedirectUris) { - let allowed: string[]; - try { - allowed = JSON.parse(client.postLogoutRedirectUris) as string[]; - } catch { - allowed = []; - } - if (Array.isArray(allowed) && allowed.includes(postLogoutRedirectUri)) { - throw redirect(302, postLogoutRedirectUri); + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const signingKeySecret = locals.runtimeConfig.signingKeySecret; + + const bcTargets = await getOidcBackchannelTargets(db, tenantId, sessionId); + const fcTargets = await getOidcFrontchannelTargets(db, tenantId, sessionId, idpSessionId, issuerUrl); + + // BC 로그아웃 발송 (서명 키가 있을 때만) + if (bcTargets.length > 0 && signingKeySecret) { + const signingKey = await getActiveSigningKey(db, tenantId, signingKeySecret); + 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); + if (wait) { + wait(Promise.all(bcPromises)); + } else { + await Promise.all(bcPromises); + } } } - } - throw redirect(302, "/"); -} + // IdP 세션 폐기 + await revokeSession(db, idpSessionId); + clearSessionCookie(cookies, url); + + // FC 타깃이 있으면 iframe 페이지를 렌더 + if (fcTargets.length > 0) { + const redirectTo = await resolvePostLogoutRedirect(locals, postLogoutRedirectUri, clientId); + const html = renderFrontchannelLogoutHtml( + fcTargets.map((t) => t.uri), + redirectTo, + ); + return new Response(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + + // FC 가 없으면 바로 리다이렉트 + const redirectTo = await resolvePostLogoutRedirect(locals, postLogoutRedirectUri, clientId); + throw redirect(302, redirectTo); + } -export const GET: RequestHandler = ({ locals, url, cookies }) => handleEndSession(locals, url, cookies); + // 세션이 없는 경우: 기존 흐름 + const redirectTo = await resolvePostLogoutRedirect(locals, postLogoutRedirectUri, clientId); + throw redirect(302, redirectTo); +}; -export const POST: RequestHandler = ({ locals, url, cookies }) => handleEndSession(locals, url, cookies); +export const POST: RequestHandler = (event) => GET(event); diff --git a/src/routes/saml/slo/+server.ts b/src/routes/saml/slo/+server.ts index 66d1e84..cc06923 100644 --- a/src/routes/saml/slo/+server.ts +++ b/src/routes/saml/slo/+server.ts @@ -1,27 +1,291 @@ /** - * SAML 2.0 Single Logout (SLO) 엔드포인트 — M2 최소 구현. + * SAML 2.0 Single Logout (SLO) 엔드포인트 — 순차 리다이렉트 체인 구현. * - * GET /saml/slo - * - 현재 IdP 세션을 무효화하고 RelayState 또는 홈으로 리다이렉트. - * - LogoutRequest 파싱·서명 검증은 M3 에서 구현 예정. + * 한 번의 브라우저 네비게이션에서 여러 SP 를 로그아웃시키기 위해, 체인 상태를 + * `samlSloStates` 테이블에 저장하고 RelayState 로 `samlSloStates.id` 를 전달해 + * 각 SP 의 응답을 받아 다음 SP 로 이어간다. + * + * 처리 분기 (쿼리 파라미터 기준): + * A. SAMLResponse + RelayState : 체인 진행 중인 SP 로부터의 LogoutResponse 수신 + * → 다음 pending SP 로 리다이렉트하거나, 없으면 체인 종료 + * B. state : IdP-initiated SLO 체인 시작 (로그아웃 페이지에서 진입) + * C. SAMLRequest : SP-initiated SLO 시작 + * D. 그 외 : 세션만 폐기하고 / */ -import { redirect } from "@sveltejs/kit"; -import type { RequestHandler } from "./$types"; -import { eq } from "drizzle-orm"; +import { error, redirect } from "@sveltejs/kit"; +import type { RequestEvent, RequestHandler } from "./$types"; +import { and, eq, gt } from "drizzle-orm"; +import { samlSessions, samlSloStates, samlSps, sessions } from "$lib/server/db/schema"; +import { getRequestMetadata, recordAuditEvent } from "$lib/server/audit"; import { requireDbContext } from "$lib/server/auth/guards"; -import { sessions } from "$lib/server/db/schema"; -import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit"; import { SESSION_COOKIE_NAME } from "$lib/server/auth/constants"; +import { getActiveSigningKey } from "$lib/server/crypto/keys"; +import { getOidcBackchannelTargets, sendOneBackchannelLogout } from "$lib/server/oidc/logout"; +import { buildSamlLogoutRequest, buildSamlLogoutResponse, buildSamlSloRedirectUrl, collectPendingSpData, parseSamlLogoutRequest, type PendingSpData } from "$lib/server/saml/slo"; +import { verifySamlRedirectSignature } from "$lib/server/saml/parse-authn-request"; -export const GET: RequestHandler = async (event) => { +const SLO_STATE_TTL_MS = 10 * 60 * 1000; // 10 분 + +function parsePendingSpData(json: string): PendingSpData[] { + try { + const parsed = JSON.parse(json); + if (!Array.isArray(parsed)) return []; + return parsed as PendingSpData[]; + } catch { + return []; + } +} + +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 bcTargets = await getOidcBackchannelTargets(db, tenant.id, idpSession.id); + if (bcTargets.length === 0) return; + + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + if (!signingKey) return; + + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const bcPromises = bcTargets.map((t) => sendOneBackchannelLogout(t, idpSession.userId, idpSession.idpSessionId, issuerUrl, signingKey.privateKey, signingKey.kid).catch(() => undefined)); + const wait = platform?.ctx?.waitUntil?.bind(platform.ctx); + if (wait) { + wait(Promise.all(bcPromises)); + } else { + await Promise.all(bcPromises); + } +} + +/** + * 체인의 다음 SP 에게 서명된 LogoutRequest 를 보낸다. + * pendingSpDataJson 을 먼저 업데이트한 뒤(처리할 SP 를 맨 앞에서 제거) 리다이렉트한다. + * 이렇게 해야 사용자가 새로 고침해도 같은 SP 가 중복 처리되지 않는다. + */ +async function redirectToNextSp(event: RequestEvent, stateId: string, remaining: PendingSpData[]): Promise { + const { locals, url } = event; + const { db, tenant } = requireDbContext(locals); + + if (remaining.length === 0) { + throw error(500, "체인에 남은 SP 가 없습니다"); + } + + const next = remaining[0]; + const rest = remaining.slice(1); + + // 먼저 DB 를 업데이트한다 (next SP 를 pending 에서 제거). + await db + .update(samlSloStates) + .set({ pendingSpDataJson: JSON.stringify(rest) }) + .where(eq(samlSloStates.id, stateId)); + + const signingKeySecret = locals.runtimeConfig.signingKeySecret; + if (!signingKeySecret) { + throw error(500, "서명 키가 설정되지 않아 SLO 체인을 계속 진행할 수 없습니다"); + } + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + if (!signingKey) { + throw error(500, "활성 서명 키가 없습니다"); + } + + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const lrXml = buildSamlLogoutRequest({ + id: `_l${crypto.randomUUID().replace(/-/g, "")}`, + issuerUrl, + destination: next.sloUrl, + nameId: next.nameId, + nameIdFormat: next.nameIdFormat, + sessionIndex: next.sessionIndex, + }); + + const redirectUrl = await buildSamlSloRedirectUrl({ + sloUrl: next.sloUrl, + xml: lrXml, + param: "SAMLRequest", + relayState: stateId, + privateKey: signingKey.privateKey, + }); + + throw redirect(302, redirectUrl); +} + +/** + * 체인 종료 처리: IdP 세션을 폐기하고 쿠키를 제거한 뒤, OIDC BC 로그아웃을 + * waitUntil 로 발송하고 state 행을 삭제한다. 최종 리다이렉트는 호출자가 수행한다. + */ +async function completeSloChain(event: RequestEvent, state: typeof samlSloStates.$inferSelect): Promise { const { locals, cookies } = event; const { db, tenant } = requireDbContext(locals); - if (locals.session) { - await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, locals.session.id)); + // 1. 연결된 IdP 세션 조회 (아직 revoke 되지 않았다면) + const [idpSession] = await db.select().from(sessions).where(eq(sessions.id, state.idpSessionRecordId)).limit(1); - if (locals.user) { + // 2. 남아있는 SAML 세션 전체를 endedAt 으로 표시 (일관성 유지) + if (idpSession) { + await db.update(samlSessions).set({ endedAt: new Date() }).where(eq(samlSessions.sessionId, idpSession.id)); + } + + // 3. OIDC BC 로그아웃 발송 (waitUntil) + if (idpSession) { + await fireOidcBackchannelLogout(event, idpSession); + } + + // 4. IdP 세션 폐기 + if (idpSession && !idpSession.revokedAt) { + await db + .update(sessions) + .set({ revokedAt: new Date() }) + .where(and(eq(sessions.id, idpSession.id), eq(sessions.tenantId, tenant.id))); + } + + // 5. 쿠키 제거 + cookies.delete(SESSION_COOKIE_NAME, { path: "/" }); + + // 6. state 행 삭제 + await db.delete(samlSloStates).where(eq(samlSloStates.id, state.id)); +} + +export const GET: RequestHandler = async (event) => { + const { locals, url, cookies } = event; + const { db, tenant } = requireDbContext(locals); + + const samlRequest = url.searchParams.get("SAMLRequest"); + const samlResponse = url.searchParams.get("SAMLResponse"); + const relayState = url.searchParams.get("RelayState"); + const stateParam = url.searchParams.get("state"); + + // ── Case A: SAMLResponse + RelayState → 체인 진행 중인 SP 의 응답 처리 ───── + if (samlResponse && relayState) { + const [state] = await db + .select() + .from(samlSloStates) + .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"); + } + + // pendingSpDataJson 에 남아 있는 SP 목록 (현재 응답을 보낸 SP 는 이미 제거된 상태) + const remaining = parsePendingSpData(state.pendingSpDataJson); + + // 다음 SP 가 있으면 이어서 진행 + if (remaining.length > 0) { + await redirectToNextSp(event, state.id, remaining); + // 위에서 redirect throw + } + + // 남은 SP 가 없으면 체인 종료 + await completeSloChain(event, state); + + // SP-initiated 였다면 최초 SP 로 LogoutResponse 를 돌려준다. + if (state.initiatorSloUrl && state.inResponseTo) { + const signingKeySecret = locals.runtimeConfig.signingKeySecret; + if (signingKeySecret) { + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + if (signingKey) { + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const responseXml = buildSamlLogoutResponse({ + id: `_lr${crypto.randomUUID().replace(/-/g, "")}`, + inResponseTo: state.inResponseTo, + issuerUrl, + destination: state.initiatorSloUrl, + status: "Success", + }); + const redirectUrl = await buildSamlSloRedirectUrl({ + sloUrl: state.initiatorSloUrl, + xml: responseXml, + param: "SAMLResponse", + privateKey: signingKey.privateKey, + }); + throw redirect(302, redirectUrl); + } + } + // 서명 키가 없으면 completionUri 로 폴백 + } + + throw redirect(302, state.completionUri); + } + + // ── Case B: state 파라미터 → IdP-initiated 체인 시작 ──────────────────────── + if (stateParam) { + const [state] = await db + .select() + .from(samlSloStates) + .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"); + } + + const pending = parsePendingSpData(state.pendingSpDataJson); + + if (pending.length === 0) { + // 엣지 케이스: pending 이 없는데 state 만 있음 → 바로 종료 + await completeSloChain(event, state); + throw redirect(302, state.completionUri); + } + + await redirectToNextSp(event, state.id, pending); + // redirect throw + } + + // ── Case C: SAMLRequest → SP-initiated SLO 시작 ───────────────────────────── + if (samlRequest) { + let parsed; + try { + parsed = await parseSamlLogoutRequest(samlRequest); + } catch { + throw error(400, "Invalid SAMLRequest"); + } + + // SP 조회 + const [sp] = await db + .select() + .from(samlSps) + .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"); + } + + // 서명 검증 (SP cert 가 있으면 필수) + if (sp.cert) { + const rawQuery = url.search.replace(/^\?/, ""); + const valid = await verifySamlRedirectSignature(rawQuery, sp.cert); + if (!valid) { + throw error(400, "Invalid SAMLRequest signature"); + } + } + + // SessionIndex → SAML 세션 및 IdP 세션 식별 + const targetSessionIndex = parsed.sessionIndexes[0]; + let idpSession: typeof sessions.$inferSelect | null = null; + let linkedSamlSessionId: string | null = null; + if (targetSessionIndex) { + const [row] = await db + .select({ samlSessionId: samlSessions.id, idpSessionId: samlSessions.sessionId }) + .from(samlSessions) + .where(and(eq(samlSessions.tenantId, tenant.id), eq(samlSessions.spId, sp.id), eq(samlSessions.sessionIndex, targetSessionIndex))) + .limit(1); + if (row) { + linkedSamlSessionId = row.samlSessionId; + if (row.idpSessionId) { + const [s] = await db.select().from(sessions).where(eq(sessions.id, row.idpSessionId)).limit(1); + idpSession = s ?? null; + } + } + } + if (!idpSession && locals.session) idpSession = locals.session; + + // 초기 SP 의 SAML 세션 종료 + if (linkedSamlSessionId) { + await db.update(samlSessions).set({ endedAt: new Date() }).where(eq(samlSessions.id, linkedSamlSessionId)); + } + + // 감사 로그 + if (idpSession && locals.user) { const requestMetadata = getRequestMetadata(event); await recordAuditEvent(db, { tenantId: tenant.id, @@ -31,12 +295,88 @@ export const GET: RequestHandler = async (event) => { outcome: "success", ip: requestMetadata.ip, userAgent: requestMetadata.userAgent, - detail: {}, + detail: { spEntityId: parsed.issuer }, }); } - cookies.delete(SESSION_COOKIE_NAME, { path: "/" }); + // 남은 SP 수집 (초기 요청 SP 제외) + const pending: PendingSpData[] = idpSession ? await collectPendingSpData(db, idpSession.id, parsed.issuer) : []; + + // 남은 SP 가 없으면: 세션 폐기, BC 로그아웃 발송, sp 로 LogoutResponse 반환 + if (pending.length === 0) { + // IdP 세션 일괄 종료 표시 + if (idpSession) { + await db.update(samlSessions).set({ endedAt: new Date() }).where(eq(samlSessions.sessionId, idpSession.id)); + await fireOidcBackchannelLogout(event, idpSession); + if (!idpSession.revokedAt) { + await db + .update(sessions) + .set({ revokedAt: new Date() }) + .where(and(eq(sessions.id, idpSession.id), eq(sessions.tenantId, tenant.id))); + } + } + cookies.delete(SESSION_COOKIE_NAME, { path: "/" }); + + if (!sp.sloUrl) { + throw redirect(302, "/"); + } + const signingKeySecret = locals.runtimeConfig.signingKeySecret; + if (!signingKeySecret) { + throw redirect(302, "/"); + } + const signingKey = await getActiveSigningKey(db, tenant.id, signingKeySecret); + if (!signingKey) { + throw redirect(302, "/"); + } + const issuerUrl = locals.runtimeConfig.issuerUrl ?? url.origin; + const responseXml = buildSamlLogoutResponse({ + id: `_lr${crypto.randomUUID().replace(/-/g, "")}`, + inResponseTo: parsed.id, + issuerUrl, + destination: sp.sloUrl, + status: "Success", + }); + const redirectUrl = await buildSamlSloRedirectUrl({ + sloUrl: sp.sloUrl, + xml: responseXml, + param: "SAMLResponse", + relayState, + privateKey: signingKey.privateKey, + }); + throw redirect(302, redirectUrl); + } + + // 남은 SP 가 있으면: samlSloState 생성 후 첫 SP 로 체인 시작 + if (!idpSession) { + // 이론적으로 pending.length > 0 이려면 idpSession 이 있었어야 한다. + throw error(500, "SLO 체인 초기화 실패"); + } + + const stateId = crypto.randomUUID(); + const nowMs = Date.now(); + await db.insert(samlSloStates).values({ + id: stateId, + tenantId: tenant.id, + idpSessionRecordId: idpSession.id, + userId: idpSession.userId, + initiatingSpEntityId: parsed.issuer, + inResponseTo: parsed.id, + initiatorSloUrl: sp.sloUrl ?? null, + completionUri: sp.sloUrl ?? "/login", + pendingSpDataJson: JSON.stringify(pending), + expiresAt: new Date(nowMs + SLO_STATE_TTL_MS), + }); + + await redirectToNextSp(event, stateId, pending); + // redirect throw } + // ── Case D: 그 외 fallback — 세션만 폐기하고 / 로 ─────────────────────────── + if (locals.session) { + await db.update(sessions).set({ revokedAt: new Date() }).where(eq(sessions.id, locals.session.id)); + cookies.delete(SESSION_COOKIE_NAME, { path: "/" }); + } throw redirect(302, "/"); }; + +export const POST: RequestHandler = (event) => GET(event); diff --git a/src/routes/saml/sso/+server.ts b/src/routes/saml/sso/+server.ts index 37e2556..db86941 100644 --- a/src/routes/saml/sso/+server.ts +++ b/src/routes/saml/sso/+server.ts @@ -103,6 +103,7 @@ export const GET: RequestHandler = async (event) => { if (!locals.user || !locals.session) { const loginUrl = new URL("/login", url); loginUrl.searchParams.set("redirectTo", url.pathname + url.search); + loginUrl.searchParams.set("skinHint", `saml:${sp.id}`); throw redirect(302, loginUrl.toString()); } @@ -111,6 +112,7 @@ export const GET: RequestHandler = async (event) => { if (authnRequest.forceAuthn && locals.session.createdAt < authnRequest.issueInstant) { const loginUrl = new URL("/login", url); loginUrl.searchParams.set("redirectTo", url.pathname + url.search); + loginUrl.searchParams.set("skinHint", `saml:${sp.id}`); loginUrl.searchParams.set("forceAuthn", "true"); throw redirect(302, loginUrl.toString()); } @@ -142,6 +144,7 @@ export const GET: RequestHandler = async (event) => { // 첫 시도: 재인증(MFA 포함)을 강제한다. const loginUrl = new URL("/login", url); loginUrl.searchParams.set("redirectTo", url.pathname + url.search); + loginUrl.searchParams.set("skinHint", `saml:${sp.id}`); loginUrl.searchParams.set("forceAuthn", "true"); throw redirect(302, loginUrl.toString()); } diff --git a/svelte.config.js b/svelte.config.js index 4159012..9318f03 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -18,14 +18,14 @@ const config = { "font-src": ["self", "data:"], "connect-src": ["self"], "frame-ancestors": ["none"], - "form-action": ["self", "https:"], // SAML ACS HTTP-POST 바인딩 + "form-action": ["self", "https:", "http://localhost:*"], // SAML ACS + OIDC 리다이렉트 체인 허용 (Chrome은 form-action을 redirect chain 전체에 적용) "base-uri": ["self"], "object-src": ["none"], }, }, // OIDC/SAML 엔드포인트는 서비스 프로바이더(SP)에서 cross-origin으로 호출하므로 // SvelteKit CSRF 체크를 비활성화한다. UI 폼의 CSRF는 OIDC state 파라미터가 보호한다. - csrf: { checkOrigin: false }, + csrf: { trustedOrigins: ["*"] }, typescript: { config: (config) => ({ ...config, diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc index 2ed6c15..4af51ff 100644 --- a/wrangler.example.jsonc +++ b/wrangler.example.jsonc @@ -1,51 +1,60 @@ { - "$schema": "./node_modules/wrangler/config-schema.json", - "name": "keystone", - "compatibility_date": "2026-04-15", - "compatibility_flags": ["nodejs_als", "nodejs_compat"], - "main": ".svelte-kit/cloudflare/_worker.js", - "routes": [ - { - // 실제 배포 도메인으로 변경하세요 - "pattern": "keystone.example.com", - "custom_domain": true - } - ], - "assets": { - "binding": "ASSETS", - "directory": ".svelte-kit/cloudflare" - }, - "workers_dev": true, - "preview_urls": true, - "vars": { - // Cloudflare 계정 ID (대시보드 우측 하단에서 확인) - "CLOUDFLARE_ACCOUNT_ID": "YOUR_CLOUDFLARE_ACCOUNT_ID", + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "keystone", + "compatibility_date": "2026-04-15", + "compatibility_flags": ["nodejs_als", "nodejs_compat"], + "main": ".svelte-kit/cloudflare/_worker.js", + "routes": [ + { + // 실제 배포 도메인으로 변경하세요 + "pattern": "keystone.example.com", + "custom_domain": true, + }, + ], + "assets": { + "binding": "ASSETS", + "directory": ".svelte-kit/cloudflare", + }, + "workers_dev": true, + "preview_urls": true, + "vars": { + // Cloudflare 계정 ID (대시보드 우측 하단에서 확인) + "CLOUDFLARE_ACCOUNT_ID": "YOUR_CLOUDFLARE_ACCOUNT_ID", - // D1 데이터베이스 ID - "CLOUDFLARE_D1_DATABASE_ID": "YOUR_D1_DATABASE_ID", + // D1 데이터베이스 ID + "CLOUDFLARE_D1_DATABASE_ID": "YOUR_D1_DATABASE_ID", - // 프리뷰용 D1 데이터베이스 ID (선택) - "CLOUDFLARE_D1_PREVIEW_DATABASE_ID": "YOUR_D1_PREVIEW_DATABASE_ID", + // 프리뷰용 D1 데이터베이스 ID (선택) + "CLOUDFLARE_D1_PREVIEW_DATABASE_ID": "YOUR_D1_PREVIEW_DATABASE_ID", - // 기본 테넌트 이름 - "IDP_DEFAULT_TENANT_NAME": "My Organization", + // 기본 테넌트 이름 + "IDP_DEFAULT_TENANT_NAME": "My Organization", - // ⚠️ 아래 값은 반드시 wrangler secret put 으로 설정하세요 (vars에 평문 입력 금지) - // wrangler secret put IDP_SIGNING_KEY_SECRET (openssl rand -base64 32 으로 생성) + // ⚠️ 아래 값은 반드시 wrangler secret put 으로 설정하세요 (vars에 평문 입력 금지) + // wrangler secret put IDP_SIGNING_KEY_SECRET (openssl rand -base64 32 으로 생성) - // OIDC/SAML 토큰 발급 issuer URL (배포 도메인과 일치시킬 것) - "IDP_ISSUER_URL": "https://keystone.example.com" - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "keystone-db", - // D1 데이터베이스 ID (CLOUDFLARE_D1_DATABASE_ID와 동일) - "database_id": "YOUR_D1_DATABASE_ID", - // 프리뷰 DB가 따로 있다면 주석 해제 - // "preview_database_id": "YOUR_D1_PREVIEW_DATABASE_ID", - "migrations_dir": "drizzle", - "remote": true - } - ] + // OIDC/SAML 토큰 발급 issuer URL (배포 도메인과 일치시킬 것) + "IDP_ISSUER_URL": "https://keystone.example.com", + }, + "r2_buckets": [ + { + // 커스텀 로그인 스킨 R2 캐시 버킷 + // 생성: wrangler r2 bucket create keystone-skin-cache + "binding": "SKIN_CACHE", + "bucket_name": "keystone-skin-cache", + "remote": true, + }, + ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "keystone-db", + // D1 데이터베이스 ID (CLOUDFLARE_D1_DATABASE_ID와 동일) + "database_id": "YOUR_D1_DATABASE_ID", + // 프리뷰 DB가 따로 있다면 주석 해제 + // "preview_database_id": "YOUR_D1_PREVIEW_DATABASE_ID", + "migrations_dir": "drizzle", + "remote": true, + }, + ], }