From 8f06f75cca9bb6f1cb92e04bb68e79594efe54b6 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 30 Mar 2026 00:52:09 +0800 Subject: [PATCH] feat: add username-password login flow via authentik token endpoint --- .env.development | 2 ++ .env.example | 2 ++ .env.production.example | 2 ++ README.md | 3 +++ app/api/auth.py | 57 +++++++++++++++++++++++++++++++++++++++++ app/core/config.py | 2 ++ app/main.py | 2 ++ app/schemas/login.py | 13 ++++++++++ 8 files changed, 83 insertions(+) create mode 100644 app/api/auth.py create mode 100644 app/schemas/login.py diff --git a/.env.development b/.env.development index 9732013..2a3b9a3 100644 --- a/.env.development +++ b/.env.development @@ -14,7 +14,9 @@ AUTHENTIK_VERIFY_TLS=true AUTHENTIK_ISSUER=https://auth.ose.tw/application/o/member-ose-frontend/ AUTHENTIK_JWKS_URL=https://auth.ose.tw/application/o/member-ose-frontend/jwks/ AUTHENTIK_AUDIENCE=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN +AUTHENTIK_CLIENT_ID=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN AUTHENTIK_CLIENT_SECRET=MHTv0SHkIuic9Quk8Br9jB9gzT2bERvRfhHU4ogPlUtY3eBEXJj80RTEp3zpFBUXQ8PAwYrihWfNqKawWUOmKpQd8SwuyiAuVwLJTS7vB3LGvx1XtXqgMhR76EL2mLnP +AUTHENTIK_TOKEN_ENDPOINT=https://auth.ose.tw/application/o/token/ PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 INTERNAL_SHARED_SECRET=CHANGE_ME diff --git a/.env.example b/.env.example index 9021329..ba37b10 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,9 @@ AUTHENTIK_VERIFY_TLS=false AUTHENTIK_ISSUER= AUTHENTIK_JWKS_URL= AUTHENTIK_AUDIENCE= +AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= +AUTHENTIK_TOKEN_ENDPOINT= PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw INTERNAL_SHARED_SECRET=CHANGE_ME diff --git a/.env.production.example b/.env.production.example index b4bde73..3afbb04 100644 --- a/.env.production.example +++ b/.env.production.example @@ -14,7 +14,9 @@ AUTHENTIK_VERIFY_TLS=false AUTHENTIK_ISSUER= AUTHENTIK_JWKS_URL= AUTHENTIK_AUDIENCE= +AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= +AUTHENTIK_TOKEN_ENDPOINT= PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw INTERNAL_SHARED_SECRET=CHANGE_ME diff --git a/README.md b/README.md index dd96435..401e095 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' - `AUTHENTIK_ISSUER` (the service infers `/jwks/`) - Optional: - `AUTHENTIK_AUDIENCE` (enables audience claim validation) + - `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 Admin API setup @@ -40,6 +42,7 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' ## Main APIs - `GET /healthz` +- `POST /auth/login` - `GET /me` (Bearer token required) - `GET /me/permissions/snapshot` (Bearer token required) - `POST /internal/users/upsert-by-sub` diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..d631383 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,57 @@ +from urllib.parse import urljoin + +import httpx +from fastapi import APIRouter, HTTPException, status + +from app.core.config import get_settings +from app.schemas.login import LoginRequest, LoginResponse + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login", response_model=LoginResponse) +def login(payload: LoginRequest) -> LoginResponse: + settings = get_settings() + client_id = settings.authentik_client_id or settings.authentik_audience + + if not settings.authentik_base_url or not client_id or not settings.authentik_client_secret: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured") + + token_endpoint = settings.authentik_token_endpoint or urljoin( + settings.authentik_base_url.rstrip("/") + "/", "application/o/token/" + ) + + form = { + "grant_type": "password", + "client_id": client_id, + "client_secret": settings.authentik_client_secret, + "username": payload.username, + "password": payload.password, + "scope": "openid profile email", + } + + try: + resp = httpx.post( + token_endpoint, + data=form, + timeout=10, + verify=settings.authentik_verify_tls, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + except Exception as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc + + if resp.status_code >= 400: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password") + + data = resp.json() + token = data.get("access_token") + if not token: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_access_token") + + return LoginResponse( + access_token=token, + token_type=data.get("token_type", "Bearer"), + expires_in=data.get("expires_in"), + scope=data.get("scope"), + ) diff --git a/app/core/config.py b/app/core/config.py index c7d9969..63175ed 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -23,7 +23,9 @@ class Settings(BaseSettings): authentik_issuer: str = "" authentik_jwks_url: str = "" authentik_audience: str = "" + authentik_client_id: str = "" authentik_client_secret: str = "" + authentik_token_endpoint: str = "" public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"] internal_shared_secret: str = "" diff --git a/app/main.py b/app/main.py index 8d776fd..7d35d36 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.admin import router as admin_router +from app.api.auth import router as auth_router from app.api.internal import router as internal_router from app.api.me import router as me_router from app.core.config import get_settings @@ -26,3 +27,4 @@ def healthz() -> dict[str, str]: app.include_router(internal_router) app.include_router(admin_router) app.include_router(me_router) +app.include_router(auth_router) diff --git a/app/schemas/login.py b/app/schemas/login.py new file mode 100644 index 0000000..ffb2e18 --- /dev/null +++ b/app/schemas/login.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "Bearer" + expires_in: int | None = None + scope: str | None = None