feat(idp): add keycloak-first support with authentik fallback

This commit is contained in:
Chris
2026-04-01 00:41:38 +08:00
parent febfafc55c
commit 34ba57034d
22 changed files with 458 additions and 123 deletions

View File

@@ -19,6 +19,21 @@ AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_TOKEN_ENDPOINT= AUTHENTIK_TOKEN_ENDPOINT=
AUTHENTIK_USERINFO_ENDPOINT= AUTHENTIK_USERINFO_ENDPOINT=
# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set)
KEYCLOAK_BASE_URL=
KEYCLOAK_REALM=
KEYCLOAK_VERIFY_TLS=true
KEYCLOAK_ISSUER=
KEYCLOAK_JWKS_URL=
KEYCLOAK_AUDIENCE=
KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=
KEYCLOAK_TOKEN_ENDPOINT=
KEYCLOAK_USERINFO_ENDPOINT=
KEYCLOAK_ADMIN_CLIENT_ID=
KEYCLOAK_ADMIN_CLIENT_SECRET=
KEYCLOAK_ADMIN_REALM=
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
ADMIN_REQUIRED_GROUPS=member-admin ADMIN_REQUIRED_GROUPS=member-admin

View File

@@ -19,5 +19,20 @@ AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_TOKEN_ENDPOINT= AUTHENTIK_TOKEN_ENDPOINT=
AUTHENTIK_USERINFO_ENDPOINT= AUTHENTIK_USERINFO_ENDPOINT=
# Keycloak (preferred when KEYCLOAK_BASE_URL + KEYCLOAK_REALM are set)
KEYCLOAK_BASE_URL=
KEYCLOAK_REALM=
KEYCLOAK_VERIFY_TLS=true
KEYCLOAK_ISSUER=
KEYCLOAK_JWKS_URL=
KEYCLOAK_AUDIENCE=
KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=
KEYCLOAK_TOKEN_ENDPOINT=
KEYCLOAK_USERINFO_ENDPOINT=
KEYCLOAK_ADMIN_CLIENT_ID=
KEYCLOAK_ADMIN_CLIENT_SECRET=
KEYCLOAK_ADMIN_REALM=
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

View File

@@ -21,7 +21,26 @@ cp .env.example .env
python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
``` ```
## Authentik JWT setup ## IdP JWT setupKeycloak 優先)
- 若設定 `KEYCLOAK_BASE_URL` + `KEYCLOAK_REALM`,後端會優先走 Keycloak。
- 未設定 Keycloak 時,才走 `AUTHENTIK_*`
### Keycloak
- 必填:
- `KEYCLOAK_BASE_URL`
- `KEYCLOAK_REALM`
- `KEYCLOAK_CLIENT_ID`
- `KEYCLOAK_CLIENT_SECRET`
- 可選:
- `KEYCLOAK_ISSUER`(預設:`<base>/realms/<realm>`
- `KEYCLOAK_JWKS_URL`(預設:`<issuer>/protocol/openid-connect/certs`
- `KEYCLOAK_TOKEN_ENDPOINT`(預設:`<issuer>/protocol/openid-connect/token`
- `KEYCLOAK_USERINFO_ENDPOINT`(預設:`<issuer>/protocol/openid-connect/userinfo`
- `KEYCLOAK_AUDIENCE`
- `KEYCLOAK_VERIFY_TLS`(預設 true
### Authentik備援
- Configure at least one of: - Configure at least one of:
- `AUTHENTIK_JWKS_URL` - `AUTHENTIK_JWKS_URL`
@@ -33,9 +52,16 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
- `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_USERINFO_ENDPOINT` (optional, default inferred from issuer/base URL; used to fill missing email/name claims)
## Authentik Admin API setup ## IdP Admin API setup
- Required for `/internal/authentik/users/ensure`: - Keycloak優先
- `KEYCLOAK_BASE_URL`
- `KEYCLOAK_REALM`
- `KEYCLOAK_ADMIN_CLIENT_ID`
- `KEYCLOAK_ADMIN_CLIENT_SECRET`
- `KEYCLOAK_ADMIN_REALM`(可選,預設同 `KEYCLOAK_REALM`
- Authentik備援
- `AUTHENTIK_BASE_URL` - `AUTHENTIK_BASE_URL`
- `AUTHENTIK_ADMIN_TOKEN` - `AUTHENTIK_ADMIN_TOKEN`
- `AUTHENTIK_VERIFY_TLS` - `AUTHENTIK_VERIFY_TLS`
@@ -49,7 +75,7 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
- `GET /me/permissions/snapshot` (Bearer token required) - `GET /me/permissions/snapshot` (Bearer token required)
- `POST /internal/users/upsert-by-sub` - `POST /internal/users/upsert-by-sub`
- `GET /internal/permissions/{user_sub}/snapshot` - `GET /internal/permissions/{user_sub}/snapshot`
- `POST /internal/authentik/users/ensure` - `POST /internal/idp/users/ensure`(相容:`/internal/authentik/users/ensure`
- `POST /admin/permissions/grant` - `POST /admin/permissions/grant`
- `POST /admin/permissions/revoke` - `POST /admin/permissions/revoke`
- `GET|POST /admin/systems` - `GET|POST /admin/systems`

View File

@@ -136,12 +136,12 @@ def _generate_api_key() -> str:
def _sync_member_to_authentik( def _sync_member_to_authentik(
*, *,
user_sub: str | None, user_sub: str | None,
idp_user_id: int | None, idp_user_id: str | None,
username: str | None, username: str | None,
email: str | None, email: str | None,
display_name: str | None, display_name: str | None,
is_active: bool, is_active: bool,
) -> dict[str, str | int]: ) -> dict[str, str]:
if not email: if not email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_authentik_sync") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_required_for_authentik_sync")
settings = get_settings() settings = get_settings()
@@ -602,7 +602,7 @@ def upsert_member(
display_name=payload.display_name, display_name=payload.display_name,
is_active=payload.is_active, is_active=payload.is_active,
) )
idp_user_id = int(sync["idp_user_id"]) idp_user_id = str(sync["idp_user_id"])
if sync.get("user_sub"): if sync.get("user_sub"):
resolved_sub = str(sync["user_sub"]) resolved_sub = str(sync["user_sub"])
if not resolved_sub: if not resolved_sub:
@@ -651,7 +651,7 @@ def update_member(
display_name=next_display_name, display_name=next_display_name,
is_active=next_is_active, is_active=next_is_active,
) )
idp_user_id = int(sync["idp_user_id"]) idp_user_id = str(sync["idp_user_id"])
row = users_repo.upsert_by_sub( row = users_repo.upsert_by_sub(
user_sub=row.user_sub, user_sub=row.user_sub,

View File

@@ -1,6 +1,5 @@
import logging import logging
import secrets import secrets
from urllib.parse import urljoin
import httpx import httpx
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
@@ -18,6 +17,9 @@ logger = logging.getLogger(__name__)
def _resolve_username_by_email(settings, email: str) -> str | None: def _resolve_username_by_email(settings, email: str) -> str | None:
# Authentik-only helper. Keycloak does not need this path.
if settings.use_keycloak:
return None
if not settings.authentik_base_url or not settings.authentik_admin_token: if not settings.authentik_base_url or not settings.authentik_admin_token:
return None return None
@@ -51,19 +53,17 @@ def _resolve_username_by_email(settings, email: str) -> str | None:
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest) -> LoginResponse: def login(payload: LoginRequest) -> LoginResponse:
settings = get_settings() settings = get_settings()
client_id = settings.authentik_client_id or settings.authentik_audience client_id = settings.idp_client_id or settings.idp_audience
if not settings.authentik_base_url or not client_id or not settings.authentik_client_secret: if not settings.idp_base_url or not client_id or not settings.idp_client_secret:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured") raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
token_endpoint = settings.authentik_token_endpoint or urljoin( token_endpoint = settings.idp_token_endpoint
settings.authentik_base_url.rstrip("/") + "/", "application/o/token/"
)
form = { form = {
"grant_type": "password", "grant_type": "password",
"client_id": client_id, "client_id": client_id,
"client_secret": settings.authentik_client_secret, "client_secret": settings.idp_client_secret,
"username": payload.username, "username": payload.username,
"password": payload.password, "password": payload.password,
"scope": "openid profile email", "scope": "openid profile email",
@@ -74,7 +74,7 @@ def login(payload: LoginRequest) -> LoginResponse:
token_endpoint, token_endpoint,
data=form_data, data=form_data,
timeout=10, timeout=10,
verify=settings.authentik_verify_tls, verify=settings.idp_verify_tls,
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
) )
return resp return resp
@@ -95,7 +95,7 @@ def login(payload: LoginRequest) -> LoginResponse:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
if resp.status_code >= 400: if resp.status_code >= 400:
logger.warning("authentik password grant failed: status=%s body=%s", resp.status_code, resp.text) logger.warning("idp password grant failed: status=%s body=%s", resp.status_code, resp.text)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password")
data = resp.json() data = resp.json()
@@ -116,13 +116,14 @@ def get_oidc_authorize_url(
redirect_uri: str, redirect_uri: str,
login_hint: str | None = None, login_hint: str | None = None,
prompt: str = "login", prompt: str = "login",
idp_hint: str | None = None,
) -> OIDCAuthUrlResponse: ) -> OIDCAuthUrlResponse:
settings = get_settings() settings = get_settings()
client_id = settings.authentik_client_id or settings.authentik_audience client_id = settings.idp_client_id or settings.idp_audience
if not settings.authentik_base_url or not client_id: if not settings.idp_base_url or not client_id:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured") raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
authorize_endpoint = urljoin(settings.authentik_base_url.rstrip("/") + "/", "application/o/authorize/") authorize_endpoint = settings.idp_authorize_endpoint
state = secrets.token_urlsafe(24) state = secrets.token_urlsafe(24)
query = { query = {
"client_id": client_id, "client_id": client_id,
@@ -134,6 +135,8 @@ def get_oidc_authorize_url(
} }
if login_hint: if login_hint:
query["login_hint"] = login_hint query["login_hint"] = login_hint
if idp_hint and settings.use_keycloak:
query["kc_idp_hint"] = idp_hint
params = httpx.QueryParams(query) params = httpx.QueryParams(query)
return OIDCAuthUrlResponse(authorize_url=f"{authorize_endpoint}?{params}") return OIDCAuthUrlResponse(authorize_url=f"{authorize_endpoint}?{params}")
@@ -142,17 +145,15 @@ def get_oidc_authorize_url(
@router.post("/oidc/exchange", response_model=LoginResponse) @router.post("/oidc/exchange", response_model=LoginResponse)
def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse: def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse:
settings = get_settings() settings = get_settings()
client_id = settings.authentik_client_id or settings.authentik_audience client_id = settings.idp_client_id or settings.idp_audience
if not settings.authentik_base_url or not client_id or not settings.authentik_client_secret: if not settings.idp_base_url or not client_id or not settings.idp_client_secret:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured") raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
token_endpoint = settings.authentik_token_endpoint or urljoin( token_endpoint = settings.idp_token_endpoint
settings.authentik_base_url.rstrip("/") + "/", "application/o/token/"
)
form = { form = {
"grant_type": "authorization_code", "grant_type": "authorization_code",
"client_id": client_id, "client_id": client_id,
"client_secret": settings.authentik_client_secret, "client_secret": settings.idp_client_secret,
"code": payload.code, "code": payload.code,
"redirect_uri": payload.redirect_uri, "redirect_uri": payload.redirect_uri,
} }
@@ -162,14 +163,14 @@ def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse:
token_endpoint, token_endpoint,
data=form, data=form,
timeout=10, timeout=10,
verify=settings.authentik_verify_tls, verify=settings.idp_verify_tls,
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
) )
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
if resp.status_code >= 400: if resp.status_code >= 400:
logger.warning("authentik auth-code exchange failed: status=%s body=%s", resp.status_code, resp.text) logger.warning("idp auth-code exchange failed: status=%s body=%s", resp.status_code, resp.text)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentik_code_exchange_failed") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentik_code_exchange_failed")
data = resp.json() data = resp.json()

View File

@@ -57,6 +57,8 @@ def get_permission_snapshot(
@router.post("/authentik/users/ensure", response_model=AuthentikEnsureUserResponse) @router.post("/authentik/users/ensure", response_model=AuthentikEnsureUserResponse)
@router.post("/idp/users/ensure", response_model=AuthentikEnsureUserResponse)
@router.post("/keycloak/users/ensure", response_model=AuthentikEnsureUserResponse)
def ensure_authentik_user( def ensure_authentik_user(
payload: AuthentikEnsureUserRequest, payload: AuthentikEnsureUserRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),

View File

@@ -27,6 +27,20 @@ class Settings(BaseSettings):
authentik_client_secret: str = "" authentik_client_secret: str = ""
authentik_token_endpoint: str = "" authentik_token_endpoint: str = ""
authentik_userinfo_endpoint: str = "" authentik_userinfo_endpoint: str = ""
# Keycloak (preferred when configured)
keycloak_base_url: str = ""
keycloak_realm: str = ""
keycloak_verify_tls: bool = True
keycloak_issuer: str = ""
keycloak_jwks_url: str = ""
keycloak_audience: str = ""
keycloak_client_id: str = ""
keycloak_client_secret: str = ""
keycloak_token_endpoint: str = ""
keycloak_userinfo_endpoint: str = ""
keycloak_admin_client_id: str = ""
keycloak_admin_client_secret: str = ""
keycloak_admin_realm: 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 = ""
@@ -57,6 +71,80 @@ class Settings(BaseSettings):
f"{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}" f"{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
) )
@property
def use_keycloak(self) -> bool:
return bool(self.keycloak_base_url and self.keycloak_realm)
@property
def idp_base_url(self) -> str:
if self.use_keycloak:
return self.keycloak_base_url.rstrip("/")
return self.authentik_base_url.rstrip("/")
@property
def idp_verify_tls(self) -> bool:
if self.use_keycloak:
return self.keycloak_verify_tls
return self.authentik_verify_tls
@property
def idp_issuer(self) -> str:
if self.use_keycloak:
if self.keycloak_issuer:
return self.keycloak_issuer.rstrip("/")
return f"{self.idp_base_url}/realms/{self.keycloak_realm}"
return self.authentik_issuer.rstrip("/")
@property
def idp_jwks_url(self) -> str:
if self.use_keycloak:
if self.keycloak_jwks_url:
return self.keycloak_jwks_url
return f"{self.idp_issuer}/protocol/openid-connect/certs"
return self.authentik_jwks_url
@property
def idp_audience(self) -> str:
if self.use_keycloak:
return self.keycloak_audience or self.keycloak_client_id
return self.authentik_audience or self.authentik_client_id
@property
def idp_client_id(self) -> str:
if self.use_keycloak:
return self.keycloak_client_id
return self.authentik_client_id
@property
def idp_client_secret(self) -> str:
if self.use_keycloak:
return self.keycloak_client_secret
return self.authentik_client_secret
@property
def idp_token_endpoint(self) -> str:
if self.use_keycloak:
if self.keycloak_token_endpoint:
return self.keycloak_token_endpoint
return f"{self.idp_issuer}/protocol/openid-connect/token"
return self.authentik_token_endpoint or (f"{self.idp_base_url}/application/o/token/" if self.idp_base_url else "")
@property
def idp_userinfo_endpoint(self) -> str:
if self.use_keycloak:
if self.keycloak_userinfo_endpoint:
return self.keycloak_userinfo_endpoint
return f"{self.idp_issuer}/protocol/openid-connect/userinfo"
return self.authentik_userinfo_endpoint or (
f"{self.idp_base_url}/application/o/userinfo/" if self.idp_base_url else ""
)
@property
def idp_authorize_endpoint(self) -> str:
if self.use_keycloak:
return f"{self.idp_issuer}/protocol/openid-connect/auth"
return f"{self.idp_base_url}/application/o/authorize/" if self.idp_base_url else ""
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import Boolean, DateTime, Integer, String, func from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -13,7 +13,7 @@ class User(Base):
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
user_sub: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) user_sub: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
idp_user_id: Mapped[int | None] = mapped_column(Integer) idp_user_id: Mapped[str | None] = mapped_column(String(128))
username: Mapped[str | None] = mapped_column(String(255), unique=True) username: Mapped[str | None] = mapped_column(String(255), unique=True)
email: Mapped[str | None] = mapped_column(String(320)) email: Mapped[str | None] = mapped_column(String(320))
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))

View File

@@ -53,7 +53,7 @@ class UsersRepository:
email: str | None, email: str | None,
display_name: str | None, display_name: str | None,
is_active: bool, is_active: bool,
idp_user_id: int | None = None, idp_user_id: str | None = None,
) -> User: ) -> User:
user = self.get_by_sub(user_sub) user = self.get_by_sub(user_sub)
if user is None: if user is None:

View File

@@ -10,5 +10,5 @@ class AuthentikEnsureUserRequest(BaseModel):
class AuthentikEnsureUserResponse(BaseModel): class AuthentikEnsureUserResponse(BaseModel):
idp_user_id: int idp_user_id: str
action: str action: str

View File

@@ -78,7 +78,7 @@ class InternalMemberListResponse(BaseModel):
class InternalUpsertUserBySubResponse(BaseModel): class InternalUpsertUserBySubResponse(BaseModel):
id: str id: str
user_sub: str user_sub: str
idp_user_id: int | None = None idp_user_id: str | None = None
username: str | None = None username: str | None = None
email: str | None = None email: str | None = None
display_name: str | None = None display_name: str | None = None

View File

@@ -50,16 +50,18 @@ class AuthentikTokenVerifier:
@staticmethod @staticmethod
def _infer_userinfo_endpoint(issuer: str | None, base_url: str | None) -> str | None: def _infer_userinfo_endpoint(issuer: str | None, base_url: str | None) -> str | None:
if base_url:
return base_url.rstrip("/") + "/application/o/userinfo/"
if issuer: if issuer:
normalized = issuer.rstrip("/") normalized = issuer.rstrip("/")
if "/realms/" in normalized:
return normalized + "/protocol/openid-connect/userinfo"
marker = "/application/o/" marker = "/application/o/"
marker_index = normalized.find(marker) marker_index = normalized.find(marker)
if marker_index != -1: if marker_index != -1:
root = normalized[:marker_index] root = normalized[:marker_index]
return root + marker + "userinfo/" return root + marker + "userinfo/"
return normalized + "/userinfo/" return normalized + "/userinfo/"
if base_url:
return base_url.rstrip("/") + "/application/o/userinfo/"
return None return None
def _enrich_from_userinfo(self, principal: AuthentikPrincipal, token: str) -> AuthentikPrincipal: def _enrich_from_userinfo(self, principal: AuthentikPrincipal, token: str) -> AuthentikPrincipal:
@@ -156,13 +158,13 @@ class AuthentikTokenVerifier:
def _get_verifier() -> AuthentikTokenVerifier: def _get_verifier() -> AuthentikTokenVerifier:
settings = get_settings() settings = get_settings()
return AuthentikTokenVerifier( return AuthentikTokenVerifier(
issuer=settings.authentik_issuer, issuer=settings.idp_issuer,
jwks_url=settings.authentik_jwks_url, jwks_url=settings.idp_jwks_url,
audience=settings.authentik_audience, audience=settings.idp_audience,
client_secret=settings.authentik_client_secret, client_secret=settings.idp_client_secret,
base_url=settings.authentik_base_url, base_url=settings.idp_base_url,
userinfo_endpoint=settings.authentik_userinfo_endpoint, userinfo_endpoint=settings.idp_userinfo_endpoint,
verify_tls=settings.authentik_verify_tls, verify_tls=settings.idp_verify_tls,
) )

View File

@@ -12,46 +12,47 @@ from app.core.config import Settings
@dataclass @dataclass
class AuthentikSyncResult: class AuthentikSyncResult:
user_id: int user_id: str
action: str action: str
user_sub: str | None = None user_sub: str | None = None
@dataclass @dataclass
class AuthentikPasswordResetResult: class AuthentikPasswordResetResult:
user_id: int user_id: str
temporary_password: str temporary_password: str
@dataclass @dataclass
class AuthentikDeleteResult: class AuthentikDeleteResult:
action: str action: str
user_id: int | None = None user_id: str | None = None
class AuthentikAdminService: class AuthentikAdminService:
"""
Backward-compatible service name.
Supports Keycloak (preferred, when KEYCLOAK_* configured) and Authentik.
"""
def __init__(self, settings: Settings) -> None: def __init__(self, settings: Settings) -> None:
self.settings = settings
self.is_keycloak = settings.use_keycloak
self.verify_tls = settings.idp_verify_tls
if self.is_keycloak:
self.base_url = settings.keycloak_base_url.rstrip("/")
self.realm = settings.keycloak_realm
self.admin_realm = settings.keycloak_admin_realm or settings.keycloak_realm
self.admin_client_id = settings.keycloak_admin_client_id
self.admin_client_secret = settings.keycloak_admin_client_secret
if not self.base_url or not self.realm or not self.admin_client_id or not self.admin_client_secret:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_admin_not_configured")
else:
self.base_url = settings.authentik_base_url.rstrip("/") self.base_url = settings.authentik_base_url.rstrip("/")
self.admin_token = settings.authentik_admin_token self.admin_token = settings.authentik_admin_token
self.verify_tls = settings.authentik_verify_tls
if not self.base_url or not self.admin_token: if not self.base_url or not self.admin_token:
raise HTTPException( raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_admin_not_configured")
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="authentik_admin_not_configured",
)
def _client(self) -> httpx.Client:
return httpx.Client(
base_url=self.base_url,
headers={
"Authorization": f"Bearer {self.admin_token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=10,
verify=self.verify_tls,
)
@staticmethod @staticmethod
def _safe_username(sub: str | None, email: str) -> str: def _safe_username(sub: str | None, email: str) -> str:
@@ -71,7 +72,48 @@ class AuthentikAdminService:
results = data.get("results") if isinstance(data, dict) else None results = data.get("results") if isinstance(data, dict) else None
return results[0] if isinstance(results, list) and results else None return results[0] if isinstance(results, list) and results else None
def _lookup_user_by_id(self, client: httpx.Client, user_id: int) -> dict | None: def _get_keycloak_admin_token(self) -> str:
token_endpoint = f"{self.base_url}/realms/{self.admin_realm}/protocol/openid-connect/token"
try:
resp = httpx.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": self.admin_client_id,
"client_secret": self.admin_client_secret,
},
timeout=10,
verify=self.verify_tls,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
except Exception as exc:
raise HTTPException(status_code=502, detail="authentik_lookup_failed") from exc
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_lookup_failed")
token = resp.json().get("access_token")
if not token:
raise HTTPException(status_code=502, detail="authentik_lookup_failed")
return str(token)
def _client(self) -> httpx.Client:
if self.is_keycloak:
bearer_token = self._get_keycloak_admin_token()
else:
bearer_token = self.admin_token
return httpx.Client(
base_url=self.base_url,
headers={
"Authorization": f"Bearer {bearer_token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=10,
verify=self.verify_tls,
)
# -------- Authentik lookups --------
def _ak_lookup_user_by_id(self, client: httpx.Client, user_id: str) -> dict | None:
resp = client.get(f"/api/v3/core/users/{user_id}/") resp = client.get(f"/api/v3/core/users/{user_id}/")
if resp.status_code == 404: if resp.status_code == 404:
return None return None
@@ -79,7 +121,7 @@ class AuthentikAdminService:
raise HTTPException(status_code=502, detail="authentik_lookup_failed") raise HTTPException(status_code=502, detail="authentik_lookup_failed")
return resp.json() return resp.json()
def _lookup_user_by_email_or_username( def _ak_lookup_user_by_email_or_username(
self, client: httpx.Client, *, email: str | None, username: str | None self, client: httpx.Client, *, email: str | None, username: str | None
) -> dict | None: ) -> dict | None:
if email: if email:
@@ -89,7 +131,6 @@ class AuthentikAdminService:
existing = self._extract_first_result(resp.json()) existing = self._extract_first_result(resp.json())
if existing: if existing:
return existing return existing
if username: if username:
resp = client.get("/api/v3/core/users/", params={"username": username}) resp = client.get("/api/v3/core/users/", params={"username": username})
if resp.status_code >= 400: if resp.status_code >= 400:
@@ -97,7 +138,34 @@ class AuthentikAdminService:
existing = self._extract_first_result(resp.json()) existing = self._extract_first_result(resp.json())
if existing: if existing:
return existing return existing
return None
# -------- Keycloak lookups --------
def _kc_lookup_user_by_id(self, client: httpx.Client, user_id: str) -> dict | None:
resp = client.get(f"/admin/realms/{self.realm}/users/{user_id}")
if resp.status_code == 404:
return None
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_lookup_failed")
return resp.json()
def _kc_lookup_user_by_email_or_username(
self, client: httpx.Client, *, email: str | None, username: str | None
) -> dict | None:
if email:
resp = client.get(f"/admin/realms/{self.realm}/users", params={"email": email, "exact": "true"})
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_lookup_failed")
matches = resp.json() if isinstance(resp.json(), list) else []
if matches:
return matches[0]
if username:
resp = client.get(f"/admin/realms/{self.realm}/users", params={"username": username, "exact": "true"})
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_lookup_failed")
matches = resp.json() if isinstance(resp.json(), list) else []
if matches:
return matches[0]
return None return None
def ensure_user( def ensure_user(
@@ -108,25 +176,56 @@ class AuthentikAdminService:
username: str | None, username: str | None,
display_name: str | None, display_name: str | None,
is_active: bool = True, is_active: bool = True,
idp_user_id: int | None = None, idp_user_id: str | None = None,
) -> AuthentikSyncResult: ) -> AuthentikSyncResult:
resolved_username = username or self._safe_username(sub=sub, email=email) resolved_username = username or self._safe_username(sub=sub, email=email)
with self._client() as client:
if self.is_keycloak:
return self._ensure_user_keycloak(
client,
sub=sub,
email=email,
resolved_username=resolved_username,
display_name=display_name,
is_active=is_active,
idp_user_id=idp_user_id,
)
return self._ensure_user_authentik(
client,
sub=sub,
email=email,
resolved_username=resolved_username,
display_name=display_name,
is_active=is_active,
idp_user_id=idp_user_id,
)
def _ensure_user_authentik(
self,
client: httpx.Client,
*,
sub: str | None,
email: str,
resolved_username: str,
display_name: str | None,
is_active: bool,
idp_user_id: str | None,
) -> AuthentikSyncResult:
payload = { payload = {
"username": resolved_username, "username": resolved_username,
"name": display_name or email, "name": display_name or email,
"email": email, "email": email,
"is_active": is_active, "is_active": is_active,
} }
with self._client() as client:
existing = None existing = None
if idp_user_id is not None: if idp_user_id:
existing = self._lookup_user_by_id(client, idp_user_id) existing = self._ak_lookup_user_by_id(client, idp_user_id)
if existing is None: if existing is None:
existing = self._lookup_user_by_email_or_username(client, email=email, username=resolved_username) existing = self._ak_lookup_user_by_email_or_username(client, email=email, username=resolved_username)
if existing and existing.get("pk") is not None: if existing and existing.get("pk") is not None:
user_pk = int(existing["pk"]) user_pk = str(existing["pk"])
patch_resp = client.patch(f"/api/v3/core/users/{user_pk}/", json=payload) patch_resp = client.patch(f"/api/v3/core/users/{user_pk}/", json=payload)
if patch_resp.status_code >= 400: if patch_resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_update_failed") raise HTTPException(status_code=502, detail="authentik_update_failed")
@@ -136,53 +235,129 @@ class AuthentikAdminService:
if create_resp.status_code >= 400: if create_resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_create_failed") raise HTTPException(status_code=502, detail="authentik_create_failed")
created = create_resp.json() created = create_resp.json()
return AuthentikSyncResult( return AuthentikSyncResult(user_id=str(created["pk"]), action="created", user_sub=created.get("uid"))
user_id=int(created["pk"]),
action="created", def _ensure_user_keycloak(
user_sub=created.get("uid"), self,
) client: httpx.Client,
*,
sub: str | None,
email: str,
resolved_username: str,
display_name: str | None,
is_active: bool,
idp_user_id: str | None,
) -> AuthentikSyncResult:
first_name = display_name or resolved_username
payload = {
"username": resolved_username,
"email": email,
"enabled": is_active,
"emailVerified": True,
"firstName": first_name,
"attributes": {"user_sub": [sub]} if sub else {},
}
existing = None
if idp_user_id:
existing = self._kc_lookup_user_by_id(client, idp_user_id)
if existing is None:
existing = self._kc_lookup_user_by_email_or_username(client, email=email, username=resolved_username)
if existing and existing.get("id"):
user_id = str(existing["id"])
put_resp = client.put(f"/admin/realms/{self.realm}/users/{user_id}", json=payload)
if put_resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_update_failed")
return AuthentikSyncResult(user_id=user_id, action="updated", user_sub=user_id)
create_resp = client.post(f"/admin/realms/{self.realm}/users", json=payload)
if create_resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_create_failed")
user_id: str | None = None
location = create_resp.headers.get("Location", "")
if location and "/" in location:
user_id = location.rstrip("/").split("/")[-1]
if not user_id:
found = self._kc_lookup_user_by_email_or_username(client, email=email, username=resolved_username)
user_id = str(found["id"]) if found and found.get("id") else None
if not user_id:
raise HTTPException(status_code=502, detail="authentik_create_failed")
return AuthentikSyncResult(user_id=user_id, action="created", user_sub=user_id)
def reset_password( def reset_password(
self, self,
*, *,
idp_user_id: int | None, idp_user_id: str | None,
email: str | None, email: str | None,
username: str | None, username: str | None,
) -> AuthentikPasswordResetResult: ) -> AuthentikPasswordResetResult:
with self._client() as client: with self._client() as client:
if self.is_keycloak:
existing = None existing = None
if idp_user_id is not None: if idp_user_id:
existing = self._lookup_user_by_id(client, idp_user_id) existing = self._kc_lookup_user_by_id(client, idp_user_id)
if existing is None: if existing is None:
existing = self._lookup_user_by_email_or_username(client, email=email, username=username) existing = self._kc_lookup_user_by_email_or_username(client, email=email, username=username)
if not existing or not existing.get("id"):
raise HTTPException(status_code=404, detail="authentik_user_not_found")
user_id = str(existing["id"])
temp_password = self._generate_temporary_password()
resp = client.put(
f"/admin/realms/{self.realm}/users/{user_id}/reset-password",
json={"type": "password", "value": temp_password, "temporary": True},
)
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_set_password_failed")
return AuthentikPasswordResetResult(user_id=user_id, temporary_password=temp_password)
existing = None
if idp_user_id:
existing = self._ak_lookup_user_by_id(client, idp_user_id)
if existing is None:
existing = self._ak_lookup_user_by_email_or_username(client, email=email, username=username)
if not existing or existing.get("pk") is None: if not existing or existing.get("pk") is None:
raise HTTPException(status_code=404, detail="authentik_user_not_found") raise HTTPException(status_code=404, detail="authentik_user_not_found")
user_pk = str(existing["pk"])
user_pk = int(existing["pk"])
temp_password = self._generate_temporary_password() temp_password = self._generate_temporary_password()
set_pwd_resp = client.post(f"/api/v3/core/users/{user_pk}/set_password/", json={"password": temp_password}) set_pwd_resp = client.post(f"/api/v3/core/users/{user_pk}/set_password/", json={"password": temp_password})
if set_pwd_resp.status_code >= 400: if set_pwd_resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_set_password_failed") raise HTTPException(status_code=502, detail="authentik_set_password_failed")
return AuthentikPasswordResetResult(user_id=user_pk, temporary_password=temp_password) return AuthentikPasswordResetResult(user_id=user_pk, temporary_password=temp_password)
def delete_user( def delete_user(
self, self,
*, *,
idp_user_id: int | None, idp_user_id: str | None,
email: str | None, email: str | None,
username: str | None, username: str | None,
) -> AuthentikDeleteResult: ) -> AuthentikDeleteResult:
with self._client() as client: with self._client() as client:
if self.is_keycloak:
existing = None existing = None
if idp_user_id is not None: if idp_user_id:
existing = self._lookup_user_by_id(client, idp_user_id) existing = self._kc_lookup_user_by_id(client, idp_user_id)
if existing is None: if existing is None:
existing = self._lookup_user_by_email_or_username(client, email=email, username=username) existing = self._kc_lookup_user_by_email_or_username(client, email=email, username=username)
if not existing or not existing.get("id"):
return AuthentikDeleteResult(action="not_found")
user_id = str(existing["id"])
resp = client.delete(f"/admin/realms/{self.realm}/users/{user_id}")
if resp.status_code in {204, 404}:
return AuthentikDeleteResult(action="deleted" if resp.status_code == 204 else "not_found", user_id=user_id)
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_delete_failed")
return AuthentikDeleteResult(action="deleted", user_id=user_id)
existing = None
if idp_user_id:
existing = self._ak_lookup_user_by_id(client, idp_user_id)
if existing is None:
existing = self._ak_lookup_user_by_email_or_username(client, email=email, username=username)
if not existing or existing.get("pk") is None: if not existing or existing.get("pk") is None:
return AuthentikDeleteResult(action="not_found") return AuthentikDeleteResult(action="not_found")
user_pk = str(existing["pk"])
user_pk = int(existing["pk"])
delete_resp = client.delete(f"/api/v3/core/users/{user_pk}/") delete_resp = client.delete(f"/api/v3/core/users/{user_pk}/")
if delete_resp.status_code in {204, 404}: if delete_resp.status_code in {204, 404}:
return AuthentikDeleteResult( return AuthentikDeleteResult(

View File

@@ -20,7 +20,7 @@ DROP TABLE IF EXISTS permissions CASCADE;
CREATE TABLE users ( CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_sub TEXT NOT NULL UNIQUE, user_sub TEXT NOT NULL UNIQUE,
idp_user_id INTEGER, idp_user_id VARCHAR(128),
username TEXT UNIQUE, username TEXT UNIQUE,
email TEXT UNIQUE, email TEXT UNIQUE,
display_name TEXT, display_name TEXT,

View File

@@ -1,2 +1,2 @@
ALTER TABLE users ALTER TABLE users
ADD COLUMN IF NOT EXISTS idp_user_id INTEGER; ADD COLUMN IF NOT EXISTS idp_user_id VARCHAR(128);

View File

@@ -0,0 +1,6 @@
ALTER TABLE users
ALTER COLUMN idp_user_id TYPE VARCHAR(128)
USING CASE
WHEN idp_user_id IS NULL THEN NULL
ELSE idp_user_id::text
END;

View File

@@ -13,13 +13,13 @@
## 後台安全線 ## 後台安全線
- 所有 `/admin/*` 需 Bearer token - 所有 `/admin/*` 需 Bearer token
- 後端僅依 `ADMIN_REQUIRED_GROUPS` 判定可否進後台 - 後端僅依 `ADMIN_REQUIRED_GROUPS` 判定可否進後台
- 不在群組就算有網址、有 Authentik 帳號也會 403 - 不在群組就算有網址、有 IdP 帳號也會 403
## 會員資料與 Authentik 對齊 ## 會員資料與 IdP 對齊Keycloak 優先)
- `username`:登入帳號(可編輯,可同步) - `username`:登入帳號(可編輯,可同步)
- `display_name`:顯示名稱(可編輯,可同步到 Authentik `name` - `display_name`:顯示名稱(可編輯,可同步到 IdP profile
- `user_sub`:由 Authentik UID 回寫 - `user_sub`:由 IdP 主體識別值回寫
- `idp_user_id`:保留 Authentik user id供更新/密碼重設 - `idp_user_id`:保存 IdP 端 user id(字串),供更新/密碼重設
## 密碼流程 ## 密碼流程
- 目前:後台可觸發重設密碼(產生臨時密碼) - 目前:後台可觸發重設密碼(產生臨時密碼)

View File

@@ -8,7 +8,7 @@
## 主要表 ## 主要表
- `users` - `users`
- `user_sub` UNIQUE - `user_sub` UNIQUE
- `idp_user_id` INTEGER - `idp_user_id` VARCHAR(128)
- `username` UNIQUE - `username` UNIQUE
- `email` UNIQUE - `email` UNIQUE
- `display_name` - `display_name`
@@ -27,10 +27,10 @@
- `scope_type='site'` - `scope_type='site'`
- `action in ('view','edit')` - `action in ('view','edit')`
## 會員與 Authentik 對齊 ## 會員與 IdP 對齊Keycloak 優先)
- `users.user_sub` 對應 Authentik `uid` - `users.user_sub` 對應 IdP 主體識別
- `users.username` 對應 Authentik `username` - `users.username` 對應 IdP `username`
- `users.display_name` 對應 Authentik `name` - `users.display_name` 對應 IdP 顯示名稱
## 快速檢查 SQL ## 快速檢查 SQL
```sql ```sql

View File

@@ -141,7 +141,7 @@ Response:
{ {
"id": "uuid", "id": "uuid",
"user_sub": "authentik-uid", "user_sub": "authentik-uid",
"idp_user_id": 123, "idp_user_id": "idp-user-id-or-uuid",
"username": "chris", "username": "chris",
"email": "chris@ose.tw", "email": "chris@ose.tw",
"display_name": "Chris", "display_name": "Chris",
@@ -166,7 +166,8 @@ Response:
} }
``` ```
### POST `/internal/authentik/users/ensure` ### POST `/internal/idp/users/ensure`
(相容路徑:`/internal/authentik/users/ensure`
Request: Request:
```json ```json
{ {
@@ -181,7 +182,7 @@ Request:
Response: Response:
```json ```json
{ {
"idp_user_id": 123, "idp_user_id": "idp-user-id-or-uuid",
"action": "created" "action": "created"
} }
``` ```

View File

@@ -16,7 +16,8 @@ npm run dev
## 3) 重要環境變數 ## 3) 重要環境變數
- `backend/.env.development` - `backend/.env.development`
- `ADMIN_REQUIRED_GROUPS=member-admin` - `ADMIN_REQUIRED_GROUPS=member-admin`
- `AUTHENTIK_*` 需可連到 Authentik - 優先使用 `KEYCLOAK_*`(若有設定 `KEYCLOAK_BASE_URL + KEYCLOAK_REALM`
- 未設定 Keycloak 時,才使用 `AUTHENTIK_*` 備援
## 4) 基本檢查 ## 4) 基本檢查
- `GET http://127.0.0.1:8000/healthz` - `GET http://127.0.0.1:8000/healthz`
@@ -24,7 +25,7 @@ npm run dev
- 非 admin 群組帳號打 `/admin/*` 應回 `403` - 非 admin 群組帳號打 `/admin/*` 應回 `403`
## 5) 會員流程驗收 ## 5) 會員流程驗收
1. 新增會員username/email/display_name開啟 sync_to_authentik 1. 新增會員username/email/display_name開啟 sync_to_authentik;此旗標目前代表「同步到外部 IdP」
2. 確認列表可看到新會員與 `user_sub` 2. 確認列表可看到新會員與 `user_sub`
3. 點「重設密碼」,取得臨時密碼 3. 點「重設密碼」,取得臨時密碼
4. 到 Authentik 驗證該會員可用新密碼登入 4. Keycloak Authentik驗證該會員可用新密碼登入

View File

@@ -5,7 +5,8 @@ export const getOidcAuthorizeUrl = (redirectUri, options = {}) =>
params: { params: {
redirect_uri: redirectUri, redirect_uri: redirectUri,
login_hint: options.loginHint || undefined, login_hint: options.loginHint || undefined,
prompt: options.prompt || undefined prompt: options.prompt || undefined,
idp_hint: options.idpHint || undefined
} }
}) })

View File

@@ -97,8 +97,10 @@ async function handleOidcLogin() {
oidcLoading.value = true oidcLoading.value = true
error.value = '' error.value = ''
try { try {
const googleIdpHint = import.meta.env.VITE_OIDC_IDP_HINT_GOOGLE || undefined
await redirectToOidc({ await redirectToOidc({
prompt: 'select_account' prompt: 'select_account',
idpHint: googleIdpHint
}) })
} catch (err) { } catch (err) {
error.value = err.message || '登入失敗,請稍後再試' error.value = err.message || '登入失敗,請稍後再試'