From ebaef16daf633824718466e5f3c96860d214e337 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Sun, 1 Mar 2026 19:27:25 -0500 Subject: [PATCH] feat: implement long-term user login with refresh tokens - Introduced a dual-token system for user authentication: a short-lived access token and a long-lived rotating refresh token. - Created a new RefreshToken model to manage refresh tokens securely. - Updated auth_api.py to handle login, refresh, and logout processes with the new token system. - Enhanced security measures including token rotation and theft detection. - Updated frontend to handle token refresh on 401 errors and adjusted SSE authentication. - Removed CORS middleware as it's unnecessary behind the nginx proxy. - Added tests to ensure functionality and security of the new token system. --- .../{ => archive}/feat-child-confirm-chore.md | 0 .github/specs/feat-login-security.md | 138 ++++++++++++ .vscode/launch.json | 4 +- backend/api/admin_api.py | 46 +--- backend/api/auth_api.py | 204 ++++++++++++++++-- backend/api/error_codes.py | 3 + backend/api/tracking_api.py | 51 +---- backend/api/user_api.py | 2 +- backend/api/utils.py | 48 ++++- backend/db/db.py | 4 + backend/main.py | 25 ++- backend/models/refresh_token.py | 34 +++ backend/requirements.txt | Bin 996 -> 958 bytes backend/tests/conftest.py | 10 +- backend/tests/test_admin_api.py | 42 ++-- backend/tests/test_auth_api.py | 35 ++- backend/tests/test_auth_api_marked.py | 9 +- backend/tests/test_child_api.py | 9 +- backend/tests/test_child_override_api.py | 4 +- backend/tests/test_chore_api.py | 4 +- backend/tests/test_chore_confirmation.py | 4 +- backend/tests/test_chore_schedule_api.py | 4 +- backend/tests/test_image_api.py | 9 +- backend/tests/test_kindness_api.py | 4 +- backend/tests/test_penalty_api.py | 4 +- backend/tests/test_reward_api.py | 9 +- backend/tests/test_task_api.py | 9 +- backend/tests/test_user_api.py | 13 +- .../common/__tests__/api.interceptor.spec.ts | 124 +++++++++-- .../common/__tests__/backendEvents.spec.ts | 4 +- frontend/vue-app/src/common/api.ts | 55 +++++ frontend/vue-app/src/common/backendEvents.ts | 3 +- 32 files changed, 713 insertions(+), 201 deletions(-) rename .github/specs/{ => archive}/feat-child-confirm-chore.md (100%) create mode 100644 .github/specs/feat-login-security.md create mode 100644 backend/models/refresh_token.py diff --git a/.github/specs/feat-child-confirm-chore.md b/.github/specs/archive/feat-child-confirm-chore.md similarity index 100% rename from .github/specs/feat-child-confirm-chore.md rename to .github/specs/archive/feat-child-confirm-chore.md diff --git a/.github/specs/feat-login-security.md b/.github/specs/feat-login-security.md new file mode 100644 index 0000000..9de85a7 --- /dev/null +++ b/.github/specs/feat-login-security.md @@ -0,0 +1,138 @@ +# 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 diff --git a/.vscode/launch.json b/.vscode/launch.json index f4e4c01..5fdd249 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,9 @@ "python": "${command:python.interpreterPath}", "env": { "FLASK_APP": "backend/main.py", - "FLASK_DEBUG": "1" + "FLASK_DEBUG": "1", + "SECRET_KEY": "dev-secret-key-change-in-production", + "REFRESH_TOKEN_EXPIRY_DAYS": "90" }, "args": [ "run", diff --git a/backend/api/admin_api.py b/backend/api/admin_api.py index 0a2f7d0..8eb3d60 100644 --- a/backend/api/admin_api.py +++ b/backend/api/admin_api.py @@ -1,11 +1,10 @@ from flask import Blueprint, request, jsonify from datetime import datetime, timedelta from tinydb import Query -import jwt -from functools import wraps from db.db import users_db from models.user import User +from api.utils import admin_required from config.deletion_config import ( ACCOUNT_DELETION_THRESHOLD_HOURS, MIN_THRESHOLD_HOURS, @@ -16,49 +15,6 @@ from utils.account_deletion_scheduler import trigger_deletion_manually admin_api = Blueprint('admin_api', __name__) -def admin_required(f): - """ - Decorator to require admin role for endpoints. - """ - @wraps(f) - def decorated_function(*args, **kwargs): - # Get JWT token from cookie - token = request.cookies.get('token') - if not token: - return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401 - - try: - # Verify JWT token - payload = jwt.decode(token, 'supersecretkey', algorithms=['HS256']) - user_id = payload.get('user_id') - - if not user_id: - return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401 - - # Get user from database - Query_ = Query() - user_dict = users_db.get(Query_.id == user_id) - - if not user_dict: - return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404 - - user = User.from_dict(user_dict) - - # Check if user has admin role - if user.role != 'admin': - return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403 - - # Pass user to the endpoint - request.current_user = user - - except jwt.ExpiredSignatureError: - return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401 - except jwt.InvalidTokenError: - return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401 - - return f(*args, **kwargs) - - return decorated_function @admin_api.route('/admin/deletion-queue', methods=['GET']) @admin_required diff --git a/backend/api/auth_api.py b/backend/api/auth_api.py index 290b6de..cf38307 100644 --- a/backend/api/auth_api.py +++ b/backend/api/auth_api.py @@ -1,7 +1,11 @@ +import hashlib import logging -import secrets, jwt +import secrets +import uuid +import jwt from datetime import datetime, timedelta, timezone from models.user import User +from models.refresh_token import RefreshToken from flask import Blueprint, request, jsonify, current_app from tinydb import Query import os @@ -11,17 +15,22 @@ from werkzeug.security import generate_password_hash, check_password_hash from api.utils import sanitize_email from config.paths import get_user_image_dir -from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING, \ - TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \ - NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION -from db.db import users_db +from api.error_codes import ( + MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING, + TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, + INVALID_CREDENTIALS, NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION, + REFRESH_TOKEN_REUSE, REFRESH_TOKEN_EXPIRED, MISSING_REFRESH_TOKEN, +) +from db.db import users_db, refresh_tokens_db from api.utils import normalize_email logger = logging.getLogger(__name__) auth_api = Blueprint('auth_api', __name__) UserQuery = Query() -TOKEN_EXPIRY_MINUTES = 60*4 +TokenQuery = Query() +TOKEN_EXPIRY_MINUTES = 60 * 4 RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10 +ACCESS_TOKEN_EXPIRY_MINUTES = 15 def send_verification_email(to_email, token): @@ -30,6 +39,77 @@ def send_verification_email(to_email, token): def send_reset_password_email(to_email, token): email_sender.send_reset_password_email(to_email, token) + +def _hash_token(raw_token: str) -> str: + """SHA-256 hash a raw refresh token for secure storage.""" + return hashlib.sha256(raw_token.encode('utf-8')).hexdigest() + + +def _create_access_token(user: User) -> str: + """Create a short-lived JWT access token.""" + payload = { + 'email': user.email, + 'user_id': user.id, + 'token_version': user.token_version, + 'exp': datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRY_MINUTES), + } + return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') + + +def _create_refresh_token(user_id: str, token_family: str | None = None) -> tuple[str, RefreshToken]: + """ + Create a refresh token: returns (raw_token, RefreshToken record). + If token_family is None, a new family is created (login). + Otherwise, the existing family is reused (rotation). + """ + raw_token = secrets.token_urlsafe(32) + expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS'] + expires_at = (datetime.now(timezone.utc) + timedelta(days=expiry_days)).isoformat() + family = token_family or str(uuid.uuid4()) + + record = RefreshToken( + user_id=user_id, + token_hash=_hash_token(raw_token), + token_family=family, + expires_at=expires_at, + is_used=False, + ) + refresh_tokens_db.insert(record.to_dict()) + return raw_token, record + + +def _set_auth_cookies(resp, access_token: str, raw_refresh_token: str): + """Set both access and refresh token cookies on a response.""" + expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS'] + resp.set_cookie('access_token', access_token, httponly=True, secure=True, samesite='Strict') + resp.set_cookie( + 'refresh_token', raw_refresh_token, + httponly=True, secure=True, samesite='Strict', + max_age=expiry_days * 24 * 3600, + path='/auth', + ) + + +def _clear_auth_cookies(resp): + """Clear both access and refresh token cookies.""" + resp.set_cookie('access_token', '', expires=0, httponly=True, secure=True, samesite='Strict') + resp.set_cookie('refresh_token', '', expires=0, httponly=True, secure=True, samesite='Strict', path='/auth') + + +def _purge_expired_tokens(user_id: str): + """Remove expired refresh tokens for a user to prevent unbounded DB growth.""" + now = datetime.now(timezone.utc) + all_tokens = refresh_tokens_db.search(TokenQuery.user_id == user_id) + for t in all_tokens: + try: + exp = datetime.fromisoformat(t['expires_at']) + if exp.tzinfo is None: + exp = exp.replace(tzinfo=timezone.utc) + if now > exp: + refresh_tokens_db.remove(TokenQuery.id == t['id']) + except (ValueError, KeyError): + refresh_tokens_db.remove(TokenQuery.id == t['id']) + @auth_api.route('/signup', methods=['POST']) def signup(): data = request.get_json() @@ -159,21 +239,22 @@ def login(): if user.marked_for_deletion: return jsonify({'error': 'This account has been marked for deletion and cannot be accessed.', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403 - payload = { - 'email': norm_email, - 'user_id': user.id, - 'token_version': user.token_version, - 'exp': datetime.utcnow() + timedelta(days=62) - } - token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256') + # Purge expired refresh tokens for this user + _purge_expired_tokens(user.id) + + # Create access token (short-lived JWT) + access_token = _create_access_token(user) + + # Create refresh token (long-lived, new family for fresh login) + raw_refresh, _ = _create_refresh_token(user.id) resp = jsonify({'message': 'Login successful'}) - resp.set_cookie('token', token, httponly=True, secure=True, samesite='Strict') + _set_auth_cookies(resp, access_token, raw_refresh) return resp, 200 @auth_api.route('/me', methods=['GET']) def me(): - token = request.cookies.get('token') + token = request.cookies.get('access_token') if not token: return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401 @@ -275,13 +356,100 @@ def reset_password(): user.token_version += 1 users_db.update(user.to_dict(), UserQuery.email == user.email) + # Invalidate ALL refresh tokens for this user + refresh_tokens_db.remove(TokenQuery.user_id == user.id) + resp = jsonify({'message': 'Password has been reset'}) - resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict') + _clear_auth_cookies(resp) return resp, 200 + +@auth_api.route('/refresh', methods=['POST']) +def refresh(): + raw_token = request.cookies.get('refresh_token') + if not raw_token: + return jsonify({'error': 'Missing refresh token', 'code': MISSING_REFRESH_TOKEN}), 401 + + token_hash = _hash_token(raw_token) + token_dict = refresh_tokens_db.get(TokenQuery.token_hash == token_hash) + + if not token_dict: + # Token not found — could be invalid or already purged + resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}) + _clear_auth_cookies(resp) + return resp, 401 + + token_record = RefreshToken.from_dict(token_dict) + + # THEFT DETECTION: token was already used (rotated out) but replayed + if token_record.is_used: + logger.warning( + 'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing all sessions', + token_record.user_id, token_record.token_family, request.remote_addr, + ) + # Nuke ALL refresh tokens for this user + refresh_tokens_db.remove(TokenQuery.user_id == token_record.user_id) + resp = jsonify({'error': 'Token reuse detected, all sessions invalidated', 'code': REFRESH_TOKEN_REUSE}) + _clear_auth_cookies(resp) + return resp, 401 + + # Check expiry + try: + exp = datetime.fromisoformat(token_record.expires_at) + if exp.tzinfo is None: + exp = exp.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) > exp: + refresh_tokens_db.remove(TokenQuery.id == token_record.id) + resp = jsonify({'error': 'Refresh token expired', 'code': REFRESH_TOKEN_EXPIRED}) + _clear_auth_cookies(resp) + return resp, 401 + except ValueError: + refresh_tokens_db.remove(TokenQuery.id == token_record.id) + resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}) + _clear_auth_cookies(resp) + return resp, 401 + + # Look up the user + user_dict = users_db.get(UserQuery.id == token_record.user_id) + user = User.from_dict(user_dict) if user_dict else None + if not user: + refresh_tokens_db.remove(TokenQuery.id == token_record.id) + resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}) + _clear_auth_cookies(resp) + return resp, 401 + + if user.marked_for_deletion: + refresh_tokens_db.remove(TokenQuery.user_id == user.id) + resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}) + _clear_auth_cookies(resp) + return resp, 403 + + # ROTATION: mark old token as used, create new one in same family + refresh_tokens_db.update({'is_used': True}, TokenQuery.id == token_record.id) + raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family) + + # Issue new access token + access_token = _create_access_token(user) + + resp = jsonify({ + 'email': user.email, + 'id': user.id, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'verified': user.verified, + }) + _set_auth_cookies(resp, access_token, raw_new_refresh) + return resp, 200 + + @auth_api.route('/logout', methods=['POST']) def logout(): + # Delete the refresh token from DB if present + raw_token = request.cookies.get('refresh_token') + if raw_token: + token_hash = _hash_token(raw_token) + refresh_tokens_db.remove(TokenQuery.token_hash == token_hash) + resp = jsonify({'message': 'Logged out'}) - # Remove the token cookie by setting it to empty and expiring it - resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict') + _clear_auth_cookies(resp) return resp, 200 diff --git a/backend/api/error_codes.py b/backend/api/error_codes.py index f521c80..c97e7d7 100644 --- a/backend/api/error_codes.py +++ b/backend/api/error_codes.py @@ -12,6 +12,9 @@ INVALID_CREDENTIALS = "INVALID_CREDENTIALS" NOT_VERIFIED = "NOT_VERIFIED" ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION" ALREADY_MARKED = "ALREADY_MARKED" +REFRESH_TOKEN_REUSE = "REFRESH_TOKEN_REUSE" +REFRESH_TOKEN_EXPIRED = "REFRESH_TOKEN_EXPIRED" +MISSING_REFRESH_TOKEN = "MISSING_REFRESH_TOKEN" class ErrorCodes: diff --git a/backend/api/tracking_api.py b/backend/api/tracking_api.py index 8b17696..8683043 100644 --- a/backend/api/tracking_api.py +++ b/backend/api/tracking_api.py @@ -1,61 +1,12 @@ from flask import Blueprint, request, jsonify -from api.utils import get_validated_user_id +from api.utils import get_validated_user_id, admin_required from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user from models.tracking_event import TrackingEvent -from functools import wraps -import jwt -from tinydb import Query -from db.db import users_db -from models.user import User tracking_api = Blueprint('tracking_api', __name__) -def admin_required(f): - """ - Decorator to require admin role for endpoints. - """ - @wraps(f) - def decorated_function(*args, **kwargs): - # Get JWT token from cookie - token = request.cookies.get('token') - if not token: - return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401 - - try: - # Verify JWT token - payload = jwt.decode(token, 'supersecretkey', algorithms=['HS256']) - user_id = payload.get('user_id') - - if not user_id: - return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401 - - # Get user from database - Query_ = Query() - user_dict = users_db.get(Query_.id == user_id) - - if not user_dict: - return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404 - - user = User.from_dict(user_dict) - - # Check if user has admin role - if user.role != 'admin': - return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403 - - # Store user_id in request context - request.admin_user_id = user_id - return f(*args, **kwargs) - - except jwt.ExpiredSignatureError: - return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401 - except jwt.InvalidTokenError: - return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401 - - return decorated_function - - @tracking_api.route('/admin/tracking', methods=['GET']) @admin_required def get_tracking(): diff --git a/backend/api/user_api.py b/backend/api/user_api.py index b365da6..7408bee 100644 --- a/backend/api/user_api.py +++ b/backend/api/user_api.py @@ -21,7 +21,7 @@ user_api = Blueprint('user_api', __name__) UserQuery = Query() def get_current_user(): - token = request.cookies.get('token') + token = request.cookies.get('access_token') if not token: return None try: diff --git a/backend/api/utils.py b/backend/api/utils.py index 6476fd9..a93e7fe 100644 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -1,10 +1,12 @@ import jwt import re +from functools import wraps from db.db import users_db from tinydb import Query from flask import request, current_app, jsonify from events.sse import send_event_to_user +from models.user import User def normalize_email(email: str) -> str: @@ -21,7 +23,7 @@ def sanitize_email(email): return email.replace('@', '_at_').replace('.', '_dot_') def get_current_user_id(): - token = request.cookies.get('token') + token = request.cookies.get('access_token') if not token: return None try: @@ -50,4 +52,46 @@ def send_event_for_current_user(event): if not user_id: return jsonify({'error': 'Unauthorized'}), 401 send_event_to_user(user_id, event) - return None \ No newline at end of file + return None + + +def admin_required(f): + """ + Decorator to require admin role for endpoints. + Validates JWT from access_token cookie and checks admin role. + """ + @wraps(f) + def decorated_function(*args, **kwargs): + token = request.cookies.get('access_token') + if not token: + return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401 + + try: + payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256']) + user_id = payload.get('user_id') + + if not user_id: + return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401 + + user_dict = users_db.get(Query().id == user_id) + + if not user_dict: + return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404 + + user = User.from_dict(user_dict) + + if user.role != 'admin': + return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403 + + # Store user info in request context for the endpoint + request.current_user = user + request.admin_user_id = user_id + + except jwt.ExpiredSignatureError: + return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401 + except jwt.InvalidTokenError: + return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401 + + return f(*args, **kwargs) + + return decorated_function \ No newline at end of file diff --git a/backend/db/db.py b/backend/db/db.py index c4d7669..d260076 100644 --- a/backend/db/db.py +++ b/backend/db/db.py @@ -78,6 +78,7 @@ tracking_events_path = os.path.join(base_dir, 'tracking_events.json') child_overrides_path = os.path.join(base_dir, 'child_overrides.json') chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json') task_extensions_path = os.path.join(base_dir, 'task_extensions.json') +refresh_tokens_path = os.path.join(base_dir, 'refresh_tokens.json') # Use separate TinyDB instances/files for each collection _child_db = TinyDB(child_path, indent=2) @@ -91,6 +92,7 @@ _tracking_events_db = TinyDB(tracking_events_path, indent=2) _child_overrides_db = TinyDB(child_overrides_path, indent=2) _chore_schedules_db = TinyDB(chore_schedules_path, indent=2) _task_extensions_db = TinyDB(task_extensions_path, indent=2) +_refresh_tokens_db = TinyDB(refresh_tokens_path, indent=2) # Expose table objects wrapped with locking child_db = LockedTable(_child_db) @@ -104,6 +106,7 @@ tracking_events_db = LockedTable(_tracking_events_db) child_overrides_db = LockedTable(_child_overrides_db) chore_schedules_db = LockedTable(_chore_schedules_db) task_extensions_db = LockedTable(_task_extensions_db) +refresh_tokens_db = LockedTable(_refresh_tokens_db) if os.environ.get('DB_ENV', 'prod') == 'test': child_db.truncate() @@ -117,4 +120,5 @@ if os.environ.get('DB_ENV', 'prod') == 'test': child_overrides_db.truncate() chore_schedules_db.truncate() task_extensions_db.truncate() + refresh_tokens_db.truncate() diff --git a/backend/main.py b/backend/main.py index b655d7f..65aee3d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,7 +3,6 @@ import sys import os from flask import Flask, request, jsonify -from flask_cors import CORS from api.admin_api import admin_api from api.auth_api import auth_api @@ -23,6 +22,7 @@ from config.version import get_full_version from db.default import initializeImages, createDefaultTasks, createDefaultRewards from events.broadcaster import Broadcaster from events.sse import sse_response_for_user, send_to_user +from api.utils import get_current_user_id from utils.account_deletion_scheduler import start_deletion_scheduler # Configure logging once at application startup @@ -60,10 +60,23 @@ app.config.update( MAIL_PASSWORD='ruyj hxjf nmrz buar', MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com', FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost - SECRET_KEY='supersecretkey' # Replace with a secure key in production ) -CORS(app) +# Security: require SECRET_KEY and REFRESH_TOKEN_EXPIRY_DAYS from environment +_secret_key = os.environ.get('SECRET_KEY') +if not _secret_key: + raise RuntimeError( + 'SECRET_KEY environment variable is required. ' + 'Set it to a random string (e.g. python -c "import secrets; print(secrets.token_urlsafe(64))")') +app.config['SECRET_KEY'] = _secret_key + +_refresh_expiry = os.environ.get('REFRESH_TOKEN_EXPIRY_DAYS') +if not _refresh_expiry: + raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS environment variable is required (e.g. 90).') +try: + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = int(_refresh_expiry) +except ValueError: + raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS must be an integer.') @app.route("/version") def api_version(): @@ -71,11 +84,9 @@ def api_version(): @app.route("/events") def events(): - # Authenticate user or read a token - user_id = request.args.get("user_id") + user_id = get_current_user_id() if not user_id: - return {"error": "Missing user_id"}, 400 - + return {"error": "Authentication required"}, 401 return sse_response_for_user(user_id) diff --git a/backend/models/refresh_token.py b/backend/models/refresh_token.py new file mode 100644 index 0000000..9bba5ad --- /dev/null +++ b/backend/models/refresh_token.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass, field +from models.base import BaseModel + + +@dataclass(kw_only=True) +class RefreshToken(BaseModel): + user_id: str = '' + token_hash: str = '' + token_family: str = '' + expires_at: str = '' + is_used: bool = False + + def to_dict(self): + return { + **super().to_dict(), + 'user_id': self.user_id, + 'token_hash': self.token_hash, + 'token_family': self.token_family, + 'expires_at': self.expires_at, + 'is_used': self.is_used, + } + + @staticmethod + def from_dict(data: dict) -> 'RefreshToken': + return RefreshToken( + id=data.get('id', ''), + created_at=data.get('created_at', 0), + updated_at=data.get('updated_at', 0), + user_id=data.get('user_id', ''), + token_hash=data.get('token_hash', ''), + token_family=data.get('token_family', ''), + expires_at=data.get('expires_at', ''), + is_used=data.get('is_used', False), + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 0d279dd77391028b86c5f5862b26cac0551cbc93..de971088b0d9435525dff38dee55b8cd26d3a62d 100644 GIT binary patch delta 11 ScmaFDzK?yvo6SOumzV$_>jcLD delta 44 vcmdnT{)BzP8^tt+9EL=OVuox6U4~?ae1;+*+ZG7T81xtnfY@;3^Xp6i4Xz6z diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b52d8af..9a54700 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,11 +1,19 @@ import os os.environ['DB_ENV'] = 'test' +os.environ.setdefault('SECRET_KEY', 'test-secret-key') +os.environ.setdefault('REFRESH_TOKEN_EXPIRY_DAYS', '90') import sys import pytest # Ensure backend root is in sys.path for imports like 'config.paths' sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +# Shared test constants — import these in test files instead of hardcoding +TEST_SECRET_KEY = 'test-secret-key' +TEST_REFRESH_TOKEN_EXPIRY_DAYS = 90 + @pytest.fixture(scope="session", autouse=True) def set_test_db_env(): - os.environ['DB_ENV'] = 'test' \ No newline at end of file + os.environ['DB_ENV'] = 'test' + os.environ['SECRET_KEY'] = TEST_SECRET_KEY + os.environ['REFRESH_TOKEN_EXPIRY_DAYS'] = str(TEST_REFRESH_TOKEN_EXPIRY_DAYS) \ No newline at end of file diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py index f052382..5d233a9 100644 --- a/backend/tests/test_admin_api.py +++ b/backend/tests/test_admin_api.py @@ -14,13 +14,13 @@ from models.user import User from db.db import users_db from config.deletion_config import MIN_THRESHOLD_HOURS, MAX_THRESHOLD_HOURS from tinydb import Query +from tests.conftest import TEST_SECRET_KEY @pytest.fixture def client(): """Create test client.""" app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' with app.test_client() as client: yield client @@ -45,7 +45,7 @@ def admin_user(): users_db.insert(user.to_dict()) # Create JWT token - token = jwt.encode({'user_id': 'admin_user'}, 'supersecretkey', algorithm='HS256') + token = jwt.encode({'user_id': 'admin_user'}, TEST_SECRET_KEY, algorithm='HS256') return token @@ -117,7 +117,7 @@ class TestGetDeletionQueue: def test_get_deletion_queue_success(self, client, admin_user, setup_deletion_queue): """Test getting deletion queue returns correct users.""" - client.set_cookie('token', admin_user) + client.set_cookie('access_token', admin_user) response = client.get('/admin/deletion-queue') assert response.status_code == 200 @@ -147,7 +147,7 @@ class TestGetDeletionQueue: def test_get_deletion_queue_invalid_token(self, client, setup_deletion_queue): """Test that invalid token is rejected.""" - client.set_cookie('token', 'invalid_token') + client.set_cookie('access_token', 'invalid_token') response = client.get('/admin/deletion-queue') assert response.status_code == 401 @@ -161,11 +161,11 @@ class TestGetDeletionQueue: # Create expired token expired_token = jwt.encode( {'user_id': 'admin_user', 'exp': datetime.now() - timedelta(hours=1)}, - 'supersecretkey', + TEST_SECRET_KEY, algorithm='HS256' ) - client.set_cookie('token', expired_token) + client.set_cookie('access_token', expired_token) response = client.get('/admin/deletion-queue') assert response.status_code == 401 @@ -192,7 +192,7 @@ class TestGetDeletionQueue: ) users_db.insert(admin.to_dict()) - client.set_cookie('token', admin_user) + client.set_cookie('access_token', admin_user) response = client.get('/admin/deletion-queue') assert response.status_code == 200 @@ -206,7 +206,7 @@ class TestGetDeletionThreshold: def test_get_threshold_success(self, client, admin_user): """Test getting current threshold configuration.""" - client.set_cookie('token', admin_user) + client.set_cookie('access_token', admin_user) response = client.get('/admin/deletion-threshold') assert response.status_code == 200 @@ -232,7 +232,7 @@ class TestUpdateDeletionThreshold: def test_update_threshold_success(self, client, admin_user): """Test updating threshold with valid value.""" - client.set_cookie('token', admin_user) + client.set_cookie('access_token', admin_user) response = client.put( '/admin/deletion-threshold', json={'threshold_hours': 168} @@ -245,7 +245,7 @@ class TestUpdateDeletionThreshold: def test_update_threshold_validates_minimum(self, client, admin_user): """Test that threshold below minimum is rejected.""" - client.set_cookie('token', admin_user) + client.set_cookie('access_token', admin_user) response = client.put( '/admin/deletion-threshold', json={'threshold_hours': 23} @@ -258,7 +258,7 @@ class TestUpdateDeletionThreshold: def test_update_threshold_validates_maximum(self, client, admin_user): """Test that threshold above maximum is rejected.""" - client.set_cookie('token', admin_user) + client.set_cookie('access_token', admin_user) response = client.put( '/admin/deletion-threshold', json={'threshold_hours': 721} @@ -271,7 +271,7 @@ class TestUpdateDeletionThreshold: def test_update_threshold_missing_value(self, client, admin_user): """Test that missing threshold value is rejected.""" - client.set_cookie('token', admin_user) + client.set_cookie('access_token', admin_user) response = client.put( '/admin/deletion-threshold', json={} @@ -284,7 +284,7 @@ class TestUpdateDeletionThreshold: def test_update_threshold_invalid_type(self, client, admin_user): """Test that non-integer threshold is rejected.""" - client.set_cookie('token', admin_user) + client.set_cookie('access_token', admin_user) response = client.put( '/admin/deletion-threshold', json={'threshold_hours': 'invalid'} @@ -310,7 +310,7 @@ class TestTriggerDeletionQueue: def test_trigger_deletion_success(self, client, admin_user, setup_deletion_queue): """Test manually triggering deletion queue.""" - client.set_cookie('token', admin_user) + client.set_cookie('access_token', admin_user) response = client.post('/admin/deletion-queue/trigger') assert response.status_code == 200 @@ -348,7 +348,7 @@ class TestTriggerDeletionQueue: ) users_db.insert(admin.to_dict()) - client.set_cookie('token', admin_user) + client.set_cookie('access_token', admin_user) response = client.post('/admin/deletion-queue/trigger') assert response.status_code == 200 @@ -381,9 +381,9 @@ class TestAdminRoleValidation: users_db.insert(user.to_dict()) # Create token for non-admin - token = jwt.encode({'user_id': 'regular_user'}, 'supersecretkey', algorithm='HS256') + token = jwt.encode({'user_id': 'regular_user'}, TEST_SECRET_KEY, algorithm='HS256') - client.set_cookie('token', token) + client.set_cookie('access_token', token) response = client.get('/admin/deletion-queue') # Should return 403 Forbidden @@ -414,9 +414,9 @@ class TestAdminRoleValidation: users_db.insert(admin.to_dict()) # Create token for admin - token = jwt.encode({'user_id': 'admin_user'}, 'supersecretkey', algorithm='HS256') + token = jwt.encode({'user_id': 'admin_user'}, TEST_SECRET_KEY, algorithm='HS256') - client.set_cookie('token', token) + client.set_cookie('access_token', token) response = client.get('/admin/deletion-queue') # Should succeed @@ -439,9 +439,9 @@ class TestAdminRoleValidation: ) users_db.insert(user.to_dict()) - token = jwt.encode({'user_id': 'regular_user'}, 'supersecretkey', algorithm='HS256') + token = jwt.encode({'user_id': 'regular_user'}, TEST_SECRET_KEY, algorithm='HS256') - client.set_cookie('token', token) + client.set_cookie('access_token', token) response = client.put('/admin/deletion-threshold', json={'threshold_hours': 168}) assert response.status_code == 403 diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 331483d..b972693 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -2,10 +2,11 @@ import pytest from werkzeug.security import generate_password_hash, check_password_hash from flask import Flask from api.auth_api import auth_api -from db.db import users_db +from db.db import users_db, refresh_tokens_db from tinydb import Query from models.user import User from datetime import datetime +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS @pytest.fixture def client(): @@ -13,7 +14,8 @@ def client(): app = Flask(__name__) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS app.config['FRONTEND_URL'] = 'http://localhost:5173' with app.test_client() as client: yield client @@ -54,7 +56,10 @@ def test_login_with_correct_password(client): data = {'email': 'test@example.com', 'password': 'password123'} response = client.post('/auth/login', json=data) assert response.status_code == 200 - assert 'token' in response.headers.get('Set-Cookie', '') + cookies = response.headers.getlist('Set-Cookie') + cookie_str = ' '.join(cookies) + assert 'access_token=' in cookie_str + assert 'refresh_token=' in cookie_str def test_login_with_incorrect_password(client): """Test login fails with incorrect password.""" @@ -116,18 +121,30 @@ def test_reset_password_invalidates_existing_jwt(client): login_response = client.post('/auth/login', json={'email': 'test@example.com', 'password': 'oldpassword123'}) assert login_response.status_code == 200 - login_cookie = login_response.headers.get('Set-Cookie', '') - assert 'token=' in login_cookie - old_token = login_cookie.split('token=', 1)[1].split(';', 1)[0] + login_cookies = login_response.headers.getlist('Set-Cookie') + login_cookie_str = ' '.join(login_cookies) + assert 'access_token=' in login_cookie_str + # Extract the old access token + old_token = None + for c in login_cookies: + if c.startswith('access_token='): + old_token = c.split('access_token=', 1)[1].split(';', 1)[0] + break assert old_token reset_response = client.post('/auth/reset-password', json={'token': 'validtoken2', 'password': 'newpassword123'}) assert reset_response.status_code == 200 - reset_cookie = reset_response.headers.get('Set-Cookie', '') - assert 'token=' in reset_cookie + reset_cookies = reset_response.headers.getlist('Set-Cookie') + reset_cookie_str = ' '.join(reset_cookies) + assert 'access_token=' in reset_cookie_str + + # Verify all refresh tokens for this user are deleted + user_dict = users_db.get(Query().email == 'test@example.com') + user_tokens = refresh_tokens_db.search(Query().user_id == user_dict['id']) + assert len(user_tokens) == 0 # Set the old token as a cookie and test that it's now invalid - client.set_cookie('token', old_token) + client.set_cookie('access_token', old_token) me_response = client.get('/auth/me') assert me_response.status_code == 401 assert me_response.json['code'] == 'INVALID_TOKEN' diff --git a/backend/tests/test_auth_api_marked.py b/backend/tests/test_auth_api_marked.py index 821ccf3..be86371 100644 --- a/backend/tests/test_auth_api_marked.py +++ b/backend/tests/test_auth_api_marked.py @@ -7,13 +7,15 @@ from models.user import User from werkzeug.security import generate_password_hash from datetime import datetime, timedelta import jwt +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS @pytest.fixture def client(): app = Flask(__name__) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS with app.test_client() as client: yield client @@ -70,12 +72,13 @@ def test_me_marked_for_deletion(client): payload = { 'email': email, 'user_id': user.id, + 'token_version': user.token_version, 'exp': datetime.utcnow() + timedelta(hours=24) } - token = jwt.encode(payload, 'supersecretkey', algorithm='HS256') + token = jwt.encode(payload, TEST_SECRET_KEY, algorithm='HS256') # Make request with token cookie - client.set_cookie('token', token) + client.set_cookie('access_token', token) response = client.get('/auth/me') assert response.status_code == 403 diff --git a/backend/tests/test_child_api.py b/backend/tests/test_child_api.py index 4183fac..ce2286f 100644 --- a/backend/tests/test_child_api.py +++ b/backend/tests/test_child_api.py @@ -1,4 +1,5 @@ import pytest +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS import os from flask import Flask @@ -33,8 +34,9 @@ def login_and_set_cookie(client): resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) assert resp.status_code == 200 # Set cookie for subsequent requests - token = resp.headers.get("Set-Cookie") - assert token and "token=" in token + cookies = resp.headers.getlist("Set-Cookie") + cookie_str = ' '.join(cookies) + assert cookie_str and "access_token=" in cookie_str # Flask test client automatically handles cookies @pytest.fixture @@ -43,7 +45,8 @@ def client(): app.register_blueprint(child_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS with app.test_client() as client: add_test_user() login_and_set_cookie(client) diff --git a/backend/tests/test_child_override_api.py b/backend/tests/test_child_override_api.py index 78d0e8d..6f324df 100644 --- a/backend/tests/test_child_override_api.py +++ b/backend/tests/test_child_override_api.py @@ -1,5 +1,6 @@ """Tests for child override API endpoints and integration.""" import pytest +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS import os from flask import Flask from unittest.mock import patch, MagicMock @@ -61,7 +62,8 @@ def client(): app.register_blueprint(child_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS with app.test_client() as client: add_test_user() diff --git a/backend/tests/test_chore_api.py b/backend/tests/test_chore_api.py index 478d2eb..6a056db 100644 --- a/backend/tests/test_chore_api.py +++ b/backend/tests/test_chore_api.py @@ -1,4 +1,5 @@ import pytest +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS import os from werkzeug.security import generate_password_hash @@ -34,7 +35,8 @@ def client(): app.register_blueprint(chore_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS with app.test_client() as client: add_test_user() login_and_set_cookie(client) diff --git a/backend/tests/test_chore_confirmation.py b/backend/tests/test_chore_confirmation.py index 3ec1b37..9eede70 100644 --- a/backend/tests/test_chore_confirmation.py +++ b/backend/tests/test_chore_confirmation.py @@ -1,4 +1,5 @@ import pytest +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS import os from werkzeug.security import generate_password_hash from datetime import date as date_type @@ -42,7 +43,8 @@ def client(): app.register_blueprint(child_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS with app.test_client() as client: add_test_user() login_and_set_cookie(client) diff --git a/backend/tests/test_chore_schedule_api.py b/backend/tests/test_chore_schedule_api.py index 175b36a..845f81f 100644 --- a/backend/tests/test_chore_schedule_api.py +++ b/backend/tests/test_chore_schedule_api.py @@ -1,4 +1,5 @@ import pytest +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS import os from werkzeug.security import generate_password_hash @@ -53,7 +54,8 @@ def client(): app.register_blueprint(chore_schedule_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS with app.test_client() as client: add_test_user() add_test_child() diff --git a/backend/tests/test_image_api.py b/backend/tests/test_image_api.py index a88bdb6..59b823e 100644 --- a/backend/tests/test_image_api.py +++ b/backend/tests/test_image_api.py @@ -5,6 +5,7 @@ import time from config.paths import get_user_image_dir from PIL import Image as PILImage import pytest +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS from werkzeug.security import generate_password_hash from flask import Flask @@ -38,8 +39,9 @@ def add_test_user(): def login_and_set_cookie(client): resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) assert resp.status_code == 200 - token = resp.headers.get("Set-Cookie") - assert token and "token=" in token + cookies = resp.headers.getlist("Set-Cookie") + cookie_str = ' '.join(cookies) + assert cookie_str and "access_token=" in cookie_str def safe_remove(path): try: @@ -67,7 +69,8 @@ def client(): app.register_blueprint(image_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS with app.test_client() as c: add_test_user() remove_test_data() diff --git a/backend/tests/test_kindness_api.py b/backend/tests/test_kindness_api.py index ec790a3..e0952b3 100644 --- a/backend/tests/test_kindness_api.py +++ b/backend/tests/test_kindness_api.py @@ -1,4 +1,5 @@ import pytest +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS import os from werkzeug.security import generate_password_hash @@ -34,7 +35,8 @@ def client(): app.register_blueprint(kindness_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS with app.test_client() as client: add_test_user() login_and_set_cookie(client) diff --git a/backend/tests/test_penalty_api.py b/backend/tests/test_penalty_api.py index e3b9989..9807d60 100644 --- a/backend/tests/test_penalty_api.py +++ b/backend/tests/test_penalty_api.py @@ -1,4 +1,5 @@ import pytest +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS import os from werkzeug.security import generate_password_hash @@ -34,7 +35,8 @@ def client(): app.register_blueprint(penalty_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS with app.test_client() as client: add_test_user() login_and_set_cookie(client) diff --git a/backend/tests/test_reward_api.py b/backend/tests/test_reward_api.py index 2d59879..b2d13e1 100644 --- a/backend/tests/test_reward_api.py +++ b/backend/tests/test_reward_api.py @@ -1,4 +1,5 @@ import pytest +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS import os from werkzeug.security import generate_password_hash @@ -30,8 +31,9 @@ def add_test_user(): def login_and_set_cookie(client): resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) assert resp.status_code == 200 - token = resp.headers.get("Set-Cookie") - assert token and "token=" in token + cookies = resp.headers.getlist("Set-Cookie") + cookie_str = ' '.join(cookies) + assert cookie_str and "access_token=" in cookie_str @pytest.fixture def client(): @@ -39,7 +41,8 @@ def client(): app.register_blueprint(reward_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS with app.test_client() as client: add_test_user() login_and_set_cookie(client) diff --git a/backend/tests/test_task_api.py b/backend/tests/test_task_api.py index 424ccd4..58ce5ac 100644 --- a/backend/tests/test_task_api.py +++ b/backend/tests/test_task_api.py @@ -1,4 +1,5 @@ import pytest +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS import os from werkzeug.security import generate_password_hash @@ -29,8 +30,9 @@ def add_test_user(): def login_and_set_cookie(client): resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) assert resp.status_code == 200 - token = resp.headers.get("Set-Cookie") - assert token and "token=" in token + cookies = resp.headers.getlist("Set-Cookie") + cookie_str = ' '.join(cookies) + assert cookie_str and "access_token=" in cookie_str @pytest.fixture def client(): @@ -38,7 +40,8 @@ def client(): app.register_blueprint(task_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS with app.test_client() as client: add_test_user() login_and_set_cookie(client) diff --git a/backend/tests/test_user_api.py b/backend/tests/test_user_api.py index c306096..9383ab9 100644 --- a/backend/tests/test_user_api.py +++ b/backend/tests/test_user_api.py @@ -7,6 +7,7 @@ from db.db import users_db from tinydb import Query import jwt from werkzeug.security import generate_password_hash +from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS # Test user credentials TEST_EMAIL = "usertest@example.com" @@ -50,9 +51,10 @@ def login_and_get_token(client, email, password): """Login and extract JWT token from response.""" resp = client.post('/auth/login', json={"email": email, "password": password}) assert resp.status_code == 200 - # Extract token from Set-Cookie header - set_cookie = resp.headers.get("Set-Cookie") - assert set_cookie and "token=" in set_cookie + # Verify auth cookies are set + cookies = resp.headers.getlist('Set-Cookie') + cookie_str = ' '.join(cookies) + assert 'access_token=' in cookie_str # Flask test client automatically handles cookies return resp @@ -63,7 +65,8 @@ def client(): app.register_blueprint(user_api) app.register_blueprint(auth_api, url_prefix='/auth') app.config['TESTING'] = True - app.config['SECRET_KEY'] = 'supersecretkey' + app.config['SECRET_KEY'] = TEST_SECRET_KEY + app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS app.config['FRONTEND_URL'] = 'http://localhost:5173' # Needed for email_sender with app.test_client() as client: add_test_users() @@ -200,7 +203,7 @@ def test_mark_for_deletion_clears_tokens(authenticated_client): def test_mark_for_deletion_with_invalid_jwt(client): """Test marking for deletion with invalid JWT token.""" # Set invalid cookie manually - client.set_cookie('token', 'invalid.jwt.token') + client.set_cookie('access_token', 'invalid.jwt.token') response = client.post('/user/mark-for-deletion', json={}) assert response.status_code == 401 diff --git a/frontend/vue-app/src/common/__tests__/api.interceptor.spec.ts b/frontend/vue-app/src/common/__tests__/api.interceptor.spec.ts index c3f96f9..a2549dd 100644 --- a/frontend/vue-app/src/common/__tests__/api.interceptor.spec.ts +++ b/frontend/vue-app/src/common/__tests__/api.interceptor.spec.ts @@ -19,15 +19,56 @@ describe('installUnauthorizedFetchInterceptor', () => { globalThis.fetch = originalFetch }) - it('logs out and redirects to /auth on 401 outside auth routes', async () => { + it('attempts refresh on 401, retries the original request on success', async () => { const fetchMock = globalThis.fetch as unknown as ReturnType - fetchMock.mockResolvedValue({ status: 401 } as Response) + // First call: original request → 401 + // Second call: refresh → 200 (success) + // Third call: retry original → 200 + fetchMock + .mockResolvedValueOnce({ status: 401 } as Response) + .mockResolvedValueOnce({ ok: true, status: 200 } as Response) + .mockResolvedValueOnce({ status: 200, body: 'retried' } as unknown as Response) window.history.pushState({}, '', '/parent/profile') const redirectSpy = vi.fn() - const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } = - await import('../api') + const { + installUnauthorizedFetchInterceptor, + setUnauthorizedRedirectHandlerForTests, + resetInterceptorStateForTests, + } = await import('../api') + resetInterceptorStateForTests() + setUnauthorizedRedirectHandlerForTests(redirectSpy) + installUnauthorizedFetchInterceptor() + + const result = await fetch('/api/user/profile') + + // Should NOT have logged out + expect(mockLogoutUser).not.toHaveBeenCalled() + expect(redirectSpy).not.toHaveBeenCalled() + // Should have called fetch 3 times: original, refresh, retry + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/auth/refresh', { method: 'POST' }) + expect((result as Response).status).toBe(200) + }) + + it('logs out when refresh also fails with 401', async () => { + const fetchMock = globalThis.fetch as unknown as ReturnType + // First call: original → 401 + // Second call: refresh → 401 (fail) + fetchMock + .mockResolvedValueOnce({ status: 401 } as Response) + .mockResolvedValueOnce({ ok: false, status: 401 } as Response) + + window.history.pushState({}, '', '/parent/profile') + const redirectSpy = vi.fn() + + const { + installUnauthorizedFetchInterceptor, + setUnauthorizedRedirectHandlerForTests, + resetInterceptorStateForTests, + } = await import('../api') + resetInterceptorStateForTests() setUnauthorizedRedirectHandlerForTests(redirectSpy) installUnauthorizedFetchInterceptor() @@ -37,15 +78,46 @@ describe('installUnauthorizedFetchInterceptor', () => { expect(redirectSpy).toHaveBeenCalledTimes(1) }) - it('does not redirect when already on auth route', async () => { + it('does not attempt refresh for auth endpoint 401s', async () => { const fetchMock = globalThis.fetch as unknown as ReturnType fetchMock.mockResolvedValue({ status: 401 } as Response) + window.history.pushState({}, '', '/parent/profile') + const redirectSpy = vi.fn() + + const { + installUnauthorizedFetchInterceptor, + setUnauthorizedRedirectHandlerForTests, + resetInterceptorStateForTests, + } = await import('../api') + resetInterceptorStateForTests() + setUnauthorizedRedirectHandlerForTests(redirectSpy) + installUnauthorizedFetchInterceptor() + + await fetch('/api/auth/refresh', { method: 'POST' }) + + // Should log out immediately without attempting refresh + expect(mockLogoutUser).toHaveBeenCalledTimes(1) + // Only 1 fetch call — no refresh attempt + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('does not redirect when already on auth route', async () => { + const fetchMock = globalThis.fetch as unknown as ReturnType + // original → 401, refresh → fail + fetchMock + .mockResolvedValueOnce({ status: 401 } as Response) + .mockResolvedValueOnce({ ok: false, status: 401 } as Response) + window.history.pushState({}, '', '/auth/login') const redirectSpy = vi.fn() - const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } = - await import('../api') + const { + installUnauthorizedFetchInterceptor, + setUnauthorizedRedirectHandlerForTests, + resetInterceptorStateForTests, + } = await import('../api') + resetInterceptorStateForTests() setUnauthorizedRedirectHandlerForTests(redirectSpy) installUnauthorizedFetchInterceptor() @@ -55,23 +127,37 @@ describe('installUnauthorizedFetchInterceptor', () => { expect(redirectSpy).not.toHaveBeenCalled() }) - it('handles unauthorized redirect only once even for repeated 401 responses', async () => { + it('only makes one refresh call for concurrent 401 responses', async () => { const fetchMock = globalThis.fetch as unknown as ReturnType - fetchMock.mockResolvedValue({ status: 401 } as Response) + // Both original calls → 401, then one refresh → 200, then both retries → 200 + fetchMock + .mockResolvedValueOnce({ status: 401 } as Response) + .mockResolvedValueOnce({ status: 401 } as Response) + .mockResolvedValueOnce({ ok: true, status: 200 } as Response) // refresh + .mockResolvedValueOnce({ status: 200 } as Response) // retry 1 + .mockResolvedValueOnce({ status: 200 } as Response) // retry 2 window.history.pushState({}, '', '/parent/tasks') const redirectSpy = vi.fn() - const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } = - await import('../api') + const { + installUnauthorizedFetchInterceptor, + setUnauthorizedRedirectHandlerForTests, + resetInterceptorStateForTests, + } = await import('../api') + resetInterceptorStateForTests() setUnauthorizedRedirectHandlerForTests(redirectSpy) installUnauthorizedFetchInterceptor() - await fetch('/api/task/add', { method: 'PUT' }) - await fetch('/api/image/list?type=2') + const [r1, r2] = await Promise.all([ + fetch('/api/task/add', { method: 'PUT' }), + fetch('/api/image/list?type=2'), + ]) - expect(mockLogoutUser).toHaveBeenCalledTimes(1) - expect(redirectSpy).toHaveBeenCalledTimes(1) + expect(mockLogoutUser).not.toHaveBeenCalled() + expect(redirectSpy).not.toHaveBeenCalled() + expect((r1 as Response).status).toBe(200) + expect((r2 as Response).status).toBe(200) }) it('does not log out for non-401 responses', async () => { @@ -81,8 +167,12 @@ describe('installUnauthorizedFetchInterceptor', () => { window.history.pushState({}, '', '/parent') const redirectSpy = vi.fn() - const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } = - await import('../api') + const { + installUnauthorizedFetchInterceptor, + setUnauthorizedRedirectHandlerForTests, + resetInterceptorStateForTests, + } = await import('../api') + resetInterceptorStateForTests() setUnauthorizedRedirectHandlerForTests(redirectSpy) installUnauthorizedFetchInterceptor() diff --git a/frontend/vue-app/src/common/__tests__/backendEvents.spec.ts b/frontend/vue-app/src/common/__tests__/backendEvents.spec.ts index 28fc7b1..402697b 100644 --- a/frontend/vue-app/src/common/__tests__/backendEvents.spec.ts +++ b/frontend/vue-app/src/common/__tests__/backendEvents.spec.ts @@ -59,7 +59,7 @@ describe('useBackendEvents', () => { await wrapper.setProps({ userId: 'user-1' }) expect(MockEventSource.instances.length).toBe(1) - expect(MockEventSource.instances[0]?.url).toBe('/events?user_id=user-1') + expect(MockEventSource.instances[0]?.url).toBe('/api/events') }) it('reconnects when user id changes and closes previous connection', async () => { @@ -72,7 +72,7 @@ describe('useBackendEvents', () => { expect(firstConnection?.close).toHaveBeenCalledTimes(1) expect(MockEventSource.instances.length).toBe(2) - expect(MockEventSource.instances[1]?.url).toBe('/events?user_id=user-2') + expect(MockEventSource.instances[1]?.url).toBe('/api/events') }) it('emits parsed backend events on message', async () => { diff --git a/frontend/vue-app/src/common/api.ts b/frontend/vue-app/src/common/api.ts index 630e470..ecd121b 100644 --- a/frontend/vue-app/src/common/api.ts +++ b/frontend/vue-app/src/common/api.ts @@ -4,10 +4,19 @@ let unauthorizedInterceptorInstalled = false let unauthorizedRedirectHandler: (() => void) | null = null let unauthorizedHandlingInProgress = false +// Mutex for refresh token requests — prevents concurrent refresh calls +let refreshPromise: Promise | null = null + export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | null): void { unauthorizedRedirectHandler = handler } +/** Reset interceptor state (for tests). */ +export function resetInterceptorStateForTests(): void { + unauthorizedHandlingInProgress = false + refreshPromise = null +} + function handleUnauthorizedResponse(): void { if (unauthorizedHandlingInProgress) return unauthorizedHandlingInProgress = true @@ -21,6 +30,33 @@ function handleUnauthorizedResponse(): void { window.location.assign('/auth') } +/** + * Attempt to refresh the access token by calling the refresh endpoint. + * Returns true if refresh succeeded, false otherwise. + * Uses a mutex so concurrent 401s only trigger one refresh call. + */ +async function attemptTokenRefresh(originalFetch: typeof fetch): Promise { + if (refreshPromise) return refreshPromise + + refreshPromise = (async () => { + try { + const res = await originalFetch('/api/auth/refresh', { method: 'POST' }) + return res.ok + } catch { + return false + } finally { + refreshPromise = null + } + })() + + return refreshPromise +} + +/** URLs that should NOT trigger a refresh attempt on 401. */ +function isAuthUrl(url: string): boolean { + return url.includes('/api/auth/refresh') || url.includes('/api/auth/login') +} + export function installUnauthorizedFetchInterceptor(): void { if (unauthorizedInterceptorInstalled || typeof window === 'undefined') return unauthorizedInterceptorInstalled = true @@ -28,9 +64,28 @@ export function installUnauthorizedFetchInterceptor(): void { const originalFetch = globalThis.fetch.bind(globalThis) const wrappedFetch = (async (...args: Parameters) => { const response = await originalFetch(...args) + if (response.status === 401) { + // Determine the request URL + const requestUrl = typeof args[0] === 'string' ? args[0] : (args[0] as Request).url + + // Don't attempt refresh for auth endpoints themselves + if (isAuthUrl(requestUrl)) { + handleUnauthorizedResponse() + return response + } + + // Attempt silent refresh + const refreshed = await attemptTokenRefresh(originalFetch) + if (refreshed) { + // Retry the original request with the new access token cookie + return originalFetch(...args) + } + + // Refresh failed — log out handleUnauthorizedResponse() } + return response }) as typeof fetch diff --git a/frontend/vue-app/src/common/backendEvents.ts b/frontend/vue-app/src/common/backendEvents.ts index 003b371..9a44cd4 100644 --- a/frontend/vue-app/src/common/backendEvents.ts +++ b/frontend/vue-app/src/common/backendEvents.ts @@ -26,7 +26,8 @@ export function useBackendEvents(userId: Ref) { const connect = () => { if (eventSource) eventSource.close() if (userId.value) { - eventSource = new EventSource(`/events?user_id=${userId.value}`) + // Auth is cookie-based; the browser sends the access_token cookie automatically + eventSource = new EventSource(`/api/events`) eventSource.onmessage = (event) => { const data = JSON.parse(event.data)