FastAPI-UI-Auth 0.0.1__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.
- fastapi_ui_auth-0.0.1.dist-info/METADATA +102 -0
- fastapi_ui_auth-0.0.1.dist-info/RECORD +16 -0
- fastapi_ui_auth-0.0.1.dist-info/WHEEL +5 -0
- fastapi_ui_auth-0.0.1.dist-info/licenses/LICENSE +21 -0
- fastapi_ui_auth-0.0.1.dist-info/top_level.txt +1 -0
- fastapiauthenticator/__init__.py +3 -0
- fastapiauthenticator/endpoints.py +76 -0
- fastapiauthenticator/enums.py +16 -0
- fastapiauthenticator/models.py +60 -0
- fastapiauthenticator/secure.py +41 -0
- fastapiauthenticator/service.py +180 -0
- fastapiauthenticator/templates/index.html +259 -0
- fastapiauthenticator/templates/session.html +86 -0
- fastapiauthenticator/templates/unauthorized.html +86 -0
- fastapiauthenticator/utils.py +167 -0
- fastapiauthenticator/version.py +1 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: FastAPI-UI-Auth
|
|
3
|
+
Version: 0.0.1
|
|
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
|
+
© 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.0.1.dist-info/licenses/LICENSE,sha256=_sOIKJWdD2o1WwwDIwYB2qTP2nlSWqT5Tyg9jr1Xa4w,1070
|
|
2
|
+
fastapiauthenticator/__init__.py,sha256=b8LChxHIMiYL64em-dnSAglCgQwt0qL8NgFaaLWwFUg,213
|
|
3
|
+
fastapiauthenticator/endpoints.py,sha256=9qGQbLny8OtkcJg4D5xT2gJO43ivuQdrY8A6xhQ7ma4,2161
|
|
4
|
+
fastapiauthenticator/enums.py,sha256=FSKEU8QjAvW8kijMgbSATokd6Ig6EQ4G-_xW7hfz5Jk,373
|
|
5
|
+
fastapiauthenticator/models.py,sha256=itH5YsFJa8-eR1iRm_UCkBtrLZ7ypDMNt8tnaJGiMuk,1778
|
|
6
|
+
fastapiauthenticator/secure.py,sha256=ZOH6kT4BD56VqwaKdKocX7eSE8tqZcu-tK0QOmjY58k,1089
|
|
7
|
+
fastapiauthenticator/service.py,sha256=mJURiebq6nugXh6PkFsZkTAbwkkvKXJJb0CJDjsENNc,7013
|
|
8
|
+
fastapiauthenticator/utils.py,sha256=-wLLL7Qd9TSBYL_qla69Ng6gnMQtpHlrnZd_CqIxC2o,5528
|
|
9
|
+
fastapiauthenticator/version.py,sha256=CactNZqrHHYTPrkHKccy2WKXmaiUdtTgPqSjFyVXnJk,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.0.1.dist-info/METADATA,sha256=XZDh_Vi29aIxQSGJnKEMnn1woBYkvsgTuJ3fR2DPi2c,2402
|
|
14
|
+
fastapi_ui_auth-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
15
|
+
fastapi_ui_auth-0.0.1.dist-info/top_level.txt,sha256=EpDRP7uLM0f-Vd5rUtLBh4MTMAnpXzw1pr0DSknW_Ds,21
|
|
16
|
+
fastapi_ui_auth-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 TheVickypedia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fastapiauthenticator
|
|
@@ -0,0 +1,76 @@
|
|
|
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.clear_session(
|
|
19
|
+
request,
|
|
20
|
+
models.templates.TemplateResponse(
|
|
21
|
+
name="session.html",
|
|
22
|
+
context={
|
|
23
|
+
"request": request,
|
|
24
|
+
"signin": enums.APIEndpoints.fastapi_login,
|
|
25
|
+
"reason": "Session expired or invalid.",
|
|
26
|
+
"fallback_path": models.fallback.path,
|
|
27
|
+
"fallback_button": models.fallback.button,
|
|
28
|
+
"version": f"v{version}",
|
|
29
|
+
},
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def login(request: Request) -> HTMLResponse:
|
|
35
|
+
"""Render the login page with the verification path and version.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
HTMLResponse:
|
|
39
|
+
Rendered HTML response for the login page.
|
|
40
|
+
"""
|
|
41
|
+
return utils.clear_session(
|
|
42
|
+
request,
|
|
43
|
+
models.templates.TemplateResponse(
|
|
44
|
+
name="index.html",
|
|
45
|
+
context={
|
|
46
|
+
"request": request,
|
|
47
|
+
"signin": enums.APIEndpoints.fastapi_verify_login,
|
|
48
|
+
"version": f"v{version}",
|
|
49
|
+
},
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def error(request: Request) -> HTMLResponse:
|
|
55
|
+
"""Error endpoint for the authenticator.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
request: Reference to the FastAPI request object.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
HTMLResponse:
|
|
62
|
+
Returns an HTML response templated using Jinja2.
|
|
63
|
+
"""
|
|
64
|
+
return utils.clear_session(
|
|
65
|
+
request,
|
|
66
|
+
models.templates.TemplateResponse(
|
|
67
|
+
name="unauthorized.html",
|
|
68
|
+
context={
|
|
69
|
+
"request": request,
|
|
70
|
+
"signin": enums.APIEndpoints.fastapi_login,
|
|
71
|
+
"fallback_path": models.fallback.path,
|
|
72
|
+
"fallback_button": models.fallback.button,
|
|
73
|
+
"version": f"v{version}",
|
|
74
|
+
},
|
|
75
|
+
),
|
|
76
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class APIEndpoints(StrEnum):
|
|
5
|
+
"""API endpoints for all the routes.
|
|
6
|
+
|
|
7
|
+
>>> APIEndpoints
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
fastapi_error = "/fastapi-error"
|
|
12
|
+
fastapi_login = "/fastapi-login"
|
|
13
|
+
fastapi_logout = "/fastapi-logout"
|
|
14
|
+
fastapi_secure = "/fastapi-secure"
|
|
15
|
+
fastapi_session = "/fastapi-session"
|
|
16
|
+
fastapi_verify_login = "/fastapi-verify-login"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import pathlib
|
|
2
|
+
from typing import Dict, Optional
|
|
3
|
+
|
|
4
|
+
from fastapi.templating import Jinja2Templates
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
templates = Jinja2Templates(directory=pathlib.Path(__file__).parent / "templates")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WSSession(BaseModel):
|
|
11
|
+
"""Object to store websocket session information.
|
|
12
|
+
|
|
13
|
+
>>> WSSession
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
invalid: Dict[str, int] = Field(default_factory=dict)
|
|
18
|
+
client_auth: Dict[str, Dict[str, int]] = Field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Fallback(BaseModel):
|
|
22
|
+
"""Object to store fallback information.
|
|
23
|
+
|
|
24
|
+
>>> Fallback
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
button: str = Field(default="LOGIN", description="Title for the fallback button.")
|
|
29
|
+
path: str = Field(
|
|
30
|
+
default="/", description="Path to redirect when fallback button is clicked."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RedirectException(Exception):
|
|
35
|
+
"""Custom ``RedirectException`` raised within the API since HTTPException doesn't support returning HTML content.
|
|
36
|
+
|
|
37
|
+
>>> RedirectException
|
|
38
|
+
|
|
39
|
+
See Also:
|
|
40
|
+
- RedirectException allows the API to redirect on demand in cases where returning is not a solution.
|
|
41
|
+
- There are alternatives to raise HTML content as an exception but none work with our use-case with JavaScript.
|
|
42
|
+
- This way of exception handling comes handy for many unexpected scenarios.
|
|
43
|
+
|
|
44
|
+
References:
|
|
45
|
+
https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, location: str, detail: Optional[str] = ""):
|
|
49
|
+
"""Instantiates the ``RedirectException`` object with the required parameters.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
location: Location for redirect.
|
|
53
|
+
detail: Reason for redirect.
|
|
54
|
+
"""
|
|
55
|
+
self.location = location
|
|
56
|
+
self.detail = detail
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
ws_session = WSSession()
|
|
60
|
+
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,180 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from threading import Timer
|
|
4
|
+
from typing import Callable, Dict, List
|
|
5
|
+
|
|
6
|
+
import dotenv
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.params import Depends
|
|
9
|
+
from fastapi.requests import Request
|
|
10
|
+
from fastapi.responses import Response
|
|
11
|
+
from fastapi.routing import APIRoute
|
|
12
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
13
|
+
|
|
14
|
+
from fastapiauthenticator import endpoints, enums, models, utils
|
|
15
|
+
|
|
16
|
+
dotenv.load_dotenv(dotenv_path=dotenv.find_dotenv(), override=True)
|
|
17
|
+
LOGGER = logging.getLogger("uvicorn.default")
|
|
18
|
+
BEARER_AUTH = HTTPBearer()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# noinspection PyDefaultArgument
|
|
22
|
+
class Authenticator:
|
|
23
|
+
"""Authenticator is a FastAPI integration that provides authentication for secure routes.
|
|
24
|
+
|
|
25
|
+
>>> Authenticator
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
app: FastAPI,
|
|
32
|
+
secure_function: Callable = None,
|
|
33
|
+
secure_methods: List[str] = ["GET", "POST"],
|
|
34
|
+
secure_path: str = enums.APIEndpoints.fastapi_secure,
|
|
35
|
+
username: str = os.environ.get("USERNAME"),
|
|
36
|
+
password: str = os.environ.get("PASSWORD"),
|
|
37
|
+
session_timeout: int = 3600,
|
|
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
|
+
secure_function: Function to be called for secure routes after authentication.
|
|
46
|
+
secure_methods: List of HTTP methods that the secure function will handle.
|
|
47
|
+
secure_path: Path for the secure route, must start with '/'.
|
|
48
|
+
username: Username for authentication, can be set via environment variable 'USERNAME'.
|
|
49
|
+
password: Password for authentication, can be set via environment variable 'PASSWORD'.
|
|
50
|
+
session_timeout: Duration in seconds after which the session expires.
|
|
51
|
+
fallback_button: Title for the fallback button, defaults to "LOGIN".
|
|
52
|
+
fallback_path: Fallback path to redirect to in case of session timeout or invalid session.
|
|
53
|
+
"""
|
|
54
|
+
assert all((username, password)), "'username' and 'password' are mandatory."
|
|
55
|
+
assert secure_function, "Secure function must be provided."
|
|
56
|
+
assert secure_path.startswith("/"), "Secure path must start with '/'"
|
|
57
|
+
assert fallback_path.startswith("/"), "Fallback path must start with '/'"
|
|
58
|
+
|
|
59
|
+
self.app = app
|
|
60
|
+
self.secure_methods = secure_methods
|
|
61
|
+
self.secure_function = secure_function
|
|
62
|
+
self.secure_path = secure_path
|
|
63
|
+
self.session_timeout = session_timeout
|
|
64
|
+
models.fallback.path = fallback_path
|
|
65
|
+
models.fallback.button = fallback_button
|
|
66
|
+
|
|
67
|
+
# noinspection PyTypeChecker
|
|
68
|
+
self.app.add_exception_handler(
|
|
69
|
+
exc_class_or_status_code=models.RedirectException,
|
|
70
|
+
handler=utils.redirect_exception_handler,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
self.username = username
|
|
74
|
+
self.password = password
|
|
75
|
+
|
|
76
|
+
self._secure()
|
|
77
|
+
|
|
78
|
+
def _verify_auth(
|
|
79
|
+
self,
|
|
80
|
+
request: Request,
|
|
81
|
+
authorization: HTTPAuthorizationCredentials = Depends(BEARER_AUTH),
|
|
82
|
+
response: Response = None,
|
|
83
|
+
) -> Dict[str, str]:
|
|
84
|
+
"""Verify the authentication credentials and redirect to the secure route.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
request: Request object containing client information.
|
|
88
|
+
authorization: Authorization credentials from the request, provided by FastAPI's HTTPBearer.
|
|
89
|
+
response: Response object containing the response from FastAPI's HTTPBearer.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Dict[str, str]:
|
|
93
|
+
A dictionary containing the redirect URL to the secure path.
|
|
94
|
+
"""
|
|
95
|
+
utils.verify_login(
|
|
96
|
+
authorization=authorization,
|
|
97
|
+
host=request.client.host,
|
|
98
|
+
env_username=self.username,
|
|
99
|
+
env_password=self.password,
|
|
100
|
+
)
|
|
101
|
+
secure_route = APIRoute(
|
|
102
|
+
path=self.secure_path,
|
|
103
|
+
endpoint=self.secure_function,
|
|
104
|
+
methods=self.secure_methods,
|
|
105
|
+
dependencies=[Depends(utils.session_check)],
|
|
106
|
+
)
|
|
107
|
+
self.app.routes.append(secure_route)
|
|
108
|
+
LOGGER.info("Setting session timeout for %s seconds", self.session_timeout)
|
|
109
|
+
self._handle_session(
|
|
110
|
+
response=response, request=request, secure_route=secure_route
|
|
111
|
+
)
|
|
112
|
+
return {"redirect_url": self.secure_path}
|
|
113
|
+
|
|
114
|
+
def _setup_session_route(self, secure_route: APIRoute) -> None:
|
|
115
|
+
"""Removes the secure route and adds a routing logic for invalid sessions.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
secure_route: Secure route to be removed from the app after the session timeout.
|
|
119
|
+
"""
|
|
120
|
+
LOGGER.info("Session expired, removing secure route: %s", secure_route.path)
|
|
121
|
+
self.app.routes.remove(secure_route)
|
|
122
|
+
LOGGER.info(
|
|
123
|
+
"Adding session route to handle expired sessions at %s", self.secure_path
|
|
124
|
+
)
|
|
125
|
+
self.app.routes.append(
|
|
126
|
+
APIRoute(
|
|
127
|
+
path=self.secure_path,
|
|
128
|
+
endpoint=endpoints.session,
|
|
129
|
+
methods=["GET"],
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _handle_session(
|
|
134
|
+
self, response: Response, request: Request, secure_route: APIRoute
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Handle session management by setting a cookie and scheduling session removal.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
response: Response object to set the session cookie.
|
|
140
|
+
request: Request object containing client information.
|
|
141
|
+
secure_route: Secure route to be removed from the app after the session timeout.
|
|
142
|
+
"""
|
|
143
|
+
# Remove the secure route after the session timeout - backend
|
|
144
|
+
Timer(
|
|
145
|
+
function=self._setup_session_route,
|
|
146
|
+
args=(secure_route,),
|
|
147
|
+
interval=self.session_timeout,
|
|
148
|
+
).start()
|
|
149
|
+
# Set the max age in session cookie to session timeout - frontend
|
|
150
|
+
response.set_cookie(
|
|
151
|
+
key="session_token",
|
|
152
|
+
value=models.ws_session.client_auth[request.client.host].get("token"),
|
|
153
|
+
httponly=True,
|
|
154
|
+
samesite="strict",
|
|
155
|
+
max_age=self.session_timeout,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _secure(self) -> None:
|
|
159
|
+
"""Create the login and verification routes for the APIAuthenticator."""
|
|
160
|
+
login_route = APIRoute(
|
|
161
|
+
path=enums.APIEndpoints.fastapi_login,
|
|
162
|
+
endpoint=endpoints.login,
|
|
163
|
+
methods=["GET"],
|
|
164
|
+
)
|
|
165
|
+
error_route = APIRoute(
|
|
166
|
+
path=enums.APIEndpoints.fastapi_error,
|
|
167
|
+
endpoint=endpoints.error,
|
|
168
|
+
methods=["GET"],
|
|
169
|
+
)
|
|
170
|
+
session_route = APIRoute(
|
|
171
|
+
path=enums.APIEndpoints.fastapi_session,
|
|
172
|
+
endpoint=endpoints.session,
|
|
173
|
+
methods=["GET"],
|
|
174
|
+
)
|
|
175
|
+
verify_route = APIRoute(
|
|
176
|
+
path=enums.APIEndpoints.fastapi_verify_login,
|
|
177
|
+
endpoint=self._verify_auth,
|
|
178
|
+
methods=["POST"],
|
|
179
|
+
)
|
|
180
|
+
self.app.routes.extend([login_route, session_route, error_route, verify_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,167 @@
|
|
|
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
|
+
|
|
11
|
+
from fastapiauthenticator import enums, models, secure
|
|
12
|
+
|
|
13
|
+
LOGGER = logging.getLogger("uvicorn.default")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def failed_auth_counter(host: str) -> None:
|
|
17
|
+
"""Keeps track of failed login attempts from each host, and redirects if failed for 3 or more times.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
host: Host header from the request.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
models.ws_session.invalid[host] += 1
|
|
24
|
+
except KeyError:
|
|
25
|
+
models.ws_session.invalid[host] = 1
|
|
26
|
+
if models.ws_session.invalid[host] >= 3:
|
|
27
|
+
raise models.RedirectException(location=enums.APIEndpoints.fastapi_error)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def redirect_exception_handler(
|
|
31
|
+
request: Request, exception: models.RedirectException
|
|
32
|
+
) -> JSONResponse | RedirectResponse:
|
|
33
|
+
"""Custom exception handler to handle redirect.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
request: Takes the ``Request`` object as an argument.
|
|
37
|
+
exception: Takes the ``RedirectException`` object inherited from ``Exception`` as an argument.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
JSONResponse:
|
|
41
|
+
Returns the JSONResponse with content, status code and cookie.
|
|
42
|
+
"""
|
|
43
|
+
LOGGER.warning("Exception headers: %s", request.headers)
|
|
44
|
+
LOGGER.warning("Exception cookies: %s", request.cookies)
|
|
45
|
+
if request.url.path == enums.APIEndpoints.fastapi_verify_login:
|
|
46
|
+
response = JSONResponse(
|
|
47
|
+
content={"redirect_url": exception.location}, status_code=200
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
response = RedirectResponse(url=exception.location)
|
|
51
|
+
if exception.detail:
|
|
52
|
+
response.set_cookie(
|
|
53
|
+
"detail", exception.detail.upper(), httponly=True, samesite="strict"
|
|
54
|
+
)
|
|
55
|
+
return response
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def raise_error(host: str) -> NoReturn:
|
|
59
|
+
"""Raises a 401 Unauthorized error in case of bad credentials.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
host: Host header from the request.
|
|
63
|
+
"""
|
|
64
|
+
failed_auth_counter(host)
|
|
65
|
+
LOGGER.error(
|
|
66
|
+
"Incorrect username or password: %d",
|
|
67
|
+
models.ws_session.invalid[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
|
+
host: str,
|
|
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
|
+
Returns:
|
|
102
|
+
Dict[str, str]:
|
|
103
|
+
Returns a dictionary with the payload required to create the session token.
|
|
104
|
+
"""
|
|
105
|
+
username, signature, timestamp = extract_credentials(authorization, host)
|
|
106
|
+
if secrets.compare_digest(username, env_username):
|
|
107
|
+
hex_user = secure.hex_encode(env_username)
|
|
108
|
+
hex_pass = secure.hex_encode(env_password)
|
|
109
|
+
else:
|
|
110
|
+
LOGGER.warning("User '%s' not allowed", username)
|
|
111
|
+
raise_error(host)
|
|
112
|
+
message = f"{hex_user}{hex_pass}{timestamp}"
|
|
113
|
+
expected_signature = secure.calculate_hash(message)
|
|
114
|
+
if secrets.compare_digest(signature, expected_signature):
|
|
115
|
+
models.ws_session.invalid[host] = 0
|
|
116
|
+
key = secrets.token_urlsafe(64)
|
|
117
|
+
models.ws_session.client_auth[host] = dict(
|
|
118
|
+
username=username, token=key, timestamp=int(timestamp)
|
|
119
|
+
)
|
|
120
|
+
return models.ws_session.client_auth[host]
|
|
121
|
+
raise_error(host)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def session_check(request: Request) -> None:
|
|
125
|
+
"""Check if the session is still valid.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
request: Request object containing client information.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
HTTPException: If the session is invalid or expired.
|
|
132
|
+
"""
|
|
133
|
+
stored_token = models.ws_session.client_auth.get(request.client.host, {}).get(
|
|
134
|
+
"token"
|
|
135
|
+
)
|
|
136
|
+
session_token = request.cookies.get("session_token")
|
|
137
|
+
if (
|
|
138
|
+
stored_token
|
|
139
|
+
and session_token
|
|
140
|
+
and secrets.compare_digest(session_token, stored_token)
|
|
141
|
+
):
|
|
142
|
+
LOGGER.info("Session is valid for host: %s", request.client.host)
|
|
143
|
+
return
|
|
144
|
+
raise models.RedirectException(
|
|
145
|
+
location=enums.APIEndpoints.fastapi_session,
|
|
146
|
+
detail="Session expired or invalid. Please log in again.",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def clear_session(request: Request, response: HTMLResponse) -> HTMLResponse:
|
|
151
|
+
"""Clear the session token from the response.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
request: FastAPI ``request`` object.
|
|
155
|
+
response: FastAPI ``response`` object.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
HTMLResponse:
|
|
159
|
+
Returns the response object with the session token cleared.
|
|
160
|
+
"""
|
|
161
|
+
for cookie in request.cookies:
|
|
162
|
+
# Deletes all cookies stored in current session
|
|
163
|
+
LOGGER.info("Deleting cookie: '%s'", cookie)
|
|
164
|
+
response.delete_cookie(cookie)
|
|
165
|
+
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
|
166
|
+
response.headers["Authorization"] = ""
|
|
167
|
+
return response
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version = "0.0.1"
|