fix: enrich me profile via userinfo and add org-member management plan
This commit is contained in:
@@ -17,6 +17,7 @@ AUTHENTIK_AUDIENCE=
|
|||||||
AUTHENTIK_CLIENT_ID=
|
AUTHENTIK_CLIENT_ID=
|
||||||
AUTHENTIK_CLIENT_SECRET=
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
AUTHENTIK_TOKEN_ENDPOINT=
|
AUTHENTIK_TOKEN_ENDPOINT=
|
||||||
|
AUTHENTIK_USERINFO_ENDPOINT=
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ AUTHENTIK_AUDIENCE=
|
|||||||
AUTHENTIK_CLIENT_ID=
|
AUTHENTIK_CLIENT_ID=
|
||||||
AUTHENTIK_CLIENT_SECRET=
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
AUTHENTIK_TOKEN_ENDPOINT=
|
AUTHENTIK_TOKEN_ENDPOINT=
|
||||||
|
AUTHENTIK_USERINFO_ENDPOINT=
|
||||||
|
|
||||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
||||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||||
|
|||||||
@@ -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_ID` (used by `/auth/login`, fallback to `AUTHENTIK_AUDIENCE`)
|
||||||
- `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing)
|
- `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing)
|
||||||
- `AUTHENTIK_TOKEN_ENDPOINT` (default: `<AUTHENTIK_BASE_URL>/application/o/token/`)
|
- `AUTHENTIK_TOKEN_ENDPOINT` (default: `<AUTHENTIK_BASE_URL>/application/o/token/`)
|
||||||
|
- `AUTHENTIK_USERINFO_ENDPOINT` (optional, default inferred from issuer/base URL; used to fill missing email/name claims)
|
||||||
|
|
||||||
## Authentik Admin API setup
|
## Authentik Admin API setup
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
|||||||
authentik_client_id: str = ""
|
authentik_client_id: str = ""
|
||||||
authentik_client_secret: str = ""
|
authentik_client_secret: str = ""
|
||||||
authentik_token_endpoint: str = ""
|
authentik_token_endpoint: str = ""
|
||||||
|
authentik_userinfo_endpoint: str = ""
|
||||||
|
|
||||||
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
||||||
internal_shared_secret: str = ""
|
internal_shared_secret: str = ""
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
|
import httpx
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
@@ -19,11 +20,19 @@ class AuthentikTokenVerifier:
|
|||||||
jwks_url: str | None,
|
jwks_url: str | None,
|
||||||
audience: str | None,
|
audience: str | None,
|
||||||
client_secret: str | None,
|
client_secret: str | None,
|
||||||
|
base_url: str | None,
|
||||||
|
userinfo_endpoint: str | None,
|
||||||
|
verify_tls: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.issuer = issuer.strip() if issuer else 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.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer)
|
||||||
self.audience = audience.strip() if audience else None
|
self.audience = audience.strip() if audience else None
|
||||||
self.client_secret = client_secret.strip() if client_secret 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:
|
if not self.jwks_url:
|
||||||
raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required")
|
raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required")
|
||||||
@@ -39,6 +48,50 @@ class AuthentikTokenVerifier:
|
|||||||
return normalized
|
return normalized
|
||||||
return normalized + "jwks/"
|
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:
|
def verify_access_token(self, token: str) -> AuthentikPrincipal:
|
||||||
try:
|
try:
|
||||||
header = jwt.get_unverified_header(token)
|
header = jwt.get_unverified_header(token)
|
||||||
@@ -78,12 +131,13 @@ class AuthentikTokenVerifier:
|
|||||||
if not sub:
|
if not sub:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub")
|
||||||
|
|
||||||
return AuthentikPrincipal(
|
principal = AuthentikPrincipal(
|
||||||
sub=sub,
|
sub=sub,
|
||||||
email=claims.get("email"),
|
email=claims.get("email"),
|
||||||
name=claims.get("name"),
|
name=claims.get("name"),
|
||||||
preferred_username=claims.get("preferred_username"),
|
preferred_username=claims.get("preferred_username"),
|
||||||
)
|
)
|
||||||
|
return self._enrich_from_userinfo(principal, token)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
@@ -94,6 +148,9 @@ def _get_verifier() -> AuthentikTokenVerifier:
|
|||||||
jwks_url=settings.authentik_jwks_url,
|
jwks_url=settings.authentik_jwks_url,
|
||||||
audience=settings.authentik_audience,
|
audience=settings.authentik_audience,
|
||||||
client_secret=settings.authentik_client_secret,
|
client_secret=settings.authentik_client_secret,
|
||||||
|
base_url=settings.authentik_base_url,
|
||||||
|
userinfo_endpoint=settings.authentik_userinfo_endpoint,
|
||||||
|
verify_tls=settings.authentik_verify_tls,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
87
docs/ORG_MEMBER_MANAGEMENT_PLAN.md
Normal file
87
docs/ORG_MEMBER_MANAGEMENT_PLAN.md
Normal file
@@ -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`)
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
## 任務管理
|
## 任務管理
|
||||||
- `docs/TASKPLAN_FRONTEND.md`
|
- `docs/TASKPLAN_FRONTEND.md`
|
||||||
- `docs/TASKPLAN_BACKEND.md`
|
- `docs/TASKPLAN_BACKEND.md`
|
||||||
|
- `docs/ORG_MEMBER_MANAGEMENT_PLAN.md`(公司組織/會員管理規劃)
|
||||||
|
|
||||||
## SQL 與配置
|
## SQL 與配置
|
||||||
- `docs/API_CLIENTS_SQL.sql`
|
- `docs/API_CLIENTS_SQL.sql`
|
||||||
|
|||||||
Reference in New Issue
Block a user