feat: implement long-term user login with refresh tokens
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m23s
- 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.
This commit is contained in:
138
.github/specs/feat-login-security.md
vendored
Normal file
138
.github/specs/feat-login-security.md
vendored
Normal file
@@ -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
|
||||||
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@@ -9,7 +9,9 @@
|
|||||||
"python": "${command:python.interpreterPath}",
|
"python": "${command:python.interpreterPath}",
|
||||||
"env": {
|
"env": {
|
||||||
"FLASK_APP": "backend/main.py",
|
"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": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
import jwt
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from db.db import users_db
|
from db.db import users_db
|
||||||
from models.user import User
|
from models.user import User
|
||||||
|
from api.utils import admin_required
|
||||||
from config.deletion_config import (
|
from config.deletion_config import (
|
||||||
ACCOUNT_DELETION_THRESHOLD_HOURS,
|
ACCOUNT_DELETION_THRESHOLD_HOURS,
|
||||||
MIN_THRESHOLD_HOURS,
|
MIN_THRESHOLD_HOURS,
|
||||||
@@ -16,49 +15,6 @@ from utils.account_deletion_scheduler import trigger_deletion_manually
|
|||||||
|
|
||||||
admin_api = Blueprint('admin_api', __name__)
|
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_api.route('/admin/deletion-queue', methods=['GET'])
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import secrets, jwt
|
import secrets
|
||||||
|
import uuid
|
||||||
|
import jwt
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from models.user import User
|
from models.user import User
|
||||||
|
from models.refresh_token import RefreshToken
|
||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
import os
|
import os
|
||||||
@@ -11,17 +15,22 @@ from werkzeug.security import generate_password_hash, check_password_hash
|
|||||||
from api.utils import sanitize_email
|
from api.utils import sanitize_email
|
||||||
from config.paths import get_user_image_dir
|
from config.paths import get_user_image_dir
|
||||||
|
|
||||||
from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING, \
|
from api.error_codes import (
|
||||||
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \
|
MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING,
|
||||||
NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION
|
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD,
|
||||||
from db.db import users_db
|
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
|
from api.utils import normalize_email
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
auth_api = Blueprint('auth_api', __name__)
|
auth_api = Blueprint('auth_api', __name__)
|
||||||
UserQuery = Query()
|
UserQuery = Query()
|
||||||
TOKEN_EXPIRY_MINUTES = 60*4
|
TokenQuery = Query()
|
||||||
|
TOKEN_EXPIRY_MINUTES = 60 * 4
|
||||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
||||||
|
ACCESS_TOKEN_EXPIRY_MINUTES = 15
|
||||||
|
|
||||||
|
|
||||||
def send_verification_email(to_email, token):
|
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):
|
def send_reset_password_email(to_email, token):
|
||||||
email_sender.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'])
|
@auth_api.route('/signup', methods=['POST'])
|
||||||
def signup():
|
def signup():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
@@ -159,21 +239,22 @@ def login():
|
|||||||
if user.marked_for_deletion:
|
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
|
return jsonify({'error': 'This account has been marked for deletion and cannot be accessed.', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
|
||||||
|
|
||||||
payload = {
|
# Purge expired refresh tokens for this user
|
||||||
'email': norm_email,
|
_purge_expired_tokens(user.id)
|
||||||
'user_id': user.id,
|
|
||||||
'token_version': user.token_version,
|
# Create access token (short-lived JWT)
|
||||||
'exp': datetime.utcnow() + timedelta(days=62)
|
access_token = _create_access_token(user)
|
||||||
}
|
|
||||||
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
# Create refresh token (long-lived, new family for fresh login)
|
||||||
|
raw_refresh, _ = _create_refresh_token(user.id)
|
||||||
|
|
||||||
resp = jsonify({'message': 'Login successful'})
|
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
|
return resp, 200
|
||||||
|
|
||||||
@auth_api.route('/me', methods=['GET'])
|
@auth_api.route('/me', methods=['GET'])
|
||||||
def me():
|
def me():
|
||||||
token = request.cookies.get('token')
|
token = request.cookies.get('access_token')
|
||||||
if not token:
|
if not token:
|
||||||
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401
|
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401
|
||||||
|
|
||||||
@@ -275,13 +356,100 @@ def reset_password():
|
|||||||
user.token_version += 1
|
user.token_version += 1
|
||||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
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 = 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
|
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'])
|
@auth_api.route('/logout', methods=['POST'])
|
||||||
def logout():
|
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'})
|
resp = jsonify({'message': 'Logged out'})
|
||||||
# Remove the token cookie by setting it to empty and expiring it
|
_clear_auth_cookies(resp)
|
||||||
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
|
|
||||||
return resp, 200
|
return resp, 200
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
|
|||||||
NOT_VERIFIED = "NOT_VERIFIED"
|
NOT_VERIFIED = "NOT_VERIFIED"
|
||||||
ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION"
|
ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION"
|
||||||
ALREADY_MARKED = "ALREADY_MARKED"
|
ALREADY_MARKED = "ALREADY_MARKED"
|
||||||
|
REFRESH_TOKEN_REUSE = "REFRESH_TOKEN_REUSE"
|
||||||
|
REFRESH_TOKEN_EXPIRED = "REFRESH_TOKEN_EXPIRED"
|
||||||
|
MISSING_REFRESH_TOKEN = "MISSING_REFRESH_TOKEN"
|
||||||
|
|
||||||
|
|
||||||
class ErrorCodes:
|
class ErrorCodes:
|
||||||
|
|||||||
@@ -1,61 +1,12 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
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 db.tracking import get_tracking_events_by_child, get_tracking_events_by_user
|
||||||
from models.tracking_event import TrackingEvent
|
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__)
|
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'])
|
@tracking_api.route('/admin/tracking', methods=['GET'])
|
||||||
@admin_required
|
@admin_required
|
||||||
def get_tracking():
|
def get_tracking():
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ user_api = Blueprint('user_api', __name__)
|
|||||||
UserQuery = Query()
|
UserQuery = Query()
|
||||||
|
|
||||||
def get_current_user():
|
def get_current_user():
|
||||||
token = request.cookies.get('token')
|
token = request.cookies.get('access_token')
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import jwt
|
import jwt
|
||||||
import re
|
import re
|
||||||
|
from functools import wraps
|
||||||
from db.db import users_db
|
from db.db import users_db
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
from flask import request, current_app, jsonify
|
from flask import request, current_app, jsonify
|
||||||
|
|
||||||
from events.sse import send_event_to_user
|
from events.sse import send_event_to_user
|
||||||
|
from models.user import User
|
||||||
|
|
||||||
|
|
||||||
def normalize_email(email: str) -> str:
|
def normalize_email(email: str) -> str:
|
||||||
@@ -21,7 +23,7 @@ def sanitize_email(email):
|
|||||||
return email.replace('@', '_at_').replace('.', '_dot_')
|
return email.replace('@', '_at_').replace('.', '_dot_')
|
||||||
|
|
||||||
def get_current_user_id():
|
def get_current_user_id():
|
||||||
token = request.cookies.get('token')
|
token = request.cookies.get('access_token')
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -51,3 +53,45 @@ def send_event_for_current_user(event):
|
|||||||
return jsonify({'error': 'Unauthorized'}), 401
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
send_event_to_user(user_id, event)
|
send_event_to_user(user_id, event)
|
||||||
return None
|
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
|
||||||
@@ -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')
|
child_overrides_path = os.path.join(base_dir, 'child_overrides.json')
|
||||||
chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json')
|
chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json')
|
||||||
task_extensions_path = os.path.join(base_dir, 'task_extensions.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
|
# Use separate TinyDB instances/files for each collection
|
||||||
_child_db = TinyDB(child_path, indent=2)
|
_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)
|
_child_overrides_db = TinyDB(child_overrides_path, indent=2)
|
||||||
_chore_schedules_db = TinyDB(chore_schedules_path, indent=2)
|
_chore_schedules_db = TinyDB(chore_schedules_path, indent=2)
|
||||||
_task_extensions_db = TinyDB(task_extensions_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
|
# Expose table objects wrapped with locking
|
||||||
child_db = LockedTable(_child_db)
|
child_db = LockedTable(_child_db)
|
||||||
@@ -104,6 +106,7 @@ tracking_events_db = LockedTable(_tracking_events_db)
|
|||||||
child_overrides_db = LockedTable(_child_overrides_db)
|
child_overrides_db = LockedTable(_child_overrides_db)
|
||||||
chore_schedules_db = LockedTable(_chore_schedules_db)
|
chore_schedules_db = LockedTable(_chore_schedules_db)
|
||||||
task_extensions_db = LockedTable(_task_extensions_db)
|
task_extensions_db = LockedTable(_task_extensions_db)
|
||||||
|
refresh_tokens_db = LockedTable(_refresh_tokens_db)
|
||||||
|
|
||||||
if os.environ.get('DB_ENV', 'prod') == 'test':
|
if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||||
child_db.truncate()
|
child_db.truncate()
|
||||||
@@ -117,4 +120,5 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
|
|||||||
child_overrides_db.truncate()
|
child_overrides_db.truncate()
|
||||||
chore_schedules_db.truncate()
|
chore_schedules_db.truncate()
|
||||||
task_extensions_db.truncate()
|
task_extensions_db.truncate()
|
||||||
|
refresh_tokens_db.truncate()
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import sys
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Flask, request, jsonify
|
from flask import Flask, request, jsonify
|
||||||
from flask_cors import CORS
|
|
||||||
|
|
||||||
from api.admin_api import admin_api
|
from api.admin_api import admin_api
|
||||||
from api.auth_api import auth_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 db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
||||||
from events.broadcaster import Broadcaster
|
from events.broadcaster import Broadcaster
|
||||||
from events.sse import sse_response_for_user, send_to_user
|
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
|
from utils.account_deletion_scheduler import start_deletion_scheduler
|
||||||
|
|
||||||
# Configure logging once at application startup
|
# Configure logging once at application startup
|
||||||
@@ -60,10 +60,23 @@ app.config.update(
|
|||||||
MAIL_PASSWORD='ruyj hxjf nmrz buar',
|
MAIL_PASSWORD='ruyj hxjf nmrz buar',
|
||||||
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
|
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
|
||||||
FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost
|
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")
|
@app.route("/version")
|
||||||
def api_version():
|
def api_version():
|
||||||
@@ -71,11 +84,9 @@ def api_version():
|
|||||||
|
|
||||||
@app.route("/events")
|
@app.route("/events")
|
||||||
def events():
|
def events():
|
||||||
# Authenticate user or read a token
|
user_id = get_current_user_id()
|
||||||
user_id = request.args.get("user_id")
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return {"error": "Missing user_id"}, 400
|
return {"error": "Authentication required"}, 401
|
||||||
|
|
||||||
|
|
||||||
return sse_response_for_user(user_id)
|
return sse_response_for_user(user_id)
|
||||||
|
|
||||||
|
|||||||
34
backend/models/refresh_token.py
Normal file
34
backend/models/refresh_token.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
Binary file not shown.
@@ -1,11 +1,19 @@
|
|||||||
import os
|
import os
|
||||||
os.environ['DB_ENV'] = 'test'
|
os.environ['DB_ENV'] = 'test'
|
||||||
|
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
|
||||||
|
os.environ.setdefault('REFRESH_TOKEN_EXPIRY_DAYS', '90')
|
||||||
import sys
|
import sys
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# Ensure backend root is in sys.path for imports like 'config.paths'
|
# 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__), '..')))
|
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)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def set_test_db_env():
|
def set_test_db_env():
|
||||||
os.environ['DB_ENV'] = 'test'
|
os.environ['DB_ENV'] = 'test'
|
||||||
|
os.environ['SECRET_KEY'] = TEST_SECRET_KEY
|
||||||
|
os.environ['REFRESH_TOKEN_EXPIRY_DAYS'] = str(TEST_REFRESH_TOKEN_EXPIRY_DAYS)
|
||||||
@@ -14,13 +14,13 @@ from models.user import User
|
|||||||
from db.db import users_db
|
from db.db import users_db
|
||||||
from config.deletion_config import MIN_THRESHOLD_HOURS, MAX_THRESHOLD_HOURS
|
from config.deletion_config import MIN_THRESHOLD_HOURS, MAX_THRESHOLD_HOURS
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
|
from tests.conftest import TEST_SECRET_KEY
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
"""Create test client."""
|
"""Create test client."""
|
||||||
app.config['TESTING'] = True
|
app.config['TESTING'] = True
|
||||||
app.config['SECRET_KEY'] = 'supersecretkey'
|
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ def admin_user():
|
|||||||
users_db.insert(user.to_dict())
|
users_db.insert(user.to_dict())
|
||||||
|
|
||||||
# Create JWT token
|
# 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
|
return token
|
||||||
|
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ class TestGetDeletionQueue:
|
|||||||
|
|
||||||
def test_get_deletion_queue_success(self, client, admin_user, setup_deletion_queue):
|
def test_get_deletion_queue_success(self, client, admin_user, setup_deletion_queue):
|
||||||
"""Test getting deletion queue returns correct users."""
|
"""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')
|
response = client.get('/admin/deletion-queue')
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -147,7 +147,7 @@ class TestGetDeletionQueue:
|
|||||||
|
|
||||||
def test_get_deletion_queue_invalid_token(self, client, setup_deletion_queue):
|
def test_get_deletion_queue_invalid_token(self, client, setup_deletion_queue):
|
||||||
"""Test that invalid token is rejected."""
|
"""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')
|
response = client.get('/admin/deletion-queue')
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
@@ -161,11 +161,11 @@ class TestGetDeletionQueue:
|
|||||||
# Create expired token
|
# Create expired token
|
||||||
expired_token = jwt.encode(
|
expired_token = jwt.encode(
|
||||||
{'user_id': 'admin_user', 'exp': datetime.now() - timedelta(hours=1)},
|
{'user_id': 'admin_user', 'exp': datetime.now() - timedelta(hours=1)},
|
||||||
'supersecretkey',
|
TEST_SECRET_KEY,
|
||||||
algorithm='HS256'
|
algorithm='HS256'
|
||||||
)
|
)
|
||||||
|
|
||||||
client.set_cookie('token', expired_token)
|
client.set_cookie('access_token', expired_token)
|
||||||
response = client.get('/admin/deletion-queue')
|
response = client.get('/admin/deletion-queue')
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
@@ -192,7 +192,7 @@ class TestGetDeletionQueue:
|
|||||||
)
|
)
|
||||||
users_db.insert(admin.to_dict())
|
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')
|
response = client.get('/admin/deletion-queue')
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -206,7 +206,7 @@ class TestGetDeletionThreshold:
|
|||||||
|
|
||||||
def test_get_threshold_success(self, client, admin_user):
|
def test_get_threshold_success(self, client, admin_user):
|
||||||
"""Test getting current threshold configuration."""
|
"""Test getting current threshold configuration."""
|
||||||
client.set_cookie('token', admin_user)
|
client.set_cookie('access_token', admin_user)
|
||||||
response = client.get('/admin/deletion-threshold')
|
response = client.get('/admin/deletion-threshold')
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -232,7 +232,7 @@ class TestUpdateDeletionThreshold:
|
|||||||
|
|
||||||
def test_update_threshold_success(self, client, admin_user):
|
def test_update_threshold_success(self, client, admin_user):
|
||||||
"""Test updating threshold with valid value."""
|
"""Test updating threshold with valid value."""
|
||||||
client.set_cookie('token', admin_user)
|
client.set_cookie('access_token', admin_user)
|
||||||
response = client.put(
|
response = client.put(
|
||||||
'/admin/deletion-threshold',
|
'/admin/deletion-threshold',
|
||||||
json={'threshold_hours': 168}
|
json={'threshold_hours': 168}
|
||||||
@@ -245,7 +245,7 @@ class TestUpdateDeletionThreshold:
|
|||||||
|
|
||||||
def test_update_threshold_validates_minimum(self, client, admin_user):
|
def test_update_threshold_validates_minimum(self, client, admin_user):
|
||||||
"""Test that threshold below minimum is rejected."""
|
"""Test that threshold below minimum is rejected."""
|
||||||
client.set_cookie('token', admin_user)
|
client.set_cookie('access_token', admin_user)
|
||||||
response = client.put(
|
response = client.put(
|
||||||
'/admin/deletion-threshold',
|
'/admin/deletion-threshold',
|
||||||
json={'threshold_hours': 23}
|
json={'threshold_hours': 23}
|
||||||
@@ -258,7 +258,7 @@ class TestUpdateDeletionThreshold:
|
|||||||
|
|
||||||
def test_update_threshold_validates_maximum(self, client, admin_user):
|
def test_update_threshold_validates_maximum(self, client, admin_user):
|
||||||
"""Test that threshold above maximum is rejected."""
|
"""Test that threshold above maximum is rejected."""
|
||||||
client.set_cookie('token', admin_user)
|
client.set_cookie('access_token', admin_user)
|
||||||
response = client.put(
|
response = client.put(
|
||||||
'/admin/deletion-threshold',
|
'/admin/deletion-threshold',
|
||||||
json={'threshold_hours': 721}
|
json={'threshold_hours': 721}
|
||||||
@@ -271,7 +271,7 @@ class TestUpdateDeletionThreshold:
|
|||||||
|
|
||||||
def test_update_threshold_missing_value(self, client, admin_user):
|
def test_update_threshold_missing_value(self, client, admin_user):
|
||||||
"""Test that missing threshold value is rejected."""
|
"""Test that missing threshold value is rejected."""
|
||||||
client.set_cookie('token', admin_user)
|
client.set_cookie('access_token', admin_user)
|
||||||
response = client.put(
|
response = client.put(
|
||||||
'/admin/deletion-threshold',
|
'/admin/deletion-threshold',
|
||||||
json={}
|
json={}
|
||||||
@@ -284,7 +284,7 @@ class TestUpdateDeletionThreshold:
|
|||||||
|
|
||||||
def test_update_threshold_invalid_type(self, client, admin_user):
|
def test_update_threshold_invalid_type(self, client, admin_user):
|
||||||
"""Test that non-integer threshold is rejected."""
|
"""Test that non-integer threshold is rejected."""
|
||||||
client.set_cookie('token', admin_user)
|
client.set_cookie('access_token', admin_user)
|
||||||
response = client.put(
|
response = client.put(
|
||||||
'/admin/deletion-threshold',
|
'/admin/deletion-threshold',
|
||||||
json={'threshold_hours': 'invalid'}
|
json={'threshold_hours': 'invalid'}
|
||||||
@@ -310,7 +310,7 @@ class TestTriggerDeletionQueue:
|
|||||||
|
|
||||||
def test_trigger_deletion_success(self, client, admin_user, setup_deletion_queue):
|
def test_trigger_deletion_success(self, client, admin_user, setup_deletion_queue):
|
||||||
"""Test manually triggering 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')
|
response = client.post('/admin/deletion-queue/trigger')
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -348,7 +348,7 @@ class TestTriggerDeletionQueue:
|
|||||||
)
|
)
|
||||||
users_db.insert(admin.to_dict())
|
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')
|
response = client.post('/admin/deletion-queue/trigger')
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -381,9 +381,9 @@ class TestAdminRoleValidation:
|
|||||||
users_db.insert(user.to_dict())
|
users_db.insert(user.to_dict())
|
||||||
|
|
||||||
# Create token for non-admin
|
# 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')
|
response = client.get('/admin/deletion-queue')
|
||||||
|
|
||||||
# Should return 403 Forbidden
|
# Should return 403 Forbidden
|
||||||
@@ -414,9 +414,9 @@ class TestAdminRoleValidation:
|
|||||||
users_db.insert(admin.to_dict())
|
users_db.insert(admin.to_dict())
|
||||||
|
|
||||||
# Create token for admin
|
# 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')
|
response = client.get('/admin/deletion-queue')
|
||||||
|
|
||||||
# Should succeed
|
# Should succeed
|
||||||
@@ -439,9 +439,9 @@ class TestAdminRoleValidation:
|
|||||||
)
|
)
|
||||||
users_db.insert(user.to_dict())
|
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})
|
response = client.put('/admin/deletion-threshold', json={'threshold_hours': 168})
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import pytest
|
|||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from api.auth_api import auth_api
|
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 tinydb import Query
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
@@ -13,7 +14,8 @@ def client():
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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'
|
app.config['FRONTEND_URL'] = 'http://localhost:5173'
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
yield client
|
yield client
|
||||||
@@ -54,7 +56,10 @@ def test_login_with_correct_password(client):
|
|||||||
data = {'email': 'test@example.com', 'password': 'password123'}
|
data = {'email': 'test@example.com', 'password': 'password123'}
|
||||||
response = client.post('/auth/login', json=data)
|
response = client.post('/auth/login', json=data)
|
||||||
assert response.status_code == 200
|
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):
|
def test_login_with_incorrect_password(client):
|
||||||
"""Test login fails with incorrect password."""
|
"""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'})
|
login_response = client.post('/auth/login', json={'email': 'test@example.com', 'password': 'oldpassword123'})
|
||||||
assert login_response.status_code == 200
|
assert login_response.status_code == 200
|
||||||
login_cookie = login_response.headers.get('Set-Cookie', '')
|
login_cookies = login_response.headers.getlist('Set-Cookie')
|
||||||
assert 'token=' in login_cookie
|
login_cookie_str = ' '.join(login_cookies)
|
||||||
old_token = login_cookie.split('token=', 1)[1].split(';', 1)[0]
|
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
|
assert old_token
|
||||||
|
|
||||||
reset_response = client.post('/auth/reset-password', json={'token': 'validtoken2', 'password': 'newpassword123'})
|
reset_response = client.post('/auth/reset-password', json={'token': 'validtoken2', 'password': 'newpassword123'})
|
||||||
assert reset_response.status_code == 200
|
assert reset_response.status_code == 200
|
||||||
reset_cookie = reset_response.headers.get('Set-Cookie', '')
|
reset_cookies = reset_response.headers.getlist('Set-Cookie')
|
||||||
assert 'token=' in reset_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
|
# 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')
|
me_response = client.get('/auth/me')
|
||||||
assert me_response.status_code == 401
|
assert me_response.status_code == 401
|
||||||
assert me_response.json['code'] == 'INVALID_TOKEN'
|
assert me_response.json['code'] == 'INVALID_TOKEN'
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ from models.user import User
|
|||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import jwt
|
import jwt
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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:
|
with app.test_client() as client:
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
@@ -70,12 +72,13 @@ def test_me_marked_for_deletion(client):
|
|||||||
payload = {
|
payload = {
|
||||||
'email': email,
|
'email': email,
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
|
'token_version': user.token_version,
|
||||||
'exp': datetime.utcnow() + timedelta(hours=24)
|
'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
|
# Make request with token cookie
|
||||||
client.set_cookie('token', token)
|
client.set_cookie('access_token', token)
|
||||||
response = client.get('/auth/me')
|
response = client.get('/auth/me')
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Flask
|
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})
|
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# Set cookie for subsequent requests
|
# Set cookie for subsequent requests
|
||||||
token = resp.headers.get("Set-Cookie")
|
cookies = resp.headers.getlist("Set-Cookie")
|
||||||
assert token and "token=" in token
|
cookie_str = ' '.join(cookies)
|
||||||
|
assert cookie_str and "access_token=" in cookie_str
|
||||||
# Flask test client automatically handles cookies
|
# Flask test client automatically handles cookies
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -43,7 +45,8 @@ def client():
|
|||||||
app.register_blueprint(child_api)
|
app.register_blueprint(child_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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:
|
with app.test_client() as client:
|
||||||
add_test_user()
|
add_test_user()
|
||||||
login_and_set_cookie(client)
|
login_and_set_cookie(client)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for child override API endpoints and integration."""
|
"""Tests for child override API endpoints and integration."""
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
import os
|
import os
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
@@ -61,7 +62,8 @@ def client():
|
|||||||
app.register_blueprint(child_api)
|
app.register_blueprint(child_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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:
|
with app.test_client() as client:
|
||||||
add_test_user()
|
add_test_user()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
import os
|
import os
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
@@ -34,7 +35,8 @@ def client():
|
|||||||
app.register_blueprint(chore_api)
|
app.register_blueprint(chore_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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:
|
with app.test_client() as client:
|
||||||
add_test_user()
|
add_test_user()
|
||||||
login_and_set_cookie(client)
|
login_and_set_cookie(client)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
import os
|
import os
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
@@ -42,7 +43,8 @@ def client():
|
|||||||
app.register_blueprint(child_api)
|
app.register_blueprint(child_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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:
|
with app.test_client() as client:
|
||||||
add_test_user()
|
add_test_user()
|
||||||
login_and_set_cookie(client)
|
login_and_set_cookie(client)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
import os
|
import os
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
@@ -53,7 +54,8 @@ def client():
|
|||||||
app.register_blueprint(chore_schedule_api)
|
app.register_blueprint(chore_schedule_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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:
|
with app.test_client() as client:
|
||||||
add_test_user()
|
add_test_user()
|
||||||
add_test_child()
|
add_test_child()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import time
|
|||||||
from config.paths import get_user_image_dir
|
from config.paths import get_user_image_dir
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
import pytest
|
import pytest
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -38,8 +39,9 @@ def add_test_user():
|
|||||||
def login_and_set_cookie(client):
|
def login_and_set_cookie(client):
|
||||||
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
|
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
token = resp.headers.get("Set-Cookie")
|
cookies = resp.headers.getlist("Set-Cookie")
|
||||||
assert token and "token=" in token
|
cookie_str = ' '.join(cookies)
|
||||||
|
assert cookie_str and "access_token=" in cookie_str
|
||||||
|
|
||||||
def safe_remove(path):
|
def safe_remove(path):
|
||||||
try:
|
try:
|
||||||
@@ -67,7 +69,8 @@ def client():
|
|||||||
app.register_blueprint(image_api)
|
app.register_blueprint(image_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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:
|
with app.test_client() as c:
|
||||||
add_test_user()
|
add_test_user()
|
||||||
remove_test_data()
|
remove_test_data()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
import os
|
import os
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
@@ -34,7 +35,8 @@ def client():
|
|||||||
app.register_blueprint(kindness_api)
|
app.register_blueprint(kindness_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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:
|
with app.test_client() as client:
|
||||||
add_test_user()
|
add_test_user()
|
||||||
login_and_set_cookie(client)
|
login_and_set_cookie(client)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
import os
|
import os
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
@@ -34,7 +35,8 @@ def client():
|
|||||||
app.register_blueprint(penalty_api)
|
app.register_blueprint(penalty_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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:
|
with app.test_client() as client:
|
||||||
add_test_user()
|
add_test_user()
|
||||||
login_and_set_cookie(client)
|
login_and_set_cookie(client)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
import os
|
import os
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
@@ -30,8 +31,9 @@ def add_test_user():
|
|||||||
def login_and_set_cookie(client):
|
def login_and_set_cookie(client):
|
||||||
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
|
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
token = resp.headers.get("Set-Cookie")
|
cookies = resp.headers.getlist("Set-Cookie")
|
||||||
assert token and "token=" in token
|
cookie_str = ' '.join(cookies)
|
||||||
|
assert cookie_str and "access_token=" in cookie_str
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
@@ -39,7 +41,8 @@ def client():
|
|||||||
app.register_blueprint(reward_api)
|
app.register_blueprint(reward_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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:
|
with app.test_client() as client:
|
||||||
add_test_user()
|
add_test_user()
|
||||||
login_and_set_cookie(client)
|
login_and_set_cookie(client)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
import os
|
import os
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
@@ -29,8 +30,9 @@ def add_test_user():
|
|||||||
def login_and_set_cookie(client):
|
def login_and_set_cookie(client):
|
||||||
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
|
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
token = resp.headers.get("Set-Cookie")
|
cookies = resp.headers.getlist("Set-Cookie")
|
||||||
assert token and "token=" in token
|
cookie_str = ' '.join(cookies)
|
||||||
|
assert cookie_str and "access_token=" in cookie_str
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
@@ -38,7 +40,8 @@ def client():
|
|||||||
app.register_blueprint(task_api)
|
app.register_blueprint(task_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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:
|
with app.test_client() as client:
|
||||||
add_test_user()
|
add_test_user()
|
||||||
login_and_set_cookie(client)
|
login_and_set_cookie(client)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from db.db import users_db
|
|||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
import jwt
|
import jwt
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
|
||||||
# Test user credentials
|
# Test user credentials
|
||||||
TEST_EMAIL = "usertest@example.com"
|
TEST_EMAIL = "usertest@example.com"
|
||||||
@@ -50,9 +51,10 @@ def login_and_get_token(client, email, password):
|
|||||||
"""Login and extract JWT token from response."""
|
"""Login and extract JWT token from response."""
|
||||||
resp = client.post('/auth/login', json={"email": email, "password": password})
|
resp = client.post('/auth/login', json={"email": email, "password": password})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# Extract token from Set-Cookie header
|
# Verify auth cookies are set
|
||||||
set_cookie = resp.headers.get("Set-Cookie")
|
cookies = resp.headers.getlist('Set-Cookie')
|
||||||
assert set_cookie and "token=" in set_cookie
|
cookie_str = ' '.join(cookies)
|
||||||
|
assert 'access_token=' in cookie_str
|
||||||
# Flask test client automatically handles cookies
|
# Flask test client automatically handles cookies
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@@ -63,7 +65,8 @@ def client():
|
|||||||
app.register_blueprint(user_api)
|
app.register_blueprint(user_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.config['TESTING'] = True
|
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
|
app.config['FRONTEND_URL'] = 'http://localhost:5173' # Needed for email_sender
|
||||||
with app.test_client() as client:
|
with app.test_client() as client:
|
||||||
add_test_users()
|
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):
|
def test_mark_for_deletion_with_invalid_jwt(client):
|
||||||
"""Test marking for deletion with invalid JWT token."""
|
"""Test marking for deletion with invalid JWT token."""
|
||||||
# Set invalid cookie manually
|
# 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={})
|
response = client.post('/user/mark-for-deletion', json={})
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|||||||
@@ -19,15 +19,56 @@ describe('installUnauthorizedFetchInterceptor', () => {
|
|||||||
globalThis.fetch = originalFetch
|
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<typeof vi.fn>
|
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||||
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')
|
window.history.pushState({}, '', '/parent/profile')
|
||||||
const redirectSpy = vi.fn()
|
const redirectSpy = vi.fn()
|
||||||
|
|
||||||
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } =
|
const {
|
||||||
await import('../api')
|
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<typeof vi.fn>
|
||||||
|
// 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)
|
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||||
installUnauthorizedFetchInterceptor()
|
installUnauthorizedFetchInterceptor()
|
||||||
|
|
||||||
@@ -37,15 +78,46 @@ describe('installUnauthorizedFetchInterceptor', () => {
|
|||||||
expect(redirectSpy).toHaveBeenCalledTimes(1)
|
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<typeof vi.fn>
|
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||||
fetchMock.mockResolvedValue({ status: 401 } as Response)
|
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<typeof vi.fn>
|
||||||
|
// original → 401, refresh → fail
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({ status: 401 } as Response)
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 401 } as Response)
|
||||||
|
|
||||||
window.history.pushState({}, '', '/auth/login')
|
window.history.pushState({}, '', '/auth/login')
|
||||||
const redirectSpy = vi.fn()
|
const redirectSpy = vi.fn()
|
||||||
|
|
||||||
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } =
|
const {
|
||||||
await import('../api')
|
installUnauthorizedFetchInterceptor,
|
||||||
|
setUnauthorizedRedirectHandlerForTests,
|
||||||
|
resetInterceptorStateForTests,
|
||||||
|
} = await import('../api')
|
||||||
|
resetInterceptorStateForTests()
|
||||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||||
installUnauthorizedFetchInterceptor()
|
installUnauthorizedFetchInterceptor()
|
||||||
|
|
||||||
@@ -55,23 +127,37 @@ describe('installUnauthorizedFetchInterceptor', () => {
|
|||||||
expect(redirectSpy).not.toHaveBeenCalled()
|
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<typeof vi.fn>
|
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
|
||||||
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')
|
window.history.pushState({}, '', '/parent/tasks')
|
||||||
const redirectSpy = vi.fn()
|
const redirectSpy = vi.fn()
|
||||||
|
|
||||||
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } =
|
const {
|
||||||
await import('../api')
|
installUnauthorizedFetchInterceptor,
|
||||||
|
setUnauthorizedRedirectHandlerForTests,
|
||||||
|
resetInterceptorStateForTests,
|
||||||
|
} = await import('../api')
|
||||||
|
resetInterceptorStateForTests()
|
||||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||||
installUnauthorizedFetchInterceptor()
|
installUnauthorizedFetchInterceptor()
|
||||||
|
|
||||||
await fetch('/api/task/add', { method: 'PUT' })
|
const [r1, r2] = await Promise.all([
|
||||||
await fetch('/api/image/list?type=2')
|
fetch('/api/task/add', { method: 'PUT' }),
|
||||||
|
fetch('/api/image/list?type=2'),
|
||||||
|
])
|
||||||
|
|
||||||
expect(mockLogoutUser).toHaveBeenCalledTimes(1)
|
expect(mockLogoutUser).not.toHaveBeenCalled()
|
||||||
expect(redirectSpy).toHaveBeenCalledTimes(1)
|
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 () => {
|
it('does not log out for non-401 responses', async () => {
|
||||||
@@ -81,8 +167,12 @@ describe('installUnauthorizedFetchInterceptor', () => {
|
|||||||
window.history.pushState({}, '', '/parent')
|
window.history.pushState({}, '', '/parent')
|
||||||
const redirectSpy = vi.fn()
|
const redirectSpy = vi.fn()
|
||||||
|
|
||||||
const { installUnauthorizedFetchInterceptor, setUnauthorizedRedirectHandlerForTests } =
|
const {
|
||||||
await import('../api')
|
installUnauthorizedFetchInterceptor,
|
||||||
|
setUnauthorizedRedirectHandlerForTests,
|
||||||
|
resetInterceptorStateForTests,
|
||||||
|
} = await import('../api')
|
||||||
|
resetInterceptorStateForTests()
|
||||||
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
setUnauthorizedRedirectHandlerForTests(redirectSpy)
|
||||||
installUnauthorizedFetchInterceptor()
|
installUnauthorizedFetchInterceptor()
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ describe('useBackendEvents', () => {
|
|||||||
await wrapper.setProps({ userId: 'user-1' })
|
await wrapper.setProps({ userId: 'user-1' })
|
||||||
|
|
||||||
expect(MockEventSource.instances.length).toBe(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 () => {
|
it('reconnects when user id changes and closes previous connection', async () => {
|
||||||
@@ -72,7 +72,7 @@ describe('useBackendEvents', () => {
|
|||||||
|
|
||||||
expect(firstConnection?.close).toHaveBeenCalledTimes(1)
|
expect(firstConnection?.close).toHaveBeenCalledTimes(1)
|
||||||
expect(MockEventSource.instances.length).toBe(2)
|
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 () => {
|
it('emits parsed backend events on message', async () => {
|
||||||
|
|||||||
@@ -4,10 +4,19 @@ let unauthorizedInterceptorInstalled = false
|
|||||||
let unauthorizedRedirectHandler: (() => void) | null = null
|
let unauthorizedRedirectHandler: (() => void) | null = null
|
||||||
let unauthorizedHandlingInProgress = false
|
let unauthorizedHandlingInProgress = false
|
||||||
|
|
||||||
|
// Mutex for refresh token requests — prevents concurrent refresh calls
|
||||||
|
let refreshPromise: Promise<boolean> | null = null
|
||||||
|
|
||||||
export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | null): void {
|
export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | null): void {
|
||||||
unauthorizedRedirectHandler = handler
|
unauthorizedRedirectHandler = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reset interceptor state (for tests). */
|
||||||
|
export function resetInterceptorStateForTests(): void {
|
||||||
|
unauthorizedHandlingInProgress = false
|
||||||
|
refreshPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
function handleUnauthorizedResponse(): void {
|
function handleUnauthorizedResponse(): void {
|
||||||
if (unauthorizedHandlingInProgress) return
|
if (unauthorizedHandlingInProgress) return
|
||||||
unauthorizedHandlingInProgress = true
|
unauthorizedHandlingInProgress = true
|
||||||
@@ -21,6 +30,33 @@ function handleUnauthorizedResponse(): void {
|
|||||||
window.location.assign('/auth')
|
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<boolean> {
|
||||||
|
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 {
|
export function installUnauthorizedFetchInterceptor(): void {
|
||||||
if (unauthorizedInterceptorInstalled || typeof window === 'undefined') return
|
if (unauthorizedInterceptorInstalled || typeof window === 'undefined') return
|
||||||
unauthorizedInterceptorInstalled = true
|
unauthorizedInterceptorInstalled = true
|
||||||
@@ -28,9 +64,28 @@ export function installUnauthorizedFetchInterceptor(): void {
|
|||||||
const originalFetch = globalThis.fetch.bind(globalThis)
|
const originalFetch = globalThis.fetch.bind(globalThis)
|
||||||
const wrappedFetch = (async (...args: Parameters<typeof fetch>) => {
|
const wrappedFetch = (async (...args: Parameters<typeof fetch>) => {
|
||||||
const response = await originalFetch(...args)
|
const response = await originalFetch(...args)
|
||||||
|
|
||||||
if (response.status === 401) {
|
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()
|
handleUnauthorizedResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}) as typeof fetch
|
}) as typeof fetch
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export function useBackendEvents(userId: Ref<string>) {
|
|||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (eventSource) eventSource.close()
|
if (eventSource) eventSource.close()
|
||||||
if (userId.value) {
|
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) => {
|
eventSource.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
|
|||||||
Reference in New Issue
Block a user