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)
|
||||
@echo "k6: $$($(K6) version 2>&1 | head -1)"
|
||||
|
||||
k6-up: ## 起 k6 全棧(mongo + redis + mailhog + postgres + zitadel)
|
||||
$(COMPOSE) --profile k6 up -d mongo redis mailhog postgres zitadel
|
||||
@echo "ZITADEL bootstrapping (this can take 30–90s the first time)…"
|
||||
@echo "→ run 'make k6-wait' to block until it is ready"
|
||||
k6-up: ## 起 k6 全棧(mongo + redis + mailhog + postgres + openldap + zitadel)
|
||||
$(COMPOSE) --profile k6 up -d mongo redis mailhog postgres openldap zitadel
|
||||
@echo "OpenLDAP + ZITADEL bootstrapping (ZITADEL 首次約 30–90s)…"
|
||||
@echo "→ run 'make k6-wait' to block until ready"
|
||||
@echo "→ LDAP 測試帳號見 deploy/openldap/README.md(alice / Password1!)"
|
||||
|
||||
k6-wait: ## 等 ZITADEL ready + 把 PAT 寫到 deploy/zitadel/machinekey/k6.env
|
||||
k6-wait-ldap: ## 等 OpenLDAP ready 並 seed alice/bob
|
||||
@echo "waiting for OpenLDAP (gateway-openldap)…"
|
||||
@for i in $$(seq 1 90); do \
|
||||
if docker exec gateway-openldap ldapsearch -x -H ldap://localhost \
|
||||
-b "dc=gateway,dc=local" \
|
||||
-D "cn=admin,dc=gateway,dc=local" -w admin -LLL -s base "(objectClass=*)" dn 2>/dev/null | grep -q 'dc=gateway'; then \
|
||||
echo "openldap ready ($$i s)"; \
|
||||
$(MAKE) -s ldap-seed; exit 0; \
|
||||
fi; \
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "openldap did not become ready in 90s — check: docker logs gateway-openldap"; exit 1
|
||||
|
||||
ldap-seed: ## 將 deploy/openldap/bootstrap 測試帳號寫入目錄(可重複執行)
|
||||
@docker cp deploy/openldap/bootstrap/10-people.ldif gateway-openldap:/tmp/10-people.ldif
|
||||
@docker exec gateway-openldap ldapadd -x -H ldap://localhost \
|
||||
-D "cn=admin,dc=gateway,dc=local" -w admin -c -f /tmp/10-people.ldif 2>&1 \
|
||||
| grep -Ev '^(ldap_add: Already exists|adding new entry)' || true
|
||||
@echo "ldap seed done (alice / bob @ ou=people,dc=gateway,dc=local)"
|
||||
|
||||
ldap-test: ## 列出 LDAP 測試使用者 alice / bob
|
||||
@docker exec gateway-openldap ldapsearch -x -H ldap://localhost \
|
||||
-b "ou=people,dc=gateway,dc=local" \
|
||||
-D "cn=admin,dc=gateway,dc=local" -w admin \
|
||||
"(|(uid=alice)(uid=bob))" uid mail cn
|
||||
|
||||
k6-wait: k6-wait-ldap ## 等 OpenLDAP + ZITADEL ready + 把 PAT 寫到 k6.env
|
||||
@echo "waiting for ZITADEL at $(ZITADEL_HEALTH_URL)…"
|
||||
@for i in $$(seq 1 120); do \
|
||||
if curl -fsS $(ZITADEL_HEALTH_URL) >/dev/null 2>&1; then \
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ Gateway 啟用 **Notification** / **Member OTP** 需要:
|
|||
| **MongoDB** | `notifications`、`notification_dlq` collections | 27017 |
|
||||
| **Redis** | 冪等、配額、異步重試佇列、member OTP challenge | 6379 |
|
||||
| MailHog(選用) | 本機 SMTP 測試 | 1025 / 8025 |
|
||||
| OpenLDAP(`make k6-up`) | ZITADEL LDAP IdP 本機目錄 | 389 |
|
||||
| ZITADEL(`make k6-up`) | OIDC / Social / LDAP 登入 | 8080 |
|
||||
|
||||
Mongo **不需要**事先手動建 collection;應用程式寫入時會自動建立。索引由 init script 或 `make mongo-index` 建立。
|
||||
|
||||
|
|
@ -40,12 +42,16 @@ make run-dev
|
|||
```bash
|
||||
make deps-up # docker compose up -d mongo redis
|
||||
make deps-up-smtp # 再加上 mailhog(profile smtp)
|
||||
make k6-up # 全棧含 OpenLDAP + ZITADEL(見 deploy/zitadel、deploy/openldap README)
|
||||
make ldap-test # 確認 LDAP 測試帳號 alice/bob
|
||||
make deps-down # 停止並移除容器(保留 volume)
|
||||
make deps-down-v # 停止並刪除 volume(會清掉 Mongo 資料)
|
||||
make deps-logs # 查看 log
|
||||
make mongo-index # 手動建立/補齊索引
|
||||
```
|
||||
|
||||
LDAP 本機測試:[deploy/openldap/README.md](openldap/README.md)
|
||||
|
||||
## 連線設定
|
||||
|
||||
設定說明:[`etc/README.md`](../etc/README.md)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# 本機開發 / k6 測試依賴:MongoDB、Redis、MailHog、Postgres、ZITADEL
|
||||
# 本機開發 / k6 測試依賴:MongoDB、Redis、MailHog、Postgres、OpenLDAP、ZITADEL
|
||||
#
|
||||
# 啟動:
|
||||
# make deps-up → mongo + redis(最小,吃 etc/gateway.dev.yaml)
|
||||
|
|
@ -70,6 +70,47 @@ services:
|
|||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
openldap:
|
||||
profiles: ["k6"]
|
||||
image: osixia/openldap:1.5.0
|
||||
container_name: gateway-openldap
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LDAP_ORGANISATION: "GatewayDev"
|
||||
LDAP_DOMAIN: "gateway.local"
|
||||
LDAP_ADMIN_PASSWORD: "admin"
|
||||
LDAP_CONFIG_PASSWORD: "config"
|
||||
LDAP_TLS: "false"
|
||||
ports:
|
||||
- "389:389"
|
||||
volumes:
|
||||
- openldap_data:/var/lib/ldap
|
||||
- openldap_config:/etc/ldap/slapd.d
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"ldapsearch",
|
||||
"-x",
|
||||
"-H",
|
||||
"ldap://localhost",
|
||||
"-b",
|
||||
"dc=gateway,dc=local",
|
||||
"-D",
|
||||
"cn=admin,dc=gateway,dc=local",
|
||||
"-w",
|
||||
"admin",
|
||||
"-LLL",
|
||||
"-s",
|
||||
"base",
|
||||
"(objectClass=*)",
|
||||
"dn",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 24
|
||||
start_period: 20s
|
||||
|
||||
zitadel:
|
||||
profiles: ["k6"]
|
||||
image: ghcr.io/zitadel/zitadel:v2.65.0
|
||||
|
|
@ -79,6 +120,8 @@ services:
|
|||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
openldap:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
|
|
@ -98,3 +141,5 @@ volumes:
|
|||
mongo_data:
|
||||
redis_data:
|
||||
postgres_data:
|
||||
openldap_data:
|
||||
openldap_config:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
會啟動 mongo / redis / mailhog / postgres / zitadel。
|
||||
會啟動 mongo / redis / mailhog / postgres / **openldap** / zitadel。
|
||||
|
||||
ZITADEL 首次啟動會 init Postgres schema 並執行 [steps.yaml](steps.yaml) 預載:
|
||||
- Instance 名稱:`ZITADEL`
|
||||
|
|
@ -52,6 +52,73 @@ docker volume rm template-monorepo_postgres_data # 清 ZITADEL 資料
|
|||
rm deploy/zitadel/machinekey/zitadel-admin-sa.* # 清 PAT
|
||||
```
|
||||
|
||||
## Google / LDAP 聯邦登入(Social + LDAP IdP)
|
||||
|
||||
Gateway **不直接** bind LDAP;登入/註冊走 ZITADEL OIDC,並以 `idp_id` 指定外部 IdP。
|
||||
|
||||
### 1. 建立 OIDC Application(User Agent)
|
||||
|
||||
在 ZITADEL Console → Project → Applications → **User Agent**:
|
||||
|
||||
- Redirect URIs(本機前端,經 Vite proxy 打 API):
|
||||
- `http://localhost:5173/auth/callback/login`
|
||||
- `http://localhost:5173/auth/callback/register`
|
||||
- Grant types:`Authorization Code`
|
||||
- Response type:`code`
|
||||
- Scopes:`openid` `profile` `email`
|
||||
|
||||
記下 **Client ID** / **Client Secret**,寫入 Gateway:
|
||||
|
||||
```bash
|
||||
export ZITADEL_OAUTH_CLIENT_ID=...
|
||||
export ZITADEL_OAUTH_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
或 `etc/gateway.k6.yaml` 的 `Zitadel.OAuthClientID` / `OAuthClientSecret`。
|
||||
|
||||
### 2. 設定 Google IdP
|
||||
|
||||
Console → Settings → Identity Providers → **Google** → 建立後複製 **IdP ID**:
|
||||
|
||||
```yaml
|
||||
Zitadel:
|
||||
GoogleIdPID: "<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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export ZITADEL_SERVICE_TOKEN=KK234msGgYozMn56fEzHAjsf-lVm5qyoHCGR10ay1nGYUtN06RIxHtSq90Wtle9cuIVhXWg
|
||||
export ZITADEL_SERVICE_TOKEN=j2uy--q988R_b4kaB0-kCsWe-NpzpejaiV66AYT5g1pRvLSJff4Mke833P0XkTJtmWt6iLM
|
||||
export BASE_URL=http://localhost:8888
|
||||
export MAILHOG_URL=http://localhost:8025
|
||||
export REDIS_ADDR=localhost:6379
|
||||
|
|
|
|||
|
|
@ -117,5 +117,6 @@ Zitadel:
|
|||
GoogleClientID: ""
|
||||
GoogleClientSecret: ""
|
||||
GoogleIdPID: ""
|
||||
LdapIdPID: ""
|
||||
JWKSUrl: ""
|
||||
TimeoutSeconds: 15
|
||||
|
|
|
|||
|
|
@ -112,5 +112,6 @@ Zitadel:
|
|||
GoogleClientID: ""
|
||||
GoogleClientSecret: ""
|
||||
GoogleIdPID: ""
|
||||
LdapIdPID: ""
|
||||
JWKSUrl: ""
|
||||
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;
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.auth-hint-small {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.auth-divider::before,
|
||||
.auth-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.auth-oauth {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-oauth {
|
||||
width: 100%;
|
||||
padding: 0.65rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-oauth:hover:not(:disabled) {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.btn-oauth:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-oauth-secondary {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Shell */
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { ConfirmPage } from './pages/user/ConfirmPage';
|
|||
import { ForgotPasswordPage } from './pages/user/ForgotPasswordPage';
|
||||
import { HomePage } from './pages/user/HomePage';
|
||||
import { LoginPage } from './pages/user/LoginPage';
|
||||
import { OAuthCallbackPage } from './pages/user/OAuthCallbackPage';
|
||||
import { ProfilePage } from './pages/user/ProfilePage';
|
||||
import { RegisterPage } from './pages/user/RegisterPage';
|
||||
import { SecurityPage } from './pages/user/SecurityPage';
|
||||
|
|
@ -29,7 +30,15 @@ function App() {
|
|||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/auth/callback/login"
|
||||
element={<OAuthCallbackPage mode="login" />}
|
||||
/>
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route
|
||||
path="/auth/callback/register"
|
||||
element={<OAuthCallbackPage mode="register" />}
|
||||
/>
|
||||
<Route path="/register/confirm" element={<ConfirmPage />} />
|
||||
<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() {
|
||||
const refresh = localStorage.getItem('refresh_token');
|
||||
if (!refresh) throw new Error('無 refresh token');
|
||||
|
|
|
|||
|
|
@ -67,5 +67,8 @@ export async function api<T>(
|
|||
const msg = json?.message ?? res.statusText ?? '請求失敗';
|
||||
throw new ApiError(msg, res.status, json?.code, json);
|
||||
}
|
||||
if (json && (json.code === 102000 || json.code === 0) && json.data != null) {
|
||||
return json.data as T;
|
||||
}
|
||||
return (json?.data ?? json) as T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,12 +100,41 @@ export function disableTOTP() {
|
|||
return api('/api/v1/members/me/totp', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword: string, newPassword: string) {
|
||||
return api<{ ok: boolean }>('/api/v1/members/me/password', {
|
||||
export interface TOTPVerifyResult {
|
||||
step_up_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export function verifyTOTP(code: string, purpose = 'change_password') {
|
||||
return api<TOTPVerifyResult>('/api/v1/members/me/totp/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
body: JSON.stringify({ code, purpose }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 從 verify 回應取出 step-up token(相容舊版 envelope 誤解析) */
|
||||
export function pickStepUpToken(
|
||||
res: TOTPVerifyResult | Record<string, 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';
|
||||
/** 與 Gateway Member.OTP.ResendCooldownSeconds 預設一致 */
|
||||
export const OTP_RESEND_COOLDOWN_SECONDS = 60;
|
||||
|
||||
/** OAuth 完成後由前端承接,再 proxy 呼叫 Gateway callback */
|
||||
export function oauthRedirectUri(path: '/auth/callback/login' | '/auth/callback/register'): string {
|
||||
return `${window.location.origin}${path}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState, type FormEvent } from 'react';
|
|||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as authApi from '../../api/auth';
|
||||
import { ApiError } from '../../api/http';
|
||||
import { DEFAULT_TENANT } from '../../config';
|
||||
import { DEFAULT_TENANT, oauthRedirectUri } from '../../config';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import * as permApi from '../../api/permission';
|
||||
|
||||
|
|
@ -22,13 +22,44 @@ export function LoginPage() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as { message?: string } | null;
|
||||
const state = location.state as {
|
||||
message?: string;
|
||||
mfa_challenge_id?: string;
|
||||
tenant?: string;
|
||||
} | null;
|
||||
if (state?.tenant) {
|
||||
setTenant(state.tenant);
|
||||
localStorage.setItem('tenant_slug', state.tenant);
|
||||
}
|
||||
if (state?.mfa_challenge_id) {
|
||||
setMfaChallengeId(state.mfa_challenge_id);
|
||||
setInfo(state.message ?? '請輸入驗證碼以完成登入');
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
return;
|
||||
}
|
||||
if (state?.message) {
|
||||
setInfo(state.message);
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}
|
||||
}, [location.pathname, location.state, navigate]);
|
||||
|
||||
const startFederated = async (provider: authApi.FederatedProvider) => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
localStorage.setItem('tenant_slug', tenant);
|
||||
const { oauth_url } = await authApi.loginSocialStart(
|
||||
tenant,
|
||||
provider,
|
||||
oauthRedirectUri('/auth/callback/login'),
|
||||
);
|
||||
authApi.startOAuthRedirect(oauth_url);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '無法啟動登入');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const finishLogin = async () => {
|
||||
syncSession();
|
||||
await refreshRoles();
|
||||
|
|
@ -44,11 +75,13 @@ export function LoginPage() {
|
|||
try {
|
||||
localStorage.setItem('tenant_slug', tenant);
|
||||
const result = await authApi.login(tenant, email, password);
|
||||
if (result.mfa_required) {
|
||||
if (result.mfa_required || result.mfa_challenge_id) {
|
||||
if (!result.mfa_challenge_id) {
|
||||
throw new Error('缺少 MFA challenge');
|
||||
}
|
||||
setMfaChallengeId(result.mfa_challenge_id);
|
||||
setTotpCode('');
|
||||
setInfo('');
|
||||
return;
|
||||
}
|
||||
await finishLogin();
|
||||
|
|
@ -69,7 +102,8 @@ export function LoginPage() {
|
|||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await authApi.loginMfaConfirm(tenant, mfaChallengeId, totpCode);
|
||||
const code = totpCode.replace(/\s/g, '');
|
||||
await authApi.loginMfaConfirm(tenant, mfaChallengeId, code);
|
||||
await finishLogin();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '驗證碼錯誤');
|
||||
|
|
@ -88,7 +122,16 @@ export function LoginPage() {
|
|||
return (
|
||||
<div className="auth-card">
|
||||
<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">
|
||||
<label>
|
||||
驗證碼
|
||||
|
|
@ -97,8 +140,11 @@ export function LoginPage() {
|
|||
onChange={(e) => setTotpCode(e.target.value)}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
required
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
maxLength={32}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
|
|
@ -150,6 +196,27 @@ export function LoginPage() {
|
|||
{loading ? '登入中…' : '登入'}
|
||||
</button>
|
||||
</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">
|
||||
還沒有帳號? <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 * as authApi from '../../api/auth';
|
||||
import { ApiError } from '../../api/http';
|
||||
import { DEFAULT_INVITE, DEFAULT_TENANT } from '../../config';
|
||||
import { DEFAULT_INVITE, DEFAULT_TENANT, oauthRedirectUri } from '../../config';
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -16,6 +16,25 @@ export function RegisterPage() {
|
|||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const startGoogle = async () => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
localStorage.setItem('tenant_slug', tenant);
|
||||
const { oauth_url } = await authApi.registerSocialStart({
|
||||
tenant_slug: tenant,
|
||||
invite_code: invite,
|
||||
provider: 'google',
|
||||
redirect_uri: oauthRedirectUri('/auth/callback/register'),
|
||||
language: 'zh-TW',
|
||||
});
|
||||
authApi.startOAuthRedirect(oauth_url);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : '無法啟動 Google 註冊');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
|
@ -89,6 +108,22 @@ export function RegisterPage() {
|
|||
{loading ? '送出中…' : '註冊並寄送驗證碼'}
|
||||
</button>
|
||||
</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">
|
||||
已有帳號? <Link to="/login">登入</Link>
|
||||
{' · '}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ export function SecurityPage() {
|
|||
const [phoneCode, setPhoneCode] = useState('');
|
||||
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [pwdStepUpCode, setPwdStepUpCode] = useState('');
|
||||
const [pwdStepUpToken, setPwdStepUpToken] = useState('');
|
||||
const [pwdStepVerified, setPwdStepVerified] = useState(false);
|
||||
const [pwdVerifyLoading, setPwdVerifyLoading] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
|
@ -135,16 +139,52 @@ export function SecurityPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const verifyForPasswordChange = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setMsg('');
|
||||
setPwdVerifyLoading(true);
|
||||
try {
|
||||
const r = await memberApi.verifyTOTP(
|
||||
pwdStepUpCode.replace(/\s/g, ''),
|
||||
'change_password',
|
||||
);
|
||||
const token = memberApi.pickStepUpToken(r);
|
||||
if (!token) {
|
||||
setError(
|
||||
'驗證已通過但未取得授權憑證,請執行 make dev-restart-gateway 更新後端後再試',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setPwdStepUpToken(token);
|
||||
setPwdStepVerified(true);
|
||||
setPwdStepUpCode('');
|
||||
setMsg('驗證成功,請在下方輸入新密碼(約 5 分鐘內有效)');
|
||||
} catch (e) {
|
||||
showErr(e);
|
||||
} finally {
|
||||
setPwdVerifyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('兩次輸入的新密碼不一致');
|
||||
return;
|
||||
}
|
||||
if (totpActive && !pwdStepVerified) {
|
||||
setError('請先完成 TOTP 驗證');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setMsg('');
|
||||
try {
|
||||
await memberApi.changePassword(currentPassword, newPassword);
|
||||
await memberApi.changePassword(currentPassword, newPassword, {
|
||||
stepUpToken: pwdStepUpToken || undefined,
|
||||
});
|
||||
setPwdStepUpToken('');
|
||||
setPwdStepVerified(false);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
|
|
@ -154,7 +194,19 @@ export function SecurityPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const resetPasswordFlow = () => {
|
||||
setPwdStepUpToken('');
|
||||
setPwdStepVerified(false);
|
||||
setPwdStepUpCode('');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const canChangePassword = me?.origin === 'platform_native';
|
||||
const totpActive = Boolean(me?.totp_enrolled ?? totp?.enrolled);
|
||||
const passwordNeedsTotp = totpActive && !pwdStepVerified;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -165,7 +217,46 @@ export function SecurityPage() {
|
|||
<section className="section">
|
||||
<h2>登入密碼</h2>
|
||||
{canChangePassword ? (
|
||||
<>
|
||||
{!totpActive ? (
|
||||
<p className="hint">
|
||||
變更登入密碼前須先完成下方「雙因素驗證 (TOTP)」綁定。
|
||||
</p>
|
||||
) : (
|
||||
<p className="hint">
|
||||
已啟用雙因素驗證:變更密碼前須先輸入驗證器或備援碼。
|
||||
</p>
|
||||
)}
|
||||
{!totpActive ? null : passwordNeedsTotp ? (
|
||||
<form onSubmit={verifyForPasswordChange} className="form">
|
||||
<label>
|
||||
驗證碼(TOTP / 備援碼)
|
||||
<input
|
||||
value={pwdStepUpCode}
|
||||
onChange={(e) => setPwdStepUpCode(e.target.value)}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
required
|
||||
minLength={6}
|
||||
maxLength={32}
|
||||
placeholder="000000"
|
||||
disabled={pwdVerifyLoading}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={pwdVerifyLoading}
|
||||
>
|
||||
{pwdVerifyLoading ? '驗證中…' : '驗證並繼續'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={changePassword} className="form">
|
||||
{totpActive && (
|
||||
<p className="form-ok">TOTP 已驗證,請輸入新密碼。</p>
|
||||
)}
|
||||
<label>
|
||||
目前密碼
|
||||
<input
|
||||
|
|
@ -198,10 +289,23 @@ export function SecurityPage() {
|
|||
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">
|
||||
您的帳號由第三方或企業目錄登入,無法在此變更密碼。
|
||||
|
|
@ -285,6 +389,11 @@ export function SecurityPage() {
|
|||
{totp?.enrolled &&
|
||||
` · 備援碼剩餘 ${totp.backup_codes_remaining} 組`}
|
||||
</p>
|
||||
{totp?.enrolled && (
|
||||
<p className="form-ok">
|
||||
下次登入時,輸入密碼後會進入「雙因素驗證」步驟,需再輸入驗證器或備援碼。
|
||||
</p>
|
||||
)}
|
||||
{!totp?.enrolled ? (
|
||||
<>
|
||||
<button type="button" className="btn-primary" onClick={startTotp}>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ type (
|
|||
RegisterSocialStartReq {
|
||||
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
|
||||
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"` // 使用者接受的服務條款版本
|
||||
Language string `json:"language,optional"` // 語系代碼,如 zh-TW / en(可選)
|
||||
RedirectURI string `json:"redirect_uri" validate:"required,url"` // OAuth 完成後要 redirect 回的 URI
|
||||
|
|
@ -104,7 +104,7 @@ type (
|
|||
LoginMFAConfirmReq {
|
||||
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
|
||||
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 {
|
||||
|
|
@ -118,7 +118,7 @@ type (
|
|||
|
||||
LoginSocialStartReq {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -566,7 +566,7 @@ service gateway {
|
|||
|
||||
@doc "Social 登入 OAuth callback"
|
||||
/*
|
||||
@respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
|
||||
@respdoc-200 (LoginOKStatus) // 成功(code=102000);若已啟用 TOTP 則僅回 MFA challenge
|
||||
@respdoc-400 (
|
||||
10101000: (APIErrorStatus) 參數格式錯誤
|
||||
10104000: (APIErrorStatus) 缺少必填欄位
|
||||
|
|
@ -591,7 +591,7 @@ service gateway {
|
|||
) // 第三方服務錯誤
|
||||
*/
|
||||
@handler loginSocialCallback
|
||||
get /login/social/callback (LoginSocialCallbackReq) returns (AuthTokenData)
|
||||
get /login/social/callback (LoginSocialCallbackReq) returns (LoginData)
|
||||
}
|
||||
|
||||
@server(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ type (
|
|||
ChangePasswordReq {
|
||||
CurrentPassword string `json:"current_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 {
|
||||
|
|
@ -78,7 +80,13 @@ type (
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -174,7 +182,7 @@ service gateway {
|
|||
@handler updateMemberMe
|
||||
patch /me (UpdateMemberMeReq) returns (MemberMeData)
|
||||
|
||||
@doc "變更登入密碼(僅 platform_native 平台帳號)"
|
||||
@doc "變更登入密碼(僅 platform_native;須已綁定 TOTP,並帶 step_up_token 或 totp_code)"
|
||||
/*
|
||||
@respdoc-200 (EmptyOKStatus) // 成功(code=102000)
|
||||
@respdoc-400 (
|
||||
|
|
@ -185,7 +193,7 @@ service gateway {
|
|||
29501000: (APIErrorStatus) 目前密碼錯誤
|
||||
) // 未授權
|
||||
@respdoc-403 (
|
||||
29505000: (APIErrorStatus) 外部身份帳號不可變更密碼(Member scope)
|
||||
29505000: (APIErrorStatus) 外部身份帳號不可變更密碼 / TOTP 未驗證(Member scope)
|
||||
) // 禁止存取
|
||||
@respdoc-501 (
|
||||
29605000: (APIErrorStatus) 功能未配置
|
||||
|
|
@ -396,7 +404,7 @@ service gateway {
|
|||
) // 未實作
|
||||
*/
|
||||
@handler verifyTOTP
|
||||
post /me/totp/verify (TOTPVerifyReq)
|
||||
post /me/totp/verify (TOTPVerifyReq) returns (TOTPVerifyData)
|
||||
|
||||
@doc "重產 TOTP 備援碼"
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ func VerifyTOTPHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||
}
|
||||
|
||||
l := member.NewVerifyTOTPLogic(actorContext(r.Context(), r), svcCtx)
|
||||
err := l.VerifyTOTP(&req)
|
||||
response.Write(r.Context(), w, nil, err)
|
||||
data, err := l.VerifyTOTP(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Handler: member.UpdateMemberMeHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 變更登入密碼(僅 platform_native 平台帳號)
|
||||
// 變更登入密碼(僅 platform_native;須已綁定 TOTP,並帶 step_up_token 或 totp_code)
|
||||
Method: http.MethodPost,
|
||||
Path: "/me/password",
|
||||
Handler: member.ChangePasswordHandler(serverCtx),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ type Conf struct {
|
|||
GoogleClientSecret string `json:",optional,env=ZITADEL_GOOGLE_CLIENT_SECRET"`
|
||||
// GoogleIdPID is the ZITADEL external IdP id for Google (optional idp_id authorize hint).
|
||||
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 string `json:",optional"`
|
||||
TimeoutSeconds int `json:",optional"`
|
||||
|
|
|
|||
|
|
@ -38,9 +38,16 @@ func (c *Client) AuthorizeURL(redirectURI, state, provider string) (string, erro
|
|||
q.Set("response_type", "code")
|
||||
q.Set("scope", "openid profile email")
|
||||
q.Set("state", state)
|
||||
if provider == "google" && c.conf.GoogleIdPID != "" {
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "google":
|
||||
if c.conf.GoogleIdPID != "" {
|
||||
q.Set("idp_id", c.conf.GoogleIdPID)
|
||||
}
|
||||
case "ldap":
|
||||
if c.conf.LdapIdPID != "" {
|
||||
q.Set("idp_id", c.conf.LdapIdPID)
|
||||
}
|
||||
}
|
||||
return c.issuer + "/oauth/v2/authorize?" + q.Encode(), nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,24 @@ func TestAuthorizeURLWithoutGoogleIdP(t *testing.T) {
|
|||
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) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -61,14 +61,24 @@ func (l *LoginSocialCallbackLogic) LoginSocialCallback(req *types.LoginSocialCal
|
|||
if err != nil {
|
||||
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 {
|
||||
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 (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gateway/internal/library/zitadel"
|
||||
authmetaenum "gateway/internal/model/auth/domain/enum"
|
||||
domauth "gateway/internal/model/auth/domain/usecase"
|
||||
memberdom "gateway/internal/model/member/domain"
|
||||
dommember "gateway/internal/model/member/domain/usecase"
|
||||
|
|
@ -68,8 +68,9 @@ func (l *RegisterSocialCallbackLogic) RegisterSocialCallback(req *types.Register
|
|||
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
|
||||
}
|
||||
|
||||
isExisting := false
|
||||
|
|
@ -97,7 +98,19 @@ func (l *RegisterSocialCallbackLogic) RegisterSocialCallback(req *types.Register
|
|||
inviteCodeID = consumed.ID
|
||||
}
|
||||
|
||||
memberDTO, err := l.svcCtx.MemberProvisioning.EnsureFromOIDC(l.ctx, &dommember.EnsureFromOIDCRequest{
|
||||
provider := strings.ToLower(strings.TrimSpace(session.Provider))
|
||||
var memberDTO *dommember.MemberDTO
|
||||
switch provider {
|
||||
case "ldap":
|
||||
memberDTO, err = l.svcCtx.MemberProvisioning.EnsureFromLDAP(l.ctx, &dommember.EnsureFromLDAPRequest{
|
||||
TenantID: session.TenantID,
|
||||
ZitadelSub: claims.Sub,
|
||||
ExternalID: claims.Sub,
|
||||
Email: claims.Email,
|
||||
DisplayName: claims.Name,
|
||||
})
|
||||
default:
|
||||
memberDTO, err = l.svcCtx.MemberProvisioning.EnsureFromOIDC(l.ctx, &dommember.EnsureFromOIDCRequest{
|
||||
TenantID: session.TenantID,
|
||||
ZitadelSub: claims.Sub,
|
||||
Email: claims.Email,
|
||||
|
|
@ -105,12 +118,13 @@ func (l *RegisterSocialCallbackLogic) RegisterSocialCallback(req *types.Register
|
|||
DisplayName: claims.Name,
|
||||
Locale: firstNonEmpty(session.Language, claims.Locale),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) (*typ
|
|||
if err := ensurePlatformNativePassword(member); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensurePasswordChangeStepUp(l.ctx, l.svcCtx, actor.TenantID, actor.UID, member, req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if member.ZitadelUserID == "" {
|
||||
return nil, errb.ResInvalidState("member has no zitadel identity")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
package member
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
memberenum "gateway/internal/model/member/domain/enum"
|
||||
domusecase "gateway/internal/model/member/domain/usecase"
|
||||
"gateway/internal/svc"
|
||||
"gateway/internal/types"
|
||||
)
|
||||
|
||||
func ensurePlatformNativePassword(member *domusecase.MemberDTO) error {
|
||||
|
|
@ -22,3 +27,69 @@ func ensurePlatformNativePassword(member *domusecase.MemberDTO) error {
|
|||
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}
|
||||
}
|
||||
|
||||
func (l *VerifyTOTPLogic) VerifyTOTP(req *types.TOTPVerifyReq) error {
|
||||
func (l *VerifyTOTPLogic) VerifyTOTP(req *types.TOTPVerifyReq) (*types.TOTPVerifyData, error) {
|
||||
actor, err := actorOrErr(l.ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if err := requireTOTP(l.svcCtx); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
code := ""
|
||||
purposeRaw := ""
|
||||
if req != nil {
|
||||
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 (
|
||||
RegistrationChannelEmail RegistrationChannel = "email"
|
||||
RegistrationChannelGoogle RegistrationChannel = "google"
|
||||
RegistrationChannelLDAP RegistrationChannel = "ldap"
|
||||
)
|
||||
|
||||
func (c RegistrationChannel) String() string {
|
||||
|
|
@ -14,7 +15,7 @@ func (c RegistrationChannel) String() string {
|
|||
|
||||
func (c RegistrationChannel) Valid() bool {
|
||||
switch c {
|
||||
case RegistrationChannelEmail, RegistrationChannelGoogle:
|
||||
case RegistrationChannelEmail, RegistrationChannelGoogle, RegistrationChannelLDAP:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ type TOTPConfig struct {
|
|||
BackupCodeLength int `json:",optional"`
|
||||
EnrollTTLSeconds int `json:",optional"`
|
||||
ReplayTTLSeconds int `json:",optional"`
|
||||
StepUpTTLSeconds int `json:",optional"`
|
||||
SecretKEK string `json:",optional,env=TOTP_SECRET_KEK"`
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +88,9 @@ func (c Config) Defaults() Config {
|
|||
if c.TOTP.ReplayTTLSeconds <= 0 {
|
||||
c.TOTP.ReplayTTLSeconds = 90
|
||||
}
|
||||
if c.TOTP.StepUpTTLSeconds <= 0 {
|
||||
c.TOTP.StepUpTTLSeconds = 300
|
||||
}
|
||||
// RequireInviteCode defaults true when unset (zero value).
|
||||
// TrustSocialEmailVerified defaults true when unset (zero value).
|
||||
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")
|
||||
ErrTOTPInvalidCode = fmt.Errorf("member: invalid totp code")
|
||||
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")
|
||||
ErrDuplicateTenant = fmt.Errorf("member: duplicate tenant")
|
||||
ErrInvalidStatus = fmt.Errorf("member: invalid member status transition")
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const (
|
|||
VerifyDailyRedisKey RedisKey = "member:verify:daily"
|
||||
TOTPEnrollRedisKey RedisKey = "member:totp:enroll"
|
||||
TOTPUsedRedisKey RedisKey = "member:totp:used"
|
||||
StepUpRedisKey RedisKey = "member:stepup"
|
||||
MemberSeqRedisKey RedisKey = "member:seq"
|
||||
)
|
||||
|
||||
|
|
@ -65,3 +66,8 @@ func GetTOTPUsedRedisKey(tenantID, uid, timestep string) string {
|
|||
func GetMemberSeqRedisKey(tenantID string) 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 {
|
||||
OTP domusecase.OTPUseCase
|
||||
TOTP domusecase.TOTPUseCase
|
||||
StepUp domusecase.StepUpUseCase
|
||||
Profile domusecase.ProfileUseCase
|
||||
Lifecycle domusecase.LifecycleUseCase
|
||||
Provisioning domusecase.ProvisioningUseCase
|
||||
|
|
@ -52,6 +53,7 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
|||
cfg := param.Config.Defaults()
|
||||
otpStore := repository.NewRedisOTPChallengeStore(param.Redis)
|
||||
rateStore := repository.NewRedisVerifyRateStore(param.Redis)
|
||||
stepUpStore := repository.NewRedisStepUpStore(param.Redis)
|
||||
|
||||
members := param.Members
|
||||
tenants := param.Tenants
|
||||
|
|
@ -83,6 +85,7 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
|||
mod := &Module{
|
||||
OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}),
|
||||
VerifyRate: MustVerifyRateUseCase(VerifyRateUseCaseParam{Store: rateStore}),
|
||||
StepUp: MustStepUpUseCase(StepUpUseCaseParam{Store: stepUpStore, Config: cfg}),
|
||||
Members: members,
|
||||
Tenants: tenants,
|
||||
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
|
||||
MemberTOTP dommember.TOTPUseCase
|
||||
MemberStepUp dommember.StepUpUseCase
|
||||
MemberProfile dommember.ProfileUseCase
|
||||
MemberLifecycle dommember.LifecycleUseCase
|
||||
MemberProvisioning dommember.ProvisioningUseCase
|
||||
|
|
@ -143,6 +144,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
|||
}
|
||||
sc.MemberOTP = memberMod.OTP
|
||||
sc.MemberTOTP = memberMod.TOTP
|
||||
sc.MemberStepUp = memberMod.StepUp
|
||||
sc.MemberProfile = memberMod.Profile
|
||||
sc.MemberLifecycle = memberMod.Lifecycle
|
||||
sc.MemberProvisioning = memberMod.Provisioning
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ type ChangePasswordData struct {
|
|||
type ChangePasswordReq struct {
|
||||
CurrentPassword string `json:"current_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 {
|
||||
|
|
@ -92,7 +94,7 @@ type LoginData struct {
|
|||
type LoginMFAConfirmReq struct {
|
||||
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
|
||||
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 {
|
||||
|
|
@ -126,7 +128,7 @@ type LoginSocialStartOKStatus struct {
|
|||
|
||||
type LoginSocialStartReq struct {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -329,7 +331,7 @@ type RegisterSocialStartOKStatus struct {
|
|||
type RegisterSocialStartReq struct {
|
||||
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
|
||||
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"` // 使用者接受的服務條款版本
|
||||
Language string `json:"language,optional"` // 語系代碼,如 zh-TW / en(可選)
|
||||
RedirectURI string `json:"redirect_uri" validate:"required,url"` // OAuth 完成後要 redirect 回的 URI
|
||||
|
|
@ -477,8 +479,14 @@ type TOTPStatusOKStatus struct {
|
|||
Data TOTPStatusData `json:"data"`
|
||||
}
|
||||
|
||||
type TOTPVerifyData struct {
|
||||
StepUpToken string `json:"step_up_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
// Journey: change password while logged in → 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
|
||||
// Journey: enroll TOTP → step-up → change password → login with new password
|
||||
import { post, checkError } from '../lib/http.js';
|
||||
import { cfg } from '../lib/config.js';
|
||||
import { registerAndConfirm, loginStep } from '../lib/auth.js';
|
||||
import { changePassword } from '../lib/member.js';
|
||||
|
||||
import { registerAndConfirm, login } from '../lib/auth.js';
|
||||
import {
|
||||
changePassword,
|
||||
enrollTOTP,
|
||||
verifyTOTPForPasswordChange,
|
||||
} from '../lib/member.js';
|
||||
export const options = {
|
||||
vus: 1,
|
||||
iterations: 1,
|
||||
|
|
@ -20,16 +18,22 @@ export default function () {
|
|||
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
|
||||
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) {
|
||||
throw new Error('change password journey: expected ok=true');
|
||||
}
|
||||
|
||||
const login = loginStep({
|
||||
const session = login({
|
||||
email: identity.email,
|
||||
password: newPassword,
|
||||
otpauthUrl,
|
||||
});
|
||||
if (!login.access_token) {
|
||||
if (!session.access_token) {
|
||||
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 { 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) {
|
||||
const enroll = checkEnvelope(
|
||||
post('/api/v1/members/me/totp/enroll-start', null, bearer),
|
||||
|
|
@ -18,11 +34,13 @@ export function enrollTOTP(bearer) {
|
|||
return { otpauthUrl: enroll.otpauth_url };
|
||||
}
|
||||
|
||||
export function changePassword(currentPassword, newPassword, bearer) {
|
||||
const res = post(
|
||||
'/api/v1/members/me/password',
|
||||
{ current_password: currentPassword, new_password: newPassword },
|
||||
bearer,
|
||||
);
|
||||
export function changePassword(currentPassword, newPassword, bearer, opts = {}) {
|
||||
const body = {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,16 +107,16 @@ export default function () {
|
|||
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(
|
||||
post(
|
||||
'/api/v1/members/me/password',
|
||||
{ current_password: 'WrongPass-1!', new_password: 'K6-NewPass-2!' },
|
||||
{ current_password: identity.password, new_password: 'K6-NewPass-2!' },
|
||||
bearer,
|
||||
),
|
||||
'POST /me/password (wrong current)',
|
||||
401,
|
||||
29501000,
|
||||
'POST /me/password (totp not enrolled)',
|
||||
403,
|
||||
29505000,
|
||||
);
|
||||
|
||||
// 14. POST /me/password (negative — no bearer)
|
||||
|
|
@ -130,13 +130,4 @@ export default function () {
|
|||
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