Rebuild auth on Keycloak OIDC, fix rootless Ansible deploy
Replaces the single shared HTTP Basic service-account credential (which caused a production outage from a username mismatch) with per-user login: Keycloak (already running on this VM for gcnm, now also fronted on auth.friessn.de with its own "homekeeper" realm) authenticates the user once via the landing page, FastAPI verifies the OIDC id_token and mints its own signed session JWT as a cookie, and both Shiny apps forward that per-session token as a Bearer credential instead of a static shared one. Authorization is a simple ALLOWED_USERS allowlist; the old auth.users table and bcrypt seeding are gone entirely. Also carries forward the in-progress rootless Podman/Quadlet migration (gitea, homekeeper, podman roles) and fixes a pre-existing bug where each role's handlers were malformed inside tasks/main.yml instead of their own handlers/main.yml, which broke ansible-playbook entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbiCdckkTX2HyAkyi1R39d
This commit is contained in:
+66
-56
@@ -1,67 +1,77 @@
|
||||
import os
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import text
|
||||
import time
|
||||
from functools import lru_cache
|
||||
|
||||
security = HTTPBasic()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request, status
|
||||
from jose import JWTError, jwt
|
||||
|
||||
COOKIE_NAME = "hk_session"
|
||||
SESSION_JWT_ALG = "HS256"
|
||||
SESSION_TTL_SECONDS = 30 * 24 * 3600 # 30 days — low-traffic personal app, optimize for staying logged in
|
||||
|
||||
SESSION_JWT_SECRET = os.getenv("SESSION_JWT_SECRET", "")
|
||||
# Full issuer URL including realm, e.g. https://auth.friessn.de/realms/homekeeper (Keycloak)
|
||||
OIDC_ISSUER_URL = os.getenv("OIDC_ISSUER_URL", "").rstrip("/")
|
||||
OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID", "")
|
||||
OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET", "")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
def _allowed_users() -> set[str]:
|
||||
return {u.strip() for u in os.getenv("ALLOWED_USERS", "").split(",") if u.strip()}
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return pwd_context.hash(plain)
|
||||
def is_allowed_user(username: str) -> bool:
|
||||
return username in _allowed_users()
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPBasicCredentials = Depends(security)):
|
||||
from app.database import engine
|
||||
def mint_session_jwt(username: str) -> str:
|
||||
now = int(time.time())
|
||||
payload = {"sub": username, "iat": now, "exp": now + SESSION_TTL_SECONDS}
|
||||
return jwt.encode(payload, SESSION_JWT_SECRET, algorithm=SESSION_JWT_ALG)
|
||||
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT hashed_password FROM auth.users WHERE username = :u"),
|
||||
{"u": credentials.username},
|
||||
).fetchone()
|
||||
|
||||
if row is None or not verify_password(credentials.password, row[0]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Basic"},
|
||||
def get_current_user(request: Request) -> str:
|
||||
"""Accepts either 'Authorization: Bearer <jwt>' (used by the Shiny apps)
|
||||
or the hk_session cookie (used by same-origin browser fetches from the
|
||||
landing page)."""
|
||||
token = None
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header[7:]
|
||||
if not token:
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, SESSION_JWT_SECRET, algorithms=[SESSION_JWT_ALG])
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session")
|
||||
return payload["sub"]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def oidc_config() -> dict:
|
||||
resp = httpx.get(f"{OIDC_ISSUER_URL}/.well-known/openid-configuration", timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _oidc_jwks() -> dict:
|
||||
resp = httpx.get(oidc_config()["jwks_uri"], timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def verify_oidc_id_token(id_token: str) -> dict:
|
||||
try:
|
||||
return jwt.decode(
|
||||
id_token,
|
||||
_oidc_jwks(),
|
||||
algorithms=["RS256"],
|
||||
audience=OIDC_CLIENT_ID,
|
||||
)
|
||||
return credentials.username
|
||||
|
||||
|
||||
def seed_users():
|
||||
"""Seed initial users from INITIAL_USERS env var if users table is empty."""
|
||||
from app.database import engine
|
||||
|
||||
initial_users_env = os.getenv("INITIAL_USERS", "")
|
||||
if not initial_users_env:
|
||||
return
|
||||
|
||||
with engine.connect() as conn:
|
||||
count = conn.execute(text("SELECT COUNT(*) FROM auth.users")).scalar()
|
||||
if count and count > 0:
|
||||
return
|
||||
|
||||
for pair in initial_users_env.split(","):
|
||||
pair = pair.strip()
|
||||
if ":" not in pair:
|
||||
continue
|
||||
username, password = pair.split(":", 1)
|
||||
username = username.strip()
|
||||
password = password.strip()
|
||||
if not username or not password:
|
||||
continue
|
||||
hashed = hash_password(password)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO auth.users (username, hashed_password) "
|
||||
"VALUES (:u, :h) ON CONFLICT (username) DO NOTHING"
|
||||
),
|
||||
{"u": username, "h": hashed},
|
||||
)
|
||||
conn.commit()
|
||||
except JWTError as e:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid OIDC token: {e}")
|
||||
|
||||
+1
-15
@@ -21,20 +21,8 @@ def get_session():
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Create schemas and tables, then seed initial users."""
|
||||
from app.auth import seed_users
|
||||
|
||||
"""Create schemas and tables."""
|
||||
with engine.connect() as conn:
|
||||
# Create auth schema and users table
|
||||
conn.execute(text("CREATE SCHEMA IF NOT EXISTS auth"))
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS auth.users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
hashed_password TEXT NOT NULL
|
||||
)
|
||||
"""))
|
||||
|
||||
# Create lk schema and tables
|
||||
conn.execute(text("CREATE SCHEMA IF NOT EXISTS lk"))
|
||||
conn.execute(text("""
|
||||
@@ -239,5 +227,3 @@ def init_db():
|
||||
pass
|
||||
|
||||
conn.commit()
|
||||
|
||||
seed_users()
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import FastAPI
|
||||
|
||||
from app.routers import (
|
||||
apiaries,
|
||||
auth,
|
||||
colonies,
|
||||
inspections,
|
||||
varroa,
|
||||
@@ -29,6 +30,7 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/v1/auth", tags=["auth"])
|
||||
app.include_router(apiaries.router, prefix="/v1/apiaries", tags=["apiaries"])
|
||||
app.include_router(colonies.router, prefix="/v1/colonies", tags=["colonies"])
|
||||
app.include_router(inspections.router, prefix="/v1/inspections", tags=["inspections"])
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import os
|
||||
import secrets
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.auth import (
|
||||
COOKIE_NAME,
|
||||
OIDC_CLIENT_ID,
|
||||
OIDC_CLIENT_SECRET,
|
||||
SESSION_TTL_SECONDS,
|
||||
get_current_user,
|
||||
is_allowed_user,
|
||||
mint_session_jwt,
|
||||
oidc_config,
|
||||
verify_oidc_id_token,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ENV = os.getenv("ENV", "production")
|
||||
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "true").lower() == "true"
|
||||
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://localhost").rstrip("/")
|
||||
STATE_COOKIE = "hk_oauth_state"
|
||||
CALLBACK_PATH = "/api/v1/auth/callback"
|
||||
|
||||
|
||||
def _set_session_cookie(resp, token: str):
|
||||
resp.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=SESSION_TTL_SECONDS,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=COOKIE_SECURE,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def login():
|
||||
config = oidc_config()
|
||||
state = secrets.token_urlsafe(24)
|
||||
params = {
|
||||
"client_id": OIDC_CLIENT_ID,
|
||||
"redirect_uri": f"{PUBLIC_BASE_URL}{CALLBACK_PATH}",
|
||||
"response_type": "code",
|
||||
"scope": "openid profile",
|
||||
"state": state,
|
||||
}
|
||||
resp = RedirectResponse(f"{config['authorization_endpoint']}?{urlencode(params)}")
|
||||
resp.set_cookie(STATE_COOKIE, state, max_age=600, path="/", httponly=True, secure=COOKIE_SECURE, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
def callback(request: Request, code: str | None = Query(None), state: str | None = Query(None), error: str | None = Query(None)):
|
||||
if error:
|
||||
raise HTTPException(status_code=400, detail=f"OIDC error: {error}")
|
||||
|
||||
expected_state = request.cookies.get(STATE_COOKIE)
|
||||
if not code or not state or not expected_state or state != expected_state:
|
||||
raise HTTPException(status_code=400, detail="Invalid OAuth state")
|
||||
|
||||
config = oidc_config()
|
||||
token_resp = httpx.post(
|
||||
config["token_endpoint"],
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": f"{PUBLIC_BASE_URL}{CALLBACK_PATH}",
|
||||
"client_id": OIDC_CLIENT_ID,
|
||||
"client_secret": OIDC_CLIENT_SECRET,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
token_resp.raise_for_status()
|
||||
id_token = token_resp.json()["id_token"]
|
||||
claims = verify_oidc_id_token(id_token)
|
||||
username = claims.get("preferred_username") or claims.get("name")
|
||||
|
||||
if not username or not is_allowed_user(username):
|
||||
raise HTTPException(status_code=403, detail=f"User '{username}' is not allowed to access Homekeeper")
|
||||
|
||||
resp = RedirectResponse("/")
|
||||
_set_session_cookie(resp, mint_session_jwt(username))
|
||||
resp.delete_cookie(STATE_COOKIE, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
def logout():
|
||||
resp = RedirectResponse("/")
|
||||
resp.delete_cookie(COOKIE_NAME, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(username: str = Depends(get_current_user)):
|
||||
return {"username": username}
|
||||
|
||||
|
||||
@router.get("/dev-login")
|
||||
def dev_login(username: str = Query(...)):
|
||||
"""Local-dev-only bypass: mints a session for `username` without touching
|
||||
the OIDC provider. Only reachable when ENV=development."""
|
||||
if ENV != "development":
|
||||
raise HTTPException(status_code=404)
|
||||
if not is_allowed_user(username):
|
||||
raise HTTPException(status_code=403, detail=f"User '{username}' is not in ALLOWED_USERS")
|
||||
|
||||
resp = RedirectResponse("/")
|
||||
_set_session_cookie(resp, mint_session_jwt(username))
|
||||
return resp
|
||||
@@ -2,6 +2,6 @@ fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
sqlmodel>=0.0.21
|
||||
psycopg2-binary>=2.9
|
||||
passlib[bcrypt]>=1.7
|
||||
bcrypt>=4.0,<5.0
|
||||
python-jose[cryptography]>=3.3
|
||||
httpx>=0.27
|
||||
python-multipart>=0.0.9
|
||||
|
||||
Reference in New Issue
Block a user