diff --git a/Makefile b/Makefile index 649dd40..f8417c8 100644 --- a/Makefile +++ b/Makefile @@ -122,12 +122,39 @@ k6-check: ## 檢查 k6 是否安裝(沒裝會印 install 指引) @command -v $(K6) >/dev/null 2>&1 || (echo "$$K6_INSTALL_HINT"; exit 1) @echo "k6: $$($(K6) version 2>&1 | head -1)" -k6-up: ## 起 k6 全棧(mongo + redis + mailhog + postgres + zitadel) - $(COMPOSE) --profile k6 up -d mongo redis mailhog postgres zitadel - @echo "ZITADEL bootstrapping (this can take 30–90s the first time)…" - @echo "→ run 'make k6-wait' to block until it is ready" +k6-up: ## 起 k6 全棧(mongo + redis + mailhog + postgres + openldap + zitadel) + $(COMPOSE) --profile k6 up -d mongo redis mailhog postgres openldap zitadel + @echo "OpenLDAP + ZITADEL bootstrapping (ZITADEL 首次約 30–90s)…" + @echo "→ run 'make k6-wait' to block until ready" + @echo "→ LDAP 測試帳號見 deploy/openldap/README.md(alice / Password1!)" -k6-wait: ## 等 ZITADEL ready + 把 PAT 寫到 deploy/zitadel/machinekey/k6.env +k6-wait-ldap: ## 等 OpenLDAP ready 並 seed alice/bob + @echo "waiting for OpenLDAP (gateway-openldap)…" + @for i in $$(seq 1 90); do \ + if docker exec gateway-openldap ldapsearch -x -H ldap://localhost \ + -b "dc=gateway,dc=local" \ + -D "cn=admin,dc=gateway,dc=local" -w admin -LLL -s base "(objectClass=*)" dn 2>/dev/null | grep -q 'dc=gateway'; then \ + echo "openldap ready ($$i s)"; \ + $(MAKE) -s ldap-seed; exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo "openldap did not become ready in 90s — check: docker logs gateway-openldap"; exit 1 + +ldap-seed: ## 將 deploy/openldap/bootstrap 測試帳號寫入目錄(可重複執行) + @docker cp deploy/openldap/bootstrap/10-people.ldif gateway-openldap:/tmp/10-people.ldif + @docker exec gateway-openldap ldapadd -x -H ldap://localhost \ + -D "cn=admin,dc=gateway,dc=local" -w admin -c -f /tmp/10-people.ldif 2>&1 \ + | grep -Ev '^(ldap_add: Already exists|adding new entry)' || true + @echo "ldap seed done (alice / bob @ ou=people,dc=gateway,dc=local)" + +ldap-test: ## 列出 LDAP 測試使用者 alice / bob + @docker exec gateway-openldap ldapsearch -x -H ldap://localhost \ + -b "ou=people,dc=gateway,dc=local" \ + -D "cn=admin,dc=gateway,dc=local" -w admin \ + "(|(uid=alice)(uid=bob))" uid mail cn + +k6-wait: k6-wait-ldap ## 等 OpenLDAP + ZITADEL ready + 把 PAT 寫到 k6.env @echo "waiting for ZITADEL at $(ZITADEL_HEALTH_URL)…" @for i in $$(seq 1 120); do \ if curl -fsS $(ZITADEL_HEALTH_URL) >/dev/null 2>&1; then \ diff --git a/deploy/README.md b/deploy/README.md index 6427c1c..550bd42 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -7,6 +7,8 @@ Gateway 啟用 **Notification** / **Member OTP** 需要: | **MongoDB** | `notifications`、`notification_dlq` collections | 27017 | | **Redis** | 冪等、配額、異步重試佇列、member OTP challenge | 6379 | | MailHog(選用) | 本機 SMTP 測試 | 1025 / 8025 | +| OpenLDAP(`make k6-up`) | ZITADEL LDAP IdP 本機目錄 | 389 | +| ZITADEL(`make k6-up`) | OIDC / Social / LDAP 登入 | 8080 | Mongo **不需要**事先手動建 collection;應用程式寫入時會自動建立。索引由 init script 或 `make mongo-index` 建立。 @@ -40,12 +42,16 @@ make run-dev ```bash make deps-up # docker compose up -d mongo redis make deps-up-smtp # 再加上 mailhog(profile smtp) +make k6-up # 全棧含 OpenLDAP + ZITADEL(見 deploy/zitadel、deploy/openldap README) +make ldap-test # 確認 LDAP 測試帳號 alice/bob make deps-down # 停止並移除容器(保留 volume) make deps-down-v # 停止並刪除 volume(會清掉 Mongo 資料) make deps-logs # 查看 log make mongo-index # 手動建立/補齊索引 ``` +LDAP 本機測試:[deploy/openldap/README.md](openldap/README.md) + ## 連線設定 設定說明:[`etc/README.md`](../etc/README.md) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ddc0cf6..a62b108 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,4 +1,4 @@ -# 本機開發 / k6 測試依賴:MongoDB、Redis、MailHog、Postgres、ZITADEL +# 本機開發 / k6 測試依賴:MongoDB、Redis、MailHog、Postgres、OpenLDAP、ZITADEL # # 啟動: # make deps-up → mongo + redis(最小,吃 etc/gateway.dev.yaml) @@ -70,6 +70,47 @@ services: timeout: 3s retries: 20 + openldap: + profiles: ["k6"] + image: osixia/openldap:1.5.0 + container_name: gateway-openldap + restart: unless-stopped + environment: + LDAP_ORGANISATION: "GatewayDev" + LDAP_DOMAIN: "gateway.local" + LDAP_ADMIN_PASSWORD: "admin" + LDAP_CONFIG_PASSWORD: "config" + LDAP_TLS: "false" + ports: + - "389:389" + volumes: + - openldap_data:/var/lib/ldap + - openldap_config:/etc/ldap/slapd.d + healthcheck: + test: + [ + "CMD", + "ldapsearch", + "-x", + "-H", + "ldap://localhost", + "-b", + "dc=gateway,dc=local", + "-D", + "cn=admin,dc=gateway,dc=local", + "-w", + "admin", + "-LLL", + "-s", + "base", + "(objectClass=*)", + "dn", + ] + interval: 5s + timeout: 5s + retries: 24 + start_period: 20s + zitadel: profiles: ["k6"] image: ghcr.io/zitadel/zitadel:v2.65.0 @@ -79,6 +120,8 @@ services: depends_on: postgres: condition: service_healthy + openldap: + condition: service_healthy ports: - "8080:8080" volumes: @@ -98,3 +141,5 @@ volumes: mongo_data: redis_data: postgres_data: + openldap_data: + openldap_config: diff --git a/deploy/openldap/README.md b/deploy/openldap/README.md new file mode 100644 index 0000000..f624723 --- /dev/null +++ b/deploy/openldap/README.md @@ -0,0 +1,87 @@ +# 本機 OpenLDAP(k6 / dev) + +與 `make k6-up` 一併啟動的測試用 LDAP 目錄。ZITADEL 透過 **LDAP IdP** 連到這台伺服器;Gateway 仍只走 OIDC(`provider=ldap` + `LdapIdPID`)。 + +## 啟動 + +```bash +make k6-up # 含 openldap +make k6-wait # 等 OpenLDAP seed + ZITADEL ready +make ldap-test # 確認 alice / bob 可查 +``` + +`make k6-wait` 會自動執行 `ldap-seed`,把 [bootstrap/10-people.ldif](bootstrap/10-people.ldif) 寫入目錄(可重複執行)。 + +## 連線資訊 + +| 項目 | 值 | +|------|-----| +| 主機(本機) | `localhost:389` | +| 主機(ZITADEL 容器內) | `openldap:389` | +| Base DN | `dc=gateway,dc=local` | +| Bind DN | `cn=admin,dc=gateway,dc=local` | +| Bind 密碼 | `admin` | +| Users OU | `ou=people,dc=gateway,dc=local` | + +## 測試帳號 + +| uid | 密碼 | Email | +|-----|------|-------| +| `alice` | `Password1!` | alice@gateway.local | +| `bob` | `Password1!` | bob@gateway.local | + +登入 ZITADEL LDAP IdP 時,使用者名稱通常填 **uid**(例如 `alice`),不是 email。 + +## 在 ZITADEL Console 建立 LDAP IdP + +1. 開啟 http://localhost:8080/ui/console(admin:`zitadel-admin@zitadel.localhost` / `Password1!`) +2. **Settings → Identity Providers → New → LDAP**(Generic LDAP) +3. 建議欄位(名稱可自訂): + +| 欄位 | 建議值 | +|------|--------| +| Servers | `ldap://openldap:389` | +| StartTLS | 關閉 | +| Bind DN / Username | `cn=admin,dc=gateway,dc=local` | +| Bind password | `admin` | +| User base DN | `ou=people,dc=gateway,dc=local` | +| User object class | `inetOrgPerson` | +| User unique attribute | `uid` | +| User filters / Login filter | `(&(objectClass=inetOrgPerson)(uid=%s))` 或 `(uid=%s)` | +| Email attribute | `mail` | +| Display name attribute | `cn` | +| Username attribute | `uid` | + +4. 儲存後複製 **IdP ID**,寫入 Gateway: + +```yaml +# etc/gateway.k6.yaml 或 gateway.dev.yaml +Zitadel: + OAuthClientID: "<你的 OIDC app client id>" + OAuthClientSecret: "" + LdapIdPID: "" +``` + +5. 重啟 Gateway,前端登入頁點 **使用 LDAP 登入**,以 `alice` / `Password1!` 測試。 + +## 本機 ldapsearch(選用) + +```bash +make ldap-test + +# 或手動(需本機 openldap 客戶端:brew install openldap) +ldapsearch -x -H ldap://localhost:389 \ + -D "cn=admin,dc=gateway,dc=local" -w admin \ + -b "ou=people,dc=gateway,dc=local" "(uid=alice)" mail cn +``` + +## 重設資料 + +```bash +make k6-down # 會刪除 openldap volume,下次 k6-up 會重新 bootstrap LDIF +``` + +## 注意 + +- 僅供本機開發;`admin` / `Password1!` 不可上線。 +- 若改過 LDIF,可 `make ldap-seed` 重灌(已存在的 entry 會略過);要完全重建請 `make k6-down` 後再 `make k6-up`。 diff --git a/deploy/openldap/bootstrap/10-people.ldif b/deploy/openldap/bootstrap/10-people.ldif new file mode 100644 index 0000000..fb8af8c --- /dev/null +++ b/deploy/openldap/bootstrap/10-people.ldif @@ -0,0 +1,31 @@ +# 本機 k6 測試用使用者(密碼皆為 Password1!) +# Base DN:dc=gateway,dc=local(由 LDAP_DOMAIN=gateway.local 建立) + +dn: ou=people,dc=gateway,dc=local +objectClass: top +objectClass: organizationalUnit +ou: people + +dn: uid=alice,ou=people,dc=gateway,dc=local +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +cn: Alice Dev +sn: Dev +givenName: Alice +uid: alice +mail: alice@gateway.local +userPassword: Password1! + +dn: uid=bob,ou=people,dc=gateway,dc=local +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +cn: Bob Dev +sn: Dev +givenName: Bob +uid: bob +mail: bob@gateway.local +userPassword: Password1! diff --git a/deploy/zitadel/README.md b/deploy/zitadel/README.md index 67b4e39..36a0e71 100644 --- a/deploy/zitadel/README.md +++ b/deploy/zitadel/README.md @@ -8,7 +8,7 @@ make k6-up ``` -會啟動 mongo / redis / mailhog / postgres / zitadel。 +會啟動 mongo / redis / mailhog / postgres / **openldap** / zitadel。 ZITADEL 首次啟動會 init Postgres schema 並執行 [steps.yaml](steps.yaml) 預載: - Instance 名稱:`ZITADEL` @@ -52,6 +52,73 @@ docker volume rm template-monorepo_postgres_data # 清 ZITADEL 資料 rm deploy/zitadel/machinekey/zitadel-admin-sa.* # 清 PAT ``` +## Google / LDAP 聯邦登入(Social + LDAP IdP) + +Gateway **不直接** bind LDAP;登入/註冊走 ZITADEL OIDC,並以 `idp_id` 指定外部 IdP。 + +### 1. 建立 OIDC Application(User Agent) + +在 ZITADEL Console → Project → Applications → **User Agent**: + +- Redirect URIs(本機前端,經 Vite proxy 打 API): + - `http://localhost:5173/auth/callback/login` + - `http://localhost:5173/auth/callback/register` +- Grant types:`Authorization Code` +- Response type:`code` +- Scopes:`openid` `profile` `email` + +記下 **Client ID** / **Client Secret**,寫入 Gateway: + +```bash +export ZITADEL_OAUTH_CLIENT_ID=... +export ZITADEL_OAUTH_CLIENT_SECRET=... +``` + +或 `etc/gateway.k6.yaml` 的 `Zitadel.OAuthClientID` / `OAuthClientSecret`。 + +### 2. 設定 Google IdP + +Console → Settings → Identity Providers → **Google** → 建立後複製 **IdP ID**: + +```yaml +Zitadel: + GoogleIdPID: "" + # Google OAuth client 也可放在 Zitadel IdP 設定內 +``` + +### 3. 設定 LDAP IdP(本機 OpenLDAP) + +`make k6-up` 已含測試目錄,帳號與 ZITADEL 欄位對照見 **[deploy/openldap/README.md](../openldap/README.md)**。 + +摘要(在 ZITADEL Console 建 Generic LDAP IdP): + +| 欄位 | 值 | +|------|-----| +| Server | `ldap://openldap:389` | +| Bind DN | `cn=admin,dc=gateway,dc=local` | +| Bind password | `admin` | +| User base | `ou=people,dc=gateway,dc=local` | +| Login | uid=`alice`,密碼=`Password1!` | + +建立後複製 **IdP ID** → `Zitadel.LdapIdPID`,並確認已設定 OIDC App(§1)。 + +```yaml +Zitadel: + LdapIdPID: "" +``` + +驗證目錄:`make ldap-test` + +### 4. 行為摘要 + +| 流程 | Google | LDAP | +|------|--------|------| +| 登入 | 需已有會員;否則 404「請先註冊」 | 首登可 `EnsureFromLDAP` 自動建立會員 | +| 註冊 | `EnsureFromOIDC` + 邀請碼 | 支援但通常改走 LDAP 登入 | +| TOTP 已啟用 | callback 回 `mfa_required`,前端導回登入頁輸入 TOTP | 同左 | + +重啟 Gateway:`make dev-restart-gateway`(或 `make k6-gateway`)。 + ## 端點 - Console UI:http://localhost:8080/ui/console diff --git a/deploy/zitadel/machinekey/k6.env b/deploy/zitadel/machinekey/k6.env index 43fdc61..4981a83 100644 --- a/deploy/zitadel/machinekey/k6.env +++ b/deploy/zitadel/machinekey/k6.env @@ -1,4 +1,4 @@ -export ZITADEL_SERVICE_TOKEN=KK234msGgYozMn56fEzHAjsf-lVm5qyoHCGR10ay1nGYUtN06RIxHtSq90Wtle9cuIVhXWg +export ZITADEL_SERVICE_TOKEN=j2uy--q988R_b4kaB0-kCsWe-NpzpejaiV66AYT5g1pRvLSJff4Mke833P0XkTJtmWt6iLM export BASE_URL=http://localhost:8888 export MAILHOG_URL=http://localhost:8025 export REDIS_ADDR=localhost:6379 diff --git a/etc/gateway.dev.example.yaml b/etc/gateway.dev.example.yaml index 33f088a..908cd7f 100644 --- a/etc/gateway.dev.example.yaml +++ b/etc/gateway.dev.example.yaml @@ -117,5 +117,6 @@ Zitadel: GoogleClientID: "" GoogleClientSecret: "" GoogleIdPID: "" + LdapIdPID: "" JWKSUrl: "" TimeoutSeconds: 15 diff --git a/etc/gateway.k6.yaml b/etc/gateway.k6.yaml index 11f7456..f54e8de 100644 --- a/etc/gateway.k6.yaml +++ b/etc/gateway.k6.yaml @@ -112,5 +112,6 @@ Zitadel: GoogleClientID: "" GoogleClientSecret: "" GoogleIdPID: "" + LdapIdPID: "" JWKSUrl: "" TimeoutSeconds: 15 diff --git a/frontend/.vite/deps/_metadata.json b/frontend/.vite/deps/_metadata.json new file mode 100644 index 0000000..83d69f4 --- /dev/null +++ b/frontend/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "a1ca2740", + "configHash": "4e2c8afe", + "lockfileHash": "c76a71e0", + "browserHash": "fda5bc46", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/frontend/.vite/deps/package.json b/frontend/.vite/deps/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/frontend/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/frontend/src/App.css b/frontend/src/App.css index 7936527..13e2a99 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -59,6 +59,73 @@ a:hover { font-size: 0.9rem; } +.auth-hint { + margin: 0 0 1.25rem; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.auth-hint-small { + margin-top: 0.75rem; + font-size: 0.85rem; +} + +.auth-divider { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 1.25rem 0; + color: var(--muted); + font-size: 0.85rem; +} + +.auth-divider::before, +.auth-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +.auth-oauth { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.btn-oauth { + width: 100%; + padding: 0.65rem 1rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font-size: 0.95rem; + cursor: pointer; +} + +.btn-oauth:hover:not(:disabled) { + border-color: var(--primary); + color: var(--primary); +} + +.btn-oauth:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-oauth-secondary { + background: #f8fafc; +} + +.form-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + /* Shell */ .shell { min-height: 100vh; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 802a2bc..27fb882 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { ConfirmPage } from './pages/user/ConfirmPage'; import { ForgotPasswordPage } from './pages/user/ForgotPasswordPage'; import { HomePage } from './pages/user/HomePage'; import { LoginPage } from './pages/user/LoginPage'; +import { OAuthCallbackPage } from './pages/user/OAuthCallbackPage'; import { ProfilePage } from './pages/user/ProfilePage'; import { RegisterPage } from './pages/user/RegisterPage'; import { SecurityPage } from './pages/user/SecurityPage'; @@ -29,7 +30,15 @@ function App() { } /> + } + /> } /> + } + /> } /> } /> diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index e024908..235aaed 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -152,6 +152,77 @@ export async function logout() { } } +export type FederatedProvider = 'google' | 'ldap'; + +export interface SocialStartData { + oauth_url: string; + session_id: string; + expires_in: number; +} + +export async function loginSocialStart( + tenantSlug: string, + provider: FederatedProvider, + redirectUri: string, +) { + return api('/api/v1/auth/login/social/start', { + auth: false, + method: 'POST', + body: JSON.stringify({ + tenant_slug: tenantSlug, + provider, + redirect_uri: redirectUri, + }), + }); +} + +export async function loginSocialCallback(code: string, state: string) { + const qs = new URLSearchParams({ code, state }); + const data = await api( + `/api/v1/auth/login/social/callback?${qs.toString()}`, + { auth: false }, + ); + if (data.mfa_required) { + return data; + } + if (data.access_token && data.refresh_token && data.uid) { + applyAuthTokens(data as AuthTokenData); + } + return data; +} + +export async function registerSocialStart(body: { + tenant_slug: string; + invite_code: string; + provider: FederatedProvider; + redirect_uri: string; + language?: string; +}) { + return api('/api/v1/auth/register/social/start', { + auth: false, + method: 'POST', + body: JSON.stringify({ + ...body, + accept_terms_version: '2025-01-01', + marketing_opt_in: false, + }), + }); +} + +export async function registerSocialCallback(code: string, state: string) { + const qs = new URLSearchParams({ code, state }); + const data = await api( + `/api/v1/auth/register/social/callback?${qs.toString()}`, + { auth: false }, + ); + applyAuthTokens(data); + return data; +} + +export function startOAuthRedirect(oauthUrl: string) { + window.location.assign(oauthUrl); +} + export async function refreshToken() { const refresh = localStorage.getItem('refresh_token'); if (!refresh) throw new Error('無 refresh token'); diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index cac2eb3..a685d81 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -67,5 +67,8 @@ export async function api( const msg = json?.message ?? res.statusText ?? '請求失敗'; throw new ApiError(msg, res.status, json?.code, json); } + if (json && (json.code === 102000 || json.code === 0) && json.data != null) { + return json.data as T; + } return (json?.data ?? json) as T; } diff --git a/frontend/src/api/member.ts b/frontend/src/api/member.ts index 5c6ba20..a896759 100644 --- a/frontend/src/api/member.ts +++ b/frontend/src/api/member.ts @@ -100,12 +100,41 @@ export function disableTOTP() { return api('/api/v1/members/me/totp', { method: 'DELETE' }); } -export function changePassword(currentPassword: string, newPassword: string) { - return api<{ ok: boolean }>('/api/v1/members/me/password', { +export interface TOTPVerifyResult { + step_up_token: string; + expires_in: number; +} + +export function verifyTOTP(code: string, purpose = 'change_password') { + return api('/api/v1/members/me/totp/verify', { method: 'POST', - body: JSON.stringify({ - current_password: currentPassword, - new_password: newPassword, - }), + body: JSON.stringify({ code, purpose }), + }); +} + +/** 從 verify 回應取出 step-up token(相容舊版 envelope 誤解析) */ +export function pickStepUpToken( + res: TOTPVerifyResult | Record, +): string { + const token = + (res as TOTPVerifyResult).step_up_token ?? + (res as { stepUpToken?: string }).stepUpToken; + return typeof token === 'string' ? token.trim() : ''; +} + +export function changePassword( + currentPassword: string, + newPassword: string, + opts?: { stepUpToken?: string; totpCode?: string }, +) { + const body: Record = { + current_password: currentPassword, + new_password: newPassword, + }; + if (opts?.stepUpToken) body.step_up_token = opts.stepUpToken; + if (opts?.totpCode) body.totp_code = opts.totpCode.replace(/\s/g, ''); + return api<{ ok: boolean }>('/api/v1/members/me/password', { + method: 'POST', + body: JSON.stringify(body), }); } diff --git a/frontend/src/config.ts b/frontend/src/config.ts index c4f5247..af51ff9 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -3,3 +3,8 @@ export const DEFAULT_TENANT = 'k6-tenant'; export const DEFAULT_INVITE = 'K6INVITE'; /** 與 Gateway Member.OTP.ResendCooldownSeconds 預設一致 */ export const OTP_RESEND_COOLDOWN_SECONDS = 60; + +/** OAuth 完成後由前端承接,再 proxy 呼叫 Gateway callback */ +export function oauthRedirectUri(path: '/auth/callback/login' | '/auth/callback/register'): string { + return `${window.location.origin}${path}`; +} diff --git a/frontend/src/pages/user/LoginPage.tsx b/frontend/src/pages/user/LoginPage.tsx index 6432cc3..5c0ed41 100644 --- a/frontend/src/pages/user/LoginPage.tsx +++ b/frontend/src/pages/user/LoginPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, type FormEvent } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import * as authApi from '../../api/auth'; import { ApiError } from '../../api/http'; -import { DEFAULT_TENANT } from '../../config'; +import { DEFAULT_TENANT, oauthRedirectUri } from '../../config'; import { useAuth } from '../../context/AuthContext'; import * as permApi from '../../api/permission'; @@ -22,13 +22,44 @@ export function LoginPage() { const [loading, setLoading] = useState(false); useEffect(() => { - const state = location.state as { message?: string } | null; + const state = location.state as { + message?: string; + mfa_challenge_id?: string; + tenant?: string; + } | null; + if (state?.tenant) { + setTenant(state.tenant); + localStorage.setItem('tenant_slug', state.tenant); + } + if (state?.mfa_challenge_id) { + setMfaChallengeId(state.mfa_challenge_id); + setInfo(state.message ?? '請輸入驗證碼以完成登入'); + navigate(location.pathname, { replace: true, state: null }); + return; + } if (state?.message) { setInfo(state.message); navigate(location.pathname, { replace: true, state: null }); } }, [location.pathname, location.state, navigate]); + const startFederated = async (provider: authApi.FederatedProvider) => { + setError(''); + setLoading(true); + try { + localStorage.setItem('tenant_slug', tenant); + const { oauth_url } = await authApi.loginSocialStart( + tenant, + provider, + oauthRedirectUri('/auth/callback/login'), + ); + authApi.startOAuthRedirect(oauth_url); + } catch (err) { + setError(err instanceof ApiError ? err.message : '無法啟動登入'); + setLoading(false); + } + }; + const finishLogin = async () => { syncSession(); await refreshRoles(); @@ -44,11 +75,13 @@ export function LoginPage() { try { localStorage.setItem('tenant_slug', tenant); const result = await authApi.login(tenant, email, password); - if (result.mfa_required) { + if (result.mfa_required || result.mfa_challenge_id) { if (!result.mfa_challenge_id) { throw new Error('缺少 MFA challenge'); } setMfaChallengeId(result.mfa_challenge_id); + setTotpCode(''); + setInfo(''); return; } await finishLogin(); @@ -69,7 +102,8 @@ export function LoginPage() { setError(''); setLoading(true); try { - await authApi.loginMfaConfirm(tenant, mfaChallengeId, totpCode); + const code = totpCode.replace(/\s/g, ''); + await authApi.loginMfaConfirm(tenant, mfaChallengeId, code); await finishLogin(); } catch (err) { setError(err instanceof ApiError ? err.message : '驗證碼錯誤'); @@ -88,7 +122,16 @@ export function LoginPage() { return (

雙因素驗證

-

請輸入驗證器 App 的 6 位數驗證碼,或備援碼。

+

+ {email ? ( + <> + 帳號 {email} 已啟用 TOTP。 + + ) : ( + <>此帳號已啟用 TOTP。 + )} + 請輸入驗證器 App 的 6 位數驗證碼,或一次性備援碼。 +

{error &&

{error}

} @@ -150,6 +196,27 @@ export function LoginPage() { {loading ? '登入中…' : '登入'}
+
+ +
+
+ + +

還沒有帳號? 註冊 {' · '} diff --git a/frontend/src/pages/user/OAuthCallbackPage.tsx b/frontend/src/pages/user/OAuthCallbackPage.tsx new file mode 100644 index 0000000..d01b2d7 --- /dev/null +++ b/frontend/src/pages/user/OAuthCallbackPage.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import * as authApi from '../../api/auth'; +import { ApiError } from '../../api/http'; +import { DEFAULT_TENANT } from '../../config'; +import { useAuth } from '../../context/AuthContext'; +import * as permApi from '../../api/permission'; + +type OAuthMode = 'login' | 'register'; + +export function OAuthCallbackPage({ mode }: { mode: OAuthMode }) { + const navigate = useNavigate(); + const [search] = useSearchParams(); + const { syncSession, refreshRoles } = useAuth(); + const [error, setError] = useState(''); + + useEffect(() => { + const code = search.get('code'); + const state = search.get('state'); + const oauthError = search.get('error'); + const tenant = + localStorage.getItem('tenant_slug') ?? DEFAULT_TENANT; + + if (oauthError) { + setError(search.get('error_description') ?? oauthError); + return; + } + if (!code || !state) { + setError('缺少 OAuth 參數(code / state)'); + return; + } + + let cancelled = false; + + (async () => { + try { + if (mode === 'login') { + const result = await authApi.loginSocialCallback(code, state); + if (result.mfa_required && result.mfa_challenge_id) { + navigate('/login', { + replace: true, + state: { + message: '請完成雙因素驗證以完成登入', + mfa_challenge_id: result.mfa_challenge_id, + tenant, + }, + }); + return; + } + } else { + await authApi.registerSocialCallback(code, state); + } + + if (cancelled) return; + syncSession(); + await refreshRoles(); + const me = await permApi.getMyPermissions(); + const admin = permApi.isAdminRole(me.roles ?? []); + navigate(admin ? '/admin' : '/app', { replace: true }); + } catch (err) { + if (cancelled) return; + if (err instanceof ApiError && err.code === 29301000 && mode === 'login') { + setError('尚無帳號,請先註冊或聯絡管理員開通 LDAP 帳號。'); + } else { + setError(err instanceof ApiError ? err.message : '登入失敗'); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [mode, navigate, refreshRoles, search, syncSession]); + + return ( +

+

{mode === 'login' ? '登入處理中' : '註冊處理中'}

+ {error ? ( + <> +

{error}

+

+ 返回 +

+ + ) : ( +

正在與身分提供者確認,請稍候…

+ )} +
+ ); +} diff --git a/frontend/src/pages/user/RegisterPage.tsx b/frontend/src/pages/user/RegisterPage.tsx index d36ff9e..cb6acd0 100644 --- a/frontend/src/pages/user/RegisterPage.tsx +++ b/frontend/src/pages/user/RegisterPage.tsx @@ -2,7 +2,7 @@ import { useState, type FormEvent } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import * as authApi from '../../api/auth'; import { ApiError } from '../../api/http'; -import { DEFAULT_INVITE, DEFAULT_TENANT } from '../../config'; +import { DEFAULT_INVITE, DEFAULT_TENANT, oauthRedirectUri } from '../../config'; export function RegisterPage() { const navigate = useNavigate(); @@ -16,6 +16,25 @@ export function RegisterPage() { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const startGoogle = async () => { + setError(''); + setLoading(true); + try { + localStorage.setItem('tenant_slug', tenant); + const { oauth_url } = await authApi.registerSocialStart({ + tenant_slug: tenant, + invite_code: invite, + provider: 'google', + redirect_uri: oauthRedirectUri('/auth/callback/register'), + language: 'zh-TW', + }); + authApi.startOAuthRedirect(oauth_url); + } catch (err) { + setError(err instanceof ApiError ? err.message : '無法啟動 Google 註冊'); + setLoading(false); + } + }; + const submit = async (e: FormEvent) => { e.preventDefault(); setError(''); @@ -89,6 +108,22 @@ export function RegisterPage() { {loading ? '送出中…' : '註冊並寄送驗證碼'} +
+ +
+
+ +
+

+ LDAP 帳號由目錄同步/管理員開通,請在登入頁使用「LDAP 登入」。 +

已有帳號? 登入 {' · '} diff --git a/frontend/src/pages/user/SecurityPage.tsx b/frontend/src/pages/user/SecurityPage.tsx index b5b3b8d..21e0f7d 100644 --- a/frontend/src/pages/user/SecurityPage.tsx +++ b/frontend/src/pages/user/SecurityPage.tsx @@ -20,6 +20,10 @@ export function SecurityPage() { const [phoneCode, setPhoneCode] = useState(''); const [totpCode, setTotpCode] = useState(''); + const [pwdStepUpCode, setPwdStepUpCode] = useState(''); + const [pwdStepUpToken, setPwdStepUpToken] = useState(''); + const [pwdStepVerified, setPwdStepVerified] = useState(false); + const [pwdVerifyLoading, setPwdVerifyLoading] = useState(false); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -135,16 +139,52 @@ export function SecurityPage() { } }; + const verifyForPasswordChange = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setMsg(''); + setPwdVerifyLoading(true); + try { + const r = await memberApi.verifyTOTP( + pwdStepUpCode.replace(/\s/g, ''), + 'change_password', + ); + const token = memberApi.pickStepUpToken(r); + if (!token) { + setError( + '驗證已通過但未取得授權憑證,請執行 make dev-restart-gateway 更新後端後再試', + ); + return; + } + setPwdStepUpToken(token); + setPwdStepVerified(true); + setPwdStepUpCode(''); + setMsg('驗證成功,請在下方輸入新密碼(約 5 分鐘內有效)'); + } catch (e) { + showErr(e); + } finally { + setPwdVerifyLoading(false); + } + }; + const changePassword = async (e: FormEvent) => { e.preventDefault(); if (newPassword !== confirmPassword) { setError('兩次輸入的新密碼不一致'); return; } + if (totpActive && !pwdStepVerified) { + setError('請先完成 TOTP 驗證'); + return; + } setError(''); setMsg(''); try { - await memberApi.changePassword(currentPassword, newPassword); + await memberApi.changePassword(currentPassword, newPassword, { + stepUpToken: pwdStepUpToken || undefined, + }); + setPwdStepUpToken(''); + setPwdStepVerified(false); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); @@ -154,7 +194,19 @@ export function SecurityPage() { } }; + const resetPasswordFlow = () => { + setPwdStepUpToken(''); + setPwdStepVerified(false); + setPwdStepUpCode(''); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setError(''); + }; + const canChangePassword = me?.origin === 'platform_native'; + const totpActive = Boolean(me?.totp_enrolled ?? totp?.enrolled); + const passwordNeedsTotp = totpActive && !pwdStepVerified; return (

@@ -165,43 +217,95 @@ export function SecurityPage() {

登入密碼

{canChangePassword ? ( -
- - - - -
+ <> + {!totpActive ? ( +

+ 變更登入密碼前須先完成下方「雙因素驗證 (TOTP)」綁定。 +

+ ) : ( +

+ 已啟用雙因素驗證:變更密碼前須先輸入驗證器或備援碼。 +

+ )} + {!totpActive ? null : passwordNeedsTotp ? ( +
+ + +
+ ) : ( +
+ {totpActive && ( +

TOTP 已驗證,請輸入新密碼。

+ )} + + + +
+ + {totpActive && ( + + )} +
+
+ )} + ) : (

您的帳號由第三方或企業目錄登入,無法在此變更密碼。 @@ -285,6 +389,11 @@ export function SecurityPage() { {totp?.enrolled && ` · 備援碼剩餘 ${totp.backup_codes_remaining} 組`}

+ {totp?.enrolled && ( +

+ 下次登入時,輸入密碼後會進入「雙因素驗證」步驟,需再輸入驗證器或備援碼。 +

+ )} {!totp?.enrolled ? ( <>