commit
142f3aff25
25 changed files with 1018 additions and 0 deletions
@ -0,0 +1,129 @@ |
|||||||
|
# Byte-compiled / optimized / DLL files |
||||||
|
__pycache__/ |
||||||
|
*.py[cod] |
||||||
|
*$py.class |
||||||
|
|
||||||
|
# C extensions |
||||||
|
*.so |
||||||
|
|
||||||
|
# Distribution / packaging |
||||||
|
.Python |
||||||
|
build/ |
||||||
|
develop-eggs/ |
||||||
|
dist/ |
||||||
|
downloads/ |
||||||
|
eggs/ |
||||||
|
.eggs/ |
||||||
|
lib/ |
||||||
|
lib64/ |
||||||
|
parts/ |
||||||
|
sdist/ |
||||||
|
var/ |
||||||
|
wheels/ |
||||||
|
pip-wheel-metadata/ |
||||||
|
share/python-wheels/ |
||||||
|
*.egg-info/ |
||||||
|
.installed.cfg |
||||||
|
*.egg |
||||||
|
MANIFEST |
||||||
|
|
||||||
|
# PyInstaller |
||||||
|
# Usually these files are written by a python script from a template |
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it. |
||||||
|
*.manifest |
||||||
|
*.spec |
||||||
|
|
||||||
|
# Installer logs |
||||||
|
pip-log.txt |
||||||
|
pip-delete-this-directory.txt |
||||||
|
|
||||||
|
# Unit test / coverage reports |
||||||
|
htmlcov/ |
||||||
|
.tox/ |
||||||
|
.nox/ |
||||||
|
.coverage |
||||||
|
.coverage.* |
||||||
|
.cache |
||||||
|
nosetests.xml |
||||||
|
coverage.xml |
||||||
|
*.cover |
||||||
|
*.py,cover |
||||||
|
.hypothesis/ |
||||||
|
.pytest_cache/ |
||||||
|
|
||||||
|
# Translations |
||||||
|
*.mo |
||||||
|
*.pot |
||||||
|
|
||||||
|
# Django stuff: |
||||||
|
*.log |
||||||
|
local_settings.py |
||||||
|
db.sqlite3 |
||||||
|
db.sqlite3-journal |
||||||
|
|
||||||
|
# Flask stuff: |
||||||
|
instance/ |
||||||
|
.webassets-cache |
||||||
|
|
||||||
|
# Scrapy stuff: |
||||||
|
.scrapy |
||||||
|
|
||||||
|
# Sphinx documentation |
||||||
|
docs/_build/ |
||||||
|
|
||||||
|
# PyBuilder |
||||||
|
target/ |
||||||
|
|
||||||
|
# Jupyter Notebook |
||||||
|
.ipynb_checkpoints |
||||||
|
|
||||||
|
# IPython |
||||||
|
profile_default/ |
||||||
|
ipython_config.py |
||||||
|
|
||||||
|
# pyenv |
||||||
|
.python-version |
||||||
|
|
||||||
|
# pipenv |
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. |
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies |
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not |
||||||
|
# install all needed dependencies. |
||||||
|
#Pipfile.lock |
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow |
||||||
|
__pypackages__/ |
||||||
|
|
||||||
|
# Celery stuff |
||||||
|
celerybeat-schedule |
||||||
|
celerybeat.pid |
||||||
|
|
||||||
|
# SageMath parsed files |
||||||
|
*.sage.py |
||||||
|
|
||||||
|
# Environments |
||||||
|
.env |
||||||
|
.venv |
||||||
|
env/ |
||||||
|
venv/ |
||||||
|
ENV/ |
||||||
|
env.bak/ |
||||||
|
venv.bak/ |
||||||
|
|
||||||
|
# Spyder project settings |
||||||
|
.spyderproject |
||||||
|
.spyproject |
||||||
|
|
||||||
|
# Rope project settings |
||||||
|
.ropeproject |
||||||
|
|
||||||
|
# mkdocs documentation |
||||||
|
/site |
||||||
|
|
||||||
|
# mypy |
||||||
|
.mypy_cache/ |
||||||
|
.dmypy.json |
||||||
|
dmypy.json |
||||||
|
|
||||||
|
# Pyre type checker |
||||||
|
.pyre/ |
||||||
@ -0,0 +1 @@ |
|||||||
|
{"id_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImI0MzViMTNmLTc2YzUtNDFkMi05N2M0LTVhNTAzMjRlYTFkYiJ9.eyJpc3MiOiJodHRwOlwvXC9jbG91ZC5lbm5lMi5uZXQiLCJzdWIiOiJlbm5lMiIsImF1ZCI6IlBUVTdMMjlONXdzNkdBZmpZVmJkMHJ0Vk1hNm9saWFQeHVxRVVLNFE5NWpVRDFDSUwzdVgwelVCdklPVm01SHQiLCJleHAiOjE3NDk1OTMwOTAsImF1dGhfdGltZSI6MTc0OTU4ODM4OCwiaWF0IjoxNzQ5NTkyMTkwLCJhY3IiOiIwIiwiYXpwIjoiUFRVN0wyOU41d3M2R0FmallWYmQwcnRWTWE2b2xpYVB4dXFFVUs0UTk1alVEMUNJTDN1WDB6VUJ2SU9WbTVIdCIsInByZWZlcnJlZF91c2VybmFtZSI6ImVubmUyIiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsIm5iZiI6MTc0OTU5MjE5MCwianRpIjoiNTYiLCJ1cGRhdGVkX2F0IjoxNzQ5NTg3MzQ4LCJuYW1lIjoiZW5uZTIiLCJmYW1pbHlfbmFtZSI6ImVubmUyIiwiZ2l2ZW5fbmFtZSI6IiIsIm1pZGRsZV9uYW1lIjoiIiwicGljdHVyZSI6Imh0dHA6XC9cL2Nsb3VkLmVubmUyLm5ldFwvYXZhdGFyXC9lbm5lMlwvNjQifQ.O6kk_HZZeCDRHXAYu3Q8W0UyPzjTR6avL1Olut2IJ8ODH8TaNsgv2YKrVdTq4w-MoXWUf-O9MLqW4Io_XzxKevYTSueoFPHMkEJfpVLuNYKxxcpPXY_RG7h1_i4qyx1uMaToZoulFBIwOQvStHtsnN0e73RVR-B1sYWNAIEwHov_M4ccMvfQxPh1hGQuPygVUKQh09sn6SXc0HQoP8DOkYd5paAlRL4MdH9K54w15hL90jB5GY3gJ7B-y_GMWOUnPRRge8aY8vmuDeyY5feM2RK-DRg19BIQ7_jhrr0dLMs9Itg2otavD2Re3A1qANuaaFUAPW9va7RAxmB0kZff-BzEsvBjqwq-MJmG3dU97GDD5aKoHuoXOJyb3ESE68WeR_JBGdgudImFg6d-WQQXnloTNog9SeGQcs0wGCDLjymDlHpcETJ5wx1-hDXWcRRPYWx-7ilCWqhFkW7dqK7H1snVzFA8H9PRpl3bZ1xyPkcvS0aoByU4uX06E1T9tMMEykp8S4lw6x3q8WC16G2xpy4tWYWJPkXruENWH-6civShy_0hfk_VbE48VnzD1EjFOuCTKsX2BHZnCqGCQddk6ZYYfyQQF6D4VSk0iRrifAZfcjxpXqHS1mY6tMWDvUfS3z-isSThX0fHQqnrQMNzaBjuGs57ecsaZP_p4wOVJpg","username":"enne2","email":null,"user_id":"enne2","authenticated":true,"access_token":"eyJ0eXAiOiJhdCtKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImI0MzViMTNmLTc2YzUtNDFkMi05N2M0LTVhNTAzMjRlYTFkYiJ9.eyJpc3MiOiJodHRwOlwvXC9jbG91ZC5lbm5lMi5uZXQiLCJzdWIiOiJlbm5lMiIsImF1ZCI6Imh0dHBzOlwvXC9ycy5sb2NhbFwvIiwiZXhwIjoxNzQ5NTkzMDkwLCJhdXRoX3RpbWUiOjE3NDk1ODgzODgsImlhdCI6MTc0OTU5MjE5MCwiYWNyIjoiMCIsImNsaWVudF9pZCI6IlBUVTdMMjlONXdzNkdBZmpZVmJkMHJ0Vk1hNm9saWFQeHVxRVVLNFE5NWpVRDFDSUwzdVgwelVCdklPVm01SHQiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIiwianRpIjoiNTYifQ.c6e576Jv9_cxC2ScrBORsNkp2o7Fa7Js0yvw2xZFA_ZO8VLS-mBN3as51Sg90fQyyOjaBAiJUmPGwYa35ckdvGE1Au6IM23fyu0aIaBnfytvg3_Zfqs7mN2Ll0JnPC3Qs52S047fSF7kEol1s36UmD4o0mMZFosGQZZUunRPVWGk7Mg43185WRvGWJO9fJwt891lx2lGIfeFRAFsO0epuF-Km8C2730pIUeA63H1qIwaZDTkUh-DaIm2w231Mnfh7DEDzs3NoC33-v9lpLCRYIXzeVm0tyqLc0OAGYwliHDzR26Q6z6dPxTrJ1pz5UQnGlY_ecMdYfi34Ni_YGLFWKbEgjs_9k01w42ycloNsMQ9XH6ky77sR6cOOYRV4stORAW075NRItYWE-oPhiD_tNzWZJSG4kdWmU8c-yVjuiXb0KTAuFDiKJNrClhAh_33_aVydGw4GOVHCRgIHv_U0J99uOYJuIwej497qtnMjUaVpIiubJVK7ZoDo-szcTy3sm9exze4SbDLl1A1ObI4iQA7SrmfcNh-jwJL1d6shvXMI8_aYK1IbpWMToJgEpmkCG0J6IEK7u6dBraJ273X5ib5uxGtuSZkmogu0pWSDgcDDc38dwD4GPqOo3sMeeUO-Oz8pIDY7Y-DABA7ynGE1ZB454_K16CsvmNzsce226A","refresh_token":"006qL68FtoOHuKyCMQQHjZ3YjIWl3yZHyyn0Qu2xQyi2FeAT6mz1oyzHNaigtp1SKM0UL6Ag5zkNIBK2XbSfWKs4yAPTCrXEEDxSrv2se75vkHQwVjfkyN1bZ9VlwrxH","token_expires_at":1749589288.921118,"expires_in":900,"last_refreshed":1749592190.9409876} |
||||||
@ -0,0 +1 @@ |
|||||||
|
{} |
||||||
@ -0,0 +1 @@ |
|||||||
|
{"username":"enne2","email":null,"user_id":"enne2","authenticated":true,"access_token":"eyJ0eXAiOiJhdCtKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImI0MzViMTNmLTc2YzUtNDFkMi05N2M0LTVhNTAzMjRlYTFkYiJ9.eyJpc3MiOiJodHRwOlwvXC9jbG91ZC5lbm5lMi5uZXQiLCJzdWIiOiJlbm5lMiIsImF1ZCI6Imh0dHBzOlwvXC9ycy5sb2NhbFwvIiwiZXhwIjoxNzQ5MzIyMDc5LCJhdXRoX3RpbWUiOjE3NDkzMjExNzksImlhdCI6MTc0OTMyMTE3OSwiYWNyIjoiMCIsImNsaWVudF9pZCI6IlBUVTdMMjlONXdzNkdBZmpZVmJkMHJ0Vk1hNm9saWFQeHVxRVVLNFE5NWpVRDFDSUwzdVgwelVCdklPVm01SHQiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIiwianRpIjoiMjcifQ.XVtOLQ1yld0LNm1s9-FWqLjpjlpKaaww9ypfUWDqyxbPccIJXev6jlM8E3XUJOC4jwDsN8lHLbjSrnrMOqnKrRH2xMM6Nbwm9dexA_qw81eQOkp931T66xEtf9XtsRkNRSssutteh_MjQDP1TvM-RRrKl5fXobsC72GpZxWbKRD7Ctems5AEBcfaLet1AqdE-yyUor1WmhGsYc-zpo13bcEdTSgPSI9SnTQ1KRwg8iyr7AdWJYgHz6_1FNySTOshA_AY6Vu0JohwXAOZTDSXSElrxDAJXZKc0avq_x3CaGSu6fcx9WRBvhF6xe4N1qh219NI7AmfJF3MxXHNJ8M271Kw7W0rsPQrs2P92YuugqaU9dRR3Q-DCN4LUu7tUHgpGyghv4KQhgP-i9B4mkygEjeS5z0yMA2mcrzPiQpqdOfmYi8hh5oHbyv1-KZDXuV8GdSlFtx0ydQZTYV_DsYWFzqbzoElCJsK4xF2NDWjJVyHT-60fa8BsOkOxcu-nxUSG1cgJvNGfbOxp6NcCrYw3Mz5JeUIFPqBD_3nEpxACHgp61BgybRwyeYZOsdkpttxxkhUtk_a9AWU4RoCVFXz4l3mECcXg8iUUtefDDR38TXudjbhzYMdNqOEo0gXgwVJ4cgFviKMAOtDEwxJ2a2RQeqcFGeyeZqv2XyYB8F9-zc","refresh_token":"lNzSuzoRExqlpXN53vcyFJmX1Dp0PsQngCjgKrBk3tSUPhQHk22u4EXybA2etkIrrnKcEX9LloUg5ZkYm6JF1N4u8XNq7XJWlsllRIv48cQe43MWJH7InSvxnvJGKrUQ","token_expires_at":1749322079.8674123} |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
from services.log import basic_logger as logger |
||||||
|
from nicegui import app, ui |
||||||
|
from middlewares.access_middleware import AccessLoggingMiddleware |
||||||
|
from middlewares.auth_middleware import OIDCAuthMiddleware |
||||||
|
|
||||||
|
# Add middlewares in order: Access logging first, then auth |
||||||
|
app.add_middleware(AccessLoggingMiddleware) |
||||||
|
app.add_middleware(OIDCAuthMiddleware) |
||||||
|
|
||||||
|
# Import pages after middleware setup |
||||||
|
from pages import main_page, auth_callback, login, subpage |
||||||
|
|
||||||
|
def main(): |
||||||
|
logger.info(f"Starting OIDC Authentication Demo with Access Logging") |
||||||
|
# Configure app |
||||||
|
ui.run( |
||||||
|
port=8080, |
||||||
|
storage_secret='your-secret-key-change-in-production', |
||||||
|
title='OIDC Authentication Demo', |
||||||
|
reload=False |
||||||
|
) |
||||||
|
|
||||||
|
if __name__ in {"__main__", "__mp_main__"}: |
||||||
|
main() |
||||||
@ -0,0 +1 @@ |
|||||||
|
"""Middlewares package for the authentication application.""" |
||||||
@ -0,0 +1,82 @@ |
|||||||
|
from services.log import access_logger |
||||||
|
import time |
||||||
|
from typing import Optional |
||||||
|
from fastapi import Request |
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware |
||||||
|
from nicegui import app |
||||||
|
import uuid |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str: |
||||||
|
"""Extract client IP address with X-Forwarded-For support""" |
||||||
|
# Check X-Forwarded-For header first (for reverse proxies) |
||||||
|
forwarded_for = request.headers.get('X-Forwarded-For') |
||||||
|
if forwarded_for: |
||||||
|
# Take the first IP in the chain (original client) |
||||||
|
return forwarded_for.split(',')[0].strip() |
||||||
|
|
||||||
|
# Check X-Real-IP header (nginx) |
||||||
|
real_ip = request.headers.get('X-Real-IP') |
||||||
|
if real_ip: |
||||||
|
return real_ip.strip() |
||||||
|
|
||||||
|
# Fall back to direct connection IP |
||||||
|
return request.client.host if request.client else 'unknown' |
||||||
|
|
||||||
|
|
||||||
|
def get_user_agent(request: Request) -> str: |
||||||
|
"""Extract user agent string""" |
||||||
|
return request.headers.get('User-Agent', 'unknown') |
||||||
|
|
||||||
|
|
||||||
|
def get_current_user() -> Optional[str]: |
||||||
|
"""Get current authenticated user""" |
||||||
|
if app.storage.user.get('authenticated', False): |
||||||
|
return app.storage.user.get('username', 'authenticated_user') |
||||||
|
return None |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class AccessLoggingMiddleware(BaseHTTPMiddleware): |
||||||
|
"""Middleware to log all HTTP requests with client IP and user info""" |
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next): |
||||||
|
start_time = time.time() |
||||||
|
request_id = str(uuid.uuid4()) # Generate a unique request ID |
||||||
|
client_ip = get_client_ip(request) |
||||||
|
user_agent = get_user_agent(request) |
||||||
|
user = get_current_user() |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Process the request |
||||||
|
response = await call_next(request) |
||||||
|
|
||||||
|
# Calculate processing time |
||||||
|
process_time = time.time() - start_time |
||||||
|
|
||||||
|
# Log the access |
||||||
|
log_message = ( |
||||||
|
f"{request.method} {request.url.path} " |
||||||
|
f"{response.status_code} " |
||||||
|
f"\"{user_agent}\"" |
||||||
|
) |
||||||
|
access_logger.extra = { |
||||||
|
'request_id': request_id, |
||||||
|
'client_ip': client_ip, |
||||||
|
'user_agent': user_agent, |
||||||
|
'user': user if user else 'anonymous', |
||||||
|
'process_time': f"{process_time:.3f}s", |
||||||
|
} |
||||||
|
|
||||||
|
# Use different log levels based on status code |
||||||
|
if response.status_code >= 500: |
||||||
|
access_logger.error(log_message) |
||||||
|
elif response.status_code >= 400: |
||||||
|
access_logger.warning(log_message) |
||||||
|
else: |
||||||
|
access_logger.info(log_message) |
||||||
|
|
||||||
|
return response |
||||||
@ -0,0 +1,44 @@ |
|||||||
|
import logging |
||||||
|
from nicegui import app |
||||||
|
from fastapi import Request |
||||||
|
from fastapi.responses import RedirectResponse |
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware |
||||||
|
from services.auth.oidc import oidc_config |
||||||
|
from .access_middleware import get_client_ip |
||||||
|
from services.log import access_logger as logger |
||||||
|
|
||||||
|
class OIDCAuthMiddleware(BaseHTTPMiddleware): |
||||||
|
"""Middleware to handle OIDC authentication""" |
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next): |
||||||
|
# Skip authentication for login, callback, and internal routes |
||||||
|
excluded_paths = {'/login', '/auth/callback'} |
||||||
|
|
||||||
|
if (request.url.path.startswith('/_nicegui') or |
||||||
|
request.url.path in excluded_paths): |
||||||
|
logger.debug(f"Skipping auth for excluded path: {request.url.path}") |
||||||
|
return await call_next(request) |
||||||
|
|
||||||
|
# Check if user is authenticated |
||||||
|
if not app.storage.user.get('authenticated', False): |
||||||
|
client_ip = get_client_ip(request) |
||||||
|
logger.info(f"Unauthenticated access from {client_ip} to {request.url.path}, redirecting to login") |
||||||
|
# Store the original URL for redirect after login |
||||||
|
app.storage.user['redirect_to'] = str(request.url.path) |
||||||
|
return RedirectResponse('/login') |
||||||
|
|
||||||
|
# Validate current token |
||||||
|
access_token = app.storage.user.get('access_token') |
||||||
|
if access_token: |
||||||
|
payload = oidc_config.validate_token(access_token) |
||||||
|
if not payload: |
||||||
|
client_ip = get_client_ip(request) |
||||||
|
user = app.storage.user.get('username', 'unknown') |
||||||
|
logger.warning(f"Invalid/expired token for user {user} from {client_ip} accessing {request.url.path}, clearing session") |
||||||
|
# Token is invalid/expired, clear session |
||||||
|
app.storage.user.clear() |
||||||
|
return RedirectResponse('/login') |
||||||
|
else: |
||||||
|
logger.debug(f"Valid token for user accessing {request.url.path}") |
||||||
|
|
||||||
|
return await call_next(request) |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
"""Pages package for the authentication application.""" |
||||||
|
from . import * |
||||||
@ -0,0 +1,86 @@ |
|||||||
|
from services.log import access_logger as logger |
||||||
|
from nicegui import app, ui |
||||||
|
from fastapi import Request |
||||||
|
from services.auth.oidc import oidc_config |
||||||
|
import time |
||||||
|
|
||||||
|
@ui.page('/auth/callback') |
||||||
|
async def auth_callback(request: Request, code: str = None, state: str = None, error: str = None, redirect_to: str = "/") -> None: |
||||||
|
logger.info(f"Auth callback received - code: {'present' if code else 'missing'}, state: {state}, error: {error}") |
||||||
|
|
||||||
|
if error: |
||||||
|
logger.error(f"Authentication error: {error}") |
||||||
|
ui.notify(f'Authentication failed: {error}', color='negative') |
||||||
|
ui.label(f'Authentication failed: {error}').classes('text-red-500') |
||||||
|
#ui.navigate.to('/login') |
||||||
|
return |
||||||
|
|
||||||
|
if not code or not state: |
||||||
|
logger.error(f"Missing callback parameters - code: {'present' if code else 'missing'}, state: {'present' if state else 'missing'}") |
||||||
|
ui.notify('Invalid callback parameters', color='negative') |
||||||
|
ui.label('Invalid callback parameters').classes('text-red-500') |
||||||
|
#ui.navigate.to('/login') |
||||||
|
return |
||||||
|
# Verify state parameter to prevent CSRF attacks |
||||||
|
stored_state = app.storage.user.get('oidc_state') |
||||||
|
state = app.storage.user.get('oidc_state', 'not set') |
||||||
|
# For debugging purposes, you can display the state parameter |
||||||
|
ui.label(f'OIDC state: {state}') |
||||||
|
if not stored_state or stored_state != state: |
||||||
|
logger.error(f"State parameter mismatch - expected: {stored_state}, received: {state}") |
||||||
|
ui.notify('Invalid state parameter', color='negative') |
||||||
|
ui.label('Invalid state parameter').classes('text-red-500') |
||||||
|
#ui.navigate.to('/login') |
||||||
|
return |
||||||
|
|
||||||
|
|
||||||
|
logger.info("State parameter verified successfully") |
||||||
|
|
||||||
|
try: |
||||||
|
# Exchange code for tokens |
||||||
|
logger.info("Exchanging authorization code for tokens") |
||||||
|
tokens = await oidc_config.exchange_code_for_tokens(code, redirect_uri=oidc_config.redirect_uri) |
||||||
|
|
||||||
|
# Validate and decode ID token to get user info |
||||||
|
logger.info("Validating ID token") |
||||||
|
user_info = oidc_config.validate_token(tokens['id_token']) |
||||||
|
if not user_info: |
||||||
|
logger.error("Invalid ID token received") |
||||||
|
ui.notify('Invalid ID token', color='negative') |
||||||
|
#ui.navigate.to('/login') |
||||||
|
return |
||||||
|
app.storage.user['id_token'] = tokens['id_token'] |
||||||
|
logger.info("ID token validated successfully") |
||||||
|
|
||||||
|
user_id = user_info.get('sub') |
||||||
|
username = user_info.get('preferred_username', user_info.get('email', 'Unknown')) |
||||||
|
email = user_info.get('email') |
||||||
|
|
||||||
|
logger.info(f"User authenticated successfully - ID: {user_id}, Username: {username}, Email: {email}") |
||||||
|
|
||||||
|
# Store user session |
||||||
|
app.storage.user.update({ |
||||||
|
'username': username, |
||||||
|
'email': email, |
||||||
|
'user_id': user_id, |
||||||
|
'authenticated': True, |
||||||
|
'access_token': tokens['access_token'], |
||||||
|
'refresh_token': tokens.get('refresh_token'), |
||||||
|
'token_expires_at': time.time() + tokens.get('expires_in', 3600) |
||||||
|
}) |
||||||
|
|
||||||
|
# Start token refresh timer |
||||||
|
logger.info(f"Starting token refresh timer for user {user_id}") |
||||||
|
|
||||||
|
# Redirect to original destination or home |
||||||
|
redirect_to = app.storage.user.get('redirect_to', '/') |
||||||
|
app.storage.user.pop('redirect_to', None) # Clean up |
||||||
|
app.storage.user.pop('oidc_state', None) # Clean up |
||||||
|
|
||||||
|
logger.info(f"Authentication complete for user {username}, redirecting to: {redirect_to}") |
||||||
|
ui.navigate.to(redirect_to) |
||||||
|
|
||||||
|
except Exception as e: |
||||||
|
logger.error(f"Authentication failed with exception: {str(e)}", exc_info=True) |
||||||
|
ui.notify(f'Authentication failed: {str(e)}', color='negative') |
||||||
|
#ui.navigate.to('/login') |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
from typing import Optional |
||||||
|
from fastapi.responses import RedirectResponse |
||||||
|
from nicegui import app, ui |
||||||
|
from services.auth.oidc import oidc_config |
||||||
|
import uuid |
||||||
|
|
||||||
|
|
||||||
|
@ui.page('/login') |
||||||
|
def login() -> Optional[RedirectResponse]: |
||||||
|
if app.storage.user.get('authenticated', False): |
||||||
|
return RedirectResponse('/') |
||||||
|
|
||||||
|
def initiate_oidc_login(): |
||||||
|
# Generate state parameter for security |
||||||
|
state = str(uuid.uuid4()) |
||||||
|
app.storage.user['oidc_state'] = state |
||||||
|
|
||||||
|
# Get authorization URL and redirect |
||||||
|
auth_url = oidc_config.get_authorization_url(state) |
||||||
|
ui.navigate.to(auth_url, new_tab=False) |
||||||
|
|
||||||
|
with ui.card().classes('absolute-center'): |
||||||
|
ui.label('Authentication Required').classes('text-xl mb-4') |
||||||
|
ui.label('Click below to login with your OIDC provider').classes('mb-4') |
||||||
|
ui.button('Log in with OIDC', on_click=initiate_oidc_login).classes('w-full') |
||||||
|
|
||||||
|
return None |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
from nicegui import app, ui |
||||||
|
from services.auth.oidc import oidc_config |
||||||
|
from fastapi import Request |
||||||
|
from services.log import access_logger as logger |
||||||
|
|
||||||
|
@ui.page('/') |
||||||
|
def main_page(request: Request = None) -> None: |
||||||
|
async def logout() -> None: |
||||||
|
user_id = app.storage.user.get('user_id') |
||||||
|
logger.info(f"User logout initiated") |
||||||
|
|
||||||
|
|
||||||
|
if oidc_config.logout_user(): |
||||||
|
ui.navigate.to('/login') |
||||||
|
else: |
||||||
|
ui.notify('Logout failed', color='negative') |
||||||
|
logger.error("Logout failed") |
||||||
|
|
||||||
|
|
||||||
|
with ui.column().classes('absolute-center items-center'): |
||||||
|
username = app.storage.user.get('username', 'Unknown') |
||||||
|
email = app.storage.user.get('email', '') |
||||||
|
ui.timer(10, oidc_config.refresh_access_token, immediate=False) |
||||||
|
|
||||||
|
ui.label(f'Hello {username}!').classes('text-2xl') |
||||||
|
if email: |
||||||
|
ui.label(f'Email: {email}').classes('text-sm text-gray-600') |
||||||
|
|
||||||
|
ui.button(on_click=logout, icon='logout').props('outline round') |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
from nicegui import ui |
||||||
|
|
||||||
|
|
||||||
|
@ui.page('/subpage') |
||||||
|
def test_page() -> None: |
||||||
|
ui.label('This is a sub page.') |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
nicegui<2.20 |
||||||
|
fastapi |
||||||
|
pytest |
||||||
|
starlette |
||||||
|
python-jose[cryptography] |
||||||
|
requests |
||||||
|
python-multipart |
||||||
@ -0,0 +1 @@ |
|||||||
|
"""Authentication package for OIDC integration.""" |
||||||
@ -0,0 +1,253 @@ |
|||||||
|
import time |
||||||
|
import os |
||||||
|
from typing import Dict, Optional, Any |
||||||
|
import requests |
||||||
|
import jwt |
||||||
|
from jwt.algorithms import RSAAlgorithm |
||||||
|
from nicegui import app |
||||||
|
from services.log import access_logger as logger, basic_logger |
||||||
|
import urllib.parse |
||||||
|
|
||||||
|
class OIDCConfig: |
||||||
|
def __init__(self): |
||||||
|
# Configure these for your OIDC provider |
||||||
|
self.client_id = "PTU7L29N5ws6GAfjYVbd0rtVMa6oliaPxuqEUK4Q95jUD1CIL3uX0zUBvIOVm5Ht" |
||||||
|
self.client_secret = "E0HRxJRDGJtdDueEulvA6Y46oNco0gkaw75a2cRUPFTdVRjQ7RhLPXg3PRfIJ3N2" |
||||||
|
self.discovery_url = "https://cloud.enne2.net/index.php/.well-known/openid-configuration" |
||||||
|
self.redirect_uri = "http://127.0.0.1:8080/auth/callback" |
||||||
|
self.scope = "openid profile email" |
||||||
|
|
||||||
|
# Cache for OIDC configuration |
||||||
|
self._config_cache = None |
||||||
|
self._jwks_cache = None |
||||||
|
|
||||||
|
basic_logger.info(f"OIDC Config initialized with discovery URL: {self.discovery_url}") |
||||||
|
|
||||||
|
def get_oidc_config(self) -> Dict: |
||||||
|
"""Fetch OIDC configuration from discovery endpoint""" |
||||||
|
if not self._config_cache: |
||||||
|
basic_logger.info(f"Fetching OIDC configuration from {self.discovery_url}") |
||||||
|
try: |
||||||
|
response = requests.get(self.discovery_url) |
||||||
|
response.raise_for_status() |
||||||
|
self._config_cache = response.json() |
||||||
|
basic_logger.info("OIDC configuration fetched successfully") |
||||||
|
except Exception as e: |
||||||
|
basic_logger.error(f"Failed to fetch OIDC configuration: {e}") |
||||||
|
raise |
||||||
|
return self._config_cache |
||||||
|
|
||||||
|
def get_jwks(self) -> Dict: |
||||||
|
"""Fetch JSON Web Key Set for token validation""" |
||||||
|
if not self._jwks_cache: |
||||||
|
config = self.get_oidc_config() |
||||||
|
jwks_uri = config['jwks_uri'] |
||||||
|
basic_logger.info(f"Fetching JWKS from {jwks_uri}") |
||||||
|
try: |
||||||
|
response = requests.get(jwks_uri) |
||||||
|
response.raise_for_status() |
||||||
|
self._jwks_cache = response.json() |
||||||
|
basic_logger.info("JWKS fetched successfully") |
||||||
|
except Exception as e: |
||||||
|
basic_logger.error(f"Failed to fetch JWKS: {e}") |
||||||
|
raise |
||||||
|
return self._jwks_cache |
||||||
|
|
||||||
|
def get_authorization_url(self, state: str) -> str: |
||||||
|
"""Generate authorization URL for OIDC login""" |
||||||
|
config = self.get_oidc_config() |
||||||
|
params = { |
||||||
|
'response_type': 'code', |
||||||
|
'client_id': self.client_id, |
||||||
|
'redirect_uri': self.redirect_uri, |
||||||
|
'scope': self.scope, |
||||||
|
'state': state, |
||||||
|
} |
||||||
|
auth_url = f"{config['authorization_endpoint']}?{urllib.parse.urlencode(params)}" |
||||||
|
logger.info(f"Generated authorization URL with state: {state}") |
||||||
|
return auth_url |
||||||
|
|
||||||
|
async def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Optional[Dict[str, Any]]: |
||||||
|
"""Exchange authorization code for access and ID tokens""" |
||||||
|
|
||||||
|
try: |
||||||
|
data = { |
||||||
|
'grant_type': 'authorization_code', |
||||||
|
'client_id': self.client_id, |
||||||
|
'client_secret': self.client_secret, |
||||||
|
'code': code, |
||||||
|
'redirect_uri': redirect_uri |
||||||
|
} |
||||||
|
|
||||||
|
headers = { |
||||||
|
'Content-Type': 'application/x-www-form-urlencoded', |
||||||
|
'Accept': 'application/json' |
||||||
|
} |
||||||
|
|
||||||
|
# Use HTTPS for the token endpoint |
||||||
|
config = self.get_oidc_config() |
||||||
|
token_url = config.get('token_endpoint', 'http://example.com/token').replace('http://', 'https://') |
||||||
|
|
||||||
|
logger.debug(f"Token exchange request data: {data}") |
||||||
|
logger.debug(f"Token exchange request URL: {token_url}") |
||||||
|
logger.debug(f"Token exchange request headers: {headers}") |
||||||
|
|
||||||
|
response = requests.post( |
||||||
|
token_url, |
||||||
|
data=data, |
||||||
|
headers=headers, |
||||||
|
timeout=30 |
||||||
|
) |
||||||
|
|
||||||
|
logger.debug(f"Token exchange response status: {response.status_code}") |
||||||
|
logger.debug(f"Token exchange response content: {response.text}") |
||||||
|
|
||||||
|
response.raise_for_status() |
||||||
|
return response.json() |
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e: |
||||||
|
logger.error(f"Failed to exchange code for tokens: {e}") |
||||||
|
return None |
||||||
|
|
||||||
|
def refresh_access_token(self) -> Dict: |
||||||
|
"""Refresh access token using refresh token""" |
||||||
|
last_refreshed = app.storage.user.get('last_refreshed', 0) |
||||||
|
if time.time() - last_refreshed < os.getenv('TOKEN_REFRESH_MINIMUM_INTERVAL', 300): |
||||||
|
#logger.info("Access token recently refreshed, skipping refresh") |
||||||
|
return app.storage.user.get('access_token', {}) |
||||||
|
config = self.get_oidc_config() |
||||||
|
refresh_token = app.storage.user.get('refresh_token') |
||||||
|
logger.info("Refreshing access token") |
||||||
|
data = { |
||||||
|
'grant_type': 'refresh_token', |
||||||
|
'client_id': self.client_id, |
||||||
|
'client_secret': self.client_secret, |
||||||
|
'refresh_token': refresh_token, |
||||||
|
} |
||||||
|
headers = { |
||||||
|
'Content-Type': 'application/x-www-form-urlencoded', |
||||||
|
'Accept': 'application/json' |
||||||
|
} |
||||||
|
token_url = config.get('token_endpoint', 'http://example.com/token').replace('http://', 'https://') |
||||||
|
try: |
||||||
|
response = requests.post( |
||||||
|
token_url, |
||||||
|
data=data, |
||||||
|
headers=headers, |
||||||
|
timeout=30 |
||||||
|
) |
||||||
|
response.raise_for_status() |
||||||
|
tokens = response.json() |
||||||
|
data = { |
||||||
|
'access_token': tokens.get('access_token'), |
||||||
|
'refresh_token': tokens.get('refresh_token'), |
||||||
|
'expires_in': tokens.get('expires_in'), |
||||||
|
'id_token': tokens.get('id_token'), |
||||||
|
'last_refreshed': time.time(), |
||||||
|
} |
||||||
|
# Update user session with new tokens |
||||||
|
app.storage.user.update(data) |
||||||
|
logger.info("Successfully refreshed access token") |
||||||
|
return tokens |
||||||
|
except Exception as e: |
||||||
|
logger.error(f"Failed to refresh access token: {e}") |
||||||
|
raise |
||||||
|
|
||||||
|
def validate_token(self, token: str) -> Optional[Dict]: |
||||||
|
"""Validate and decode JWT token with proper signature verification""" |
||||||
|
try: |
||||||
|
# Get the token header to find the key ID |
||||||
|
unverified_header = jwt.get_unverified_header(token) |
||||||
|
kid = unverified_header.get('kid') |
||||||
|
|
||||||
|
if not kid: |
||||||
|
logger.warning("No key ID found in token header") |
||||||
|
return None |
||||||
|
|
||||||
|
# Get JWKS and find the matching key |
||||||
|
jwks = self.get_jwks() |
||||||
|
public_key = None |
||||||
|
|
||||||
|
for key in jwks.get('keys', []): |
||||||
|
if key.get('kid') == kid: |
||||||
|
# Convert JWK to PEM format |
||||||
|
public_key = RSAAlgorithm.from_jwk(key) |
||||||
|
break |
||||||
|
|
||||||
|
if not public_key: |
||||||
|
logger.warning(f"No matching public key found for kid: {kid}") |
||||||
|
return None |
||||||
|
|
||||||
|
# Verify and decode the token |
||||||
|
payload = jwt.decode( |
||||||
|
token, |
||||||
|
public_key, |
||||||
|
algorithms=['RS256'], |
||||||
|
audience=self.client_id, |
||||||
|
options={ |
||||||
|
"verify_signature": True, |
||||||
|
"verify_exp": True, |
||||||
|
"verify_aud": False, |
||||||
|
"verify_iss": True |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
logger.debug("Token validated successfully with signature verification") |
||||||
|
return payload |
||||||
|
|
||||||
|
except jwt.ExpiredSignatureError: |
||||||
|
logger.warning("Token has expired") |
||||||
|
return None |
||||||
|
except jwt.InvalidAudienceError: |
||||||
|
logger.warning("Invalid token audience") |
||||||
|
return None |
||||||
|
except jwt.InvalidIssuerError: |
||||||
|
logger.warning("Invalid token issuer") |
||||||
|
return None |
||||||
|
except jwt.InvalidTokenError as e: |
||||||
|
logger.warning(f"Invalid token: {e}") |
||||||
|
return None |
||||||
|
except Exception as e: |
||||||
|
logger.error(f"Token validation error: {e}") |
||||||
|
return None |
||||||
|
|
||||||
|
def logout_user(self) -> bool: |
||||||
|
"""Clear user session and call api to log out""" |
||||||
|
config = self.get_oidc_config() |
||||||
|
logout_url = config.get('end_session_endpoint') |
||||||
|
if not logout_url: |
||||||
|
logger.error("No end session endpoint found in OIDC configuration") |
||||||
|
# Still clear local session even if remote logout fails |
||||||
|
app.storage.user.clear() |
||||||
|
return True |
||||||
|
|
||||||
|
# Prepare logout request |
||||||
|
user_info = app.storage.user.get('user_info', {}) |
||||||
|
user_id = user_info.get('sub') or user_info.get('preferred_username', 'unknown') |
||||||
|
|
||||||
|
params = { |
||||||
|
'id_token_hint': app.storage.user.get('id_token'), |
||||||
|
'client_id': self.client_id, |
||||||
|
'post_logout_redirect_uri': self.redirect_uri.replace('/auth/callback', '/login'), |
||||||
|
} |
||||||
|
|
||||||
|
# Remove None values |
||||||
|
params = {k: v for k, v in params.items() if v is not None} |
||||||
|
logout_url = f"{logout_url}?{urllib.parse.urlencode(params)}" |
||||||
|
|
||||||
|
try: |
||||||
|
response = requests.get(logout_url, timeout=10) |
||||||
|
response.raise_for_status() |
||||||
|
logger.info(f"User {user_id} logged out successfully from OIDC provider") |
||||||
|
except requests.exceptions.RequestException as e: |
||||||
|
logger.error(f"Failed to log out user {user_id} from OIDC provider: {e}") |
||||||
|
# Continue with local logout even if remote logout fails |
||||||
|
|
||||||
|
# Clear user session |
||||||
|
app.storage.user.clear() |
||||||
|
logger.info(f"Local session cleared for user {user_id}") |
||||||
|
return True |
||||||
|
|
||||||
|
|
||||||
|
# Global OIDC config instance |
||||||
|
oidc_config = OIDCConfig() |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
"""Logging package for the authentication application.""" |
||||||
|
|
||||||
|
from .basic_logger import basic_logger |
||||||
|
from .access_logger import access_logger |
||||||
|
|
||||||
|
# Make loggers available at package level |
||||||
|
__all__ = ['basic_logger', 'access_logger'] |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
import logging |
||||||
|
|
||||||
|
# Access logging configuration |
||||||
|
access_formatter = logging.Formatter( |
||||||
|
'%(asctime)s - %(request_id)s - %(module)s - %(levelname)s - %(message)s srcip: %(client_ip)s, user: %(user)s' |
||||||
|
) |
||||||
|
|
||||||
|
access_handler = logging.StreamHandler() |
||||||
|
access_handler.setFormatter(access_formatter) |
||||||
|
|
||||||
|
access_logger = logging.getLogger('access') |
||||||
|
access_logger.addHandler(access_handler) |
||||||
|
access_logger.setLevel(logging.INFO) |
||||||
|
access_logger.propagate = False |
||||||
|
|
||||||
|
# Default extra context for access logger |
||||||
|
extra = { |
||||||
|
'request_id': 'unknown', |
||||||
|
'client_ip': 'unknown', |
||||||
|
'user_agent': 'unknown', |
||||||
|
'user': 'anonymous', |
||||||
|
'process_time': '0.000s' |
||||||
|
} |
||||||
|
|
||||||
|
access_logger = logging.LoggerAdapter(access_logger, extra) |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
import logging |
||||||
|
|
||||||
|
# Set up basic logging |
||||||
|
logging.basicConfig( |
||||||
|
level=logging.INFO, |
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' |
||||||
|
) |
||||||
|
|
||||||
|
basic_logger = logging.getLogger('basic') |
||||||
@ -0,0 +1,46 @@ |
|||||||
|
import pytest |
||||||
|
from unittest.mock import patch, MagicMock |
||||||
|
from nicegui.testing import User |
||||||
|
from app import main |
||||||
|
|
||||||
|
# pylint: disable=missing-function-docstring |
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.module_under_test(main) |
||||||
|
async def test_login_redirect_to_oidc(user: User) -> None: |
||||||
|
"""Test that login page redirects to OIDC provider""" |
||||||
|
await user.open('/') |
||||||
|
await user.should_see('Log in with OIDC') |
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.module_under_test(main) |
||||||
|
async def test_subpage_access_redirect(user: User) -> None: |
||||||
|
"""Test that protected pages redirect to login""" |
||||||
|
await user.open('/subpage') |
||||||
|
await user.should_see('Authentication Required') |
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.module_under_test(main) |
||||||
|
@patch('auth.oidc.oidc_config.exchange_code_for_tokens') |
||||||
|
@patch('auth.oidc.oidc_config.validate_token') |
||||||
|
async def test_oidc_callback_success(mock_validate, mock_exchange, user: User) -> None: |
||||||
|
"""Test successful OIDC callback""" |
||||||
|
# Mock successful token exchange |
||||||
|
mock_exchange.return_value = { |
||||||
|
'access_token': 'fake_access_token', |
||||||
|
'id_token': 'fake_id_token', |
||||||
|
'refresh_token': 'fake_refresh_token', |
||||||
|
'expires_in': 3600 |
||||||
|
} |
||||||
|
|
||||||
|
# Mock successful token validation |
||||||
|
mock_validate.return_value = { |
||||||
|
'sub': 'user123', |
||||||
|
'preferred_username': 'testuser', |
||||||
|
'email': 'test@example.com', |
||||||
|
'exp': 9999999999 # Far future |
||||||
|
} |
||||||
|
|
||||||
|
# Simulate OIDC callback |
||||||
|
await user.open('/auth/callback?code=test_code&state=test_state') |
||||||
|
# Note: This test would need to be adapted based on your OIDC flow |
||||||
Loading…
Reference in new issue