Skip to content

Commit c265cfe

Browse files
Merge branch 'main' into fix/217-rate-limit-trusted-proxy
2 parents f774280 + b33ab5c commit c265cfe

31 files changed

Lines changed: 368 additions & 53 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ DOMAIN=
2929
# Comma-separated reverse-proxy peer IPs for X-Forwarded-For (e.g. 127.0.0.1,::1)
3030
# TRUSTED_PROXY_IPS=
3131

32+
# Set to 0 to disable CSRF checks (not recommended in production)
33+
# CSRF_ENABLED=1
34+
3235
# Resend
3336
RESEND_API_KEY=
3437
EMAIL_FROM=

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
<!-- version list -->
99

10+
## v1.1.2 (2026-07-02)
11+
12+
### Bug Fixes
13+
14+
- Revoke sessions on password reset
15+
([#215](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/pull/215),
16+
[`523fc48`](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/commit/523fc487daacf6a52e2bfab106f58831e2b3f846))
17+
18+
1019
## v1.1.1 (2026-07-01)
1120

1221
### Bug Fixes

exceptions/http_exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,10 @@ def __init__(
246246
status_code=500, # Internal Server Error
247247
detail=detail,
248248
)
249+
250+
251+
class CsrfError(HTTPException):
252+
"""Raised when CSRF validation fails."""
253+
254+
def __init__(self):
255+
super().__init__(status_code=403, detail="CSRF validation failed")

main.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@
2222
)
2323
from utils.core.auth import refresh_token_is_persistent, set_auth_cookies
2424
from utils.core.rate_limit import get_trusted_proxy_hosts
25+
from utils.core.csrf import (
26+
CSRF_COOKIE_NAME,
27+
UNSAFE_HTTP_METHODS,
28+
csrf_enabled,
29+
extract_submitted_csrf_token,
30+
generate_csrf_token,
31+
set_csrf_cookie,
32+
validate_csrf_token,
33+
)
2534
from utils.core.htmx import (
2635
is_htmx_request,
2736
toast_response,
@@ -31,6 +40,7 @@
3140
from exceptions.http_exceptions import (
3241
AlreadyAuthenticatedError,
3342
AuthenticationError,
43+
CsrfError,
3444
PasswordValidationError,
3545
CredentialsError,
3646
RateLimitError,
@@ -80,6 +90,22 @@ async def flash_cookie_middleware(request: Request, call_next):
8090
return response
8191

8292

93+
@app.middleware("http")
94+
async def csrf_middleware(request: Request, call_next):
95+
token = request.cookies.get(CSRF_COOKIE_NAME) or generate_csrf_token()
96+
request.state.csrf_token = token
97+
98+
if csrf_enabled() and request.method in UNSAFE_HTTP_METHODS:
99+
submitted = await extract_submitted_csrf_token(request)
100+
if not validate_csrf_token(request, submitted):
101+
return await csrf_error_handler(request, CsrfError())
102+
103+
response = await call_next(request)
104+
if request.cookies.get(CSRF_COOKIE_NAME) != token:
105+
set_csrf_cookie(response, token)
106+
return response
107+
108+
83109
# --- Include Routers ---
84110

85111

@@ -144,6 +170,25 @@ async def rate_limit_error_handler(request: Request, exc: RateLimitError):
144170
return response
145171

146172

173+
@app.exception_handler(CsrfError)
174+
async def csrf_error_handler(request: Request, exc: CsrfError):
175+
if is_htmx_request(request):
176+
return toast_response(
177+
request,
178+
templates,
179+
exc.detail,
180+
level="danger",
181+
status_code=403,
182+
)
183+
user = await get_user_from_request(request)
184+
return templates.TemplateResponse(
185+
request,
186+
"errors/error.html",
187+
{"status_code": 403, "detail": exc.detail, "errors": None, "user": user},
188+
status_code=403,
189+
)
190+
191+
147192
# Handle CredentialsError (invalid email/password) with toast for HTMX
148193
@app.exception_handler(CredentialsError)
149194
async def credentials_exception_handler(request: Request, exc: CredentialsError):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-jinja2-postgres-webapp"
3-
version = "1.1.1"
3+
version = "1.1.2"
44
description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth"
55
readme = "README.md"
66
package-mode = false

routers/core/account.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -765,9 +765,28 @@ async def reset_password(
765765

766766

767767
@router.get("/recover")
768-
async def recover_account(
768+
async def recover_account_confirm(
769+
request: Request,
769770
token: str = Query(...),
770771
session: Session = Depends(get_session),
772+
):
773+
"""Show a confirmation form before performing account recovery."""
774+
account, recovery_token = get_account_from_recovery_token(token, session)
775+
776+
if not account or not recovery_token:
777+
raise CredentialsError(message="Invalid or expired recovery token")
778+
779+
return templates.TemplateResponse(
780+
request,
781+
"account/recover_confirm.html",
782+
{"token": token, "user": None},
783+
)
784+
785+
786+
@router.post("/recover")
787+
async def recover_account(
788+
token: str = Form(...),
789+
session: Session = Depends(get_session),
771790
):
772791
"""
773792
Recover an account using a recovery token sent via email.
@@ -1023,7 +1042,6 @@ async def promote_email(
10231042
access_token,
10241043
refresh_token,
10251044
persistent=False,
1026-
samesite="lax",
10271045
)
10281046
return response
10291047

static/js/app.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ document.body.addEventListener('htmx:configRequest', function() {
1616
}
1717
}, { once: true });
1818

19+
document.body.addEventListener('htmx:configRequest', function(event) {
20+
var meta = document.querySelector('meta[name="csrf-token"]');
21+
if (meta && meta.content) {
22+
event.detail.headers['X-CSRF-Token'] = meta.content;
23+
}
24+
});
25+
1926

2027
// When a modal closes, reset any create-* form it contains so reopening it
2128
// starts blank. ui.js dispatches 'hidden.bs.modal' when a modal is hidden.

templates/account/forgot_password.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{% extends "account/auth_base.html" %}
22

3+
34
{% block title %}Forgot Password{% endblock %}
45

56
{% block auth_header %}Forgot Password{% endblock %}
@@ -10,6 +11,7 @@
1011
<form method="POST" action="{{ url_for('forgot_password') }}"
1112
hx-post="{{ url_for('forgot_password') }}" hx-swap="none"
1213
class="needs-validation" novalidate>
14+
{% include 'base/partials/csrf_field.html' %}
1315
<!-- Email Input -->
1416
<div class="mb-3">
1517
<label for="email" class="form-label">Email</label>

templates/account/login.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{% extends "account/auth_base.html" %}
22

3+
34
{% set show_warning_only = user and invitation_token_warning %}
45
{% set warning_heading = "Invitation link expired" if invitation_token_warning == 'expired' else "Invitation link invalid" %}
56

@@ -27,6 +28,7 @@
2728
<form method="POST" action="{{ url_for('login') }}"
2829
hx-post="{{ url_for('login') }}" hx-swap="none"
2930
class="needs-validation" novalidate>
31+
{% include 'base/partials/csrf_field.html' %}
3032
{# Add hidden input for invitation token if present #}
3133
{% if invitation_token %}
3234
<input type="hidden" name="invitation_token" value="{{ invitation_token }}">
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{% extends "account/auth_base.html" %}
2+
3+
4+
{% block auth_header %}Recover Your Account{% endblock %}
5+
6+
{% block auth_content %}
7+
<p class="mb-4">
8+
Use this page to restore your account email and sign out all active sessions.
9+
You will be asked to set a new password after confirming.
10+
</p>
11+
<form action="{{ url_for('recover_account') }}" method="post">
12+
{% include 'base/partials/csrf_field.html' %}
13+
<input type="hidden" name="token" value="{{ token }}">
14+
<button type="submit" class="btn btn-primary w-100">Recover account</button>
15+
</form>
16+
{% endblock %}

0 commit comments

Comments
 (0)