101 lines
3.3 KiB
Python
101 lines
3.3 KiB
Python
"""Auth orchestration shared by the JSON API and the HTMX UI.
|
|
|
|
Covers credential verification (LDAP + local) and token issuance / rotation.
|
|
"""
|
|
|
|
import jwt
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import settings
|
|
from app.core.security import (
|
|
REFRESH_TOKEN_TYPE,
|
|
create_access_token,
|
|
create_refresh_token,
|
|
decode_token,
|
|
)
|
|
from app.crud import token as token_crud
|
|
from app.crud import user as user_crud
|
|
from app.models.user import User
|
|
from app.services import ldap as ldap_service
|
|
|
|
|
|
async def authenticate(db: AsyncSession, username: str, password: str) -> User | None:
|
|
"""Verify credentials and return the matching active user, or ``None``.
|
|
|
|
When LDAP is enabled the directory is tried first; on a successful bind the
|
|
local user row is provisioned/refreshed. Local database authentication is
|
|
always attempted as a fallback, so pre-existing accounts (e.g. the seeded
|
|
superuser) keep working alongside LDAP.
|
|
"""
|
|
if settings.ldap_enabled:
|
|
ldap_user = await ldap_service.authenticate_ldap(username, password)
|
|
if ldap_user is not None:
|
|
user = await user_crud.upsert_ldap_user(
|
|
db,
|
|
email=ldap_user.email,
|
|
full_name=ldap_user.full_name,
|
|
default_role_names=settings.ldap_default_roles,
|
|
)
|
|
return user if user.is_active else None
|
|
|
|
# Local password auth (the only path when LDAP is disabled).
|
|
user = await user_crud.authenticate(db, username, password)
|
|
if user is not None and not user.is_active:
|
|
return None
|
|
return user
|
|
|
|
|
|
async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]:
|
|
"""Create an access + refresh token and persist the refresh token for revocation."""
|
|
access = create_access_token(user.id)
|
|
refresh, payload = create_refresh_token(user.id)
|
|
await token_crud.store(
|
|
db,
|
|
user_id=user.id,
|
|
jti=payload["jti"],
|
|
raw_token=refresh,
|
|
expires_at=payload["exp"], # timezone-aware UTC datetime
|
|
)
|
|
return access, refresh
|
|
|
|
|
|
class RefreshError(Exception):
|
|
pass
|
|
|
|
|
|
async def rotate_refresh_token(
|
|
db: AsyncSession, raw_refresh: str
|
|
) -> tuple[str, str, User]:
|
|
"""Validate a refresh token, revoke it, and issue a fresh pair.
|
|
|
|
Raises ``RefreshError`` if the token is invalid, expired, or already revoked.
|
|
"""
|
|
try:
|
|
payload = decode_token(raw_refresh)
|
|
except jwt.PyJWTError as exc:
|
|
raise RefreshError("Invalid refresh token") from exc
|
|
|
|
if payload.get("type") != REFRESH_TOKEN_TYPE:
|
|
raise RefreshError("Not a refresh token")
|
|
|
|
jti = payload.get("jti")
|
|
sub = payload.get("sub")
|
|
if not jti or sub is None:
|
|
raise RefreshError("Malformed refresh token")
|
|
|
|
record = await token_crud.get_active(db, jti=jti, raw_token=raw_refresh)
|
|
if record is None:
|
|
raise RefreshError("Refresh token is revoked or expired")
|
|
|
|
# Local import avoids a circular import at module load time.
|
|
from app.crud import user as user_crud
|
|
|
|
user = await user_crud.get(db, int(sub))
|
|
if user is None or not user.is_active:
|
|
raise RefreshError("User is inactive or missing")
|
|
|
|
# Rotate: revoke the presented token, then mint a new pair.
|
|
await token_crud.revoke(db, record)
|
|
access, refresh = await issue_token_pair(db, user)
|
|
return access, refresh, user
|