Skip to content

Commit 6b9fe02

Browse files
fix: add CSRF tokens and convert account recovery to POST
Use double-submit CSRF cookies on state-changing requests, show a confirmation form for account recovery, and keep auth cookies at SameSite=strict after email promotion.
1 parent a0b6f8b commit 6b9fe02

28 files changed

Lines changed: 357 additions & 51 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ DB_NAME=
2626
# Domain for production (used by Caddy for TLS)
2727
DOMAIN=
2828

29+
# Set to 0 to disable CSRF checks (not recommended in production)
30+
# CSRF_ENABLED=1
31+
2932
# Resend
3033
RESEND_API_KEY=
3134
EMAIL_FROM=

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
@@ -21,6 +21,15 @@
2121
require_unauthenticated_client,
2222
)
2323
from utils.core.auth import refresh_token_is_persistent, set_auth_cookies
24+
from utils.core.csrf import (
25+
CSRF_COOKIE_NAME,
26+
UNSAFE_HTTP_METHODS,
27+
csrf_enabled,
28+
extract_submitted_csrf_token,
29+
generate_csrf_token,
30+
set_csrf_cookie,
31+
validate_csrf_token,
32+
)
2433
from utils.core.htmx import (
2534
is_htmx_request,
2635
toast_response,
@@ -30,6 +39,7 @@
3039
from exceptions.http_exceptions import (
3140
AlreadyAuthenticatedError,
3241
AuthenticationError,
42+
CsrfError,
3343
PasswordValidationError,
3444
CredentialsError,
3545
RateLimitError,
@@ -73,6 +83,22 @@ async def flash_cookie_middleware(request: Request, call_next):
7383
return response
7484

7585

86+
@app.middleware("http")
87+
async def csrf_middleware(request: Request, call_next):
88+
token = request.cookies.get(CSRF_COOKIE_NAME) or generate_csrf_token()
89+
request.state.csrf_token = token
90+
91+
if csrf_enabled() and request.method in UNSAFE_HTTP_METHODS:
92+
submitted = await extract_submitted_csrf_token(request)
93+
if not validate_csrf_token(request, submitted):
94+
return await csrf_error_handler(request, CsrfError())
95+
96+
response = await call_next(request)
97+
if request.cookies.get(CSRF_COOKIE_NAME) != token:
98+
set_csrf_cookie(response, token)
99+
return response
100+
101+
76102
# --- Include Routers ---
77103

78104

@@ -137,6 +163,25 @@ async def rate_limit_error_handler(request: Request, exc: RateLimitError):
137163
return response
138164

139165

166+
@app.exception_handler(CsrfError)
167+
async def csrf_error_handler(request: Request, exc: CsrfError):
168+
if is_htmx_request(request):
169+
return toast_response(
170+
request,
171+
templates,
172+
exc.detail,
173+
level="danger",
174+
status_code=403,
175+
)
176+
user = await get_user_from_request(request)
177+
return templates.TemplateResponse(
178+
request,
179+
"errors/error.html",
180+
{"status_code": 403, "detail": exc.detail, "errors": None, "user": user},
181+
status_code=403,
182+
)
183+
184+
140185
# Handle CredentialsError (invalid email/password) with toast for HTMX
141186
@app.exception_handler(CredentialsError)
142187
async def credentials_exception_handler(request: Request, exc: CredentialsError):

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 %}

templates/account/register.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('register') }}"
2829
hx-post="{{ url_for('register') }}" 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 }}">

templates/account/reset_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 %}Reset Password{% endblock %}
45

56
{% block auth_header %}Reset Password{% endblock %}
@@ -9,6 +10,7 @@
910
<form method="POST" action="{{ url_for('reset_password') }}"
1011
hx-post="{{ url_for('reset_password') }}" hx-swap="none"
1112
class="needs-validation" novalidate>
13+
{% include 'base/partials/csrf_field.html' %}
1214
<!-- Hidden Email Input -->
1315
<input type="hidden" name="email" value="{{ email }}">
1416

0 commit comments

Comments
 (0)