feat(sso): replace local auth with Keycloak OIDC callback flow

This commit is contained in:
2026-03-01 00:51:14 +01:00
parent f28240c37f
commit 1daa6f6e90
3 changed files with 59 additions and 37 deletions

View File

@@ -1,43 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from authlib.integrations.starlette_client import OAuthError
from app import models, schemas from app import models, schemas
from app.auth import create_access_token, get_current_user, hash_password, verify_password from app.auth import create_access_token, get_current_user, oauth
from app.database import get_db from app.database import get_db, settings
router = APIRouter() router = APIRouter()
FIREWALL_ADMINS_GROUP = "firewall admins"
@router.post("/register", response_model=schemas.UserOut, status_code=201)
def register(body: schemas.UserCreate, db: Session = Depends(get_db)) -> models.User:
if db.query(models.User).filter(models.User.username == body.username).first():
raise HTTPException(status_code=400, detail="Username already registered")
if db.query(models.User).filter(models.User.email == body.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
user = models.User(
username=body.username,
email=body.email,
hashed_password=hash_password(body.password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login") @router.get("/oidc/login")
def login(body: schemas.LoginRequest, response: Response, db: Session = Depends(get_db)) -> dict: async def oidc_login(request: Request) -> RedirectResponse:
user = db.query(models.User).filter(models.User.username == body.username).first() return await oauth.keycloak.authorize_redirect(request, settings.keycloak_redirect_uri)
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(user.id) @router.get("/oidc/callback")
async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> RedirectResponse:
try:
token = await oauth.keycloak.authorize_access_token(request)
except OAuthError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
userinfo = token.get("userinfo") or {}
groups = userinfo.get("groups", [])
if FIREWALL_ADMINS_GROUP not in groups:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not in firewall admins group")
sub = userinfo["sub"]
email = userinfo.get("email", "")
username = userinfo.get("preferred_username", sub)
user = db.query(models.User).filter(models.User.keycloak_sub == sub).first()
if not user:
user = models.User(
keycloak_sub=sub,
email=email,
username=username,
hashed_password=None,
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
access_token = create_access_token(user.id)
response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
response.set_cookie( response.set_cookie(
key="access_token", key="access_token",
value=token, value=access_token,
httponly=True, httponly=True,
samesite="lax", samesite="lax",
max_age=3600, max_age=3600,
) )
return {"message": "Logged in"} return response
@router.post("/logout") @router.post("/logout")

View File

@@ -1,21 +1,23 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from authlib.integrations.starlette_client import OAuth
from fastapi import Cookie, HTTPException, status, Depends from fastapi import Cookie, HTTPException, status, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db, settings from app.database import get_db, settings
from app import models from app import models
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth = OAuth()
oauth.register(
name="keycloak",
def hash_password(password: str) -> str: client_id=settings.keycloak_client_id,
return pwd_context.hash(password) client_secret=settings.keycloak_client_secret,
server_metadata_url=(
f"{settings.keycloak_url}/realms/{settings.keycloak_realm}"
def verify_password(plain: str, hashed: str) -> bool: "/.well-known/openid-configuration"
return pwd_context.verify(plain, hashed) ),
client_kwargs={"scope": "openid email profile"},
)
def create_access_token(user_id: int) -> str: def create_access_token(user_id: int) -> str:

View File

@@ -1,9 +1,12 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.api import auth, configs, zones, interfaces, policies, rules, masq from app.api import auth, configs, zones, interfaces, policies, rules, masq
from app.database import settings
app = FastAPI(title="Shorefront", version="0.1.0") app = FastAPI(title="Shorefront", version="0.1.0")
app.add_middleware(SessionMiddleware, secret_key=settings.jwt_secret_key)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:80"], allow_origins=["http://localhost:5173", "http://localhost:80"],