From c6cb9d68188dde934b50aeb9c9792ec7413e179f Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 30 Mar 2026 01:14:02 +0800 Subject: [PATCH] fix: enrich me profile via userinfo and add org-member management plan --- backend/.env.example | 1 + backend/.env.production.example | 1 + backend/README.md | 1 + backend/app/core/config.py | 1 + backend/app/security/authentik_jwt.py | 59 +++++++++++++++++- docs/ORG_MEMBER_MANAGEMENT_PLAN.md | 87 +++++++++++++++++++++++++++ docs/index.md | 1 + 7 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 docs/ORG_MEMBER_MANAGEMENT_PLAN.md diff --git a/backend/.env.example b/backend/.env.example index ba37b10..fe5a9f5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,6 +17,7 @@ AUTHENTIK_AUDIENCE= AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= AUTHENTIK_TOKEN_ENDPOINT= +AUTHENTIK_USERINFO_ENDPOINT= PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw INTERNAL_SHARED_SECRET=CHANGE_ME diff --git a/backend/.env.production.example b/backend/.env.production.example index 3afbb04..c20ed60 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -17,6 +17,7 @@ AUTHENTIK_AUDIENCE= AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= AUTHENTIK_TOKEN_ENDPOINT= +AUTHENTIK_USERINFO_ENDPOINT= PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw INTERNAL_SHARED_SECRET=CHANGE_ME diff --git a/backend/README.md b/backend/README.md index 401e095..39c486b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -31,6 +31,7 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' - `AUTHENTIK_CLIENT_ID` (used by `/auth/login`, fallback to `AUTHENTIK_AUDIENCE`) - `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing) - `AUTHENTIK_TOKEN_ENDPOINT` (default: `/application/o/token/`) + - `AUTHENTIK_USERINFO_ENDPOINT` (optional, default inferred from issuer/base URL; used to fill missing email/name claims) ## Authentik Admin API setup diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 63175ed..a36ce6f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -26,6 +26,7 @@ class Settings(BaseSettings): authentik_client_id: str = "" authentik_client_secret: str = "" authentik_token_endpoint: str = "" + authentik_userinfo_endpoint: str = "" public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"] internal_shared_secret: str = "" diff --git a/backend/app/security/authentik_jwt.py b/backend/app/security/authentik_jwt.py index aa08d0d..737cbf5 100644 --- a/backend/app/security/authentik_jwt.py +++ b/backend/app/security/authentik_jwt.py @@ -2,6 +2,7 @@ from __future__ import annotations from functools import lru_cache +import httpx import jwt from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -19,11 +20,19 @@ class AuthentikTokenVerifier: jwks_url: str | None, audience: str | None, client_secret: str | None, + base_url: str | None, + userinfo_endpoint: str | None, + verify_tls: bool, ) -> None: self.issuer = issuer.strip() if issuer else None self.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer) self.audience = audience.strip() if audience else None self.client_secret = client_secret.strip() if client_secret else None + self.base_url = base_url.strip() if base_url else None + self.userinfo_endpoint = ( + userinfo_endpoint.strip() if userinfo_endpoint else self._infer_userinfo_endpoint(self.issuer, self.base_url) + ) + self.verify_tls = verify_tls if not self.jwks_url: raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required") @@ -39,6 +48,50 @@ class AuthentikTokenVerifier: return normalized return normalized + "jwks/" + @staticmethod + def _infer_userinfo_endpoint(issuer: str | None, base_url: str | None) -> str | None: + if issuer: + return issuer.rstrip("/") + "/userinfo/" + if base_url: + return base_url.rstrip("/") + "/application/o/userinfo/" + return None + + def _enrich_from_userinfo(self, principal: AuthentikPrincipal, token: str) -> AuthentikPrincipal: + if principal.email and (principal.name or principal.preferred_username): + return principal + if not self.userinfo_endpoint: + return principal + + try: + resp = httpx.get( + self.userinfo_endpoint, + timeout=5, + verify=self.verify_tls, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + ) + except Exception: + return principal + + if resp.status_code >= 400: + return principal + + data = resp.json() if resp.content else {} + sub = data.get("sub") + if isinstance(sub, str) and sub and sub != principal.sub: + return principal + + email = principal.email or (data.get("email") if isinstance(data.get("email"), str) else None) + name = principal.name or (data.get("name") if isinstance(data.get("name"), str) else None) + preferred_username = principal.preferred_username or ( + data.get("preferred_username") if isinstance(data.get("preferred_username"), str) else None + ) + return AuthentikPrincipal( + sub=principal.sub, + email=email, + name=name, + preferred_username=preferred_username, + ) + def verify_access_token(self, token: str) -> AuthentikPrincipal: try: header = jwt.get_unverified_header(token) @@ -78,12 +131,13 @@ class AuthentikTokenVerifier: if not sub: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub") - return AuthentikPrincipal( + principal = AuthentikPrincipal( sub=sub, email=claims.get("email"), name=claims.get("name"), preferred_username=claims.get("preferred_username"), ) + return self._enrich_from_userinfo(principal, token) @lru_cache @@ -94,6 +148,9 @@ def _get_verifier() -> AuthentikTokenVerifier: jwks_url=settings.authentik_jwks_url, audience=settings.authentik_audience, client_secret=settings.authentik_client_secret, + base_url=settings.authentik_base_url, + userinfo_endpoint=settings.authentik_userinfo_endpoint, + verify_tls=settings.authentik_verify_tls, ) diff --git a/docs/ORG_MEMBER_MANAGEMENT_PLAN.md b/docs/ORG_MEMBER_MANAGEMENT_PLAN.md new file mode 100644 index 0000000..0468051 --- /dev/null +++ b/docs/ORG_MEMBER_MANAGEMENT_PLAN.md @@ -0,0 +1,87 @@ +# 組織與會員管理規劃(給前端/後端協作) + +## 1. 目前狀態(你現在看到空白是正常) +- 已完成: + - Authentik 登入 + - `/me` 基本個人資料 + - 權限 grant/revoke 與 snapshot +- 尚未完成: + - 公司組織(Organization)CRUD + - 會員(Member)清單/新增/編輯/停用 + - 會員與組織關聯管理 + +## 2. 建議產品資訊架構(IA) +- `我的資料`:目前登入者基本資訊、登出 +- `我的權限`:目前登入者權限快照 +- `組織管理`:公司清單、建立公司、編輯公司、狀態切換 +- `會員管理`:會員清單、邀請/建立會員、編輯會員、停用會員、指派組織 +- `權限管理`:保留現有 grant/revoke(可作為管理員進階頁) + +## 3. 建議後端 API(v1) + +### Organization +- `GET /admin/organizations` +- `POST /admin/organizations` +- `GET /admin/organizations/{org_id}` +- `PATCH /admin/organizations/{org_id}` +- `POST /admin/organizations/{org_id}/activate` +- `POST /admin/organizations/{org_id}/deactivate` + +### Member +- `GET /admin/members` +- `POST /admin/members` +- `GET /admin/members/{member_id}` +- `PATCH /admin/members/{member_id}` +- `POST /admin/members/{member_id}/activate` +- `POST /admin/members/{member_id}/deactivate` + +### Member x Organization +- `GET /admin/members/{member_id}/organizations` +- `POST /admin/members/{member_id}/organizations/{org_id}` +- `DELETE /admin/members/{member_id}/organizations/{org_id}` + +## 4. 建議資料表(最小可行) +- `organizations` + - `id` (uuid) + - `org_code` (unique) + - `name` + - `tax_id` (nullable) + - `status` (`active|inactive`) + - `created_at`, `updated_at` +- `members` + - `id` (uuid) + - `authentik_sub` (unique) + - `email` + - `display_name` + - `status` (`active|inactive`) + - `created_at`, `updated_at` +- `member_organizations` + - `member_id` + - `organization_id` + - unique(`member_id`, `organization_id`) + +## 5. 前端頁面需求(給另一個 AI) +- `/admin/organizations` + - 表格 + 查詢 + 新增 Dialog + 編輯 Dialog + 啟停用 +- `/admin/members` + - 表格 + 查詢 + 新增 Dialog + 編輯 Dialog + 啟停用 +- `/admin/members/:id/organizations` + - 左側會員資訊,右側組織綁定清單 + 加入/移除 + +## 6. 權限模型(建議) +- `org.manage`:組織管理 +- `member.manage`:會員管理 +- `permission.manage`:權限管理 + +可映射到現有權限欄位: +- `scope_type=global` +- `scope_id=member-center` +- `module=organization|member|permission` +- `action=view|create|update|deactivate|grant|revoke` + +## 7. 驗收標準 +- 可以建立/修改/停用組織 +- 可以建立/修改/停用會員 +- 可以將會員加入/移出組織 +- UI 顯示成功/失敗訊息,並可重新整理資料 +- 所有管理 API 都有管理員金鑰驗證(`X-Client-Key` + `X-API-Key`) diff --git a/docs/index.md b/docs/index.md index 1ed249a..78d97a9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,7 @@ ## 任務管理 - `docs/TASKPLAN_FRONTEND.md` - `docs/TASKPLAN_BACKEND.md` +- `docs/ORG_MEMBER_MANAGEMENT_PLAN.md`(公司組織/會員管理規劃) ## SQL 與配置 - `docs/API_CLIENTS_SQL.sql`