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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user