|
10 | 10 | from sqlmodel import Session, select |
11 | 11 | from utils.core.models import User, DataIntegrityError, Account, Invitation |
12 | 12 | from utils.core.dependencies import get_session |
| 13 | +from utils.core.models import RefreshToken |
13 | 14 | from utils.core.auth import ( |
14 | 15 | HTML_PASSWORD_PATTERN, |
15 | 16 | COMPILED_PASSWORD_PATTERN, |
16 | 17 | COOKIE_SECURE, |
17 | 18 | oauth2_scheme_cookie, |
18 | 19 | get_password_hash, |
19 | 20 | create_access_token, |
20 | | - create_refresh_token, |
| 21 | + create_tracked_refresh_token, |
| 22 | + revoke_all_refresh_tokens, |
21 | 23 | validate_token, |
22 | 24 | send_reset_email_task, |
23 | 25 | send_email_update_confirmation |
@@ -99,13 +101,28 @@ def validate_password_strength_and_match( |
99 | 101 |
|
100 | 102 |
|
101 | 103 | @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 | +): |
103 | 108 | """ |
104 | | - Log out a user by clearing their cookies. |
| 109 | + Log out a user by revoking their refresh token and clearing cookies. |
105 | 110 | """ |
106 | 111 | response = RedirectResponse(url="/", status_code=303) |
107 | 112 | response.delete_cookie("access_token") |
108 | 113 | 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 | + |
109 | 126 | return response |
110 | 127 |
|
111 | 128 |
|
@@ -303,7 +320,8 @@ async def register( |
303 | 320 |
|
304 | 321 | # Create access token using the committed account's email |
305 | 322 | 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() |
307 | 325 |
|
308 | 326 | # Set cookie — use HX-Redirect for HTMX, 303 for regular form submissions |
309 | 327 | if is_htmx_request(request): |
@@ -404,7 +422,8 @@ async def login( |
404 | 422 | access_token = create_access_token( |
405 | 423 | data={"sub": account.email, "fresh": True} |
406 | 424 | ) |
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() |
408 | 427 |
|
409 | 428 | # Set cookie — use HX-Redirect for HTMX, 303 for regular form submissions |
410 | 429 | if is_htmx_request(request): |
@@ -450,16 +469,47 @@ async def refresh_token( |
450 | 469 | response.delete_cookie("refresh_token") |
451 | 470 | return response |
452 | 471 |
|
| 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 | + |
453 | 480 | user_email = decoded_token.get("sub") |
454 | 481 | account = session.exec(select(Account).where( |
455 | 482 | Account.email == user_email)).one_or_none() |
456 | 483 | if not account: |
457 | 484 | return RedirectResponse(url=router.url_path_for("read_login"), status_code=303) |
458 | 485 |
|
| 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 |
459 | 508 | new_access_token = create_access_token( |
460 | 509 | data={"sub": account.email, "fresh": False} |
461 | 510 | ) |
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() |
463 | 513 |
|
464 | 514 | response = RedirectResponse(url=dashboard_router.url_path_for("read_dashboard"), status_code=303) |
465 | 515 | response.set_cookie( |
@@ -616,11 +666,15 @@ async def confirm_email_update( |
616 | 666 |
|
617 | 667 | account.email = new_email |
618 | 668 | update_token.used = True |
| 669 | + |
| 670 | + # Revoke all existing refresh tokens since the email changed |
| 671 | + revoke_all_refresh_tokens(account.id, session) |
619 | 672 | session.commit() |
620 | 673 |
|
621 | 674 | # Create new tokens with the updated email |
622 | 675 | 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() |
624 | 678 |
|
625 | 679 | profile_path: URLPath = user_router.url_path_for("read_profile") |
626 | 680 | response = RedirectResponse(url=str(profile_path), status_code=303) |
|
0 commit comments