Coverage for seedboxsync/front/login_manager.py: 89%
54 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 17:10 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-31 17:10 +0000
1#
2# Copyright (C) 2015-2026 Guillaume Kulakowski <guillaume@kulakowski.fr>
3#
4# For the full copyright and license information, please view the LICENSE
5# file that was distributed with this source code.
6#
7"""SeedboxSync login manager module using Flask-Login."""
9from collections.abc import Callable
10from functools import wraps
11from typing import Any
12from flask import Request, Response, abort, current_app, redirect, request, session, url_for
13from flask_login import LoginManager, current_user, login_required as flask_login_required
14from seedboxsync.core.database.models import ApiKey, User
15from seedboxsync.front.babel import gettext as _
17# Setup Flask-Login
18login_manager = LoginManager()
19login_manager.login_view = "auth.login" # pyright: ignore[reportAttributeAccessIssue]
20login_manager.login_message = _("Please log in to access this page.")
21login_manager.login_message_category = "info"
24def _authenticate_by_api_key(request: Request) -> User | None:
25 """
26 Attempt to authenticate a user using an API Key from request headers.
28 Checks both the custom 'X-API-Key' header and the standard 'Authorization'
29 header formatted as a Bearer token.
31 Args:
32 request (Request): Flask request object containing incoming headers.
34 Returns:
35 User | None: Authenticated User instance if valid key is provided,
36 otherwise None.
37 """
38 headers = getattr(request, "headers", None)
40 # 1. Direct X-API-Key header
41 if headers is not None:
42 api_key_header = headers.get("X-API-Key")
43 if api_key_header:
44 return ApiKey.authenticate(api_key_header)
46 # 2. Authorization: Bearer token
47 auth = getattr(request, "authorization", None)
48 if auth and auth.type == "bearer" and getattr(auth, "token", None):
49 return ApiKey.authenticate(auth.token)
51 return None
54@login_manager.user_loader # type: ignore[untyped-decorator]
55def load_user(user_id: str) -> "User | None":
56 """
57 Retrieve and load a user instance by primary key for session management.
59 Callback used by Flask-Login to reload the user object from the user ID
60 stored in the session.
62 Args:
63 user_id (str): Unique database identifier of the user as a string.
65 Returns:
66 User | None: The matching User instance if found, or None if no record exists.
67 """
68 user = User.get_or_none(User.id == int(user_id))
69 if user is None:
70 session.clear() # Clear broken session.
71 return user
74@login_manager.unauthorized_handler # type: ignore[untyped-decorator]
75def unauthorized() -> Any | int | Response:
76 """
77 Handle unauthorized access attempts across application blueprints.
79 Differentiates between API and Web UI behavior: returns an HTTP 401 Unauthorized
80 error for API endpoints, or redirects to the login view with the original target
81 URL in the 'next' query parameter for frontend routes.
83 Returns:
84 Any | int | Response: HTTP 401 abort error for API routes, or a Flask HTTP
85 redirect response to the login page for frontend routes.
86 """
87 if request.blueprint == "api":
88 abort(401)
90 # Default behavior for the frontend
91 return redirect(url_for("auth.login", next=request.path))
94@login_manager.request_loader # type: ignore[untyped-decorator]
95def load_user_from_request(request: Request) -> User | None:
96 """
97 Load and authenticate a user from request credentials.
99 Evaluates authentication strategies in sequence:
100 1. API Key via 'X-API-Key' header or 'Bearer' token.
101 2. HTTP Basic Authentication credentials.
103 Args:
104 request (Request): Flask request object containing HTTP authorization data.
106 Returns:
107 User | None: Authenticated User instance or None if verification fails.
108 """
109 # Try API Key authentication first
110 user = _authenticate_by_api_key(request)
111 if user is not None:
112 return user
114 # Fallback to HTTP Basic authentication
115 auth = getattr(request, "authorization", None)
116 if auth and auth.type == "basic" and auth.username and auth.password:
117 try:
118 return User.authenticate(auth.username, auth.password)
119 except User.DoesNotExist: # pyright: ignore[reportAttributeAccessIssue]
120 return None
122 return None
125def admin_required(func: Callable[..., Any]) -> Callable[..., Any]:
126 """
127 Restrict access to authenticated administrators.
129 Unauthenticated users are handled by Flask-Login's unauthorized callback.
130 Authenticated users whose is_admin attribute is false receive HTTP 403.
132 Args:
133 func: The view function to decorate.
135 Returns:
136 The view function wrapped with authentication and administrator checks.
137 """
139 @wraps(func)
140 def decorated_view(*args: Any, **kwargs: Any) -> Any:
141 """
142 Check access permissions and execute the protected view.
144 Args:
145 *args: Positional arguments forwarded to the view.
146 **kwargs: Keyword arguments forwarded to the view.
148 Returns:
149 The view's result or the unauthorized callback's response.
151 Raises:
152 Forbidden: If the authenticated user is not an administrator.
153 """
154 # Delegate unauthenticated access to Flask-Login.
155 if not current_app.config.get("LOGIN_DISABLED") and not current_user.is_authenticated:
156 return login_manager.unauthorized()
158 # Deny access to authenticated users without administrator privileges.
159 if not current_app.config.get("LOGIN_DISABLED") and not current_user.is_admin:
160 abort(403)
162 # Support both synchronous and asynchronous views.
163 return current_app.ensure_sync(func)(*args, **kwargs)
165 return decorated_view
168login_required = flask_login_required