Skip to content

Commit 9037781

Browse files
Merge remote-tracking branch 'origin/main' into modal
2 parents 5efc3e5 + 0612c20 commit 9037781

11 files changed

Lines changed: 471 additions & 107 deletions

File tree

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 = "0.1.14"
3+
version = "0.1.15"
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: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@
1010
from sqlmodel import Session, select
1111
from utils.core.models import User, DataIntegrityError, Account, Invitation
1212
from utils.core.dependencies import get_session
13+
from utils.core.models import RefreshToken
1314
from utils.core.auth import (
1415
HTML_PASSWORD_PATTERN,
1516
COMPILED_PASSWORD_PATTERN,
1617
COOKIE_SECURE,
1718
oauth2_scheme_cookie,
1819
get_password_hash,
1920
create_access_token,
20-
create_refresh_token,
21+
create_tracked_refresh_token,
22+
revoke_all_refresh_tokens,
2123
validate_token,
2224
send_reset_email_task,
2325
send_email_update_confirmation
@@ -99,13 +101,28 @@ def validate_password_strength_and_match(
99101

100102

101103
@router.get("/logout", response_class=RedirectResponse)
102-
def logout():
104+
def logout(
105+
tokens: tuple[Optional[str], Optional[str]] = Depends(oauth2_scheme_cookie),
106+
session: Session = Depends(get_session),
107+
):
103108
"""
104-
Log out a user by clearing their cookies.
109+
Log out a user by revoking their refresh token and clearing cookies.
105110
"""
106111
response = RedirectResponse(url="/", status_code=303)
107112
response.delete_cookie("access_token")
108113
response.delete_cookie("refresh_token")
114+
115+
_, refresh_token_value = tokens
116+
if refresh_token_value:
117+
decoded = validate_token(refresh_token_value, token_type="refresh")
118+
if decoded and decoded.get("jti"):
119+
db_token = session.exec(
120+
select(RefreshToken).where(RefreshToken.jti == decoded["jti"])
121+
).first()
122+
if db_token:
123+
db_token.revoked = True
124+
session.commit()
125+
109126
return response
110127

111128

@@ -303,7 +320,8 @@ async def register(
303320

304321
# Create access token using the committed account's email
305322
access_token = create_access_token(data={"sub": account.email, "fresh": True})
306-
refresh_token = create_refresh_token(data={"sub": account.email})
323+
refresh_token = create_tracked_refresh_token(account.id, account.email, session)
324+
session.commit()
307325

308326
# Set cookie — use HX-Redirect for HTMX, 303 for regular form submissions
309327
if is_htmx_request(request):
@@ -404,7 +422,8 @@ async def login(
404422
access_token = create_access_token(
405423
data={"sub": account.email, "fresh": True}
406424
)
407-
refresh_token = create_refresh_token(data={"sub": account.email})
425+
refresh_token = create_tracked_refresh_token(account.id, account.email, session)
426+
session.commit()
408427

409428
# Set cookie — use HX-Redirect for HTMX, 303 for regular form submissions
410429
if is_htmx_request(request):
@@ -450,16 +469,47 @@ async def refresh_token(
450469
response.delete_cookie("refresh_token")
451470
return response
452471

472+
# Validate JTI server-side
473+
jti = decoded_token.get("jti")
474+
if not jti:
475+
response = RedirectResponse(url=router.url_path_for("read_login"), status_code=303)
476+
response.delete_cookie("access_token")
477+
response.delete_cookie("refresh_token")
478+
return response
479+
453480
user_email = decoded_token.get("sub")
454481
account = session.exec(select(Account).where(
455482
Account.email == user_email)).one_or_none()
456483
if not account:
457484
return RedirectResponse(url=router.url_path_for("read_login"), status_code=303)
458485

486+
db_token = session.exec(
487+
select(RefreshToken).where(RefreshToken.jti == jti)
488+
).first()
489+
490+
if not db_token or db_token.account_id != account.id:
491+
return RedirectResponse(url=router.url_path_for("read_login"), status_code=303)
492+
493+
if db_token.revoked:
494+
# Token reuse detected — revoke all tokens for this account
495+
logger.warning(
496+
f"Refresh token reuse detected for account {account.id} on /refresh endpoint. "
497+
"Revoking all refresh tokens."
498+
)
499+
revoke_all_refresh_tokens(account.id, session)
500+
session.commit()
501+
response = RedirectResponse(url=router.url_path_for("read_login"), status_code=303)
502+
response.delete_cookie("access_token")
503+
response.delete_cookie("refresh_token")
504+
return response
505+
506+
# Revoke current token and issue new ones
507+
db_token.revoked = True
459508
new_access_token = create_access_token(
460509
data={"sub": account.email, "fresh": False}
461510
)
462-
new_refresh_token = create_refresh_token(data={"sub": account.email})
511+
new_refresh_token = create_tracked_refresh_token(account.id, account.email, session)
512+
session.commit()
463513

464514
response = RedirectResponse(url=dashboard_router.url_path_for("read_dashboard"), status_code=303)
465515
response.set_cookie(
@@ -616,11 +666,15 @@ async def confirm_email_update(
616666

617667
account.email = new_email
618668
update_token.used = True
669+
670+
# Revoke all existing refresh tokens since the email changed
671+
revoke_all_refresh_tokens(account.id, session)
619672
session.commit()
620673

621674
# Create new tokens with the updated email
622675
access_token = create_access_token(data={"sub": new_email, "fresh": True})
623-
refresh_token = create_refresh_token(data={"sub": new_email})
676+
refresh_token = create_tracked_refresh_token(account.id, new_email, session)
677+
session.commit()
624678

625679
profile_path: URLPath = user_router.url_path_for("read_profile")
626680
response = RedirectResponse(url=str(profile_path), status_code=303)

templates/users/profile.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ <h1 class="mb-4">User Profile</h1>
7676
<label for="new_email" class="form-label">New Email Address</label>
7777
<input type="email" class="form-control" id="new_email" name="new_email" placeholder="newemail@example.com">
7878
</div>
79-
<p class="form-text">A confirmation link will be sent to your new email address to verify the change.</p>
79+
<p class="form-text">A confirmation link will be sent to your current email address to verify the change.</p>
8080
<button type="submit" class="btn btn-primary">Update Email</button>
8181
</form>
8282
</div>

tests/conftest.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from dotenv import load_dotenv
77
from utils.core.db import get_connection_url, tear_down_db, set_up_db, create_default_roles, ensure_database_exists
88
from utils.core.models import User, Organization, Role, Account, Invitation
9-
from utils.core.auth import get_password_hash, create_access_token, create_refresh_token
9+
from utils.core.auth import get_password_hash, create_access_token, create_tracked_refresh_token
1010
from main import app
1111
from datetime import datetime, UTC, timedelta
1212

@@ -115,7 +115,8 @@ def auth_client(session: Session, test_account: Account, test_user: User) -> Gen
115115

116116
# Create and set valid tokens
117117
access_token = create_access_token({"sub": test_account.email})
118-
refresh_token = create_refresh_token({"sub": test_account.email})
118+
refresh_token = create_tracked_refresh_token(test_account.id, test_account.email, session)
119+
session.commit()
119120

120121
client.cookies.set("access_token", access_token)
121122
client.cookies.set("refresh_token", refresh_token)
@@ -284,11 +285,12 @@ def auth_client_owner(session: Session, org_owner: User) -> Generator[TestClient
284285
# Create and set valid tokens
285286
if org_owner.account:
286287
access_token = create_access_token({"sub": org_owner.account.email})
287-
refresh_token = create_refresh_token({"sub": org_owner.account.email})
288-
288+
refresh_token = create_tracked_refresh_token(org_owner.account.id, org_owner.account.email, session)
289+
session.commit()
290+
289291
client.cookies.set("access_token", access_token)
290292
client.cookies.set("refresh_token", refresh_token)
291-
293+
292294
yield client
293295

294296

@@ -304,11 +306,12 @@ def auth_client_admin(session: Session, org_admin_user: User) -> Generator[TestC
304306
# Create and set valid tokens
305307
if org_admin_user.account:
306308
access_token = create_access_token({"sub": org_admin_user.account.email})
307-
refresh_token = create_refresh_token({"sub": org_admin_user.account.email})
308-
309+
refresh_token = create_tracked_refresh_token(org_admin_user.account.id, org_admin_user.account.email, session)
310+
session.commit()
311+
309312
client.cookies.set("access_token", access_token)
310313
client.cookies.set("refresh_token", refresh_token)
311-
314+
312315
yield client
313316

314317

@@ -324,11 +327,12 @@ def auth_client_member(session: Session, org_member_user: User) -> Generator[Tes
324327
# Create and set valid tokens
325328
if org_member_user.account:
326329
access_token = create_access_token({"sub": org_member_user.account.email})
327-
refresh_token = create_refresh_token({"sub": org_member_user.account.email})
328-
330+
refresh_token = create_tracked_refresh_token(org_member_user.account.id, org_member_user.account.email, session)
331+
session.commit()
332+
329333
client.cookies.set("access_token", access_token)
330334
client.cookies.set("refresh_token", refresh_token)
331-
335+
332336
yield client
333337

334338

@@ -344,11 +348,12 @@ def auth_client_non_member(session: Session, non_member_user: User) -> Generator
344348
# Create and set valid tokens
345349
if non_member_user.account:
346350
access_token = create_access_token({"sub": non_member_user.account.email})
347-
refresh_token = create_refresh_token({"sub": non_member_user.account.email})
348-
351+
refresh_token = create_tracked_refresh_token(non_member_user.account.id, non_member_user.account.email, session)
352+
session.commit()
353+
349354
client.cookies.set("access_token", access_token)
350355
client.cookies.set("refresh_token", refresh_token)
351-
356+
352357
yield client
353358

354359

@@ -478,11 +483,12 @@ def auth_client_invitee(session: Session, existing_invitee_user: User) -> Genera
478483
# Create and set valid tokens
479484
if existing_invitee_user.account:
480485
access_token = create_access_token({"sub": existing_invitee_user.account.email})
481-
refresh_token = create_refresh_token({"sub": existing_invitee_user.account.email})
482-
486+
refresh_token = create_tracked_refresh_token(existing_invitee_user.account.id, existing_invitee_user.account.email, session)
487+
session.commit()
488+
483489
client.cookies.set("access_token", access_token)
484490
client.cookies.set("refresh_token", refresh_token)
485-
491+
486492
yield client
487493

488494
# --- Email Mocking Fixtures ---

0 commit comments

Comments
 (0)