Coverage for seedboxsync/front/login_manager.py: 91%

43 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-26 17:14 +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 typing import Any 

10from flask import Request, Response, abort, redirect, request, session, url_for 

11from flask_login import LoginManager, login_required as flask_login_required 

12from seedboxsync.core.database.models import ApiKey, User 

13from seedboxsync.front.babel import gettext as _ 

14 

15# Setup Flask-Login 

16login_manager = LoginManager() 

17login_manager.login_view = "auth.login" # pyright: ignore[reportAttributeAccessIssue] 

18login_manager.login_message = _("Please log in to access this page.") 

19login_manager.login_message_category = "info" 

20 

21 

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

23 """ 

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

25 

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

27 header formatted as a Bearer token. 

28 

29 Args: 

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

31 

32 Returns: 

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

34 otherwise None. 

35 """ 

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

37 

38 # 1. Direct X-API-Key header 

39 if headers is not None: 

40 api_key_header = headers.get("X-API-Key") 

41 if api_key_header: 

42 return ApiKey.authenticate(api_key_header) 

43 

44 # 2. Authorization: Bearer token 

45 auth = getattr(request, "authorization", None) 

46 if auth and auth.type == "bearer" and getattr(auth, "token", None): 

47 return ApiKey.authenticate(auth.token) 

48 

49 return None 

50 

51 

52@login_manager.user_loader # type: ignore[untyped-decorator] 

53def load_user(user_id: str) -> "User | None": 

54 """ 

55 Retrieve and load a user instance by primary key for session management. 

56 

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

58 stored in the session. 

59 

60 Args: 

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

62 

63 Returns: 

64 User | None: The matching User instance if found, or None if no record exists. 

65 """ 

66 user = User.get_or_none(User.id == int(user_id)) 

67 if user is None: 

68 session.clear() # Clear broken session. 

69 return user 

70 

71 

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

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

74 """ 

75 Handle unauthorized access attempts across application blueprints. 

76 

77 Differentiates between API and Web UI behavior: returns an HTTP 401 Unauthorized 

78 error for API endpoints, or redirects to the login view with the original target 

79 URL in the 'next' query parameter for frontend routes. 

80 

81 Returns: 

82 Any | int | Response: HTTP 401 abort error for API routes, or a Flask HTTP 

83 redirect response to the login page for frontend routes. 

84 """ 

85 if request.blueprint == "api": 

86 abort(401) 

87 

88 # Default behavior for the frontend 

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

90 

91 

92@login_manager.request_loader # type: ignore[untyped-decorator] 

93def load_user_from_request(request: Request) -> User | None: 

94 """ 

95 Load and authenticate a user from request credentials. 

96 

97 Evaluates authentication strategies in sequence: 

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

99 2. HTTP Basic Authentication credentials. 

100 

101 Args: 

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

103 

104 Returns: 

105 User | None: Authenticated User instance or None if verification fails. 

106 """ 

107 # Try API Key authentication first 

108 user = _authenticate_by_api_key(request) 

109 if user is not None: 

110 return user 

111 

112 # Fallback to HTTP Basic authentication 

113 auth = getattr(request, "authorization", None) 

114 if auth and auth.type == "basic" and auth.username and auth.password: 

115 try: 

116 return User.authenticate(auth.username, auth.password) 

117 except User.DoesNotExist: # pyright: ignore[reportAttributeAccessIssue] 

118 return None 

119 

120 return None 

121 

122 

123login_required = flask_login_required