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 authlib.integrations.starlette_client import OAuthError
from app import models, schemas
from app.auth import create_access_token, get_current_user, hash_password, verify_password
from app.database import get_db
from app.auth import create_access_token, get_current_user, oauth
from app.database import get_db, settings
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")
@router.get("/oidc/login")
async def oidc_login(request: Request) -> RedirectResponse:
return await oauth.keycloak.authorize_redirect(request, settings.keycloak_redirect_uri)
@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(
username=body.username,
email=body.email,
hashed_password=hash_password(body.password),
keycloak_sub=sub,
email=email,
username=username,
hashed_password=None,
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login")
def login(body: schemas.LoginRequest, response: Response, db: Session = Depends(get_db)) -> dict:
user = db.query(models.User).filter(models.User.username == body.username).first()
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)
access_token = create_access_token(user.id)
response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
response.set_cookie(
key="access_token",
value=token,
value=access_token,
httponly=True,
samesite="lax",
max_age=3600,
)
return {"message": "Logged in"}
return response
@router.post("/logout")

View File

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

View File

@@ -1,9 +1,12 @@
from fastapi import FastAPI
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.database import settings
app = FastAPI(title="Shorefront", version="0.1.0")
app.add_middleware(SessionMiddleware, secret_key=settings.jwt_secret_key)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:80"],