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

- 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:
2026-03-01 19:27:25 -05:00
parent d7316bb00a
commit ebaef16daf
32 changed files with 713 additions and 201 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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():

View File

@@ -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:

View File

@@ -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
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

View File

@@ -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()

View File

@@ -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)

View 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.

View File

@@ -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'
os.environ['DB_ENV'] = 'test'
os.environ['SECRET_KEY'] = TEST_SECRET_KEY
os.environ['REFRESH_TOKEN_EXPIRY_DAYS'] = str(TEST_REFRESH_TOKEN_EXPIRY_DAYS)

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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