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

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.""" 

8 

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 _ 

16 

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" 

22 

23 

24def _authenticate_by_api_key(request: Request) -> User | None: 

25 """ 

26 Attempt to authenticate a user using an API Key from request headers. 

27 

28 Checks both the custom 'X-API-Key' header and the standard 'Authorization' 

29 header formatted as a Bearer token. 

30 

31 Args: 

32 request (Request): Flask request object containing incoming headers. 

33 

34 Returns: 

35 User | None: Authenticated User instance if valid key is provided, 

36 otherwise None. 

37 """ 

38 headers = getattr(request, "headers", None) 

39 

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) 

45 

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) 

50 

51 return None 

52 

53 

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. 

58 

59 Callback used by Flask-Login to reload the user object from the user ID 

60 stored in the session. 

61 

62 Args: 

63 user_id (str): Unique database identifier of the user as a string. 

64 

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 

72 

73 

74@login_manager.unauthorized_handler # type: ignore[untyped-decorator] 

75def unauthorized() -> Any | int | Response: 

76 """ 

77 Handle unauthorized access attempts across application blueprints. 

78 

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. 

82 

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) 

89 

90 # Default behavior for the frontend 

91 return redirect(url_for("auth.login", next=request.path)) 

92 

93 

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. 

98 

99 Evaluates authentication strategies in sequence: 

100 1. API Key via 'X-API-Key' header or 'Bearer' token. 

101 2. HTTP Basic Authentication credentials. 

102 

103 Args: 

104 request (Request): Flask request object containing HTTP authorization data. 

105 

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 

113 

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 

121 

122 return None 

123 

124 

125def admin_required(func: Callable[..., Any]) -> Callable[..., Any]: 

126 """ 

127 Restrict access to authenticated administrators. 

128 

129 Unauthenticated users are handled by Flask-Login's unauthorized callback. 

130 Authenticated users whose is_admin attribute is false receive HTTP 403. 

131 

132 Args: 

133 func: The view function to decorate. 

134 

135 Returns: 

136 The view function wrapped with authentication and administrator checks. 

137 """ 

138 

139 @wraps(func) 

140 def decorated_view(*args: Any, **kwargs: Any) -> Any: 

141 """ 

142 Check access permissions and execute the protected view. 

143 

144 Args: 

145 *args: Positional arguments forwarded to the view. 

146 **kwargs: Keyword arguments forwarded to the view. 

147 

148 Returns: 

149 The view's result or the unauthorized callback's response. 

150 

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() 

157 

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) 

161 

162 # Support both synchronous and asynchronous views. 

163 return current_app.ensure_sync(func)(*args, **kwargs) 

164 

165 return decorated_view 

166 

167 

168login_required = flask_login_required