2026-03-16 11:26:16 -03:00
import logging
2026-03-16 09:47:02 -03:00
from auth_gateway . models . auth import OIDCMethodModel
2026-03-16 11:26:16 -03:00
logger = logging . getLogger ( " uvicorn.error " )
2026-03-16 09:47:02 -03:00
from auth_gateway . services . oidc_service import is_oidc_identity_allowed
from auth_gateway . services . password_service import verify_user_password
from auth_gateway . services . policy_engine import evaluate_ip_access , extract_client_ip
from auth_gateway . services . resolver import normalize_host
from auth_gateway . services . totp_service import verify_totp
from auth_gateway . web . dependencies import (
build_external_url ,
get_effective_expiration ,
get_effective_policy ,
get_oidc_method ,
2026-03-16 20:23:18 -03:00
get_or_create_csrf_token ,
2026-03-16 09:47:02 -03:00
get_runtime_config ,
get_session ,
get_totp_method ,
resolve_context_from_request ,
2026-03-16 20:23:18 -03:00
set_csrf_cookie ,
2026-03-16 09:47:02 -03:00
session_is_allowed ,
2026-03-16 20:23:18 -03:00
validate_csrf ,
2026-03-16 09:47:02 -03:00
)
2026-03-16 13:42:20 -03:00
from auth_gateway . limiter import AUTH_RATE_LIMIT , limiter
2026-03-16 20:23:18 -03:00
from fastapi import APIRouter , Form , HTTPException , Request
2026-03-16 09:47:02 -03:00
from fastapi . responses import HTMLResponse , RedirectResponse
router = APIRouter ( )
def _render ( request : Request , template_name : str , status_code : int = 200 , * * context ) :
templates = request . app . state . templates
base_context = {
" request " : request ,
" external_path " : request . app . state . settings . external_path ,
}
base_context . update ( context )
2026-03-16 20:23:18 -03:00
response = templates . TemplateResponse ( template_name , base_context , status_code = status_code )
csrf_token = context . get ( " csrf_token " )
if csrf_token :
set_csrf_cookie ( request , response , csrf_token )
return response
2026-03-16 09:47:02 -03:00
def _redirect_with_cookie ( request : Request , destination : str , session ) - > RedirectResponse :
response = RedirectResponse ( destination , status_code = 303 )
response . set_cookie (
key = request . app . state . settings . cookie_name ,
value = session . session_id ,
httponly = True ,
secure = request . app . state . settings . secure_cookies ,
2026-03-16 19:35:24 -03:00
samesite = " strict " ,
2026-03-16 09:47:02 -03:00
path = " / " ,
)
return response
2026-03-16 20:23:18 -03:00
def _create_authenticated_session ( request : Request , * , username = None , email = None , subject = None , groups = None , metadata = None , factors = None , expires_in_minutes = None ) :
existing_session = get_session ( request )
if existing_session :
request . app . state . session_service . delete_session ( existing_session . session_id )
return request . app . state . session_service . issue_session (
username = username ,
email = email ,
subject = subject ,
groups = groups ,
metadata = metadata ,
add_factors = factors ,
expires_in_minutes = expires_in_minutes ,
)
def _csrf_error ( request : Request , next_path : str , application_name : str | None = None ) :
return _render (
request ,
" error.html " ,
status_code = 403 ,
title = " Invalid form submission " ,
message = " The form security token is missing or invalid. Please try again. " ,
next = next_path ,
application_name = application_name ,
csrf_token = get_or_create_csrf_token ( request ) ,
)
2026-03-16 10:34:10 -03:00
@router.get ( " / " , response_class = HTMLResponse )
async def session_page ( request : Request ) :
session = get_session ( request )
if not session or not session . auth_factors :
return RedirectResponse ( build_external_url ( request , " /login " ) , status_code = 303 )
2026-03-16 20:23:18 -03:00
return _render ( request , " session.html " , session = session , csrf_token = get_or_create_csrf_token ( request ) )
2026-03-16 10:34:10 -03:00
2026-03-16 09:47:02 -03:00
@router.get ( " /login " , response_class = HTMLResponse )
async def login_page ( request : Request , next : str = " / " ) :
runtime_config = get_runtime_config ( request )
session = get_session ( request )
context = resolve_context_from_request ( request , runtime_config , next )
effective_policy = get_effective_policy ( runtime_config , context . policy_name )
2026-03-16 14:16:28 -03:00
if effective_policy . mode == " error " :
return _render ( request , " error.html " , status_code = 500 , title = " Configuration error " , message = effective_policy . error_message or " A policy configuration error has been detected. " )
2026-03-16 09:47:02 -03:00
if effective_policy . mode == " deny " :
return _render ( request , " error.html " , status_code = 403 , title = " Access denied " , message = " This route is blocked by policy. " )
if effective_policy . mode == " bypass " :
2026-03-16 19:53:05 -03:00
return RedirectResponse ( context . path , status_code = 303 )
2026-03-16 09:47:02 -03:00
if session and session_is_allowed ( session , effective_policy ) :
current_factors = set ( session . auth_factors )
if effective_policy . ip_method_names :
client_ip = extract_client_ip ( request . headers . get ( " x-forwarded-for " , " " ) )
if evaluate_ip_access ( runtime_config , effective_policy , client_ip ) :
current_factors . add ( " ip " )
missing_factors = [ factor for factor in effective_policy . required_factors if factor not in current_factors ]
if not missing_factors :
2026-03-16 19:53:05 -03:00
return RedirectResponse ( context . path , status_code = 303 )
2026-03-16 09:47:02 -03:00
if missing_factors == [ " totp " ] :
2026-03-16 19:53:05 -03:00
return RedirectResponse ( build_external_url ( request , " /login/totp " , next = context . path ) , status_code = 303 )
2026-03-16 09:47:02 -03:00
if missing_factors == [ " oidc " ] :
2026-03-16 19:53:05 -03:00
return RedirectResponse ( build_external_url ( request , " /login/oidc/start " , next = context . path ) , status_code = 303 )
2026-03-16 09:47:02 -03:00
available_methods = [ ]
if effective_policy . password_method_names :
available_methods . append ( " password " )
if effective_policy . oidc_method_names :
available_methods . append ( " oidc " )
if effective_policy . totp_method_names and not effective_policy . password_method_names and not effective_policy . oidc_method_names :
available_methods . append ( " totp " )
if available_methods == [ " password " ] :
2026-03-16 19:53:05 -03:00
return RedirectResponse ( build_external_url ( request , " /login/password " , next = context . path ) , status_code = 303 )
2026-03-16 09:47:02 -03:00
if available_methods == [ " oidc " ] :
2026-03-16 19:53:05 -03:00
return RedirectResponse ( build_external_url ( request , " /login/oidc/start " , next = context . path ) , status_code = 303 )
2026-03-16 09:47:02 -03:00
if available_methods == [ " totp " ] :
2026-03-16 19:53:05 -03:00
return RedirectResponse ( build_external_url ( request , " /login/totp " , next = context . path ) , status_code = 303 )
2026-03-16 09:47:02 -03:00
return _render (
request ,
" login.html " ,
2026-03-16 19:53:05 -03:00
next = context . path ,
2026-03-16 09:47:02 -03:00
methods = available_methods ,
application_name = context . application . name ,
policy_name = context . policy_name ,
)
@router.get ( " /login/password " , response_class = HTMLResponse )
async def login_password_page ( request : Request , next : str = " / " ) :
runtime_config = get_runtime_config ( request )
context = resolve_context_from_request ( request , runtime_config , next )
effective_policy = get_effective_policy ( runtime_config , context . policy_name )
if not effective_policy . password_method_names :
return _render ( request , " error.html " , status_code = 400 , title = " Password login unavailable " , message = " The selected policy does not require a password step. " )
2026-03-16 20:23:18 -03:00
return _render (
request ,
" login_password.html " ,
next = next ,
application_name = context . application . name ,
error = None ,
csrf_token = get_or_create_csrf_token ( request ) ,
)
2026-03-16 09:47:02 -03:00
@router.post ( " /login/password " )
2026-03-16 13:42:20 -03:00
@limiter.limit ( AUTH_RATE_LIMIT )
2026-03-16 20:23:18 -03:00
async def login_password_submit (
request : Request ,
next : str = Form ( " / " ) ,
username : str = Form ( . . . ) ,
password : str = Form ( . . . ) ,
csrf_token : str = Form ( . . . ) ,
) :
2026-03-16 09:47:02 -03:00
runtime_config = get_runtime_config ( request )
context = resolve_context_from_request ( request , runtime_config , next )
effective_policy = get_effective_policy ( runtime_config , context . policy_name )
2026-03-16 20:23:18 -03:00
try :
validate_csrf ( request , csrf_token )
except HTTPException :
return _csrf_error ( request , next , context . application . name )
2026-03-16 09:47:02 -03:00
user = verify_user_password ( username , password , runtime_config . users )
if not user :
2026-03-16 11:26:16 -03:00
logger . warning ( " AUTH password failed for ' %s ' (policy: %s ) " , username , context . policy_name )
2026-03-16 20:23:18 -03:00
return _render (
request ,
" login_password.html " ,
status_code = 401 ,
next = next ,
application_name = context . application . name ,
error = " Invalid username or password. " ,
csrf_token = get_or_create_csrf_token ( request ) ,
)
2026-03-16 09:47:02 -03:00
if effective_policy . allowed_users and username not in effective_policy . allowed_users :
2026-03-16 11:26:16 -03:00
logger . warning ( " AUTH password denied for ' %s ' — not in allowed_users (policy: %s ) " , username , context . policy_name )
2026-03-16 20:23:18 -03:00
return _render (
request ,
" login_password.html " ,
status_code = 403 ,
next = next ,
application_name = context . application . name ,
error = " This user is not allowed by the active policy. " ,
csrf_token = get_or_create_csrf_token ( request ) ,
)
2026-03-16 09:47:02 -03:00
groups = [
group_name
for group_name , group in runtime_config . groups . items ( )
if username in group . users
]
2026-03-16 20:23:18 -03:00
session = _create_authenticated_session (
request ,
2026-03-16 09:47:02 -03:00
username = username ,
email = user . email or None ,
groups = groups ,
2026-03-16 20:23:18 -03:00
factors = [ " password " ] ,
2026-03-16 09:47:02 -03:00
expires_in_minutes = get_effective_expiration ( request , effective_policy , [ " password " ] ) ,
)
if effective_policy . totp_method_names :
2026-03-16 11:26:16 -03:00
logger . info ( " AUTH password ok for ' %s ' → totp required (policy: %s ) " , username , context . policy_name )
2026-03-16 19:47:48 -03:00
return _redirect_with_cookie ( request , build_external_url ( request , " /login/totp " , next = context . path ) , session )
2026-03-16 11:26:16 -03:00
logger . info ( " AUTH login ok for ' %s ' (policy: %s ) " , username , context . policy_name )
2026-03-16 19:47:48 -03:00
return _redirect_with_cookie ( request , context . path , session )
2026-03-16 09:47:02 -03:00
@router.get ( " /login/totp " , response_class = HTMLResponse )
async def login_totp_page ( request : Request , next : str = " / " ) :
runtime_config = get_runtime_config ( request )
context = resolve_context_from_request ( request , runtime_config , next )
effective_policy = get_effective_policy ( runtime_config , context . policy_name )
if not effective_policy . totp_method_names :
return _render ( request , " error.html " , status_code = 400 , title = " TOTP unavailable " , message = " The selected policy does not require a TOTP step. " )
if effective_policy . password_method_names :
session = get_session ( request )
if not session or " password " not in session . auth_factors :
return RedirectResponse ( build_external_url ( request , " /login/password " , next = next ) , status_code = 303 )
2026-03-16 20:23:18 -03:00
return _render (
request ,
" login_totp.html " ,
next = next ,
application_name = context . application . name ,
error = None ,
csrf_token = get_or_create_csrf_token ( request ) ,
)
2026-03-16 09:47:02 -03:00
@router.post ( " /login/totp " )
2026-03-16 13:42:20 -03:00
@limiter.limit ( AUTH_RATE_LIMIT )
2026-03-16 20:23:18 -03:00
async def login_totp_submit ( request : Request , next : str = Form ( " / " ) , token : str = Form ( . . . ) , csrf_token : str = Form ( . . . ) ) :
2026-03-16 09:47:02 -03:00
runtime_config = get_runtime_config ( request )
context = resolve_context_from_request ( request , runtime_config , next )
effective_policy = get_effective_policy ( runtime_config , context . policy_name )
session = get_session ( request )
2026-03-16 20:23:18 -03:00
try :
validate_csrf ( request , csrf_token )
except HTTPException :
return _csrf_error ( request , next , context . application . name )
2026-03-16 09:47:02 -03:00
if effective_policy . password_method_names and ( not session or " password " not in session . auth_factors ) :
return RedirectResponse ( build_external_url ( request , " /login/password " , next = next ) , status_code = 303 )
if effective_policy . oidc_method_names and ( not session or " oidc " not in session . auth_factors ) :
return RedirectResponse ( build_external_url ( request , " /login/oidc/start " , next = next ) , status_code = 303 )
method_name , method = get_totp_method ( runtime_config , effective_policy )
if not method_name or not method :
return _render ( request , " error.html " , status_code = 400 , title = " TOTP configuration missing " , message = " No usable TOTP method is configured for this policy. " )
secret = method . totp_secret or " "
if session and session . username :
user = runtime_config . users . get ( session . username )
if user and user . totp_secret :
secret = user . totp_secret
if not verify_totp ( secret , token ) :
2026-03-16 11:26:16 -03:00
logger . warning ( " AUTH totp failed for ' %s ' (policy: %s ) " , session . username if session else " ? " , context . policy_name )
2026-03-16 20:23:18 -03:00
return _render (
request ,
" login_totp.html " ,
status_code = 401 ,
next = next ,
application_name = context . application . name ,
error = " Invalid verification code. " ,
csrf_token = get_or_create_csrf_token ( request ) ,
)
2026-03-16 09:47:02 -03:00
session_service = request . app . state . session_service
refreshed_session = session_service . issue_session (
existing_session = session ,
add_factors = [ " totp " ] ,
expires_in_minutes = get_effective_expiration ( request , effective_policy , [ " totp " ] ) ,
)
2026-03-16 11:26:16 -03:00
logger . info ( " AUTH login ok for ' %s ' via password+totp (policy: %s ) " , refreshed_session . username , context . policy_name )
2026-03-16 19:47:48 -03:00
return _redirect_with_cookie ( request , context . path , refreshed_session )
2026-03-16 09:47:02 -03:00
@router.get ( " /login/oidc/start " )
2026-03-16 13:42:20 -03:00
@limiter.limit ( AUTH_RATE_LIMIT )
2026-03-16 09:47:02 -03:00
async def login_oidc_start ( request : Request , next : str = " / " ) :
runtime_config = get_runtime_config ( request )
context = resolve_context_from_request ( request , runtime_config , next )
effective_policy = get_effective_policy ( runtime_config , context . policy_name )
method_name , method = get_oidc_method ( runtime_config , effective_policy )
if not method_name or not method :
return _render ( request , " error.html " , status_code = 400 , title = " OIDC unavailable " , message = " The selected policy does not require OIDC. " )
2026-03-16 19:47:48 -03:00
session_state = request . app . state . session_service . create_oidc_state ( method_name , normalize_host ( request . headers . get ( " host " , " " ) ) , context . path )
2026-03-16 09:47:02 -03:00
redirect_uri = build_external_url ( request , " /login/oidc/callback " )
return await request . app . state . oidc_service . build_authorization_redirect (
request ,
method_name ,
method ,
redirect_uri ,
session_state . state ,
session_state . nonce ,
)
@router.get ( " /login/oidc/callback " )
2026-03-16 20:36:49 -03:00
@limiter.limit ( AUTH_RATE_LIMIT )
2026-03-16 09:47:02 -03:00
async def login_oidc_callback ( request : Request , state : str ) :
runtime_config = get_runtime_config ( request )
oidc_state = request . app . state . session_service . consume_oidc_state ( state )
if not oidc_state :
return _render ( request , " error.html " , status_code = 400 , title = " Invalid OIDC state " , message = " The OIDC login state is missing or expired. " )
2026-03-16 19:35:24 -03:00
callback_host = normalize_host ( request . headers . get ( " host " , " " ) )
if oidc_state . host != callback_host :
logger . warning ( " OIDC callback host mismatch: expected ' %s ' , got ' %s ' " , oidc_state . host , callback_host )
return _render ( request , " error.html " , status_code = 400 , title = " OIDC callback host mismatch " , message = " The OIDC callback host does not match the original request host. " )
2026-03-16 09:47:02 -03:00
context = resolve_context_from_request ( request , runtime_config , oidc_state . next_url )
effective_policy = get_effective_policy ( runtime_config , context . policy_name )
method = runtime_config . auth_methods . get ( oidc_state . method_name )
if not isinstance ( method , OIDCMethodModel ) :
return _render ( request , " error.html " , status_code = 400 , title = " OIDC configuration missing " , message = " The referenced OIDC method is no longer available. " )
identity = await request . app . state . oidc_service . finish_callback ( request , oidc_state . method_name , method , oidc_state . nonce )
if not is_oidc_identity_allowed ( method , identity . email ) :
return _render ( request , " error.html " , status_code = 403 , title = " OIDC access denied " , message = " The authenticated OIDC identity is not allowed by the configured allowlists. " )
2026-03-16 20:23:18 -03:00
session = _create_authenticated_session (
request ,
2026-03-16 09:47:02 -03:00
email = identity . email ,
subject = identity . subject ,
metadata = { " oidc_claims " : identity . claims } ,
2026-03-16 20:23:18 -03:00
factors = [ " oidc " ] ,
2026-03-16 09:47:02 -03:00
expires_in_minutes = get_effective_expiration ( request , effective_policy , [ " oidc " ] ) ,
)
if effective_policy . totp_method_names :
return _redirect_with_cookie ( request , build_external_url ( request , " /login/totp " , next = oidc_state . next_url ) , session )
return _redirect_with_cookie ( request , oidc_state . next_url , session )
2026-03-16 19:53:05 -03:00
def _safe_redirect_path ( url : str | None ) - > str :
2026-03-16 20:36:49 -03:00
""" Accept only relative paths to prevent open redirects, including protocol-relative URLs. """
if not url :
2026-03-16 19:53:05 -03:00
return " / "
2026-03-16 20:36:49 -03:00
from urllib . parse import urlsplit
path = urlsplit ( url ) . path or " / "
return path if path . startswith ( " / " ) else " / "
2026-03-16 19:53:05 -03:00
2026-03-16 10:34:10 -03:00
def _do_logout ( request : Request , next_url : str = " / " ) - > RedirectResponse :
2026-03-16 09:47:02 -03:00
session_cookie = request . cookies . get ( request . app . state . settings . cookie_name )
2026-03-16 11:26:16 -03:00
session = request . app . state . session_service . get_session ( session_cookie )
2026-03-16 09:47:02 -03:00
request . app . state . session_service . delete_session ( session_cookie )
2026-03-16 11:26:16 -03:00
if session :
logger . info ( " AUTH logout for ' %s ' " , session . username or session . email or " unknown " )
2026-03-16 19:53:05 -03:00
response = RedirectResponse ( _safe_redirect_path ( next_url ) , status_code = 303 )
2026-03-16 09:47:02 -03:00
response . delete_cookie ( request . app . state . settings . cookie_name , path = " / " )
return response
2026-03-16 10:34:10 -03:00
@router.get ( " /logout " )
async def logout_get ( request : Request , next : str = " / " ) :
return _do_logout ( request , next )
@router.post ( " /logout " )
2026-03-16 20:23:18 -03:00
async def logout_post ( request : Request , next : str = Form ( " / " ) , csrf_token : str = Form ( . . . ) ) :
try :
validate_csrf ( request , csrf_token )
except HTTPException :
return _csrf_error ( request , next )
2026-03-16 10:34:10 -03:00
return _do_logout ( request , next )