Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5bbcc88
feat: 클라이언트별 커스텀 로그인 스킨 지원
mack-erel Apr 20, 2026
a7dec91
style: 코드 자동 포맷 적용 [skip ci]
mack-erel Apr 20, 2026
239274e
docs: wrangler.example.jsonc에 R2 SKIN_CACHE 바인딩 예시 추가
mack-erel Apr 20, 2026
7c6dd33
fix: {@html} ESLint 경고 억제 주석 추가
mack-erel Apr 20, 2026
e13085a
feat: signup/find-id/find-password 스킨 타입 및 라우트 추가
mack-erel Apr 20, 2026
1960b43
feat: 커스텀 스킨 개발 가이드 페이지 추가
mack-erel Apr 20, 2026
dd62f8c
feat: setup 스크립트에 R2 버킷 생성 단계 추가
mack-erel Apr 20, 2026
b202744
refactor: 스킨 추가 폼에서 clientType 드롭다운 제거
mack-erel Apr 20, 2026
a3846a3
fix: untrack으로 data 초기값 참조 경고 제거
mack-erel Apr 20, 2026
67b63e1
fix: csrf.checkOrigin 대신 trustedOrigins: ['*'] 사용
mack-erel Apr 20, 2026
c07145e
fix: wrangler.example.jsonc에서 JSON 형식 오류 수정
mack-erel Apr 20, 2026
79d2b3e
feat: 회원가입 / 아이디 찾기 / 비밀번호 찾기 기능 구현
mack-erel Apr 20, 2026
8a0f67d
fix: nodemailer transporter MaxListeners 경고 억제
mack-erel Apr 20, 2026
06a0efb
fix: 비밀번호 재설정 URL issuer 끝 점/슬래시 제거
mack-erel Apr 20, 2026
5d2e960
fix: 비밀번호 재설정 URL에서 resolve() 제거 (서버사이드 ./path 문제)
mack-erel Apr 20, 2026
3943833
feat: OIDC FC/BC 로그아웃 및 SAML SLO 구현
mack-erel Apr 21, 2026
10ceb0c
feat: SAML SLO sequential redirect chain 구현
mack-erel Apr 21, 2026
df36d33
feat: SAML ACS 및 OIDC 리다이렉트 체인을 허용하도록 form-action CSP 수정
mack-erel Apr 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
52 changes: 52 additions & 0 deletions scripts/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ interface Args {
noPreview: boolean;
migrate?: boolean;
migratePreview?: boolean;
r2BucketName?: string;
noR2: boolean;
signingKey?: string;
tenantName?: string;
adminUsername?: string;
Expand All @@ -65,6 +67,7 @@ interface Args {
function parseArgs(argv: string[]): Args {
const args: Args = {
noPreview: false,
noR2: false,
yes: false,
help: false,
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -148,6 +157,8 @@ ${cyan("옵션:")}
--no-migrate 마이그레이션 건너뜀
--migrate-preview 프리뷰 DB 마이그레이션 자동 진행
--no-migrate-preview 프리뷰 DB 마이그레이션 건너뜀
--r2-bucket-name <name> R2 버킷 이름 (기본값: keystone-skin-cache)
--no-r2 R2 버킷 생성 건너뜀
--signing-key <secret> IDP_SIGNING_KEY_SECRET 값
--tenant-name <name> 조직(테넌트) 이름
--admin-username <id> 초기 관리자 아이디
Expand Down Expand Up @@ -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<string, string>): Promise<string[] | null> {
Expand Down Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
declare global {
namespace App {
interface Platform {
env: Env;
env: Env & { SKIN_CACHE?: R2Bucket };
ctx: ExecutionContext;
caches: CacheStorage;
cf?: IncomingRequestCfProperties;
Expand Down
106 changes: 104 additions & 2 deletions src/lib/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"login": {
"username": "아이디",
"password": "비밀번호",
"submit": "로그인"
"submit": "로그인",
"registered_success": "회원가입이 완료되었습니다. 로그인해 주세요.",
"password_reset_success": "비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요."
},
"admin": {
"title": "관리자 대시보드",
Expand All @@ -54,7 +56,8 @@
"departments": "부서",
"teams": "팀",
"parts": "파트",
"ldap_providers": "LDAP 프로바이더"
"ldap_providers": "LDAP 프로바이더",
"skins": "로그인 스킨"
},
"account": {
"profile": "내 프로필",
Expand Down Expand Up @@ -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": "추가 후 토글로 활성화하면 해당 클라이언트의 로그인 흐름에서 커스텀 스킨이 적용됩니다."
}
}
Loading
Loading