add fix eage case
This commit is contained in:
parent
93e94e8c5d
commit
9dd8287777
37
Makefile
37
Makefile
|
|
@ -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 30–90s the first time)…"
|
@echo "OpenLDAP + ZITADEL bootstrapping (ZITADEL 首次約 30–90s)…"
|
||||||
@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.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)…"
|
@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 \
|
||||||
|
|
|
||||||
|
|
@ -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 # 再加上 mailhog(profile smtp)
|
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 # 停止並移除容器(保留 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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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: "<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`。
|
||||||
|
|
@ -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!
|
||||||
|
|
@ -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 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: "<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 UI:http://localhost:8080/ui/console
|
- Console UI:http://localhost:8080/ui/console
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -117,5 +117,6 @@ Zitadel:
|
||||||
GoogleClientID: ""
|
GoogleClientID: ""
|
||||||
GoogleClientSecret: ""
|
GoogleClientSecret: ""
|
||||||
GoogleIdPID: ""
|
GoogleIdPID: ""
|
||||||
|
LdapIdPID: ""
|
||||||
JWKSUrl: ""
|
JWKSUrl: ""
|
||||||
TimeoutSeconds: 15
|
TimeoutSeconds: 15
|
||||||
|
|
|
||||||
|
|
@ -112,5 +112,6 @@ Zitadel:
|
||||||
GoogleClientID: ""
|
GoogleClientID: ""
|
||||||
GoogleClientSecret: ""
|
GoogleClientSecret: ""
|
||||||
GoogleIdPID: ""
|
GoogleIdPID: ""
|
||||||
|
LdapIdPID: ""
|
||||||
JWKSUrl: ""
|
JWKSUrl: ""
|
||||||
TimeoutSeconds: 15
|
TimeoutSeconds: 15
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"hash": "a1ca2740",
|
||||||
|
"configHash": "4e2c8afe",
|
||||||
|
"lockfileHash": "c76a71e0",
|
||||||
|
"browserHash": "fda5bc46",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
{' · '}
|
{' · '}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
{' · '}
|
{' · '}
|
||||||
|
|
|
||||||
|
|
@ -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,43 +217,95 @@ export function SecurityPage() {
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<h2>登入密碼</h2>
|
<h2>登入密碼</h2>
|
||||||
{canChangePassword ? (
|
{canChangePassword ? (
|
||||||
<form onSubmit={changePassword} className="form">
|
<>
|
||||||
<label>
|
{!totpActive ? (
|
||||||
目前密碼
|
<p className="hint">
|
||||||
<input
|
變更登入密碼前須先完成下方「雙因素驗證 (TOTP)」綁定。
|
||||||
type="password"
|
</p>
|
||||||
value={currentPassword}
|
) : (
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
<p className="hint">
|
||||||
autoComplete="current-password"
|
已啟用雙因素驗證:變更密碼前須先輸入驗證器或備援碼。
|
||||||
required
|
</p>
|
||||||
/>
|
)}
|
||||||
</label>
|
{!totpActive ? null : passwordNeedsTotp ? (
|
||||||
<label>
|
<form onSubmit={verifyForPasswordChange} className="form">
|
||||||
新密碼
|
<label>
|
||||||
<input
|
驗證碼(TOTP / 備援碼)
|
||||||
type="password"
|
<input
|
||||||
value={newPassword}
|
value={pwdStepUpCode}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setPwdStepUpCode(e.target.value)}
|
||||||
autoComplete="new-password"
|
inputMode="numeric"
|
||||||
required
|
autoComplete="one-time-code"
|
||||||
minLength={8}
|
autoFocus
|
||||||
/>
|
required
|
||||||
</label>
|
minLength={6}
|
||||||
<label>
|
maxLength={32}
|
||||||
確認新密碼
|
placeholder="000000"
|
||||||
<input
|
disabled={pwdVerifyLoading}
|
||||||
type="password"
|
/>
|
||||||
value={confirmPassword}
|
</label>
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
<button
|
||||||
autoComplete="new-password"
|
type="submit"
|
||||||
required
|
className="btn-primary"
|
||||||
minLength={8}
|
disabled={pwdVerifyLoading}
|
||||||
/>
|
>
|
||||||
</label>
|
{pwdVerifyLoading ? '驗證中…' : '驗證並繼續'}
|
||||||
<button type="submit" className="btn-primary">
|
</button>
|
||||||
更新密碼
|
</form>
|
||||||
</button>
|
) : (
|
||||||
</form>
|
<form onSubmit={changePassword} className="form">
|
||||||
|
{totpActive && (
|
||||||
|
<p className="form-ok">TOTP 已驗證,請輸入新密碼。</p>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
目前密碼
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
新密碼
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
確認新密碼
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
更新密碼
|
||||||
|
</button>
|
||||||
|
{totpActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost"
|
||||||
|
onClick={resetPasswordFlow}
|
||||||
|
>
|
||||||
|
重新驗證 TOTP
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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}>
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 備援碼"
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,15 @@ 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)) {
|
||||||
q.Set("idp_id", c.conf.GoogleIdPID)
|
case "google":
|
||||||
|
if 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,20 +98,33 @@ 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))
|
||||||
TenantID: session.TenantID,
|
var memberDTO *dommember.MemberDTO
|
||||||
ZitadelSub: claims.Sub,
|
switch provider {
|
||||||
Email: claims.Email,
|
case "ldap":
|
||||||
EmailVerified: claims.EmailVerified,
|
memberDTO, err = l.svcCtx.MemberProvisioning.EnsureFromLDAP(l.ctx, &dommember.EnsureFromLDAPRequest{
|
||||||
DisplayName: claims.Name,
|
TenantID: session.TenantID,
|
||||||
Locale: firstNonEmpty(session.Language, claims.Locale),
|
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,
|
||||||
|
ZitadelSub: claims.Sub,
|
||||||
|
Email: claims.Email,
|
||||||
|
EmailVerified: claims.EmailVerified,
|
||||||
|
DisplayName: claims.Name,
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,10 @@ 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 {
|
||||||
|
|
@ -90,9 +92,9 @@ 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 {
|
||||||
|
|
@ -125,9 +127,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogoutData struct {
|
type LogoutData struct {
|
||||||
|
|
@ -327,13 +329,13 @@ 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
|
||||||
MarketingOptIn bool `json:"marketing_opt_in,optional"` // 是否同意接收行銷訊息
|
MarketingOptIn bool `json:"marketing_opt_in,optional"` // 是否同意接收行銷訊息
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReplaceRolePermissionsByIDReq struct {
|
type ReplaceRolePermissionsByIDReq struct {
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue