FastAPI-UI-Auth 0.0.1b0__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: FastAPI-UI-Auth
3
+ Version: 0.1.0
4
+ Summary: Python module to add username and password authentication to specific FastAPI routes
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: fastapi==0.115.*
9
+ Requires-Dist: jinja2==3.1.*
10
+ Requires-Dist: pydantic==2.11.*
11
+ Requires-Dist: python-dotenv==1.1.*
12
+ Provides-Extra: dev
13
+ Requires-Dist: pre-commit==4.2.*; extra == "dev"
14
+ Requires-Dist: uvicorn==0.34.*; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # FastAPIAuthenticator
18
+
19
+ Python module to add username and password authentication to specific FastAPI routes
20
+
21
+ ![Python][label-pyversion]
22
+
23
+ **Platform Supported**
24
+
25
+ ![Platform][label-platform]
26
+
27
+ ## Installation
28
+
29
+ ```shell
30
+ repo="thevickypedia/FastAPIAuthenticator"
31
+
32
+ latest=$(curl -s https://api.github.com/repos/${repo}/tags | jq -r '.[0].name')
33
+
34
+ pip install "git+https://github.com/${repo}.git@${latest}"
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ import fastapiauthenticator
41
+
42
+ from fastapi import FastAPI
43
+
44
+ app = FastAPI()
45
+
46
+
47
+ @app.get("/public")
48
+ def public_route():
49
+ return {"message": "This is a public route"}
50
+
51
+
52
+ def private_route():
53
+ return {"message": "This is a private route"}
54
+
55
+
56
+ fastapiauthenticator.Authenticator(app=app, secure_function=private_route)
57
+ ```
58
+
59
+ ## Coding Standards
60
+ Docstring format: [`Google`][google-docs] <br>
61
+ Styling conventions: [`PEP 8`][pep8] and [`isort`][isort]
62
+
63
+ ## [Release Notes][release-notes]
64
+ **Requirement**
65
+ ```shell
66
+ python -m pip install gitverse
67
+ ```
68
+
69
+ **Usage**
70
+ ```shell
71
+ gitverse-release reverse -f release_notes.rst -t 'Release Notes'
72
+ ```
73
+
74
+ ## Linting
75
+
76
+ **Requirement**
77
+ ```shell
78
+ python -m pip install sphinx==5.1.1 pre-commit recommonmark
79
+ ```
80
+
81
+ **Usage**
82
+ ```shell
83
+ pre-commit run --all-files
84
+ ```
85
+
86
+ ## License & copyright
87
+
88
+ &copy; Vignesh Rao
89
+
90
+ Licensed under the [MIT License][license]
91
+
92
+ [//]: # (Labels)
93
+
94
+ [3.11]: https://docs.python.org/3/whatsnew/3.11.html
95
+ [license]: https://github.com/thevickypedia/FastAPIAuthenticator/blob/main/LICENSE
96
+ [google-docs]: https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings
97
+ [pep8]: https://www.python.org/dev/peps/pep-0008/
98
+ [isort]: https://pycqa.github.io/isort/
99
+
100
+ [label-pyversion]: https://img.shields.io/badge/python-3.11%20%7C%203.12-blue
101
+ [label-platform]: https://img.shields.io/badge/Platform-Linux|macOS|Windows-1f425f.svg
102
+ [release-notes]: https://github.com/thevickypedia/FastAPIAuthenticator/blob/main/release_notes.rst
@@ -0,0 +1,16 @@
1
+ fastapi_ui_auth-0.1.0.dist-info/licenses/LICENSE,sha256=_sOIKJWdD2o1WwwDIwYB2qTP2nlSWqT5Tyg9jr1Xa4w,1070
2
+ fastapiauthenticator/__init__.py,sha256=H1tJUJp4FWweEu2JeMw5vmqjtMhMwR4kUd11ek8UmwQ,320
3
+ fastapiauthenticator/endpoints.py,sha256=PF2qu6XQ3MQStFEprRYYxiS6Dl6ukYaMqEkSlO-F3Ls,2104
4
+ fastapiauthenticator/enums.py,sha256=WO0eBv3l9HHr1I_ZXtAifCgdL-db_tZj9ka7jnjiS5k,547
5
+ fastapiauthenticator/models.py,sha256=GxmQfSvg70OTsvswJ3QFq_lxq-Yz1fIfzW6x8d4Sj40,2726
6
+ fastapiauthenticator/secure.py,sha256=ZOH6kT4BD56VqwaKdKocX7eSE8tqZcu-tK0QOmjY58k,1089
7
+ fastapiauthenticator/service.py,sha256=1UCCUf7yaH11BHB2vlaNYehVn5YyWbGK6bnrN7wJlDM,6485
8
+ fastapiauthenticator/utils.py,sha256=geO78AL-nqv4EANQzQaWI2mGkkZiLZY8wm2LH_EDye0,7145
9
+ fastapiauthenticator/version.py,sha256=aOHawL1zuHMfBWKXqwUkXcW96oXLNCY-CXdHDqkz4g4,18
10
+ fastapiauthenticator/templates/index.html,sha256=mA2R6gk6lvibq_AmPgGHBFQijYtNUD7IIfeBSJWQrM4,9078
11
+ fastapiauthenticator/templates/session.html,sha256=LUCvcEdQOjfIXjRZ2gPx2s5wyzNuCve4OMge0hXaBLM,3053
12
+ fastapiauthenticator/templates/unauthorized.html,sha256=UZo1Jt64-CFfjwWTGicUMdHVWkYkXCJBRxvit4QTiQM,3015
13
+ fastapi_ui_auth-0.1.0.dist-info/METADATA,sha256=taS1d_w3xCMVJB5ymIbhjlM7GpWIfCPEf8PyR9u4pRQ,2402
14
+ fastapi_ui_auth-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
+ fastapi_ui_auth-0.1.0.dist-info/top_level.txt,sha256=EpDRP7uLM0f-Vd5rUtLBh4MTMAnpXzw1pr0DSknW_Ds,21
16
+ fastapi_ui_auth-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,6 @@
1
+ from fastapiauthenticator.enums import APIEndpoints, APIMethods # noqa: F401,E402
2
+ from fastapiauthenticator.models import Parameters # noqa: F401,E402
3
+ from fastapiauthenticator.service import Authenticator # noqa: F401,E402
4
+ from fastapiauthenticator.version import version # noqa: F401,E402
5
+
6
+ protect = Authenticator
@@ -0,0 +1,73 @@
1
+ from fastapi.requests import Request
2
+ from fastapi.responses import HTMLResponse
3
+
4
+ from fastapiauthenticator import enums, models, utils
5
+ from fastapiauthenticator.version import version
6
+
7
+
8
+ def session(request: Request) -> HTMLResponse:
9
+ """Renders the session error page.
10
+
11
+ Args:
12
+ request: Reference to the FastAPI request object.
13
+
14
+ Returns:
15
+ HTMLResponse:
16
+ Returns an HTML response templated using Jinja2.
17
+ """
18
+ return utils.deauthorize(
19
+ models.templates.TemplateResponse(
20
+ name="session.html",
21
+ context={
22
+ "request": request,
23
+ "signin": enums.APIEndpoints.fastapi_login,
24
+ "reason": "Session expired or invalid.",
25
+ "fallback_path": models.fallback.path,
26
+ "fallback_button": models.fallback.button,
27
+ "version": f"v{version}",
28
+ },
29
+ ),
30
+ )
31
+
32
+
33
+ def login(request: Request) -> HTMLResponse:
34
+ """Render the login page with the verification path and version.
35
+
36
+ Returns:
37
+ HTMLResponse:
38
+ Rendered HTML response for the login page.
39
+ """
40
+ return utils.deauthorize(
41
+ models.templates.TemplateResponse(
42
+ name="index.html",
43
+ context={
44
+ "request": request,
45
+ "signin": enums.APIEndpoints.fastapi_verify_login,
46
+ "version": f"v{version}",
47
+ },
48
+ ),
49
+ )
50
+
51
+
52
+ def error(request: Request) -> HTMLResponse:
53
+ """Error endpoint for the authenticator.
54
+
55
+ Args:
56
+ request: Reference to the FastAPI request object.
57
+
58
+ Returns:
59
+ HTMLResponse:
60
+ Returns an HTML response templated using Jinja2.
61
+ """
62
+ return utils.deauthorize(
63
+ models.templates.TemplateResponse(
64
+ name="unauthorized.html",
65
+ context={
66
+ "request": request,
67
+ "signin": enums.APIEndpoints.fastapi_login,
68
+ "fallback_path": models.fallback.path,
69
+ "fallback_button": models.fallback.button,
70
+ "version": f"v{version}",
71
+ },
72
+ ),
73
+ )
@@ -0,0 +1,30 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class APIMethods(StrEnum):
5
+ """HTTP methods for API requests.
6
+
7
+ >>> APIMethods
8
+
9
+ """
10
+
11
+ GET = "GET"
12
+ POST = "POST"
13
+ PUT = "PUT"
14
+ DELETE = "DELETE"
15
+ PATCH = "PATCH"
16
+ OPTIONS = "OPTIONS"
17
+
18
+
19
+ class APIEndpoints(StrEnum):
20
+ """API endpoints for all the routes.
21
+
22
+ >>> APIEndpoints
23
+
24
+ """
25
+
26
+ fastapi_error = "/fastapi-error"
27
+ fastapi_login = "/fastapi-login"
28
+ fastapi_logout = "/fastapi-logout"
29
+ fastapi_session = "/fastapi-session"
30
+ fastapi_verify_login = "/fastapi-verify-login"
@@ -0,0 +1,85 @@
1
+ import pathlib
2
+ from typing import Callable, Dict, List, Optional, Type
3
+
4
+ from fastapi.routing import APIRoute, APIWebSocketRoute
5
+ from fastapi.templating import Jinja2Templates
6
+ from pydantic import BaseModel, Field
7
+
8
+ from fastapiauthenticator.enums import APIMethods
9
+
10
+ templates = Jinja2Templates(directory=pathlib.Path(__file__).parent / "templates")
11
+
12
+
13
+ class Parameters(BaseModel):
14
+ """Parameters for the Authenticator class.
15
+
16
+ >>> Parameters
17
+
18
+ Attributes:
19
+ path: Path for the secure route, must start with '/'.
20
+ function: Function to be called for secure routes after authentication.
21
+ methods: List of HTTP methods that the secure function will handle.
22
+ route: Type of route to be used for secure routes, either APIWebSocketRoute or APIRoute.
23
+ """
24
+
25
+ path: str = Field(
26
+ pattern="^/.*$", description="Path for the secure route, must start with '/'"
27
+ )
28
+ function: Callable
29
+ methods: List[APIMethods] = [APIMethods.GET]
30
+ route: Type[APIWebSocketRoute] | Type[APIRoute] = APIRoute
31
+
32
+
33
+ class WSSession(BaseModel):
34
+ """Object to store websocket session information.
35
+
36
+ >>> WSSession
37
+
38
+ """
39
+
40
+ invalid: Dict[str, int] = Field(default_factory=dict)
41
+ client_auth: Dict[str, Dict[str, str | int]] = Field(default_factory=dict)
42
+
43
+
44
+ class Fallback(BaseModel):
45
+ """Object to store fallback information.
46
+
47
+ >>> Fallback
48
+
49
+ """
50
+
51
+ button: str = Field(default="LOGIN", description="Title for the fallback button.")
52
+ path: str = Field(
53
+ default="/", description="Path to redirect when fallback button is clicked."
54
+ )
55
+
56
+
57
+ class RedirectException(Exception):
58
+ """Custom ``RedirectException`` raised within the API since HTTPException doesn't support returning HTML content.
59
+
60
+ >>> RedirectException
61
+
62
+ See Also:
63
+ - RedirectException allows the API to redirect on demand in cases where returning is not a solution.
64
+ - There are alternatives to raise HTML content as an exception but none work with our use-case with JavaScript.
65
+ - This way of exception handling comes handy for many unexpected scenarios.
66
+
67
+ References:
68
+ https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers
69
+ """
70
+
71
+ def __init__(self, destination: str, source: str = "/", detail: Optional[str] = ""):
72
+ """Instantiates the ``RedirectException`` object with the required parameters.
73
+
74
+ Args:
75
+ source: Source from where the redirect is initiated.
76
+ destination: Location to redirect.
77
+ detail: Reason for redirect.
78
+ """
79
+ self.detail = detail
80
+ self.source = source
81
+ self.destination = destination
82
+
83
+
84
+ ws_session = WSSession()
85
+ fallback = Fallback()
@@ -0,0 +1,41 @@
1
+ import base64
2
+ import binascii
3
+ import hashlib
4
+ import string
5
+ from typing import Any
6
+
7
+ UNICODE_PREFIX = (
8
+ base64.b64decode(b"XA==").decode(encoding="ascii")
9
+ + string.ascii_letters[20]
10
+ + string.digits[:1] * 2
11
+ )
12
+
13
+
14
+ def calculate_hash(value: Any) -> str:
15
+ """Generate hash value for the given payload."""
16
+ return hashlib.sha512(bytes(value, "utf-8")).hexdigest()
17
+
18
+
19
+ def base64_encode(value: Any) -> str:
20
+ """Base64 encode the given payload."""
21
+ encoded_bytes = base64.b64encode(value.encode("utf-8"))
22
+ return encoded_bytes.decode("utf-8")
23
+
24
+
25
+ def base64_decode(value: Any) -> str:
26
+ """Base64 decode the given payload."""
27
+ return base64.b64decode(value).decode("utf-8")
28
+
29
+
30
+ def hex_decode(value: Any) -> str:
31
+ """Convert hex value to a string."""
32
+ return bytes(value, "utf-8").decode(encoding="unicode_escape")
33
+
34
+
35
+ def hex_encode(value: str):
36
+ """Convert string value to hex."""
37
+ return UNICODE_PREFIX + UNICODE_PREFIX.join(
38
+ binascii.hexlify(data=value.encode(encoding="utf-8"), sep="-")
39
+ .decode(encoding="utf-8")
40
+ .split(sep="-")
41
+ )
@@ -0,0 +1,167 @@
1
+ import logging
2
+ import os
3
+ from threading import Timer
4
+ from typing import Dict, List
5
+
6
+ import dotenv
7
+ from fastapi import status
8
+ from fastapi.applications import FastAPI
9
+ from fastapi.exceptions import HTTPException
10
+ from fastapi.params import Depends
11
+ from fastapi.requests import Request
12
+ from fastapi.responses import Response
13
+ from fastapi.routing import APIRoute, APIWebSocketRoute
14
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
15
+
16
+ from fastapiauthenticator import endpoints, enums, models, utils
17
+
18
+ dotenv.load_dotenv(dotenv_path=dotenv.find_dotenv(), override=True)
19
+ LOGGER = logging.getLogger("uvicorn.default")
20
+ BEARER_AUTH = HTTPBearer()
21
+
22
+
23
+ # noinspection PyDefaultArgument
24
+ class Authenticator:
25
+ """Authenticator is a FastAPI integration that provides authentication for secure routes.
26
+
27
+ >>> Authenticator
28
+
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ app: FastAPI,
34
+ params: models.Parameters | List[models.Parameters],
35
+ timeout: int = 300,
36
+ username: str = os.environ.get("USERNAME"),
37
+ password: str = os.environ.get("PASSWORD"),
38
+ fallback_button: str = models.fallback.button,
39
+ fallback_path: str = models.fallback.path,
40
+ ):
41
+ """Initialize the APIAuthenticator with the FastAPI app and secure function.
42
+
43
+ Args:
44
+ app: FastAPI application instance to which the authenticator will be added.
45
+ params: Parameters for the secure routes, can be a single `Parameters` object or a list of `Parameters`.
46
+ timeout: Session timeout in seconds, default is 300 seconds (5 minutes).
47
+ username: Username for authentication, can be set via environment variable 'USERNAME'.
48
+ password: Password for authentication, can be set via environment variable 'PASSWORD'.
49
+ fallback_button: Title for the fallback button, defaults to "LOGIN".
50
+ fallback_path: Fallback path to redirect to in case of session timeout or invalid session.
51
+ """
52
+ assert all((username, password)), "'username' and 'password' are mandatory."
53
+ assert fallback_path.startswith("/"), "Fallback path must start with '/'"
54
+
55
+ self.app = app
56
+
57
+ if isinstance(params, list):
58
+ self.params = params
59
+ elif isinstance(params, models.Parameters):
60
+ self.params = [params]
61
+
62
+ self.route_map: Dict[str, models.Parameters] = {
63
+ param.path: param for param in self.params if param.route is APIRoute
64
+ }
65
+
66
+ models.fallback.path = fallback_path
67
+ models.fallback.button = fallback_button
68
+
69
+ # noinspection PyTypeChecker
70
+ self.app.add_exception_handler(
71
+ exc_class_or_status_code=models.RedirectException,
72
+ handler=utils.redirect_exception_handler,
73
+ )
74
+
75
+ self.username = username
76
+ self.password = password
77
+ self.timeout = timeout
78
+
79
+ self._secure()
80
+
81
+ def _verify_auth(
82
+ self,
83
+ request: Request,
84
+ authorization: HTTPAuthorizationCredentials = Depends(BEARER_AUTH),
85
+ response: Response = None,
86
+ ) -> Dict[str, str]:
87
+ """Verify the authentication credentials and redirect to the secure route.
88
+
89
+ Args:
90
+ request: Request object containing client information.
91
+ authorization: Authorization credentials from the request, provided by FastAPI's HTTPBearer.
92
+ response: Response object containing the response from FastAPI's HTTPBearer.
93
+
94
+ Returns:
95
+ Dict[str, str]:
96
+ A dictionary containing the redirect URL to the secure path.
97
+ """
98
+ utils.verify_login(
99
+ authorization=authorization,
100
+ request=request,
101
+ env_username=self.username,
102
+ env_password=self.password,
103
+ )
104
+ destination = request.cookies.get("X-Requested-By")
105
+ if parameter := self.route_map.get(destination):
106
+ LOGGER.info("Setting session timeout for %s seconds", self.timeout)
107
+ # Set session_token cookie with a timeout, to be used for session validation when redirected
108
+ response.set_cookie(
109
+ key="session_token",
110
+ value=models.ws_session.client_auth[request.client.host].get("token"),
111
+ httponly=True,
112
+ samesite="strict",
113
+ max_age=self.timeout,
114
+ )
115
+ response.delete_cookie(key="X-Requested-By")
116
+ Timer(
117
+ function=utils.clear_session,
118
+ args=(request.client.host,),
119
+ interval=self.timeout,
120
+ ).start()
121
+ return {"redirect_url": parameter.path}
122
+ raise HTTPException(
123
+ status_code=status.HTTP_417_EXPECTATION_FAILED,
124
+ detail="Unable to find secure route for the requested path.\n"
125
+ "Missing cookie: 'X-Requested-By'\n"
126
+ "Reload the source page to authenticate.",
127
+ )
128
+
129
+ def _secure(self) -> None:
130
+ """Create the login and verification routes for the APIAuthenticator."""
131
+ login_route = APIRoute(
132
+ path=enums.APIEndpoints.fastapi_login,
133
+ endpoint=endpoints.login,
134
+ methods=["GET"],
135
+ )
136
+ error_route = APIRoute(
137
+ path=enums.APIEndpoints.fastapi_error,
138
+ endpoint=endpoints.error,
139
+ methods=["GET"],
140
+ )
141
+ session_route = APIRoute(
142
+ path=enums.APIEndpoints.fastapi_session,
143
+ endpoint=endpoints.session,
144
+ methods=["GET"],
145
+ )
146
+ verify_route = APIRoute(
147
+ path=enums.APIEndpoints.fastapi_verify_login,
148
+ endpoint=self._verify_auth,
149
+ methods=["POST"],
150
+ )
151
+ for param in self.params:
152
+ if param.route is APIWebSocketRoute:
153
+ # WebSocket routes will not have a login path, they will be protected by session check
154
+ secure_route = APIWebSocketRoute(
155
+ path=param.path,
156
+ endpoint=param.function,
157
+ dependencies=[Depends(utils.session_check)],
158
+ )
159
+ else:
160
+ secure_route = APIRoute(
161
+ path=param.path,
162
+ endpoint=param.function,
163
+ methods=["GET"],
164
+ dependencies=[Depends(utils.session_check)],
165
+ )
166
+ self.app.routes.append(secure_route)
167
+ self.app.routes.extend([login_route, session_route, verify_route, error_route])
@@ -0,0 +1,259 @@
1
+ <!DOCTYPE html>
2
+ <!--suppress JSUnresolvedLibraryURL -->
3
+ <html lang="en">
4
+ <head>
5
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
6
+ <title>FastAPI - Authenticator</title>
7
+ <meta property="og:type" content="Authenticator">
8
+ <meta name="keywords" content="Python, fastapi, JavaScript, HTML, CSS">
9
+ <meta name="author" content="Vignesh Rao">
10
+ <meta content="width=device-width, initial-scale=1" name="viewport">
11
+ <script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
12
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9-1/crypto-js.js"></script>
13
+ <!-- Favicon.ico and Apple Touch Icon -->
14
+ <link property="og:image" rel="icon" href="https://thevickypedia.github.io/open-source/images/logo/fastapi.ico">
15
+ <link property="og:image" rel="apple-touch-icon"
16
+ href="https://thevickypedia.github.io/open-source/images/logo/fastapi.png">
17
+ <!-- Font Awesome icons -->
18
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/fontawesome.min.css">
19
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/solid.min.css">
20
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/regular.min.css">
21
+ <style>
22
+ body {
23
+ font-family: 'Arial', sans-serif;
24
+ margin: 0;
25
+ padding: 0;
26
+ background-color: #151515;
27
+ }
28
+
29
+ .container {
30
+ max-width: 400px;
31
+ margin: 50px auto;
32
+ background: #fff;
33
+ padding: 20px;
34
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
35
+ border-radius: 8px;
36
+ }
37
+
38
+ .header {
39
+ text-align: center;
40
+ margin-bottom: 20px;
41
+ }
42
+
43
+ .header img {
44
+ max-width: 150px;
45
+ }
46
+
47
+ .content {
48
+ display: flex;
49
+ flex-direction: column;
50
+ }
51
+
52
+ form {
53
+ display: flex;
54
+ flex-direction: column;
55
+ }
56
+
57
+ label {
58
+ margin-top: 10px;
59
+ color: #000000;
60
+ font-size: large;
61
+ font-family: 'Courier New', sans-serif;
62
+ font-weight: normal;
63
+ }
64
+
65
+ input {
66
+ padding: 10px;
67
+ margin-bottom: 15px;
68
+ border: 1px solid #ddd;
69
+ border-radius: 5px;
70
+ }
71
+
72
+ button {
73
+ padding: 12px;
74
+ background-color: #000000;
75
+ color: #fff;
76
+ border: none;
77
+ border-radius: 5px;
78
+ cursor: pointer;
79
+ font-weight: bold;
80
+ }
81
+
82
+ button:hover {
83
+ background-color: #494949;
84
+ }
85
+
86
+ footer {
87
+ bottom: 20px;
88
+ position: fixed;
89
+ width: 100%;
90
+ color: #fff;
91
+ text-align: center;
92
+ font-family: 'Courier New', monospace;
93
+ font-size: small;
94
+ }
95
+ </style>
96
+ <style>
97
+ .password-container {
98
+ position: relative;
99
+ }
100
+
101
+ .password-container input[type="password"],
102
+ .password-container input[type="text"] {
103
+ width: 100%;
104
+ padding: 12px 36px 12px 12px;
105
+ box-sizing: border-box;
106
+ }
107
+
108
+ .fa-eye, .fa-eye-slash {
109
+ position: absolute;
110
+ top: 40%;
111
+ right: 4%;
112
+ transform: translateY(-50%);
113
+ cursor: pointer;
114
+ color: lightgray;
115
+ }
116
+ </style>
117
+ <noscript>
118
+ <style>
119
+ body {
120
+ width: 100%;
121
+ height: 100%;
122
+ overflow: hidden;
123
+ }
124
+ </style>
125
+ <div style="position: fixed; text-align:center; height: 100%; width: 100%; background-color: #151515;">
126
+ <h2 style="margin-top:5%">This page requires JavaScript
127
+ to be enabled.
128
+ <br><br>
129
+ Please refer <a href="https://www.enable-javascript.com/">enable-javascript</a> for how to.
130
+ </h2>
131
+ <form>
132
+ <button type="submit" onClick="<meta httpEquiv='refresh' content='0'>">RETRY</button>
133
+ </form>
134
+ </div>
135
+ </noscript>
136
+ </head>
137
+ <body>
138
+ <div class="container">
139
+ <div class="header">
140
+ <i class="fa-solid fa-user"></i>
141
+ </div>
142
+ <div class="content">
143
+ <!-- <form action="{ url_for('signin') }" method="post"> -->
144
+ <form>
145
+ <label for="username">Username:</label>
146
+ <input type="text" id="username" name="username" required>
147
+ <label for="password">Password:</label>
148
+ <div class="password-container">
149
+ <input type="password" id="password" name="password" required>
150
+ <i class="fa-regular fa-eye" id="eye"></i>
151
+ </div>
152
+ <button type="submit" onclick="submitToAPI(event)">Sign In</button>
153
+ </form>
154
+ </div>
155
+ </div>
156
+ </body>
157
+ <!-- control the behavior of the browser's navigation without triggering a full page reload -->
158
+ <script>
159
+ document.addEventListener('DOMContentLoaded', function () {
160
+ history.pushState(null, document.title, location.href);
161
+ window.addEventListener('popstate', function (event) {
162
+ history.pushState(null, document.title, location.href);
163
+ });
164
+ });
165
+ </script>
166
+ <!-- handle authentication from login page -->
167
+ <script>
168
+ async function ConvertStringToHex(str) {
169
+ let arr = [];
170
+ for (let i = 0; i < str.length; i++) {
171
+ arr[i] = ("00" + str.charCodeAt(i).toString(16)).slice(-4);
172
+ }
173
+ return "\\u" + arr.join("\\u");
174
+ }
175
+
176
+ async function CalculateHash(username, password, timestamp) {
177
+ const message = username + password + timestamp;
178
+ const encoder = new TextEncoder();
179
+ const data = encoder.encode(message);
180
+ if (crypto.subtle === undefined) {
181
+ const wordArray = CryptoJS.lib.WordArray.create(data);
182
+ const hash = CryptoJS.SHA512(wordArray);
183
+ // Convert the hash to a hexadecimal string and return it
184
+ return hash.toString(CryptoJS.enc.Hex);
185
+ } else {
186
+ const hashBuffer = await crypto.subtle.digest('SHA-512', data);
187
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
188
+ // Convert each byte to a hexadecimal string, pad with zeros, and join them to form the final hash
189
+ return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
190
+ }
191
+ }
192
+
193
+ async function submitToAPI(event) {
194
+ event.preventDefault();
195
+ const username = $("#username").val();
196
+ const password = $("#password").val();
197
+ if (username === "" || password === "") {
198
+ alert("ERROR: Username and password are required to authenticate your request!");
199
+ return false;
200
+ }
201
+
202
+ let hex_user = await ConvertStringToHex(username);
203
+ let hex_pass = await ConvertStringToHex(password);
204
+ let timestamp = Math.round(new Date().getTime() / 1000);
205
+ let hash = await CalculateHash(hex_user, hex_pass, timestamp)
206
+ let authHeaderValue = hex_user + ',' + hash + ',' + timestamp;
207
+ let origin = window.location.origin
208
+ $.ajax({
209
+ method: "POST",
210
+ url: origin.concat("{{ signin }}"),
211
+ headers: {
212
+ 'accept': 'application/json',
213
+ 'Authorization': `Bearer ${btoa(authHeaderValue)}`,
214
+ },
215
+ crossDomain: "true",
216
+ contentType: "application/json; charset=utf-8",
217
+ success: function (data) {
218
+ // Check if the response contains a redirect URL
219
+ if (data.redirect_url) {
220
+ // Manually handle the redirect
221
+ window.location.href = data.redirect_url;
222
+ } else {
223
+ console.log("Unhandled good response data")
224
+ // Handle success if needed
225
+ console.log(data);
226
+ }
227
+ },
228
+ error: function (jqXHR, textStatus, errorThrown) {
229
+ console.error(`Status: ${textStatus}, Error: ${errorThrown}`);
230
+ if (jqXHR.hasOwnProperty("responseJSON")) {
231
+ alert(jqXHR.responseJSON.detail);
232
+ } else {
233
+ alert(errorThrown);
234
+ }
235
+ }
236
+ });
237
+ }
238
+
239
+ </script>
240
+ <footer>
241
+ <div class="footer">
242
+ PyAPIAuthenticator - {{ version }}<br>
243
+ <a href="https://github.com/thevickypedia/pyapiauthenticator">https://github.com/thevickypedia/pyapiauthenticator</a>
244
+ </div>
245
+ </footer>
246
+ <script>
247
+ const passwordInput = document.querySelector("#password")
248
+ const eye = document.querySelector("#eye")
249
+ eye.addEventListener("click", function () {
250
+ if (passwordInput.type === "password") {
251
+ passwordInput.type = "text";
252
+ eye.className = "fa-regular fa-eye-slash"
253
+ } else {
254
+ passwordInput.type = "password";
255
+ eye.className = "fa-regular fa-eye"
256
+ }
257
+ });
258
+ </script>
259
+ </html>
@@ -0,0 +1,86 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
5
+ <title>FastAPI - Authenticator</title>
6
+ <meta property="og:type" content="Authenticator">
7
+ <meta name="keywords" content="Python, fastapi, JavaScript, HTML, CSS">
8
+ <meta name="author" content="Vignesh Rao">
9
+ <link property="og:image" rel="icon" href="https://thevickypedia.github.io/open-source/images/logo/fastapi.ico">
10
+ <link property="og:image" rel="apple-touch-icon"
11
+ href="https://thevickypedia.github.io/open-source/images/logo/fastapi.png">
12
+ <meta content="width=device-width, initial-scale=1" name="viewport">
13
+ <style>
14
+ img {
15
+ display: block;
16
+ margin-left: auto;
17
+ margin-right: auto;
18
+ }
19
+
20
+ :is(h1, h2, h3, h4, h5, h6) {
21
+ text-align: center;
22
+ color: #F0F0F0;
23
+ font-family: 'Courier New', sans-serif;
24
+ }
25
+
26
+ button {
27
+ width: 100px;
28
+ margin: 0 auto;
29
+ display: block;
30
+ }
31
+
32
+ body {
33
+ background-color: #151515;
34
+ }
35
+
36
+ footer {
37
+ bottom: 20px;
38
+ position: fixed;
39
+ width: 100%;
40
+ color: #fff;
41
+ text-align: center;
42
+ font-family: 'Courier New', monospace;
43
+ font-size: small;
44
+ }
45
+ </style>
46
+ <noscript>
47
+ <style>
48
+ body {
49
+ width: 100%;
50
+ height: 100%;
51
+ overflow: hidden;
52
+ }
53
+ </style>
54
+ <div style="position: fixed; text-align:center; height: 100%; width: 100%; background-color: #151515">
55
+ <h2 style="margin-top:5%">This page requires JavaScript
56
+ to be enabled.
57
+ <br><br>
58
+ Please refer <a href="https://www.enable-javascript.com/">enable-javascript</a> for how to.
59
+ </h2>
60
+ <form>
61
+ <button type="submit" onClick="<meta httpEquiv='refresh' content='0'>">RETRY</button>
62
+ </form>
63
+ </div>
64
+ </noscript>
65
+ </head>
66
+ <body>
67
+ <h2 style="margin-top:5%">{{ reason }}</h2>
68
+ <h3>Authentication doesn't last forever ¯\_(ツ)_/¯ </h3>
69
+ <p>
70
+ <img src="https://thevickypedia.github.io/open-source/images/gif/shattered_fusion.gif"
71
+ onerror="this.src='https://vigneshrao.com/open-source/images/gif/shattered_fusion.gif'"
72
+ width="200" height="200" alt="Image" class="center">
73
+ </p>
74
+ <button style="text-align:center" onClick="window.location.href = '{{ fallback_path }}';">{{ fallback_button }}</button>
75
+ <br>
76
+ <button style="text-align:center" onClick="alert('Forgot Password?\n\nRelax and try to remember your password.');">HELP
77
+ </button>
78
+ <h4>Click <a href="https://vigneshrao.com/contact">HERE</a> to reach out.</h4>
79
+ </body>
80
+ <footer>
81
+ <div class="footer">
82
+ FastAPIAuthenticator - {{ version }}<br>
83
+ <a href="https://github.com/thevickypedia/FastAPIAuthenticator">https://github.com/thevickypedia/FastAPIAuthenticator</a>
84
+ </div>
85
+ </footer>
86
+ </html>
@@ -0,0 +1,86 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
5
+ <title>FastAPI - Authenticator</title>
6
+ <meta property="og:type" content="Authenticator">
7
+ <meta name="keywords" content="Python, fastapi, JavaScript, HTML, CSS">
8
+ <meta name="author" content="Vignesh Rao">
9
+ <link property="og:image" rel="icon" href="https://thevickypedia.github.io/open-source/images/logo/fastapi.ico">
10
+ <link property="og:image" rel="apple-touch-icon"
11
+ href="https://thevickypedia.github.io/open-source/images/logo/fastapi.png">
12
+ <meta content="width=device-width, initial-scale=1" name="viewport">
13
+ <style>
14
+ img {
15
+ display: block;
16
+ margin-left: auto;
17
+ margin-right: auto;
18
+ }
19
+
20
+ :is(h1, h2, h3, h4, h5, h6) {
21
+ text-align: center;
22
+ color: #F0F0F0;
23
+ font-family: 'Courier New', sans-serif;
24
+ }
25
+
26
+ button {
27
+ width: 100px;
28
+ margin: 0 auto;
29
+ display: block;
30
+ }
31
+
32
+ body {
33
+ background-color: #151515;
34
+ }
35
+
36
+ footer {
37
+ bottom: 20px;
38
+ position: fixed;
39
+ width: 100%;
40
+ color: #fff;
41
+ text-align: center;
42
+ font-family: 'Courier New', monospace;
43
+ font-size: small;
44
+ }
45
+ </style>
46
+ <noscript>
47
+ <style>
48
+ body {
49
+ width: 100%;
50
+ height: 100%;
51
+ overflow: hidden;
52
+ }
53
+ </style>
54
+ <div style="position: fixed; text-align:center; height: 100%; width: 100%; background-color: #151515;">
55
+ <h2 style="margin-top:5%">This page requires JavaScript
56
+ to be enabled.
57
+ <br><br>
58
+ Please refer <a href="https://www.enable-javascript.com/">enable-javascript</a> for how to.
59
+ </h2>
60
+ <form>
61
+ <button type="submit" onClick="<meta httpEquiv='refresh' content='0'>">RETRY</button>
62
+ </form>
63
+ </div>
64
+ </noscript>
65
+ </head>
66
+ <body>
67
+ <h2 style="margin-top:5%">LOGIN FAILED</h2>
68
+ <h3>USER ERROR - REPLACE USER</h3>
69
+ <p>
70
+ <img src="https://thevickypedia.github.io/open-source/images/gif/lockscape.gif"
71
+ onerror="this.src='https://vigneshrao.com/open-source/images/gif/lockscape.gif'"
72
+ width="200" height="170" alt="Image" class="center">
73
+ </p>
74
+ <button style="text-align:center" onClick="window.location.href = '{{ fallback_path }}';">{{ fallback_button }}</button>
75
+ <br>
76
+ <button style="text-align:center" onClick="alert('Forgot Password?\n\nRelax and try to remember your password.');">HELP
77
+ </button>
78
+ <h4>Click <a href="https://vigneshrao.com/contact">HERE</a> to reach out.</h4>
79
+ </body>
80
+ <footer>
81
+ <div class="footer">
82
+ FastAPIAuthenticator - {{ version }}<br>
83
+ <a href="https://github.com/thevickypedia/FastAPIAuthenticator">https://github.com/thevickypedia/FastAPIAuthenticator</a>
84
+ </div>
85
+ </footer>
86
+ </html>
@@ -0,0 +1,210 @@
1
+ import logging
2
+ import secrets
3
+ from typing import Dict, List, NoReturn, Union
4
+
5
+ from fastapi import status
6
+ from fastapi.exceptions import HTTPException
7
+ from fastapi.requests import Request
8
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
9
+ from fastapi.security import HTTPAuthorizationCredentials
10
+ from fastapi.websockets import WebSocket
11
+
12
+ from fastapiauthenticator import enums, models, secure
13
+
14
+ LOGGER = logging.getLogger("uvicorn.default")
15
+
16
+
17
+ def failed_auth_counter(request: Request) -> None:
18
+ """Keeps track of failed login attempts from each host, and redirects if failed for 3 or more times.
19
+
20
+ Args:
21
+ request: Request object containing client information.
22
+ """
23
+ try:
24
+ models.ws_session.invalid[request.client.host] += 1
25
+ except KeyError:
26
+ models.ws_session.invalid[request.client.host] = 1
27
+ if models.ws_session.invalid[request.client.host] >= 3:
28
+ raise models.RedirectException(destination=enums.APIEndpoints.fastapi_error)
29
+
30
+
31
+ def redirect_exception_handler(
32
+ request: Request, exception: models.RedirectException
33
+ ) -> JSONResponse | RedirectResponse:
34
+ """Custom exception handler to handle redirect.
35
+
36
+ Args:
37
+ request: Takes the ``Request`` object as an argument.
38
+ exception: Takes the ``RedirectException`` object inherited from ``Exception`` as an argument.
39
+
40
+ Returns:
41
+ JSONResponse:
42
+ Returns the JSONResponse with content, status code and cookie.
43
+ """
44
+ if request.url.path == enums.APIEndpoints.fastapi_verify_login:
45
+ response = JSONResponse(content={"redirect_url": exception.destination})
46
+ else:
47
+ response = RedirectResponse(url=exception.destination)
48
+ if exception.detail:
49
+ response.set_cookie(
50
+ "detail", exception.detail.upper(), httponly=True, samesite="strict"
51
+ )
52
+ response.set_cookie(
53
+ "X-Requested-By", exception.source, httponly=True, samesite="strict"
54
+ )
55
+ return response
56
+
57
+
58
+ def raise_error(request: Request) -> NoReturn:
59
+ """Raises a 401 Unauthorized error in case of bad credentials.
60
+
61
+ Args:
62
+ request: Request object containing client information.
63
+ """
64
+ failed_auth_counter(request)
65
+ LOGGER.error(
66
+ "Incorrect username or password: %d",
67
+ models.ws_session.invalid[request.client.host],
68
+ )
69
+ raise HTTPException(
70
+ status_code=status.HTTP_401_UNAUTHORIZED,
71
+ detail="Incorrect username or password",
72
+ headers=None,
73
+ )
74
+
75
+
76
+ def extract_credentials(
77
+ authorization: HTTPAuthorizationCredentials, host: str
78
+ ) -> List[str]:
79
+ """Extract the credentials from ``Authorization`` headers and decode it before returning as a list of strings.
80
+
81
+ Args:
82
+ authorization: Authorization header from the request.
83
+ host: Host header from the request.
84
+ """
85
+ if not authorization:
86
+ raise_error(host)
87
+ decoded_auth = secure.base64_decode(authorization.credentials)
88
+ # convert hex to a string
89
+ auth = secure.hex_decode(decoded_auth)
90
+ return auth.split(",")
91
+
92
+
93
+ def verify_login(
94
+ authorization: HTTPAuthorizationCredentials,
95
+ request: Request,
96
+ env_username: str,
97
+ env_password: str,
98
+ ) -> Dict[str, Union[str, int]]:
99
+ """Verifies authentication and generates session token for each user.
100
+
101
+ Args:
102
+ authorization: Authorization header from the request.
103
+ request: Request object containing client information.
104
+ env_username: Environment variable for the username.
105
+ env_password: Environment variable for the password.
106
+
107
+ Returns:
108
+ Dict[str, str]:
109
+ Returns a dictionary with the payload required to create the session token.
110
+ """
111
+ username, signature, timestamp = extract_credentials(
112
+ authorization, request.client.host
113
+ )
114
+ if secrets.compare_digest(username, env_username):
115
+ hex_user = secure.hex_encode(env_username)
116
+ hex_pass = secure.hex_encode(env_password)
117
+ else:
118
+ LOGGER.warning("User '%s' not allowed", username)
119
+ raise_error(request)
120
+ message = f"{hex_user}{hex_pass}{timestamp}"
121
+ expected_signature = secure.calculate_hash(message)
122
+ if secrets.compare_digest(signature, expected_signature):
123
+ models.ws_session.invalid[request.client.host] = 0
124
+ key = secrets.token_urlsafe(64)
125
+ # fixme: By setting a path instead of timestamp, this can handle path specific sessions
126
+ models.ws_session.client_auth[request.client.host] = dict(
127
+ username=username, token=key, timestamp=int(timestamp)
128
+ )
129
+ return models.ws_session.client_auth[request.client.host]
130
+ raise_error(request)
131
+
132
+
133
+ def session_check(api_request: Request = None, api_websocket: WebSocket = None) -> None:
134
+ """Check if the session is still valid.
135
+
136
+ Args:
137
+ api_request: Request containing client information.
138
+ api_websocket: WebSocket connection object.
139
+
140
+ Raises:
141
+ HTTPException: If the session is invalid or expired.
142
+ """
143
+ if api_request:
144
+ request = api_request
145
+ elif api_websocket:
146
+ request = api_websocket
147
+ else:
148
+ raise HTTPException(
149
+ status_code=status.HTTP_400_BAD_REQUEST,
150
+ detail="Request or WebSocket connection is required for session check.",
151
+ )
152
+ session_token = request.cookies.get("session_token")
153
+ stored_token = models.ws_session.client_auth.get(request.client.host, {}).get(
154
+ "token"
155
+ )
156
+ if (
157
+ stored_token
158
+ and session_token
159
+ and secrets.compare_digest(session_token, stored_token)
160
+ ):
161
+ LOGGER.info("Session is valid for host: %s", request.client.host)
162
+ return
163
+ elif not session_token:
164
+ LOGGER.warning(
165
+ "Session is invalid or expired for host: %s", request.client.host
166
+ )
167
+ raise models.RedirectException(
168
+ source=request.url.path,
169
+ destination=enums.APIEndpoints.fastapi_login,
170
+ )
171
+ else:
172
+ LOGGER.warning(
173
+ "Session token mismatch for host: %s. Expected: %s, Received: %s",
174
+ request.client.host,
175
+ stored_token,
176
+ session_token,
177
+ )
178
+ raise models.RedirectException(
179
+ source=request.url.path,
180
+ destination=enums.APIEndpoints.fastapi_session,
181
+ )
182
+
183
+
184
+ def deauthorize(response: HTMLResponse) -> HTMLResponse:
185
+ """Remove authorization headers and clear session token from the response.
186
+
187
+ Args:
188
+ response: FastAPI ``response`` object.
189
+
190
+ Returns:
191
+ HTMLResponse:
192
+ Returns the response object with the session token cleared.
193
+ """
194
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
195
+ response.headers["Authorization"] = ""
196
+ response.delete_cookie("session_token")
197
+ return response
198
+
199
+
200
+ def clear_session(host: str) -> None:
201
+ """Clear the session for the given host.
202
+
203
+ Args:
204
+ host: Host header from the request.
205
+ """
206
+ if models.ws_session.client_auth.get(host):
207
+ models.ws_session.client_auth.pop(host)
208
+ LOGGER.info("Session cleared for host: %s", host)
209
+ else:
210
+ LOGGER.warning("No session found for host: %s", host)
@@ -1 +1 @@
1
- version = "0.0.1-b"
1
+ version = "0.1.0"
@@ -1,18 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: FastAPI-UI-Auth
3
- Version: 0.0.1b0
4
- Summary: Python module to add username and password authentication to specific FastAPI routes
5
- Requires-Python: >=3.11
6
- Description-Content-Type: text/markdown
7
- License-File: LICENSE
8
- Requires-Dist: fastapi==0.115.*
9
- Requires-Dist: jinja2==3.1.*
10
- Requires-Dist: pydantic==2.11.*
11
- Requires-Dist: python-dotenv==1.1.*
12
- Provides-Extra: dev
13
- Requires-Dist: websockets==15.0.*; extra == "dev"
14
- Requires-Dist: pre-commit==4.2.*; extra == "dev"
15
- Requires-Dist: uvicorn==0.34.*; extra == "dev"
16
- Dynamic: license-file
17
-
18
- # FastAPIAuthenticator
@@ -1,6 +0,0 @@
1
- fastapi_ui_auth-0.0.1b0.dist-info/licenses/LICENSE,sha256=_sOIKJWdD2o1WwwDIwYB2qTP2nlSWqT5Tyg9jr1Xa4w,1070
2
- fastapiauthenticator/version.py,sha256=2E5Gs4jtm0QsMW0wDxXSfLOzUQteQzS1wu23GRZA064,20
3
- fastapi_ui_auth-0.0.1b0.dist-info/METADATA,sha256=hii-1Vny63Kn7jt6OFAWF_nHHYdgUdWaDHq_bmEAK8E,582
4
- fastapi_ui_auth-0.0.1b0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
- fastapi_ui_auth-0.0.1b0.dist-info/top_level.txt,sha256=EpDRP7uLM0f-Vd5rUtLBh4MTMAnpXzw1pr0DSknW_Ds,21
6
- fastapi_ui_auth-0.0.1b0.dist-info/RECORD,,