189 lines
5.9 KiB
Python
189 lines
5.9 KiB
Python
from quart import Blueprint, jsonify, request
|
|
from quart_jwt_extended import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
jwt_refresh_token_required,
|
|
get_jwt_identity,
|
|
)
|
|
from .models import User
|
|
from .oidc_service import OIDCUserService
|
|
from config.oidc_config import oidc_config
|
|
import secrets
|
|
import httpx
|
|
from urllib.parse import urlencode
|
|
import hashlib
|
|
import base64
|
|
|
|
|
|
user_blueprint = Blueprint("user_api", __name__, url_prefix="/api/user")
|
|
|
|
# In-memory storage for OIDC state/PKCE (production: use Redis or database)
|
|
# Format: {state: {"pkce_verifier": str, "redirect_after_login": str}}
|
|
_oidc_sessions = {}
|
|
|
|
|
|
@user_blueprint.route("/oidc/login", methods=["GET"])
|
|
async def oidc_login():
|
|
"""
|
|
Initiate OIDC login flow
|
|
Generates PKCE parameters and redirects to Authelia
|
|
"""
|
|
if not oidc_config.validate_config():
|
|
return jsonify({"error": "OIDC not configured"}), 500
|
|
|
|
try:
|
|
# Generate PKCE parameters
|
|
code_verifier = secrets.token_urlsafe(64)
|
|
|
|
# For PKCE, we need code_challenge = BASE64URL(SHA256(code_verifier))
|
|
code_challenge = (
|
|
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
|
|
.decode()
|
|
.rstrip("=")
|
|
)
|
|
|
|
# Generate state for CSRF protection
|
|
state = secrets.token_urlsafe(32)
|
|
|
|
# Store PKCE verifier and state for callback validation
|
|
_oidc_sessions[state] = {
|
|
"pkce_verifier": code_verifier,
|
|
"redirect_after_login": request.args.get("redirect", "/"),
|
|
}
|
|
|
|
# Get authorization endpoint from discovery
|
|
discovery = await oidc_config.get_discovery_document()
|
|
auth_endpoint = discovery.get("authorization_endpoint")
|
|
|
|
# Build authorization URL
|
|
params = {
|
|
"client_id": oidc_config.client_id,
|
|
"response_type": "code",
|
|
"redirect_uri": oidc_config.redirect_uri,
|
|
"scope": "openid email profile groups",
|
|
"state": state,
|
|
"code_challenge": code_challenge,
|
|
"code_challenge_method": "S256",
|
|
}
|
|
|
|
auth_url = f"{auth_endpoint}?{urlencode(params)}"
|
|
|
|
return jsonify({"auth_url": auth_url})
|
|
except Exception as e:
|
|
return jsonify({"error": f"OIDC login failed: {str(e)}"}), 500
|
|
|
|
|
|
@user_blueprint.route("/oidc/callback", methods=["GET"])
|
|
async def oidc_callback():
|
|
"""
|
|
Handle OIDC callback from Authelia
|
|
Exchanges authorization code for tokens, verifies ID token, and creates/updates user
|
|
"""
|
|
# Get authorization code and state from callback
|
|
code = request.args.get("code")
|
|
state = request.args.get("state")
|
|
error = request.args.get("error")
|
|
|
|
if error:
|
|
return jsonify({"error": f"OIDC error: {error}"}), 400
|
|
|
|
if not code or not state:
|
|
return jsonify({"error": "Missing code or state"}), 400
|
|
|
|
# Validate state and retrieve PKCE verifier
|
|
session = _oidc_sessions.pop(state, None)
|
|
if not session:
|
|
return jsonify({"error": "Invalid or expired state"}), 400
|
|
|
|
pkce_verifier = session["pkce_verifier"]
|
|
|
|
# Exchange authorization code for tokens
|
|
discovery = await oidc_config.get_discovery_document()
|
|
token_endpoint = discovery.get("token_endpoint")
|
|
|
|
token_data = {
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": oidc_config.redirect_uri,
|
|
"client_id": oidc_config.client_id,
|
|
"client_secret": oidc_config.client_secret,
|
|
"code_verifier": pkce_verifier,
|
|
}
|
|
|
|
# Use client_secret_post method (credentials in POST body)
|
|
async with httpx.AsyncClient() as client:
|
|
token_response = await client.post(token_endpoint, data=token_data)
|
|
|
|
if token_response.status_code != 200:
|
|
return jsonify(
|
|
{"error": f"Failed to exchange code for token: {token_response.text}"}
|
|
), 400
|
|
|
|
tokens = token_response.json()
|
|
|
|
id_token = tokens.get("id_token")
|
|
if not id_token:
|
|
return jsonify({"error": "No ID token received"}), 400
|
|
|
|
# Verify ID token
|
|
try:
|
|
claims = await oidc_config.verify_id_token(id_token)
|
|
except Exception as e:
|
|
return jsonify({"error": f"ID token verification failed: {str(e)}"}), 400
|
|
|
|
# Get or create user from OIDC claims
|
|
user = await OIDCUserService.get_or_create_user_from_oidc(claims)
|
|
|
|
# Issue backend JWT tokens
|
|
access_token = create_access_token(identity=str(user.id))
|
|
refresh_token = create_refresh_token(identity=str(user.id))
|
|
|
|
# Return tokens to frontend
|
|
# Frontend will handle storing these and redirecting
|
|
return jsonify(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
user={
|
|
"id": str(user.id),
|
|
"username": user.username,
|
|
"email": user.email,
|
|
"groups": user.ldap_groups,
|
|
"is_admin": user.is_admin(),
|
|
},
|
|
)
|
|
|
|
|
|
@user_blueprint.route("/refresh", methods=["POST"])
|
|
@jwt_refresh_token_required
|
|
async def refresh():
|
|
"""Refresh access token (unchanged from original)"""
|
|
user_id = get_jwt_identity()
|
|
new_token = create_access_token(identity=user_id)
|
|
return jsonify(access_token=new_token)
|
|
|
|
|
|
# Legacy username/password login - kept for backward compatibility during migration
|
|
@user_blueprint.route("/login", methods=["POST"])
|
|
async def login():
|
|
"""
|
|
Legacy username/password login
|
|
This can be removed after full OIDC migration is complete
|
|
"""
|
|
data = await request.get_json()
|
|
username = data.get("username")
|
|
password = data.get("password")
|
|
|
|
user = await User.filter(username=username).first()
|
|
|
|
if not user or not user.verify_password(password):
|
|
return jsonify({"msg": "Invalid credentials"}), 401
|
|
|
|
access_token = create_access_token(identity=str(user.id))
|
|
refresh_token = create_refresh_token(identity=str(user.id))
|
|
|
|
return jsonify(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
user={"id": str(user.id), "username": user.username},
|
|
)
|