Skip to content

Commit d916614

Browse files
committed
feat: Support multiple workers and convert project to apply stateless auth checks over in-memory
1 parent 19cff1b commit d916614

5 files changed

Lines changed: 201 additions & 112 deletions

File tree

‎uiauth/endpoints.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def logout(request: Request) -> HTMLResponse:
6565
except (models.RedirectException, HTTPException):
6666
logger.CUSTOM_LOGGER.warning("Invalid session")
6767
return session(request)
68+
utils.clear_session(request.cookies.get("session_token"))
6869
return utils.deauthorize(
6970
models.templates.TemplateResponse(
7071
name="logout.html",

‎uiauth/models.py‎

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,6 @@ def env_loader(**kwargs) -> EnvConfig:
6969
env = EnvConfig
7070

7171

72-
class WSSession(BaseModel):
73-
"""Object to store websocket session information.
74-
75-
>>> WSSession
76-
77-
"""
78-
79-
invalid: Dict[str, int] = Field(default_factory=dict)
80-
client_auth: Dict[str, Dict[str, str | int]] = Field(default_factory=dict)
81-
82-
8372
class Fallback(BaseModel):
8473
"""Object to store fallback information.
8574
@@ -120,5 +109,5 @@ def __init__(self, destination: str, source: str = "/", detail: Optional[str] =
120109
self.destination = destination
121110

122111

123-
ws_session = WSSession()
112+
trusted_proxies: set = set()
124113
fallback = Fallback

‎uiauth/service.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import inspect
22
import logging
3-
import time
43
import warnings
54
from typing import Dict, List
65

76
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
87
from fastapi.routing import APIRoute, APIWebSocketRoute
98
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
109

11-
from uiauth import endpoints, enums, logger, models, utils
10+
from uiauth import endpoints, enums, logger, models, store, utils
1211

1312
BEARER_AUTH = HTTPBearer()
1413

@@ -31,6 +30,7 @@ def __init__(
3130
session_timeout: int = 300,
3231
fallback: models.Fallback | None = None,
3332
custom_logger: logging.Logger | None = None,
33+
trusted_proxies: List[str] | None = None,
3434
):
3535
"""Initialize the APIAuthenticator with the FastAPI app and secure function.
3636
@@ -43,13 +43,14 @@ def __init__(
4343
session_timeout: Session timeout in seconds, default is 300 seconds (5 minutes).
4444
fallback: Fallback configuration for redirection, that takes a 'button' and 'path' as arguments.
4545
custom_logger: Custom logger instance, defaults to the custom logger.
46+
trusted_proxies: IPs of reverse proxies whose X-Forwarded-For header can be trusted.
4647
"""
4748
# TODO:
4849
# 1. Add a reset option in the UI to refresh page after cookie expires or session becomes invalid
4950
# 2. Add support for multiple MFA methods (email, Telegram, etc.) and allow create models for it
5051
assert (
5152
isinstance(session_timeout, int) and 29 < session_timeout < 86_401
52-
), "Timeout must be an integer between 30 seconds and 24 hours (86_400 seconds)"
53+
), "'session_timeout' must be an integer between 30 seconds and 24 hours (86_400 seconds)"
5354
models.env = models.env_loader(
5455
username=username, password=password, totp_token=totp_token
5556
)
@@ -93,6 +94,8 @@ def __init__(
9394
logger.CUSTOM_LOGGER = custom_logger
9495
self.session_timeout = session_timeout
9596

97+
models.trusted_proxies = set(trusted_proxies or [])
98+
store.init()
9699
self._secure()
97100
logger.CUSTOM_LOGGER.debug("Endpoints registered: %s", len(self.routes))
98101
if not models.env.totp_token:
@@ -133,10 +136,7 @@ def _verify_auth(
133136
samesite="strict",
134137
max_age=self.session_timeout,
135138
)
136-
models.ws_session.client_auth[request.client.host] = {
137-
"token": session_token,
138-
"expires_at": time.time() + self.session_timeout,
139-
}
139+
store.add_session(session_token, self.session_timeout)
140140
response.delete_cookie(key="X-Requested-By")
141141
return {"redirect_url": destination}
142142
raise HTTPException(

‎uiauth/store.py‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import hashlib
2+
import pathlib
3+
import sqlite3
4+
import time
5+
from contextlib import contextmanager
6+
from typing import Iterator
7+
8+
DB_PATH = pathlib.Path.cwd() / "fastapi_ui_auth.db"
9+
FAILURE_TTL = 3600 # failed-attempt counters reset after 1 hour
10+
11+
12+
@contextmanager
13+
def _connect() -> Iterator[sqlite3.Connection]:
14+
"""Context manager for SQLite connection with WAL mode enabled.
15+
16+
Yields:
17+
sqlite3.Connection: The SQLite connection.
18+
"""
19+
# autocommit; WAL + busy timeout makes this safe across worker processes
20+
conn = sqlite3.connect(DB_PATH, timeout=10, isolation_level=None)
21+
try:
22+
conn.execute("PRAGMA journal_mode=WAL")
23+
yield conn
24+
finally:
25+
conn.close()
26+
27+
28+
def _hash(token: str) -> str:
29+
"""Hash a token using SHA-256.
30+
31+
Args:
32+
token: The token to hash.
33+
34+
Returns:
35+
str:
36+
The hashed token.
37+
"""
38+
# tokens are stored hashed, so a leaked DB file doesn't leak live sessions
39+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
40+
41+
42+
def init() -> None:
43+
"""Create tables (idempotent, safe to call from every worker)."""
44+
with _connect() as conn:
45+
conn.execute(
46+
"CREATE TABLE IF NOT EXISTS sessions "
47+
"(token TEXT PRIMARY KEY, expires_at REAL NOT NULL)"
48+
)
49+
conn.execute(
50+
"CREATE TABLE IF NOT EXISTS failures "
51+
"(key TEXT PRIMARY KEY, count INTEGER NOT NULL, updated_at REAL NOT NULL)"
52+
)
53+
54+
55+
def purge() -> None:
56+
"""Purge expired sessions and old failure records."""
57+
now = time.time()
58+
with _connect() as conn:
59+
conn.execute("DELETE FROM sessions WHERE expires_at < ?", (now,))
60+
conn.execute("DELETE FROM failures WHERE updated_at < ?", (now - FAILURE_TTL,))
61+
62+
63+
def add_session(token: str, ttl: int) -> None:
64+
"""Add a session with a time-to-live (TTL).
65+
66+
Args:
67+
token: The session token to add.
68+
ttl: Time-to-live in seconds.
69+
"""
70+
purge()
71+
with _connect() as conn:
72+
conn.execute(
73+
"INSERT OR REPLACE INTO sessions (token, expires_at) VALUES (?, ?)",
74+
(_hash(token), time.time() + ttl),
75+
)
76+
77+
78+
def session_valid(token: str) -> bool:
79+
"""Check if a session token is valid (exists and not expired).
80+
81+
Args:
82+
token: The session token to check.
83+
84+
Returns:
85+
bool: True if the session token is valid, False otherwise.
86+
"""
87+
with _connect() as conn:
88+
row = conn.execute(
89+
"SELECT expires_at FROM sessions WHERE token = ?", (_hash(token),)
90+
).fetchone()
91+
return bool(row and row[0] > time.time())
92+
93+
94+
def delete_session(token: str) -> None:
95+
"""Delete a session token.
96+
97+
Args:
98+
token: The session token to delete.
99+
"""
100+
with _connect() as conn:
101+
conn.execute("DELETE FROM sessions WHERE token = ?", (_hash(token),))
102+
103+
104+
def record_failure(key: str) -> int:
105+
"""Record a failed attempt and return the current count.
106+
107+
Args:
108+
key: The key for which to record the failure.
109+
110+
Returns:
111+
int:
112+
The current failure count for the given key.
113+
"""
114+
now = time.time()
115+
with _connect() as conn:
116+
conn.execute(
117+
"DELETE FROM failures WHERE key = ? AND updated_at < ?",
118+
(key, now - FAILURE_TTL),
119+
)
120+
conn.execute(
121+
"INSERT INTO failures (key, count, updated_at) VALUES (?, 1, ?) "
122+
"ON CONFLICT(key) DO UPDATE SET count = count + 1, updated_at = excluded.updated_at",
123+
(key, now),
124+
)
125+
return conn.execute(
126+
"SELECT count FROM failures WHERE key = ?", (key,)
127+
).fetchone()[0]
128+
129+
130+
def reset_failures(key: str) -> None:
131+
"""Reset the failure count for a given key.
132+
133+
Args:
134+
key: The key for which to reset the failure count.
135+
"""
136+
with _connect() as conn:
137+
conn.execute("DELETE FROM failures WHERE key = ?", (key,))

0 commit comments

Comments
 (0)