add fix eage case

This commit is contained in:
王性驊 2026-05-27 17:28:13 +08:00
parent 93e94e8c5d
commit 9dd8287777
51 changed files with 1485 additions and 144 deletions

View File

@ -122,12 +122,39 @@ k6-check: ## 檢查 k6 是否安裝(沒裝會印 install 指引)
@command -v $(K6) >/dev/null 2>&1 || (echo "$$K6_INSTALL_HINT"; exit 1) @command -v $(K6) >/dev/null 2>&1 || (echo "$$K6_INSTALL_HINT"; exit 1)
@echo "k6: $$($(K6) version 2>&1 | head -1)" @echo "k6: $$($(K6) version 2>&1 | head -1)"
k6-up: ## 起 k6 全棧mongo + redis + mailhog + postgres + zitadel k6-up: ## 起 k6 全棧mongo + redis + mailhog + postgres + openldap + zitadel
$(COMPOSE) --profile k6 up -d mongo redis mailhog postgres zitadel $(COMPOSE) --profile k6 up -d mongo redis mailhog postgres openldap zitadel
@echo "ZITADEL bootstrapping (this can take 3090s the first time)…" @echo "OpenLDAP + ZITADEL bootstrapping (ZITADEL 首次約 3090s)…"
@echo "→ run 'make k6-wait' to block until it is ready" @echo "→ run 'make k6-wait' to block until ready"
@echo "→ LDAP 測試帳號見 deploy/openldap/README.mdalice / 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)" @echo "waiting for ZITADEL at $(ZITADEL_HEALTH_URL)"
@for i in $$(seq 1 120); do \ @for i in $$(seq 1 120); do \
if curl -fsS $(ZITADEL_HEALTH_URL) >/dev/null 2>&1; then \ if curl -fsS $(ZITADEL_HEALTH_URL) >/dev/null 2>&1; then \

View File

@ -7,6 +7,8 @@ Gateway 啟用 **Notification** / **Member OTP** 需要:
| **MongoDB** | `notifications`、`notification_dlq` collections | 27017 | | **MongoDB** | `notifications`、`notification_dlq` collections | 27017 |
| **Redis** | 冪等、配額、異步重試佇列、member OTP challenge | 6379 | | **Redis** | 冪等、配額、異步重試佇列、member OTP challenge | 6379 |
| MailHog選用 | 本機 SMTP 測試 | 1025 / 8025 | | 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` 建立。 Mongo **不需要**事先手動建 collection應用程式寫入時會自動建立。索引由 init script 或 `make mongo-index` 建立。
@ -40,12 +42,16 @@ make run-dev
```bash ```bash
make deps-up # docker compose up -d mongo redis make deps-up # docker compose up -d mongo redis
make deps-up-smtp # 再加上 mailhogprofile smtp make deps-up-smtp # 再加上 mailhogprofile smtp
make k6-up # 全棧含 OpenLDAP + ZITADEL見 deploy/zitadel、deploy/openldap README
make ldap-test # 確認 LDAP 測試帳號 alice/bob
make deps-down # 停止並移除容器(保留 volume make deps-down # 停止並移除容器(保留 volume
make deps-down-v # 停止並刪除 volume會清掉 Mongo 資料) make deps-down-v # 停止並刪除 volume會清掉 Mongo 資料)
make deps-logs # 查看 log make deps-logs # 查看 log
make mongo-index # 手動建立/補齊索引 make mongo-index # 手動建立/補齊索引
``` ```
LDAP 本機測試:[deploy/openldap/README.md](openldap/README.md)
## 連線設定 ## 連線設定
設定說明:[`etc/README.md`](../etc/README.md) 設定說明:[`etc/README.md`](../etc/README.md)

View File

@ -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 # make deps-up → mongo + redis最小吃 etc/gateway.dev.yaml
@ -70,6 +70,47 @@ services:
timeout: 3s timeout: 3s
retries: 20 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: zitadel:
profiles: ["k6"] profiles: ["k6"]
image: ghcr.io/zitadel/zitadel:v2.65.0 image: ghcr.io/zitadel/zitadel:v2.65.0
@ -79,6 +120,8 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
openldap:
condition: service_healthy
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
@ -98,3 +141,5 @@ volumes:
mongo_data: mongo_data:
redis_data: redis_data:
postgres_data: postgres_data:
openldap_data:
openldap_config:

87
deploy/openldap/README.md Normal file
View File

@ -0,0 +1,87 @@
# 本機 OpenLDAPk6 / 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/consoleadmin`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: "<secret>"
LdapIdPID: "<zitadel-ldap-idp-id>"
```
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`

View File

@ -0,0 +1,31 @@
# 本機 k6 測試用使用者(密碼皆為 Password1!
# Base DNdc=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!

View File

@ -8,7 +8,7 @@
make k6-up make k6-up
``` ```
會啟動 mongo / redis / mailhog / postgres / zitadel。 會啟動 mongo / redis / mailhog / postgres / **openldap** / zitadel。
ZITADEL 首次啟動會 init Postgres schema 並執行 [steps.yaml](steps.yaml) 預載: ZITADEL 首次啟動會 init Postgres schema 並執行 [steps.yaml](steps.yaml) 預載:
- Instance 名稱:`ZITADEL` - Instance 名稱:`ZITADEL`
@ -52,6 +52,73 @@ docker volume rm template-monorepo_postgres_data # 清 ZITADEL 資料
rm deploy/zitadel/machinekey/zitadel-admin-sa.* # 清 PAT rm deploy/zitadel/machinekey/zitadel-admin-sa.* # 清 PAT
``` ```
## Google / LDAP 聯邦登入Social + LDAP IdP
Gateway **不直接** bind LDAP登入註冊走 ZITADEL OIDC並以 `idp_id` 指定外部 IdP。
### 1. 建立 OIDC ApplicationUser 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: "<zitadel-google-idp-id>"
# 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: "<zitadel-ldap-idp-id>"
```
驗證目錄:`make ldap-test`
### 4. 行為摘要
| 流程 | Google | LDAP |
|------|--------|------|
| 登入 | 需已有會員;否則 404「請先註冊」 | 首登可 `EnsureFromLDAP` 自動建立會員 |
| 註冊 | `EnsureFromOIDC` + 邀請碼 | 支援但通常改走 LDAP 登入 |
| TOTP 已啟用 | callback 回 `mfa_required`,前端導回登入頁輸入 TOTP | 同左 |
重啟 Gateway`make dev-restart-gateway`(或 `make k6-gateway`)。
## 端點 ## 端點
- Console UIhttp://localhost:8080/ui/console - Console UIhttp://localhost:8080/ui/console

View File

@ -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 BASE_URL=http://localhost:8888
export MAILHOG_URL=http://localhost:8025 export MAILHOG_URL=http://localhost:8025
export REDIS_ADDR=localhost:6379 export REDIS_ADDR=localhost:6379

View File

@ -117,5 +117,6 @@ Zitadel:
GoogleClientID: "" GoogleClientID: ""
GoogleClientSecret: "" GoogleClientSecret: ""
GoogleIdPID: "" GoogleIdPID: ""
LdapIdPID: ""
JWKSUrl: "" JWKSUrl: ""
TimeoutSeconds: 15 TimeoutSeconds: 15

View File

@ -112,5 +112,6 @@ Zitadel:
GoogleClientID: "" GoogleClientID: ""
GoogleClientSecret: "" GoogleClientSecret: ""
GoogleIdPID: "" GoogleIdPID: ""
LdapIdPID: ""
JWKSUrl: "" JWKSUrl: ""
TimeoutSeconds: 15 TimeoutSeconds: 15

View File

@ -0,0 +1,8 @@
{
"hash": "a1ca2740",
"configHash": "4e2c8afe",
"lockfileHash": "c76a71e0",
"browserHash": "fda5bc46",
"optimized": {},
"chunks": {}
}

View File

@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@ -59,6 +59,73 @@ a:hover {
font-size: 0.9rem; 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 */
.shell { .shell {
min-height: 100vh; min-height: 100vh;

View File

@ -12,6 +12,7 @@ import { ConfirmPage } from './pages/user/ConfirmPage';
import { ForgotPasswordPage } from './pages/user/ForgotPasswordPage'; import { ForgotPasswordPage } from './pages/user/ForgotPasswordPage';
import { HomePage } from './pages/user/HomePage'; import { HomePage } from './pages/user/HomePage';
import { LoginPage } from './pages/user/LoginPage'; import { LoginPage } from './pages/user/LoginPage';
import { OAuthCallbackPage } from './pages/user/OAuthCallbackPage';
import { ProfilePage } from './pages/user/ProfilePage'; import { ProfilePage } from './pages/user/ProfilePage';
import { RegisterPage } from './pages/user/RegisterPage'; import { RegisterPage } from './pages/user/RegisterPage';
import { SecurityPage } from './pages/user/SecurityPage'; import { SecurityPage } from './pages/user/SecurityPage';
@ -29,7 +30,15 @@ function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route
path="/auth/callback/login"
element={<OAuthCallbackPage mode="login" />}
/>
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route
path="/auth/callback/register"
element={<OAuthCallbackPage mode="register" />}
/>
<Route path="/register/confirm" element={<ConfirmPage />} /> <Route path="/register/confirm" element={<ConfirmPage />} />
<Route path="/password/forgot" element={<ForgotPasswordPage />} /> <Route path="/password/forgot" element={<ForgotPasswordPage />} />

View File

@ -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<SocialStartData>('/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<LoginData>(
`/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<SocialStartData>('/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<AuthTokenData>(
`/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() { export async function refreshToken() {
const refresh = localStorage.getItem('refresh_token'); const refresh = localStorage.getItem('refresh_token');
if (!refresh) throw new Error('無 refresh token'); if (!refresh) throw new Error('無 refresh token');

View File

@ -67,5 +67,8 @@ export async function api<T>(
const msg = json?.message ?? res.statusText ?? '請求失敗'; const msg = json?.message ?? res.statusText ?? '請求失敗';
throw new ApiError(msg, res.status, json?.code, json); 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; return (json?.data ?? json) as T;
} }

View File

@ -100,12 +100,41 @@ export function disableTOTP() {
return api('/api/v1/members/me/totp', { method: 'DELETE' }); return api('/api/v1/members/me/totp', { method: 'DELETE' });
} }
export function changePassword(currentPassword: string, newPassword: string) { export interface TOTPVerifyResult {
return api<{ ok: boolean }>('/api/v1/members/me/password', { step_up_token: string;
expires_in: number;
}
export function verifyTOTP(code: string, purpose = 'change_password') {
return api<TOTPVerifyResult>('/api/v1/members/me/totp/verify', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({ code, purpose }),
current_password: currentPassword, });
new_password: newPassword, }
}),
/** 從 verify 回應取出 step-up token相容舊版 envelope 誤解析) */
export function pickStepUpToken(
res: TOTPVerifyResult | Record<string, unknown>,
): 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<string, string> = {
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),
}); });
} }

View File

@ -3,3 +3,8 @@ export const DEFAULT_TENANT = 'k6-tenant';
export const DEFAULT_INVITE = 'K6INVITE'; export const DEFAULT_INVITE = 'K6INVITE';
/** 與 Gateway Member.OTP.ResendCooldownSeconds 預設一致 */ /** 與 Gateway Member.OTP.ResendCooldownSeconds 預設一致 */
export const OTP_RESEND_COOLDOWN_SECONDS = 60; 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}`;
}

View File

@ -2,7 +2,7 @@ import { useEffect, useState, type FormEvent } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import * as authApi from '../../api/auth'; import * as authApi from '../../api/auth';
import { ApiError } from '../../api/http'; import { ApiError } from '../../api/http';
import { DEFAULT_TENANT } from '../../config'; import { DEFAULT_TENANT, oauthRedirectUri } from '../../config';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import * as permApi from '../../api/permission'; import * as permApi from '../../api/permission';
@ -22,13 +22,44 @@ export function LoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { 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) { if (state?.message) {
setInfo(state.message); setInfo(state.message);
navigate(location.pathname, { replace: true, state: null }); navigate(location.pathname, { replace: true, state: null });
} }
}, [location.pathname, location.state, navigate]); }, [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 () => { const finishLogin = async () => {
syncSession(); syncSession();
await refreshRoles(); await refreshRoles();
@ -44,11 +75,13 @@ export function LoginPage() {
try { try {
localStorage.setItem('tenant_slug', tenant); localStorage.setItem('tenant_slug', tenant);
const result = await authApi.login(tenant, email, password); 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) { if (!result.mfa_challenge_id) {
throw new Error('缺少 MFA challenge'); throw new Error('缺少 MFA challenge');
} }
setMfaChallengeId(result.mfa_challenge_id); setMfaChallengeId(result.mfa_challenge_id);
setTotpCode('');
setInfo('');
return; return;
} }
await finishLogin(); await finishLogin();
@ -69,7 +102,8 @@ export function LoginPage() {
setError(''); setError('');
setLoading(true); setLoading(true);
try { try {
await authApi.loginMfaConfirm(tenant, mfaChallengeId, totpCode); const code = totpCode.replace(/\s/g, '');
await authApi.loginMfaConfirm(tenant, mfaChallengeId, code);
await finishLogin(); await finishLogin();
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : '驗證碼錯誤'); setError(err instanceof ApiError ? err.message : '驗證碼錯誤');
@ -88,7 +122,16 @@ export function LoginPage() {
return ( return (
<div className="auth-card"> <div className="auth-card">
<h1></h1> <h1></h1>
<p className="auth-hint"> App 6 </p> <p className="auth-hint">
{email ? (
<>
<strong>{email}</strong> TOTP
</>
) : (
<> TOTP</>
)}
App 6
</p>
<form onSubmit={submitMfa} className="form"> <form onSubmit={submitMfa} className="form">
<label> <label>
@ -97,8 +140,11 @@ export function LoginPage() {
onChange={(e) => setTotpCode(e.target.value)} onChange={(e) => setTotpCode(e.target.value)}
inputMode="numeric" inputMode="numeric"
autoComplete="one-time-code" autoComplete="one-time-code"
autoFocus
required required
maxLength={6} minLength={6}
maxLength={32}
placeholder="000000"
/> />
</label> </label>
{error && <p className="form-error">{error}</p>} {error && <p className="form-error">{error}</p>}
@ -150,6 +196,27 @@ export function LoginPage() {
{loading ? '登入中…' : '登入'} {loading ? '登入中…' : '登入'}
</button> </button>
</form> </form>
<div className="auth-divider">
<span></span>
</div>
<div className="auth-oauth">
<button
type="button"
className="btn-oauth"
disabled={loading}
onClick={() => startFederated('google')}
>
使 Google
</button>
<button
type="button"
className="btn-oauth btn-oauth-secondary"
disabled={loading}
onClick={() => startFederated('ldap')}
>
使 LDAP
</button>
</div>
<p className="auth-footer"> <p className="auth-footer">
<Link to="/register"></Link> <Link to="/register"></Link>
{' · '} {' · '}

View File

@ -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 (
<div className="auth-card">
<h1>{mode === 'login' ? '登入處理中' : '註冊處理中'}</h1>
{error ? (
<>
<p className="form-error">{error}</p>
<p className="auth-footer">
<Link to={mode === 'login' ? '/login' : '/register'}></Link>
</p>
</>
) : (
<p className="auth-hint"></p>
)}
</div>
);
}

View File

@ -2,7 +2,7 @@ import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import * as authApi from '../../api/auth'; import * as authApi from '../../api/auth';
import { ApiError } from '../../api/http'; import { ApiError } from '../../api/http';
import { DEFAULT_INVITE, DEFAULT_TENANT } from '../../config'; import { DEFAULT_INVITE, DEFAULT_TENANT, oauthRedirectUri } from '../../config';
export function RegisterPage() { export function RegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -16,6 +16,25 @@ export function RegisterPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); 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) => { const submit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
@ -89,6 +108,22 @@ export function RegisterPage() {
{loading ? '送出中…' : '註冊並寄送驗證碼'} {loading ? '送出中…' : '註冊並寄送驗證碼'}
</button> </button>
</form> </form>
<div className="auth-divider">
<span></span>
</div>
<div className="auth-oauth">
<button
type="button"
className="btn-oauth"
disabled={loading}
onClick={startGoogle}
>
使 Google
</button>
</div>
<p className="auth-hint auth-hint-small">
LDAP 使LDAP
</p>
<p className="auth-footer"> <p className="auth-footer">
<Link to="/login"></Link> <Link to="/login"></Link>
{' · '} {' · '}

View File

@ -20,6 +20,10 @@ export function SecurityPage() {
const [phoneCode, setPhoneCode] = useState(''); const [phoneCode, setPhoneCode] = useState('');
const [totpCode, setTotpCode] = 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 [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = 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) => { const changePassword = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setError('兩次輸入的新密碼不一致'); setError('兩次輸入的新密碼不一致');
return; return;
} }
if (totpActive && !pwdStepVerified) {
setError('請先完成 TOTP 驗證');
return;
}
setError(''); setError('');
setMsg(''); setMsg('');
try { try {
await memberApi.changePassword(currentPassword, newPassword); await memberApi.changePassword(currentPassword, newPassword, {
stepUpToken: pwdStepUpToken || undefined,
});
setPwdStepUpToken('');
setPwdStepVerified(false);
setCurrentPassword(''); setCurrentPassword('');
setNewPassword(''); setNewPassword('');
setConfirmPassword(''); 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 canChangePassword = me?.origin === 'platform_native';
const totpActive = Boolean(me?.totp_enrolled ?? totp?.enrolled);
const passwordNeedsTotp = totpActive && !pwdStepVerified;
return ( return (
<div> <div>
@ -165,7 +217,46 @@ export function SecurityPage() {
<section className="section"> <section className="section">
<h2></h2> <h2></h2>
{canChangePassword ? ( {canChangePassword ? (
<>
{!totpActive ? (
<p className="hint">
(TOTP)
</p>
) : (
<p className="hint">
</p>
)}
{!totpActive ? null : passwordNeedsTotp ? (
<form onSubmit={verifyForPasswordChange} className="form">
<label>
TOTP /
<input
value={pwdStepUpCode}
onChange={(e) => setPwdStepUpCode(e.target.value)}
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
required
minLength={6}
maxLength={32}
placeholder="000000"
disabled={pwdVerifyLoading}
/>
</label>
<button
type="submit"
className="btn-primary"
disabled={pwdVerifyLoading}
>
{pwdVerifyLoading ? '驗證中…' : '驗證並繼續'}
</button>
</form>
) : (
<form onSubmit={changePassword} className="form"> <form onSubmit={changePassword} className="form">
{totpActive && (
<p className="form-ok">TOTP </p>
)}
<label> <label>
<input <input
@ -198,10 +289,23 @@ export function SecurityPage() {
minLength={8} minLength={8}
/> />
</label> </label>
<div className="form-actions">
<button type="submit" className="btn-primary"> <button type="submit" className="btn-primary">
</button> </button>
{totpActive && (
<button
type="button"
className="btn-ghost"
onClick={resetPasswordFlow}
>
TOTP
</button>
)}
</div>
</form> </form>
)}
</>
) : ( ) : (
<p className="hint"> <p className="hint">
@ -285,6 +389,11 @@ export function SecurityPage() {
{totp?.enrolled && {totp?.enrolled &&
` · 備援碼剩餘 ${totp.backup_codes_remaining}`} ` · 備援碼剩餘 ${totp.backup_codes_remaining}`}
</p> </p>
{totp?.enrolled && (
<p className="form-ok">
</p>
)}
{!totp?.enrolled ? ( {!totp?.enrolled ? (
<> <>
<button type="button" className="btn-primary" onClick={startTotp}> <button type="button" className="btn-primary" onClick={startTotp}>

View File

@ -66,7 +66,7 @@ type (
RegisterSocialStartReq { RegisterSocialStartReq {
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
InviteCode string `json:"invite_code" validate:"required"` // 邀請碼 InviteCode string `json:"invite_code" validate:"required"` // 邀請碼
Provider string `json:"provider,options=google" validate:"required,oneof=google"` // 第三方登入提供者;可選值: google Provider string `json:"provider,options=google|ldap" validate:"required,oneof=google ldap"` // 第三方登入提供者;可選值: google / ldap
AcceptTermsVersion string `json:"accept_terms_version" validate:"required"` // 使用者接受的服務條款版本 AcceptTermsVersion string `json:"accept_terms_version" validate:"required"` // 使用者接受的服務條款版本
Language string `json:"language,optional"` // 語系代碼,如 zh-TW / en可選 Language string `json:"language,optional"` // 語系代碼,如 zh-TW / en可選
RedirectURI string `json:"redirect_uri" validate:"required,url"` // OAuth 完成後要 redirect 回的 URI RedirectURI string `json:"redirect_uri" validate:"required,url"` // OAuth 完成後要 redirect 回的 URI
@ -104,7 +104,7 @@ type (
LoginMFAConfirmReq { LoginMFAConfirmReq {
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
ChallengeID string `json:"challenge_id" validate:"required"` // 密碼登入後回傳的 MFA challenge ID ChallengeID string `json:"challenge_id" validate:"required"` // 密碼登入後回傳的 MFA challenge ID
Code string `json:"code" validate:"required,len=6"` // TOTP 或備援碼6 位數) Code string `json:"code" validate:"required,min=6,max=32"` // TOTP 6 碼或備援碼
} }
TokenRefreshReq { TokenRefreshReq {
@ -118,7 +118,7 @@ type (
LoginSocialStartReq { LoginSocialStartReq {
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
Provider string `json:"provider,options=google" validate:"required,oneof=google"` // 第三方登入提供者;可選值: google Provider string `json:"provider,options=google|ldap" validate:"required,oneof=google ldap"` // 第三方登入提供者;可選值: google / ldap
RedirectURI string `json:"redirect_uri" validate:"required,url"` // OAuth 完成後要 redirect 回的 URI RedirectURI string `json:"redirect_uri" validate:"required,url"` // OAuth 完成後要 redirect 回的 URI
} }
@ -566,7 +566,7 @@ service gateway {
@doc "Social 登入 OAuth callback" @doc "Social 登入 OAuth callback"
/* /*
@respdoc-200 (AuthTokenOKStatus) // 成功code=102000 @respdoc-200 (LoginOKStatus) // 成功code=102000若已啟用 TOTP 則僅回 MFA challenge
@respdoc-400 ( @respdoc-400 (
10101000: (APIErrorStatus) 參數格式錯誤 10101000: (APIErrorStatus) 參數格式錯誤
10104000: (APIErrorStatus) 缺少必填欄位 10104000: (APIErrorStatus) 缺少必填欄位
@ -591,7 +591,7 @@ service gateway {
) // 第三方服務錯誤 ) // 第三方服務錯誤
*/ */
@handler loginSocialCallback @handler loginSocialCallback
get /login/social/callback (LoginSocialCallbackReq) returns (AuthTokenData) get /login/social/callback (LoginSocialCallbackReq) returns (LoginData)
} }
@server( @server(

View File

@ -32,6 +32,8 @@ type (
ChangePasswordReq { ChangePasswordReq {
CurrentPassword string `json:"current_password" validate:"required,min=8,max=128"` // 目前密碼 CurrentPassword string `json:"current_password" validate:"required,min=8,max=128"` // 目前密碼
NewPassword string `json:"new_password" validate:"required,min=8,max=128"` // 新密碼 NewPassword string `json:"new_password" validate:"required,min=8,max=128"` // 新密碼
StepUpToken string `json:"step_up_token,optional"` // TOTP 已啟用:與 totp_code 二擇一
TOTPCode string `json:"totp_code,optional" validate:"omitempty,min=6,max=32"` // TOTP 已啟用:與 step_up_token 二擇一
} }
ChangePasswordData { ChangePasswordData {
@ -78,7 +80,13 @@ type (
} }
TOTPVerifyReq { TOTPVerifyReq {
Code string `json:"code"` // TOTP 6 位數碼,或 8 位數備援碼 Code string `json:"code" validate:"required,min=6,max=32"` // TOTP 6 碼或備援碼
Purpose string `json:"purpose,optional"` // 變更密碼請傳 change_password
}
TOTPVerifyData {
StepUpToken string `json:"step_up_token"`
ExpiresIn int `json:"expires_in"`
} }
TOTPBackupCodesData { TOTPBackupCodesData {
@ -174,7 +182,7 @@ service gateway {
@handler updateMemberMe @handler updateMemberMe
patch /me (UpdateMemberMeReq) returns (MemberMeData) patch /me (UpdateMemberMeReq) returns (MemberMeData)
@doc "變更登入密碼(僅 platform_native 平台帳號" @doc "變更登入密碼(僅 platform_native;須已綁定 TOTP並帶 step_up_token 或 totp_code"
/* /*
@respdoc-200 (EmptyOKStatus) // 成功code=102000 @respdoc-200 (EmptyOKStatus) // 成功code=102000
@respdoc-400 ( @respdoc-400 (
@ -185,7 +193,7 @@ service gateway {
29501000: (APIErrorStatus) 目前密碼錯誤 29501000: (APIErrorStatus) 目前密碼錯誤
) // 未授權 ) // 未授權
@respdoc-403 ( @respdoc-403 (
29505000: (APIErrorStatus) 外部身份帳號不可變更密碼Member scope 29505000: (APIErrorStatus) 外部身份帳號不可變更密碼 / TOTP 未驗證Member scope
) // 禁止存取 ) // 禁止存取
@respdoc-501 ( @respdoc-501 (
29605000: (APIErrorStatus) 功能未配置 29605000: (APIErrorStatus) 功能未配置
@ -396,7 +404,7 @@ service gateway {
) // 未實作 ) // 未實作
*/ */
@handler verifyTOTP @handler verifyTOTP
post /me/totp/verify (TOTPVerifyReq) post /me/totp/verify (TOTPVerifyReq) returns (TOTPVerifyData)
@doc "重產 TOTP 備援碼" @doc "重產 TOTP 備援碼"
/* /*

View File

@ -28,7 +28,7 @@ func VerifyTOTPHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
} }
l := member.NewVerifyTOTPLogic(actorContext(r.Context(), r), svcCtx) l := member.NewVerifyTOTPLogic(actorContext(r.Context(), r), svcCtx)
err := l.VerifyTOTP(&req) data, err := l.VerifyTOTP(&req)
response.Write(r.Context(), w, nil, err) response.Write(r.Context(), w, data, err)
} }
} }

View File

@ -139,7 +139,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Handler: member.UpdateMemberMeHandler(serverCtx), Handler: member.UpdateMemberMeHandler(serverCtx),
}, },
{ {
// 變更登入密碼(僅 platform_native 平台帳號 // 變更登入密碼(僅 platform_native;須已綁定 TOTP並帶 step_up_token 或 totp_code
Method: http.MethodPost, Method: http.MethodPost,
Path: "/me/password", Path: "/me/password",
Handler: member.ChangePasswordHandler(serverCtx), Handler: member.ChangePasswordHandler(serverCtx),

View File

@ -20,6 +20,8 @@ type Conf struct {
GoogleClientSecret string `json:",optional,env=ZITADEL_GOOGLE_CLIENT_SECRET"` GoogleClientSecret string `json:",optional,env=ZITADEL_GOOGLE_CLIENT_SECRET"`
// GoogleIdPID is the ZITADEL external IdP id for Google (optional idp_id authorize hint). // GoogleIdPID is the ZITADEL external IdP id for Google (optional idp_id authorize hint).
GoogleIdPID string `json:",optional"` GoogleIdPID string `json:",optional"`
// LdapIdPID is the ZITADEL external IdP id for LDAP (optional idp_id authorize hint).
LdapIdPID string `json:",optional"`
// JWKSUrl overrides OIDC JWKS endpoint; defaults to {Issuer}/oauth/v2/keys. // JWKSUrl overrides OIDC JWKS endpoint; defaults to {Issuer}/oauth/v2/keys.
JWKSUrl string `json:",optional"` JWKSUrl string `json:",optional"`
TimeoutSeconds int `json:",optional"` TimeoutSeconds int `json:",optional"`

View File

@ -38,9 +38,16 @@ func (c *Client) AuthorizeURL(redirectURI, state, provider string) (string, erro
q.Set("response_type", "code") q.Set("response_type", "code")
q.Set("scope", "openid profile email") q.Set("scope", "openid profile email")
q.Set("state", state) q.Set("state", state)
if provider == "google" && c.conf.GoogleIdPID != "" { switch strings.ToLower(strings.TrimSpace(provider)) {
case "google":
if c.conf.GoogleIdPID != "" {
q.Set("idp_id", c.conf.GoogleIdPID) q.Set("idp_id", c.conf.GoogleIdPID)
} }
case "ldap":
if c.conf.LdapIdPID != "" {
q.Set("idp_id", c.conf.LdapIdPID)
}
}
return c.issuer + "/oauth/v2/authorize?" + q.Encode(), nil return c.issuer + "/oauth/v2/authorize?" + q.Encode(), nil
} }

View File

@ -59,6 +59,24 @@ func TestAuthorizeURLWithoutGoogleIdP(t *testing.T) {
require.NotContains(t, raw, "idp_id=") require.NotContains(t, raw, "idp_id=")
} }
func TestAuthorizeURLForLDAP(t *testing.T) {
t.Parallel()
client, err := zitadel.NewClient(zitadel.Conf{
Issuer: testIssuerURL,
OAuthClientID: testClientID,
LdapIdPID: "ldap-idp-1",
})
require.NoError(t, err)
raw, err := client.AuthorizeURL("https://app.example.com/callback", "state-ldap", "ldap")
require.NoError(t, err)
u, err := url.Parse(raw)
require.NoError(t, err)
require.Equal(t, "ldap-idp-1", u.Query().Get("idp_id"))
}
func TestAuthorizeURLRequiresClientAndParams(t *testing.T) { func TestAuthorizeURLRequiresClientAndParams(t *testing.T) {
t.Parallel() t.Parallel()

View File

@ -0,0 +1,84 @@
package auth
import (
"context"
"strings"
"gateway/internal/library/zitadel"
authmetaenum "gateway/internal/model/auth/domain/enum"
dommember "gateway/internal/model/member/domain/usecase"
"gateway/internal/svc"
)
func federatedEmailAllowed(claims *zitadel.IDTokenClaims, provider string, trustSocial bool) error {
if claims == nil {
return errb.SvcThirdParty("empty id token claims")
}
if claims.EmailVerified || trustSocial {
return nil
}
if strings.EqualFold(strings.TrimSpace(provider), "ldap") {
return nil
}
return errb.AuthForbidden("social email is not verified")
}
// resolveMemberForFederatedLogin loads an existing member or provisions on first LDAP login.
func resolveMemberForFederatedLogin(
ctx context.Context,
sc *svc.ServiceContext,
tenantID string,
claims *zitadel.IDTokenClaims,
provider string,
) (*dommember.MemberDTO, error) {
if sc.MemberProfile == nil {
return nil, errb.SysNotImplemented("member profile not configured")
}
member, err := sc.MemberProfile.GetByZitadelUserID(ctx, tenantID, claims.Sub)
if err == nil {
if err := ensureLoginEligible(member.Status); err != nil {
return nil, err
}
return member, nil
}
if !isMemberNotFound(err) {
return nil, err
}
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "google" {
return nil, errb.ResNotFound("account not found, please register first").WithCause(err)
}
if sc.MemberProvisioning == nil {
return nil, err
}
switch provider {
case "ldap":
return sc.MemberProvisioning.EnsureFromLDAP(ctx, &dommember.EnsureFromLDAPRequest{
TenantID: tenantID,
ZitadelSub: claims.Sub,
ExternalID: claims.Sub,
Email: claims.Email,
DisplayName: claims.Name,
})
default:
return sc.MemberProvisioning.EnsureFromOIDC(ctx, &dommember.EnsureFromOIDCRequest{
TenantID: tenantID,
ZitadelSub: claims.Sub,
Email: claims.Email,
EmailVerified: claims.EmailVerified,
DisplayName: claims.Name,
Locale: claims.Locale,
})
}
}
func registrationChannelForProvider(provider string) authmetaenum.RegistrationChannel {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "ldap":
return authmetaenum.RegistrationChannelLDAP
default:
return authmetaenum.RegistrationChannelGoogle
}
}

View File

@ -24,7 +24,7 @@ func NewLoginSocialCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext
} }
} }
func (l *LoginSocialCallbackLogic) LoginSocialCallback(req *types.LoginSocialCallbackReq) (*types.AuthTokenData, error) { func (l *LoginSocialCallbackLogic) LoginSocialCallback(req *types.LoginSocialCallbackReq) (*types.LoginData, error) {
if err := requireLoginDeps(l.svcCtx); err != nil { if err := requireLoginDeps(l.svcCtx); err != nil {
return nil, err return nil, err
} }
@ -61,14 +61,24 @@ func (l *LoginSocialCallbackLogic) LoginSocialCallback(req *types.LoginSocialCal
if err != nil { if err != nil {
return nil, wrapZitadelErr(err) return nil, wrapZitadelErr(err)
} }
if !claims.EmailVerified {
return nil, errb.AuthForbidden("social email is not verified") trustSocial := l.svcCtx.Config.Member.Defaults().Registration.TrustSocialEmailVerified
if err := federatedEmailAllowed(claims, session.Provider, trustSocial); err != nil {
return nil, err
} }
member, err := memberForLogin(l.ctx, l.svcCtx, session.TenantID, claims.Sub) member, err := resolveMemberForFederatedLogin(l.ctx, l.svcCtx, session.TenantID, claims, session.Provider)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return issueAuthToken(l.ctx, l.svcCtx, session.TenantID, member.UID) if member.TOTPEnrolled {
return beginLoginMFA(l.ctx, l.svcCtx, session.TenantID, session.TenantSlug, member.UID)
}
tokens, err := issueAuthToken(l.ctx, l.svcCtx, session.TenantID, member.UID)
if err != nil {
return nil, err
}
return loginDataFromTokens(tokens), nil
} }

View File

@ -2,9 +2,9 @@ package auth
import ( import (
"context" "context"
"strings"
"gateway/internal/library/zitadel" "gateway/internal/library/zitadel"
authmetaenum "gateway/internal/model/auth/domain/enum"
domauth "gateway/internal/model/auth/domain/usecase" domauth "gateway/internal/model/auth/domain/usecase"
memberdom "gateway/internal/model/member/domain" memberdom "gateway/internal/model/member/domain"
dommember "gateway/internal/model/member/domain/usecase" dommember "gateway/internal/model/member/domain/usecase"
@ -68,8 +68,9 @@ func (l *RegisterSocialCallbackLogic) RegisterSocialCallback(req *types.Register
return nil, wrapZitadelErr(err) return nil, wrapZitadelErr(err)
} }
if !claims.EmailVerified { trustSocial := l.svcCtx.Config.Member.Defaults().Registration.TrustSocialEmailVerified
return nil, errb.AuthForbidden("social email is not verified") if err := federatedEmailAllowed(claims, session.Provider, trustSocial); err != nil {
return nil, err
} }
isExisting := false isExisting := false
@ -97,7 +98,19 @@ func (l *RegisterSocialCallbackLogic) RegisterSocialCallback(req *types.Register
inviteCodeID = consumed.ID inviteCodeID = consumed.ID
} }
memberDTO, err := l.svcCtx.MemberProvisioning.EnsureFromOIDC(l.ctx, &dommember.EnsureFromOIDCRequest{ provider := strings.ToLower(strings.TrimSpace(session.Provider))
var memberDTO *dommember.MemberDTO
switch provider {
case "ldap":
memberDTO, err = l.svcCtx.MemberProvisioning.EnsureFromLDAP(l.ctx, &dommember.EnsureFromLDAPRequest{
TenantID: session.TenantID,
ZitadelSub: claims.Sub,
ExternalID: claims.Sub,
Email: claims.Email,
DisplayName: claims.Name,
})
default:
memberDTO, err = l.svcCtx.MemberProvisioning.EnsureFromOIDC(l.ctx, &dommember.EnsureFromOIDCRequest{
TenantID: session.TenantID, TenantID: session.TenantID,
ZitadelSub: claims.Sub, ZitadelSub: claims.Sub,
Email: claims.Email, Email: claims.Email,
@ -105,12 +118,13 @@ func (l *RegisterSocialCallbackLogic) RegisterSocialCallback(req *types.Register
DisplayName: claims.Name, DisplayName: claims.Name,
Locale: firstNonEmpty(session.Language, claims.Locale), Locale: firstNonEmpty(session.Language, claims.Locale),
}) })
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !isExisting { if !isExisting {
if err := recordRegistrationMeta(l.ctx, l.svcCtx, session.TenantID, memberDTO.UID, inviteCodeID, session.AcceptTermsVersion, session.MarketingOptIn, authmetaenum.RegistrationChannelGoogle); err != nil { if err := recordRegistrationMeta(l.ctx, l.svcCtx, session.TenantID, memberDTO.UID, inviteCodeID, session.AcceptTermsVersion, session.MarketingOptIn, registrationChannelForProvider(provider)); err != nil {
return nil, err return nil, err
} }
} }

View File

@ -53,6 +53,9 @@ func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) (*typ
if err := ensurePlatformNativePassword(member); err != nil { if err := ensurePlatformNativePassword(member); err != nil {
return nil, err return nil, err
} }
if err := ensurePasswordChangeStepUp(l.ctx, l.svcCtx, actor.TenantID, actor.UID, member, req); err != nil {
return nil, err
}
if member.ZitadelUserID == "" { if member.ZitadelUserID == "" {
return nil, errb.ResInvalidState("member has no zitadel identity") return nil, errb.ResInvalidState("member has no zitadel identity")
} }

View File

@ -1,8 +1,13 @@
package member package member
import ( import (
"context"
"strings"
memberenum "gateway/internal/model/member/domain/enum" memberenum "gateway/internal/model/member/domain/enum"
domusecase "gateway/internal/model/member/domain/usecase" domusecase "gateway/internal/model/member/domain/usecase"
"gateway/internal/svc"
"gateway/internal/types"
) )
func ensurePlatformNativePassword(member *domusecase.MemberDTO) error { func ensurePlatformNativePassword(member *domusecase.MemberDTO) error {
@ -22,3 +27,69 @@ func ensurePlatformNativePassword(member *domusecase.MemberDTO) error {
return errb.AuthForbidden("account cannot change password here") return errb.AuthForbidden("account cannot change password here")
} }
} }
// totpEnrolledForActor uses TOTP status (authoritative) with profile flag as fallback.
func totpEnrolledForActor(
ctx context.Context,
sc *svc.ServiceContext,
tenantID, uid string,
member *domusecase.MemberDTO,
) (bool, error) {
if sc.MemberTOTP != nil {
status, err := sc.MemberTOTP.Status(ctx, tenantID, uid)
if err != nil {
return false, err
}
if status != nil {
return status.Enrolled, nil
}
}
if member != nil {
return member.TOTPEnrolled, nil
}
return false, nil
}
// ensurePasswordChangeStepUp requires TOTP enrollment and a consumed step-up token
// or inline TOTP code before password change.
func ensurePasswordChangeStepUp(
ctx context.Context,
sc *svc.ServiceContext,
tenantID, uid string,
member *domusecase.MemberDTO,
req *types.ChangePasswordReq,
) error {
if sc.MemberTOTP == nil {
return errb.SysNotImplemented("member TOTP not configured")
}
enrolled, err := totpEnrolledForActor(ctx, sc, tenantID, uid, member)
if err != nil {
return err
}
if !enrolled {
return errb.AuthForbidden("enroll totp before changing password")
}
token := strings.TrimSpace(req.StepUpToken)
code := strings.TrimSpace(req.TOTPCode)
if token == "" && code == "" {
return errb.AuthForbidden("totp verification required before password change")
}
if token != "" && code != "" {
return errb.InputInvalidFormat("provide either step_up_token or totp_code, not both")
}
if code != "" {
return sc.MemberTOTP.VerifyCode(ctx, tenantID, uid, code)
}
if err := requireStepUp(sc); err != nil {
return err
}
return sc.MemberStepUp.Consume(ctx, &domusecase.ConsumeStepUpRequest{
TokenID: token,
TenantID: tenantID,
UID: uid,
Purpose: memberenum.StepUpPurposeChangePassword,
})
}

View File

@ -0,0 +1,45 @@
package member
import (
"context"
"strings"
memberenum "gateway/internal/model/member/domain/enum"
domusecase "gateway/internal/model/member/domain/usecase"
"gateway/internal/svc"
)
func parseStepUpPurpose(raw string) (memberenum.StepUpPurpose, error) {
p := memberenum.StepUpPurpose(strings.TrimSpace(raw))
if p == "" {
return memberenum.StepUpPurposeChangePassword, nil
}
if !p.Valid() {
return "", errb.InputInvalidFormat("unsupported step-up purpose")
}
return p, nil
}
func requireStepUp(sc *svc.ServiceContext) error {
if sc.MemberStepUp == nil {
return errb.SysNotImplemented("member step-up not configured")
}
return nil
}
func issueStepUpAfterVerify(
ctx context.Context,
sc *svc.ServiceContext,
tenantID, uid string,
purpose memberenum.StepUpPurpose,
) (*domusecase.StepUpView, error) {
if err := requireStepUp(sc); err != nil {
return nil, err
}
return sc.MemberStepUp.Issue(ctx, &domusecase.IssueStepUpRequest{
TenantID: tenantID,
UID: uid,
Purpose: purpose,
TTL: 0,
})
}

View File

@ -20,17 +20,33 @@ func NewVerifyTOTPLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Verify
return &VerifyTOTPLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} return &VerifyTOTPLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
} }
func (l *VerifyTOTPLogic) VerifyTOTP(req *types.TOTPVerifyReq) error { func (l *VerifyTOTPLogic) VerifyTOTP(req *types.TOTPVerifyReq) (*types.TOTPVerifyData, error) {
actor, err := actorOrErr(l.ctx) actor, err := actorOrErr(l.ctx)
if err != nil { if err != nil {
return err return nil, err
} }
if err := requireTOTP(l.svcCtx); err != nil { if err := requireTOTP(l.svcCtx); err != nil {
return err return nil, err
} }
code := "" code := ""
purposeRaw := ""
if req != nil { if req != nil {
code = req.Code code = req.Code
purposeRaw = req.Purpose
} }
return l.svcCtx.MemberTOTP.VerifyCode(l.ctx, actor.TenantID, actor.UID, code) purpose, err := parseStepUpPurpose(purposeRaw)
if err != nil {
return nil, err
}
if err := l.svcCtx.MemberTOTP.VerifyCode(l.ctx, actor.TenantID, actor.UID, code); err != nil {
return nil, err
}
view, err := issueStepUpAfterVerify(l.ctx, l.svcCtx, actor.TenantID, actor.UID, purpose)
if err != nil {
return nil, err
}
return &types.TOTPVerifyData{
StepUpToken: view.StepUpToken,
ExpiresIn: view.ExpiresIn,
}, nil
} }

View File

@ -6,6 +6,7 @@ type RegistrationChannel string
const ( const (
RegistrationChannelEmail RegistrationChannel = "email" RegistrationChannelEmail RegistrationChannel = "email"
RegistrationChannelGoogle RegistrationChannel = "google" RegistrationChannelGoogle RegistrationChannel = "google"
RegistrationChannelLDAP RegistrationChannel = "ldap"
) )
func (c RegistrationChannel) String() string { func (c RegistrationChannel) String() string {
@ -14,7 +15,7 @@ func (c RegistrationChannel) String() string {
func (c RegistrationChannel) Valid() bool { func (c RegistrationChannel) Valid() bool {
switch c { switch c {
case RegistrationChannelEmail, RegistrationChannelGoogle: case RegistrationChannelEmail, RegistrationChannelGoogle, RegistrationChannelLDAP:
return true return true
default: default:
return false return false

View File

@ -37,6 +37,7 @@ type TOTPConfig struct {
BackupCodeLength int `json:",optional"` BackupCodeLength int `json:",optional"`
EnrollTTLSeconds int `json:",optional"` EnrollTTLSeconds int `json:",optional"`
ReplayTTLSeconds int `json:",optional"` ReplayTTLSeconds int `json:",optional"`
StepUpTTLSeconds int `json:",optional"`
SecretKEK string `json:",optional,env=TOTP_SECRET_KEK"` SecretKEK string `json:",optional,env=TOTP_SECRET_KEK"`
} }
@ -87,6 +88,9 @@ func (c Config) Defaults() Config {
if c.TOTP.ReplayTTLSeconds <= 0 { if c.TOTP.ReplayTTLSeconds <= 0 {
c.TOTP.ReplayTTLSeconds = 90 c.TOTP.ReplayTTLSeconds = 90
} }
if c.TOTP.StepUpTTLSeconds <= 0 {
c.TOTP.StepUpTTLSeconds = 300
}
// RequireInviteCode defaults true when unset (zero value). // RequireInviteCode defaults true when unset (zero value).
// TrustSocialEmailVerified defaults true when unset (zero value). // TrustSocialEmailVerified defaults true when unset (zero value).
return c return c

View File

@ -0,0 +1,21 @@
package enum
// StepUpPurpose scopes a short-lived step-up token to one sensitive action.
type StepUpPurpose string
const (
StepUpPurposeChangePassword StepUpPurpose = "change_password"
)
func (p StepUpPurpose) String() string {
return string(p)
}
func (p StepUpPurpose) Valid() bool {
switch p {
case StepUpPurposeChangePassword:
return true
default:
return false
}
}

View File

@ -20,6 +20,7 @@ var (
ErrTOTPAlreadyEnroll = fmt.Errorf("member: totp already enrolled") ErrTOTPAlreadyEnroll = fmt.Errorf("member: totp already enrolled")
ErrTOTPInvalidCode = fmt.Errorf("member: invalid totp code") ErrTOTPInvalidCode = fmt.Errorf("member: invalid totp code")
ErrTOTPCodeReplay = fmt.Errorf("member: totp code already used") ErrTOTPCodeReplay = fmt.Errorf("member: totp code already used")
ErrStepUpNotFound = fmt.Errorf("member: step-up session not found or expired")
ErrDuplicateMember = fmt.Errorf("member: duplicate member") ErrDuplicateMember = fmt.Errorf("member: duplicate member")
ErrDuplicateTenant = fmt.Errorf("member: duplicate tenant") ErrDuplicateTenant = fmt.Errorf("member: duplicate tenant")
ErrInvalidStatus = fmt.Errorf("member: invalid member status transition") ErrInvalidStatus = fmt.Errorf("member: invalid member status transition")

View File

@ -15,6 +15,7 @@ const (
VerifyDailyRedisKey RedisKey = "member:verify:daily" VerifyDailyRedisKey RedisKey = "member:verify:daily"
TOTPEnrollRedisKey RedisKey = "member:totp:enroll" TOTPEnrollRedisKey RedisKey = "member:totp:enroll"
TOTPUsedRedisKey RedisKey = "member:totp:used" TOTPUsedRedisKey RedisKey = "member:totp:used"
StepUpRedisKey RedisKey = "member:stepup"
MemberSeqRedisKey RedisKey = "member:seq" MemberSeqRedisKey RedisKey = "member:seq"
) )
@ -65,3 +66,8 @@ func GetTOTPUsedRedisKey(tenantID, uid, timestep string) string {
func GetMemberSeqRedisKey(tenantID string) string { func GetMemberSeqRedisKey(tenantID string) string {
return MemberSeqRedisKey.With(tenantID).String() return MemberSeqRedisKey.With(tenantID).String()
} }
// GetStepUpRedisKey returns the step-up session key for a issued token.
func GetStepUpRedisKey(tokenID string) string {
return StepUpRedisKey.With(tokenID).String()
}

View File

@ -0,0 +1,28 @@
package repository
import (
"context"
"time"
memberdomain "gateway/internal/model/member/domain"
)
// StepUpSession binds a verified step-up token to tenant/member/purpose.
type StepUpSession struct {
TokenID string
TenantID string
UID string
Purpose string
}
// StepUpStore persists short-lived step-up sessions after TOTP verify.
type StepUpStore interface {
Save(ctx context.Context, session *StepUpSession, ttl time.Duration) error
Get(ctx context.Context, tokenID string) (*StepUpSession, error)
Delete(ctx context.Context, tokenID string) error
}
// StepUpRedisKey re-exports the Redis key helper for tests.
func StepUpRedisKey(tokenID string) string {
return memberdomain.GetStepUpRedisKey(tokenID)
}

View File

@ -0,0 +1,36 @@
package usecase
import (
"context"
"time"
"gateway/internal/model/member/domain/enum"
)
// IssueStepUpRequest creates a step-up token after TOTP verification.
type IssueStepUpRequest struct {
TenantID string
UID string
Purpose enum.StepUpPurpose
TTL time.Duration
}
// StepUpView is returned to the client after successful step-up verify.
type StepUpView struct {
StepUpToken string
ExpiresIn int
}
// ConsumeStepUpRequest validates and burns a one-time step-up token.
type ConsumeStepUpRequest struct {
TokenID string
TenantID string
UID string
Purpose enum.StepUpPurpose
}
// StepUpUseCase issues and consumes short-lived step-up sessions.
type StepUpUseCase interface {
Issue(ctx context.Context, req *IssueStepUpRequest) (*StepUpView, error)
Consume(ctx context.Context, req *ConsumeStepUpRequest) error
}

View File

@ -0,0 +1,64 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
redislib "gateway/internal/library/redis"
memberdomain "gateway/internal/model/member/domain"
domrepo "gateway/internal/model/member/domain/repository"
"github.com/zeromicro/go-zero/core/stores/redis"
)
type redisStepUpStore struct {
client *redis.Redis
}
// NewRedisStepUpStore creates a Redis-backed step-up session store.
func NewRedisStepUpStore(client *redislib.Client) domrepo.StepUpStore {
if client == nil || client.Zero() == nil {
panic("member: redis client is required for step-up store")
}
return &redisStepUpStore{client: client.Zero()}
}
func (s *redisStepUpStore) Save(ctx context.Context, session *domrepo.StepUpSession, ttl time.Duration) error {
if session == nil || session.TokenID == "" {
return fmt.Errorf("member: step-up token id is required")
}
raw, err := json.Marshal(session)
if err != nil {
return fmt.Errorf("member: marshal step-up session: %w", err)
}
seconds := int(ttl.Seconds())
if seconds < 1 {
seconds = 1
}
return s.client.SetexCtx(ctx, memberdomain.GetStepUpRedisKey(session.TokenID), string(raw), seconds)
}
func (s *redisStepUpStore) Get(ctx context.Context, tokenID string) (*domrepo.StepUpSession, error) {
val, err := s.client.GetCtx(ctx, memberdomain.GetStepUpRedisKey(tokenID))
if errors.Is(err, redis.Nil) {
return nil, memberdomain.ErrStepUpNotFound
}
if err != nil {
return nil, err
}
var session domrepo.StepUpSession
if err := json.Unmarshal([]byte(val), &session); err != nil {
return nil, fmt.Errorf("member: unmarshal step-up session: %w", err)
}
return &session, nil
}
func (s *redisStepUpStore) Delete(ctx context.Context, tokenID string) error {
_, err := s.client.DelCtx(ctx, memberdomain.GetStepUpRedisKey(tokenID))
return err
}
var _ domrepo.StepUpStore = (*redisStepUpStore)(nil)

View File

@ -19,6 +19,7 @@ import (
type Module struct { type Module struct {
OTP domusecase.OTPUseCase OTP domusecase.OTPUseCase
TOTP domusecase.TOTPUseCase TOTP domusecase.TOTPUseCase
StepUp domusecase.StepUpUseCase
Profile domusecase.ProfileUseCase Profile domusecase.ProfileUseCase
Lifecycle domusecase.LifecycleUseCase Lifecycle domusecase.LifecycleUseCase
Provisioning domusecase.ProvisioningUseCase Provisioning domusecase.ProvisioningUseCase
@ -52,6 +53,7 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
cfg := param.Config.Defaults() cfg := param.Config.Defaults()
otpStore := repository.NewRedisOTPChallengeStore(param.Redis) otpStore := repository.NewRedisOTPChallengeStore(param.Redis)
rateStore := repository.NewRedisVerifyRateStore(param.Redis) rateStore := repository.NewRedisVerifyRateStore(param.Redis)
stepUpStore := repository.NewRedisStepUpStore(param.Redis)
members := param.Members members := param.Members
tenants := param.Tenants tenants := param.Tenants
@ -83,6 +85,7 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
mod := &Module{ mod := &Module{
OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}), OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}),
VerifyRate: MustVerifyRateUseCase(VerifyRateUseCaseParam{Store: rateStore}), VerifyRate: MustVerifyRateUseCase(VerifyRateUseCaseParam{Store: rateStore}),
StepUp: MustStepUpUseCase(StepUpUseCaseParam{Store: stepUpStore, Config: cfg}),
Members: members, Members: members,
Tenants: tenants, Tenants: tenants,
Identities: identities, Identities: identities,

View File

@ -0,0 +1,84 @@
package usecase
import (
"context"
"errors"
"time"
memberdomain "gateway/internal/model/member/domain"
domrepo "gateway/internal/model/member/domain/repository"
domusecase "gateway/internal/model/member/domain/usecase"
memberconfig "gateway/internal/model/member/config"
"github.com/google/uuid"
)
type stepUpUseCase struct {
store domrepo.StepUpStore
config memberconfig.Config
}
// StepUpUseCaseParam wires StepUpUseCase.
type StepUpUseCaseParam struct {
Store domrepo.StepUpStore
Config memberconfig.Config
}
// MustStepUpUseCase constructs StepUpUseCase.
func MustStepUpUseCase(param StepUpUseCaseParam) domusecase.StepUpUseCase {
if param.Store == nil {
panic("member: step-up store is required")
}
return &stepUpUseCase{store: param.Store, config: param.Config.Defaults()}
}
func (uc *stepUpUseCase) Issue(ctx context.Context, req *domusecase.IssueStepUpRequest) (*domusecase.StepUpView, error) {
if req == nil || req.TenantID == "" || req.UID == "" {
return nil, errb.InputMissingRequired("tenant_id, uid and purpose are required")
}
if !req.Purpose.Valid() {
return nil, errb.InputInvalidFormat("unsupported step-up purpose")
}
ttl := req.TTL
if ttl <= 0 {
ttl = time.Duration(uc.config.TOTP.StepUpTTLSeconds) * time.Second
}
tokenID := uuid.NewString()
if err := uc.store.Save(ctx, &domrepo.StepUpSession{
TokenID: tokenID,
TenantID: req.TenantID,
UID: req.UID,
Purpose: req.Purpose.String(),
}, ttl); err != nil {
return nil, wrapRepoErr(err, "save step-up session failed")
}
return &domusecase.StepUpView{
StepUpToken: tokenID,
ExpiresIn: int(ttl.Seconds()),
}, nil
}
func (uc *stepUpUseCase) Consume(ctx context.Context, req *domusecase.ConsumeStepUpRequest) error {
if req == nil || req.TokenID == "" || req.TenantID == "" || req.UID == "" {
return errb.InputMissingRequired("step_up_token, tenant_id and uid are required")
}
if !req.Purpose.Valid() {
return errb.InputInvalidFormat("unsupported step-up purpose")
}
session, err := uc.store.Get(ctx, req.TokenID)
if err != nil {
if errors.Is(err, memberdomain.ErrStepUpNotFound) {
return errb.ResNotFound("step-up session", req.TokenID).WithCause(err)
}
return wrapRepoErr(err, "read step-up session failed")
}
if session.TenantID != req.TenantID || session.UID != req.UID || session.Purpose != req.Purpose.String() {
return errb.AuthForbidden("step-up token mismatch")
}
if err := uc.store.Delete(ctx, req.TokenID); err != nil {
return wrapRepoErr(err, "delete step-up session failed")
}
return nil
}
var _ domusecase.StepUpUseCase = (*stepUpUseCase)(nil)

View File

@ -46,6 +46,7 @@ type ServiceContext struct {
MemberOTP dommember.OTPUseCase MemberOTP dommember.OTPUseCase
MemberTOTP dommember.TOTPUseCase MemberTOTP dommember.TOTPUseCase
MemberStepUp dommember.StepUpUseCase
MemberProfile dommember.ProfileUseCase MemberProfile dommember.ProfileUseCase
MemberLifecycle dommember.LifecycleUseCase MemberLifecycle dommember.LifecycleUseCase
MemberProvisioning dommember.ProvisioningUseCase MemberProvisioning dommember.ProvisioningUseCase
@ -143,6 +144,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
} }
sc.MemberOTP = memberMod.OTP sc.MemberOTP = memberMod.OTP
sc.MemberTOTP = memberMod.TOTP sc.MemberTOTP = memberMod.TOTP
sc.MemberStepUp = memberMod.StepUp
sc.MemberProfile = memberMod.Profile sc.MemberProfile = memberMod.Profile
sc.MemberLifecycle = memberMod.Lifecycle sc.MemberLifecycle = memberMod.Lifecycle
sc.MemberProvisioning = memberMod.Provisioning sc.MemberProvisioning = memberMod.Provisioning

View File

@ -41,6 +41,8 @@ type ChangePasswordData struct {
type ChangePasswordReq struct { type ChangePasswordReq struct {
CurrentPassword string `json:"current_password" validate:"required,min=8,max=128"` // 目前密碼 CurrentPassword string `json:"current_password" validate:"required,min=8,max=128"` // 目前密碼
NewPassword string `json:"new_password" validate:"required,min=8,max=128"` // 新密碼 NewPassword string `json:"new_password" validate:"required,min=8,max=128"` // 新密碼
StepUpToken string `json:"step_up_token,optional"` // TOTP 已啟用:與 totp_code 二擇一
TOTPCode string `json:"totp_code,optional" validate:"omitempty,min=6,max=32"` // TOTP 已啟用:與 step_up_token 二擇一
} }
type CreateRoleReq struct { type CreateRoleReq struct {
@ -92,7 +94,7 @@ type LoginData struct {
type LoginMFAConfirmReq struct { type LoginMFAConfirmReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
ChallengeID string `json:"challenge_id" validate:"required"` // 密碼登入後回傳的 MFA challenge ID ChallengeID string `json:"challenge_id" validate:"required"` // 密碼登入後回傳的 MFA challenge ID
Code string `json:"code" validate:"required,len=6"` // TOTP 或備援碼6 位數) Code string `json:"code" validate:"required,min=6,max=32"` // TOTP 6 碼或備援碼
} }
type LoginOKStatus struct { type LoginOKStatus struct {
@ -126,7 +128,7 @@ type LoginSocialStartOKStatus struct {
type LoginSocialStartReq struct { type LoginSocialStartReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
Provider string `json:"provider,options=google" validate:"required,oneof=google"` // 第三方登入提供者;可選值: google Provider string `json:"provider,options=google|ldap" validate:"required,oneof=google ldap"` // 第三方登入提供者;可選值: google / ldap
RedirectURI string `json:"redirect_uri" validate:"required,url"` // OAuth 完成後要 redirect 回的 URI RedirectURI string `json:"redirect_uri" validate:"required,url"` // OAuth 完成後要 redirect 回的 URI
} }
@ -329,7 +331,7 @@ type RegisterSocialStartOKStatus struct {
type RegisterSocialStartReq struct { type RegisterSocialStartReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
InviteCode string `json:"invite_code" validate:"required"` // 邀請碼 InviteCode string `json:"invite_code" validate:"required"` // 邀請碼
Provider string `json:"provider,options=google" validate:"required,oneof=google"` // 第三方登入提供者;可選值: google Provider string `json:"provider,options=google|ldap" validate:"required,oneof=google ldap"` // 第三方登入提供者;可選值: google / ldap
AcceptTermsVersion string `json:"accept_terms_version" validate:"required"` // 使用者接受的服務條款版本 AcceptTermsVersion string `json:"accept_terms_version" validate:"required"` // 使用者接受的服務條款版本
Language string `json:"language,optional"` // 語系代碼,如 zh-TW / en可選 Language string `json:"language,optional"` // 語系代碼,如 zh-TW / en可選
RedirectURI string `json:"redirect_uri" validate:"required,url"` // OAuth 完成後要 redirect 回的 URI RedirectURI string `json:"redirect_uri" validate:"required,url"` // OAuth 完成後要 redirect 回的 URI
@ -477,8 +479,14 @@ type TOTPStatusOKStatus struct {
Data TOTPStatusData `json:"data"` Data TOTPStatusData `json:"data"`
} }
type TOTPVerifyData struct {
StepUpToken string `json:"step_up_token"`
ExpiresIn int `json:"expires_in"`
}
type TOTPVerifyReq struct { type TOTPVerifyReq struct {
Code string `json:"code"` // TOTP 6 位數碼,或 8 位數備援碼 Code string `json:"code" validate:"required,min=6,max=32"` // TOTP 6 碼或備援碼
Purpose string `json:"purpose,optional"` // 變更密碼請傳 change_password
} }
type TokenExchangeReq struct { type TokenExchangeReq struct {

View File

@ -1,14 +1,12 @@
// Journey: change password while logged in → login with new password // Journey: enroll TOTP → step-up → change password → login with new password
//
// Endpoints:
// POST /api/v1/auth/register/confirm path (via registerAndConfirm)
// POST /api/v1/members/me/password
// POST /api/v1/auth/login
import { post, checkError } from '../lib/http.js'; import { post, checkError } from '../lib/http.js';
import { cfg } from '../lib/config.js'; import { cfg } from '../lib/config.js';
import { registerAndConfirm, loginStep } from '../lib/auth.js'; import { registerAndConfirm, login } from '../lib/auth.js';
import { changePassword } from '../lib/member.js'; import {
changePassword,
enrollTOTP,
verifyTOTPForPasswordChange,
} from '../lib/member.js';
export const options = { export const options = {
vus: 1, vus: 1,
iterations: 1, iterations: 1,
@ -20,16 +18,22 @@ export default function () {
const bearer = { Authorization: `Bearer ${tokens.access_token}` }; const bearer = { Authorization: `Bearer ${tokens.access_token}` };
const newPassword = 'K6-ChangePass-8!'; const newPassword = 'K6-ChangePass-8!';
const data = changePassword(identity.password, newPassword, bearer); const { otpauthUrl } = enrollTOTP(bearer);
const stepUpToken = verifyTOTPForPasswordChange(bearer, otpauthUrl);
const data = changePassword(identity.password, newPassword, bearer, {
stepUpToken,
});
if (!data.ok) { if (!data.ok) {
throw new Error('change password journey: expected ok=true'); throw new Error('change password journey: expected ok=true');
} }
const login = loginStep({ const session = login({
email: identity.email, email: identity.email,
password: newPassword, password: newPassword,
otpauthUrl,
}); });
if (!login.access_token) { if (!session.access_token) {
throw new Error('change password journey: login with new password failed'); throw new Error('change password journey: login with new password failed');
} }

View File

@ -0,0 +1,31 @@
// Journey: TOTP enrolled → change password without step-up must fail (403)
import { post, checkError } from '../lib/http.js';
import { registerAndConfirm } from '../lib/auth.js';
import { enrollTOTP } from '../lib/member.js';
export const options = {
vus: 1,
iterations: 1,
thresholds: { checks: ['rate==1.0'] },
};
export default function () {
const { identity, tokens } = registerAndConfirm();
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
enrollTOTP(bearer);
checkError(
post(
'/api/v1/members/me/password',
{
current_password: identity.password,
new_password: 'K6-NewPass-9!',
},
bearer,
),
'POST /me/password without step-up (totp enrolled)',
403,
29505000,
);
}

View File

@ -1,7 +1,23 @@
// Member flow helpers — TOTP enroll / change password. // Member flow helpers — TOTP enroll / step-up / change password.
import { post, checkEnvelope } from './http.js'; import { post, checkEnvelope } from './http.js';
import { generateTOTP } from './totp.js'; import { generateTOTP } from './totp.js';
export function verifyTOTPForPasswordChange(bearer, otpauthUrl, totpCode) {
const code = totpCode || generateTOTP(otpauthUrl);
const data = checkEnvelope(
post(
'/api/v1/members/me/totp/verify',
{ code, purpose: 'change_password' },
bearer,
),
'POST /me/totp/verify (change_password)',
).data;
if (!data.step_up_token) {
throw new Error('verify: missing step_up_token');
}
return data.step_up_token;
}
export function enrollTOTP(bearer) { export function enrollTOTP(bearer) {
const enroll = checkEnvelope( const enroll = checkEnvelope(
post('/api/v1/members/me/totp/enroll-start', null, bearer), post('/api/v1/members/me/totp/enroll-start', null, bearer),
@ -18,11 +34,13 @@ export function enrollTOTP(bearer) {
return { otpauthUrl: enroll.otpauth_url }; return { otpauthUrl: enroll.otpauth_url };
} }
export function changePassword(currentPassword, newPassword, bearer) { export function changePassword(currentPassword, newPassword, bearer, opts = {}) {
const res = post( const body = {
'/api/v1/members/me/password', current_password: currentPassword,
{ current_password: currentPassword, new_password: newPassword }, new_password: newPassword,
bearer, };
); if (opts.stepUpToken) body.step_up_token = opts.stepUpToken;
if (opts.totpCode) body.totp_code = opts.totpCode;
const res = post('/api/v1/members/me/password', body, bearer);
return checkEnvelope(res, 'POST /me/password').data; return checkEnvelope(res, 'POST /me/password').data;
} }

View File

@ -107,16 +107,16 @@ export default function () {
throw new Error(`DELETE /me/totp unexpected status ${delRes.status}: ${delRes.body}`); throw new Error(`DELETE /me/totp unexpected status ${delRes.status}: ${delRes.body}`);
} }
// 13. POST /me/password (negative — wrong current password) // 13. POST /me/password (negative — totp not enrolled)
checkError( checkError(
post( post(
'/api/v1/members/me/password', '/api/v1/members/me/password',
{ current_password: 'WrongPass-1!', new_password: 'K6-NewPass-2!' }, { current_password: identity.password, new_password: 'K6-NewPass-2!' },
bearer, bearer,
), ),
'POST /me/password (wrong current)', 'POST /me/password (totp not enrolled)',
401, 403,
29501000, 29505000,
); );
// 14. POST /me/password (negative — no bearer) // 14. POST /me/password (negative — no bearer)
@ -130,13 +130,4 @@ export default function () {
29501000, 29501000,
); );
// 15. POST /me/password (negative — weak new password)
const weakPwd = post(
'/api/v1/members/me/password',
{ current_password: identity.password, new_password: 'short' },
bearer,
);
if (weakPwd.status !== 400) {
throw new Error(`POST /me/password weak password: expected 400 got ${weakPwd.status}`);
}
} }