All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s
- Introduced LandingHero component with logo, tagline, and action buttons. - Created LandingFeatures component to showcase chore system benefits. - Developed LandingProblem component explaining the importance of a structured chore system. - Implemented LandingFooter for navigation and copyright information. - Added LandingPage to assemble all components and manage navigation. - Included unit tests for LandingHero component to ensure functionality.
139 lines
7.4 KiB
Markdown
139 lines
7.4 KiB
Markdown
# Feature: Long term user login through refresh tokens.
|
|
|
|
## Overview
|
|
|
|
Currently, JWT tokens have a long expiration date (62 days). However, the token cookie has no `max-age` so it's treated as a session cookie — lost when the browser closes. This feature replaces the single long-lived JWT with a dual-token system: a short-lived access token and a long-lived rotating refresh token, plus security hardening.
|
|
|
|
**Goal:**
|
|
Implement long-term user login through a short-lived access token (HttpOnly session cookie) and a configurable-duration refresh token (persistent HttpOnly cookie). Include token family tracking for theft detection, and harden related security gaps.
|
|
|
|
**User Story:**
|
|
As a user, I should be able to log in, and have my credentials remain valid for a configurable number of days (default 90).
|
|
|
|
**Rules:**
|
|
|
|
- Access token is a short-lived JWT in an HttpOnly session cookie (no max-age) — cleared on browser close.
|
|
- Refresh token is a random string in a persistent HttpOnly cookie (with max-age) — survives browser close.
|
|
- API changes are in auth_api.py.
|
|
- Login screen does not change.
|
|
- Secret key and refresh token expiry are required environment variables.
|
|
|
|
**Design:**
|
|
|
|
- Access Token (Short-lived): A JWT that lasts 15 minutes. Used for every API call. Stored as an HttpOnly session cookie named `access_token`.
|
|
- Refresh Token (Long-lived): A `secrets.token_urlsafe(32)` string stored as a persistent HttpOnly cookie named `refresh_token` with `max-age` and path restricted to `/auth`.
|
|
- The Flow: When you open the app after a restart, the access token is gone (session cookie). The frontend's 401 interceptor detects this, sends `POST /auth/refresh` with the refresh token cookie, and the server returns a brand new 15-minute access token + rotated refresh token.
|
|
- Token Rotation: Every refresh rotates the token. Old tokens are marked `is_used=True`. Replay of a used token triggers theft detection — ALL sessions for that user are killed and logged.
|
|
- On logout, the refresh token is deleted from the DB and both cookies are cleared.
|
|
|
|
---
|
|
|
|
## Data Model Changes
|
|
|
|
### Backend Model
|
|
|
|
New model: `RefreshToken` dataclass in `backend/models/refresh_token.py`
|
|
|
|
| Field | Type | Description |
|
|
| -------------- | ------- | ------------------------------------------------- |
|
|
| `id` | `str` | UUID (from BaseModel) |
|
|
| `user_id` | `str` | FK to User |
|
|
| `token_hash` | `str` | SHA-256 hash of raw token (never store raw) |
|
|
| `token_family` | `str` | UUID grouping tokens from one login session |
|
|
| `expires_at` | `str` | ISO datetime |
|
|
| `is_used` | `bool` | True after rotation; replay of used token = theft |
|
|
| `created_at` | `float` | From BaseModel |
|
|
| `updated_at` | `float` | From BaseModel |
|
|
|
|
New TinyDB table: `refresh_tokens_db` in `backend/db/db.py` backed by `refresh_tokens.json`.
|
|
|
|
### Frontend Model
|
|
|
|
No changes — refresh tokens are entirely server-side.
|
|
|
|
---
|
|
|
|
## Backend Implementation
|
|
|
|
- [x] Create `RefreshToken` model (`backend/models/refresh_token.py`)
|
|
- [x] Add `refresh_tokens_db` table to `backend/db/db.py`
|
|
- [x] Add error codes: `REFRESH_TOKEN_REUSE`, `REFRESH_TOKEN_EXPIRED`, `MISSING_REFRESH_TOKEN` in `error_codes.py`
|
|
- [x] Move secret key to `SECRET_KEY` env var in `main.py` (hard fail if missing)
|
|
- [x] Add `REFRESH_TOKEN_EXPIRY_DAYS` env var in `main.py` (hard fail if missing)
|
|
- [x] Remove CORS (`flask-cors` from requirements.txt, `CORS(app)` from main.py)
|
|
- [x] Add SSE authentication — `/events` endpoint uses `get_current_user_id()` from cookies instead of `user_id` query param
|
|
- [x] Consolidate `admin_required` decorator into `utils.py` (removed duplicates from `admin_api.py` and `tracking_api.py`)
|
|
- [x] Update cookie name from `token` to `access_token` in `utils.py`, `user_api.py`, `auth_api.py`
|
|
- [x] Refactor `auth_api.py` login: issue 15-min access token + refresh token (new family)
|
|
- [x] Add `POST /auth/refresh` endpoint with rotation and theft detection
|
|
- [x] Refactor `auth_api.py` logout: delete refresh token from DB, clear both cookies
|
|
- [x] Refactor `auth_api.py` reset-password: invalidate all refresh tokens + clear cookies
|
|
- [x] Add expired token cleanup (opportunistic, on login/refresh)
|
|
|
|
## Backend Tests
|
|
|
|
- [x] All 14 test files updated: `SECRET_KEY` → `TEST_SECRET_KEY` from conftest, cookie `token` → `access_token`
|
|
- [x] `test_login_with_correct_password`: asserts both `access_token` and `refresh_token` cookies
|
|
- [x] `test_reset_password_invalidates_existing_jwt`: verifies refresh tokens deleted from DB
|
|
- [x] `test_me_marked_for_deletion`: updated JWT payload with `token_version`
|
|
- [x] `test_admin_api.py`: all `set_cookie('token')` → `set_cookie('access_token')`; `jwt.encode` uses `TEST_SECRET_KEY`
|
|
- [x] All 258 backend tests pass
|
|
|
|
---
|
|
|
|
## Frontend Implementation
|
|
|
|
- [x] Update `api.ts` 401 interceptor: attempt `POST /api/auth/refresh` before logging out on 401
|
|
- [x] Add refresh mutex: concurrent 401s only trigger one refresh call
|
|
- [x] Skip refresh for auth endpoints (`/api/auth/refresh`, `/api/auth/login`)
|
|
- [x] Retry original request after successful refresh
|
|
- [x] Update `backendEvents.ts`: SSE URL changed from `/events?user_id=...` to `/api/events` (cookie-based auth)
|
|
|
|
## Frontend Tests
|
|
|
|
- [x] Interceptor tests rewritten: refresh-then-retry, refresh-fail-logout, auth-URL skip, concurrent mutex, non-401 passthrough (6 tests)
|
|
- [x] backendEvents tests updated: URL assertions use `/api/events`
|
|
- [x] All 287 frontend tests pass
|
|
|
|
---
|
|
|
|
## Security Hardening (included in this feature)
|
|
|
|
- [x] Secret key moved from hardcoded `'supersecretkey'` to required `SECRET_KEY` environment variable
|
|
- [x] Hardcoded secret removed from `admin_required` decorators (was copy-pasted with literal string)
|
|
- [x] SSE `/events` endpoint now requires authentication (was open to anyone with a user_id)
|
|
- [x] CORS middleware removed (unnecessary behind nginx same-origin proxy)
|
|
- [x] `admin_required` decorator consolidated into `utils.py` (was duplicated in `admin_api.py` and `tracking_api.py`)
|
|
- [x] Refresh tokens stored as SHA-256 hashes (never raw)
|
|
- [x] Token family tracking with automatic session kill on replay (theft detection)
|
|
- [x] Refresh token cookie path restricted to `/auth` (not sent with every API call)
|
|
|
|
## Future Considerations
|
|
|
|
- Rate limiting on login, signup, and refresh endpoints
|
|
- Configurable access token lifetime via env var
|
|
- Background job for expired token cleanup (currently opportunistic)
|
|
|
|
---
|
|
|
|
## Acceptance Criteria (Definition of Done)
|
|
|
|
### Backend
|
|
|
|
- [x] Login returns two cookies: `access_token` (session, 15-min JWT) and `refresh_token` (persistent, configurable-day, path=/auth)
|
|
- [x] `POST /auth/refresh` rotates refresh token and issues new access token
|
|
- [x] Replay of rotated-out refresh token kills all user sessions (theft detection)
|
|
- [x] Logout deletes refresh token from DB and clears both cookies
|
|
- [x] Password reset invalidates all refresh tokens
|
|
- [x] Secret key and refresh token expiry loaded from environment variables
|
|
- [x] SSE requires authentication
|
|
- [x] CORS removed
|
|
- [x] All 258 backend tests pass
|
|
|
|
### Frontend
|
|
|
|
- [x] 401 interceptor attempts refresh before logging out
|
|
- [x] Concurrent 401s trigger only one refresh call
|
|
- [x] SSE connects without user_id query param (cookie auth)
|
|
- [x] All 287 frontend tests pass
|