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.
- fastapi_ui_auth-0.1.0.dist-info/METADATA +102 -0
- fastapi_ui_auth-0.1.0.dist-info/RECORD +16 -0
- fastapiauthenticator/__init__.py +6 -0
- fastapiauthenticator/endpoints.py +73 -0
- fastapiauthenticator/enums.py +30 -0
- fastapiauthenticator/models.py +85 -0
- fastapiauthenticator/secure.py +41 -0
- fastapiauthenticator/service.py +167 -0
- fastapiauthenticator/templates/index.html +259 -0
- fastapiauthenticator/templates/session.html +86 -0
- fastapiauthenticator/templates/unauthorized.html +86 -0
- fastapiauthenticator/utils.py +210 -0
- fastapiauthenticator/version.py +1 -1
- fastapi_ui_auth-0.0.1b0.dist-info/METADATA +0 -18
- fastapi_ui_auth-0.0.1b0.dist-info/RECORD +0 -6
- {fastapi_ui_auth-0.0.1b0.dist-info → fastapi_ui_auth-0.1.0.dist-info}/WHEEL +0 -0
- {fastapi_ui_auth-0.0.1b0.dist-info → fastapi_ui_auth-0.1.0.dist-info}/licenses/LICENSE +0 -0
- {fastapi_ui_auth-0.0.1b0.dist-info → fastapi_ui_auth-0.1.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
© 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)
|
fastapiauthenticator/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
version = "0.0
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|