Skip to content

Commit 3bce982

Browse files
committed
Make use of HTML templates for appropriate endpoints
1 parent 64d0299 commit 3bce982

File tree

5 files changed

+103
-71
lines changed

5 files changed

+103
-71
lines changed

fastapiauthenticator/endpoints.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ def session(request: Request) -> HTMLResponse:
1515
HTMLResponse:
1616
Returns an HTML response templated using Jinja2.
1717
"""
18-
return utils.clear_session(
19-
request,
18+
return utils.deauthorize(
2019
models.templates.TemplateResponse(
2120
name="session.html",
2221
context={
@@ -38,8 +37,7 @@ def login(request: Request) -> HTMLResponse:
3837
HTMLResponse:
3938
Rendered HTML response for the login page.
4039
"""
41-
return utils.clear_session(
42-
request,
40+
return utils.deauthorize(
4341
models.templates.TemplateResponse(
4442
name="index.html",
4543
context={
@@ -61,8 +59,7 @@ def error(request: Request) -> HTMLResponse:
6159
HTMLResponse:
6260
Returns an HTML response templated using Jinja2.
6361
"""
64-
return utils.clear_session(
65-
request,
62+
return utils.deauthorize(
6663
models.templates.TemplateResponse(
6764
name="unauthorized.html",
6865
context={

fastapiauthenticator/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class WSSession(BaseModel):
3838
"""
3939

4040
invalid: Dict[str, int] = Field(default_factory=dict)
41-
client_auth: Dict[str, Dict[str, int]] = Field(default_factory=dict)
41+
client_auth: Dict[str, Dict[str, str | int]] = Field(default_factory=dict)
4242

4343

4444
class Fallback(BaseModel):
@@ -68,7 +68,7 @@ class RedirectException(Exception):
6868
https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers
6969
"""
7070

71-
def __init__(self, source: str, destination: str, detail: Optional[str] = ""):
71+
def __init__(self, destination: str, source: str = "/", detail: Optional[str] = ""):
7272
"""Instantiates the ``RedirectException`` object with the required parameters.
7373
7474
Args:

fastapiauthenticator/service.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import logging
22
import os
3+
from threading import Timer
34
from typing import Dict, List
45

56
import dotenv
7+
from fastapi import status
68
from fastapi.applications import FastAPI
9+
from fastapi.exceptions import HTTPException
710
from fastapi.params import Depends
811
from fastapi.requests import Request
912
from fastapi.responses import Response
@@ -94,24 +97,34 @@ def _verify_auth(
9497
"""
9598
utils.verify_login(
9699
authorization=authorization,
97-
host=request.client.host,
100+
request=request,
98101
env_username=self.username,
99102
env_password=self.password,
100103
)
101104
destination = request.cookies.get("X-Requested-By")
102-
parameter = self.route_map.get(destination)
103-
LOGGER.info("Setting session timeout for %s seconds", self.timeout)
104-
# Set session_token cookie with a timeout, to be used for session validation when redirected
105-
response.set_cookie(
106-
key="session_token",
107-
value=models.ws_session.client_auth[request.client.host].get("token"),
108-
httponly=True,
109-
samesite="strict",
110-
max_age=self.timeout,
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.",
111127
)
112-
# todo: Session should be cleared at client side after timeout
113-
response.delete_cookie(key="X-Requested-By")
114-
return {"redirect_url": parameter.path}
115128

116129
def _secure(self) -> None:
117130
"""Create the login and verification routes for the APIAuthenticator."""

fastapiauthenticator/utils.py

Lines changed: 62 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,18 @@
1414
LOGGER = logging.getLogger("uvicorn.default")
1515

1616

17-
def failed_auth_counter(host: str) -> None:
17+
def failed_auth_counter(request: Request) -> None:
1818
"""Keeps track of failed login attempts from each host, and redirects if failed for 3 or more times.
1919
2020
Args:
21-
host: Host header from the request.
21+
request: Request object containing client information.
2222
"""
2323
try:
24-
models.ws_session.invalid[host] += 1
24+
models.ws_session.invalid[request.client.host] += 1
2525
except KeyError:
26-
models.ws_session.invalid[host] = 1
27-
# todo: fix this
28-
# if models.ws_session.invalid[host] >= 3:
29-
# raise models.RedirectException(location=enums.APIEndpoints.fastapi_error)
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)
3029

3130

3231
def redirect_exception_handler(
@@ -42,12 +41,8 @@ def redirect_exception_handler(
4241
JSONResponse:
4342
Returns the JSONResponse with content, status code and cookie.
4443
"""
45-
LOGGER.warning("Exception headers: %s", request.headers)
46-
LOGGER.warning("Exception cookies: %s", request.cookies)
4744
if request.url.path == enums.APIEndpoints.fastapi_verify_login:
48-
response = JSONResponse(
49-
content={"redirect_url": exception.destination}, status_code=200
50-
)
45+
response = JSONResponse(content={"redirect_url": exception.destination})
5146
else:
5247
response = RedirectResponse(url=exception.destination)
5348
if exception.detail:
@@ -60,16 +55,16 @@ def redirect_exception_handler(
6055
return response
6156

6257

63-
def raise_error(host: str) -> NoReturn:
58+
def raise_error(request: Request) -> NoReturn:
6459
"""Raises a 401 Unauthorized error in case of bad credentials.
6560
6661
Args:
67-
host: Host header from the request.
62+
request: Request object containing client information.
6863
"""
69-
failed_auth_counter(host)
64+
failed_auth_counter(request)
7065
LOGGER.error(
7166
"Incorrect username or password: %d",
72-
models.ws_session.invalid[host],
67+
models.ws_session.invalid[request.client.host],
7368
)
7469
raise HTTPException(
7570
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -97,33 +92,42 @@ def extract_credentials(
9792

9893
def verify_login(
9994
authorization: HTTPAuthorizationCredentials,
100-
host: str,
95+
request: Request,
10196
env_username: str,
10297
env_password: str,
10398
) -> Dict[str, Union[str, int]]:
10499
"""Verifies authentication and generates session token for each user.
105100
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+
106107
Returns:
107108
Dict[str, str]:
108109
Returns a dictionary with the payload required to create the session token.
109110
"""
110-
username, signature, timestamp = extract_credentials(authorization, host)
111+
username, signature, timestamp = extract_credentials(
112+
authorization, request.client.host
113+
)
111114
if secrets.compare_digest(username, env_username):
112115
hex_user = secure.hex_encode(env_username)
113116
hex_pass = secure.hex_encode(env_password)
114117
else:
115118
LOGGER.warning("User '%s' not allowed", username)
116-
raise_error(host)
119+
raise_error(request)
117120
message = f"{hex_user}{hex_pass}{timestamp}"
118121
expected_signature = secure.calculate_hash(message)
119122
if secrets.compare_digest(signature, expected_signature):
120-
models.ws_session.invalid[host] = 0
123+
models.ws_session.invalid[request.client.host] = 0
121124
key = secrets.token_urlsafe(64)
122-
models.ws_session.client_auth[host] = dict(
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(
123127
username=username, token=key, timestamp=int(timestamp)
124128
)
125-
return models.ws_session.client_auth[host]
126-
raise_error(host)
129+
return models.ws_session.client_auth[request.client.host]
130+
raise_error(request)
127131

128132

129133
def session_check(api_request: Request = None, api_websocket: WebSocket = None) -> None:
@@ -156,18 +160,31 @@ def session_check(api_request: Request = None, api_websocket: WebSocket = None)
156160
):
157161
LOGGER.info("Session is valid for host: %s", request.client.host)
158162
return
159-
LOGGER.warning("Session is invalid or expired for host: %s", request.client.host)
160-
raise models.RedirectException(
161-
source=request.url.path,
162-
destination=enums.APIEndpoints.fastapi_login,
163-
)
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+
)
164182

165183

166-
def clear_session(request: Request, response: HTMLResponse) -> HTMLResponse:
167-
"""Clear the session token from the response.
184+
def deauthorize(response: HTMLResponse) -> HTMLResponse:
185+
"""Remove authorization headers and clear session token from the response.
168186
169187
Args:
170-
request: FastAPI ``request`` object.
171188
response: FastAPI ``response`` object.
172189
173190
Returns:
@@ -176,4 +193,18 @@ def clear_session(request: Request, response: HTMLResponse) -> HTMLResponse:
176193
"""
177194
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
178195
response.headers["Authorization"] = ""
196+
response.delete_cookie("session_token")
179197
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)

verify/fast_api.py

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
import uvicorn
2-
from fastapi import FastAPI
2+
from fastapi import FastAPI, status
33
from fastapi.requests import Request
4-
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
4+
from fastapi.responses import HTMLResponse, JSONResponse
55
from fastapi.routing import APIRoute
66

7-
import fastapiauthenticator
8-
9-
10-
def root_page() -> RedirectResponse:
11-
"""Re-direct the user to login page."""
12-
return RedirectResponse(url=fastapiauthenticator.APIEndpoints.fastapi_login)
7+
import fastapiauthenticator as auth
138

149

1510
def hello_world() -> JSONResponse:
@@ -21,32 +16,28 @@ def secure_function(_: Request) -> HTMLResponse:
2116
"""A sample secure function that can be used with the APIAuthenticatorException."""
2217
return HTMLResponse(
2318
content='<html><body style="background-color: gray;color: white"><h1>Authenticated</h1></body></html>',
24-
status_code=200,
19+
status_code=status.HTTP_200_OK,
2520
)
2621

2722

2823
app = FastAPI(
2924
routes=[
30-
APIRoute(
31-
path="/",
32-
endpoint=root_page,
33-
),
3425
APIRoute(
3526
path="/hello",
3627
endpoint=hello_world,
3728
methods=["GET"],
3829
),
3930
]
4031
)
41-
fastapiauthenticator.protect(
32+
auth.protect(
4233
app=app,
43-
secure_function=secure_function,
44-
route=APIRoute,
45-
secure_methods=["GET"],
46-
secure_path="/sensitive-data",
47-
session_timeout=300,
34+
params=auth.Parameters(
35+
path="/sensitive-data",
36+
function=secure_function,
37+
),
4838
fallback_button="NAVIGATE",
4939
fallback_path="/hello",
40+
timeout=3,
5041
)
5142

5243
if __name__ == "__main__":

0 commit comments

Comments
 (0)