mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-07 20:43:00 +09:00
Add an in-memory (single-worker) per-IP throttle that complements the per-user account lockout: it stops password-spraying across many accounts from one IP and prevents a single attacker draining the shared login rate-limit bucket. - new app/services/auth_throttle_service.py — lock-guarded TTL map with is_blocked / register_failure / reset (+ opportunistic memory sweep) - config: AUTH_IP_MAX_ATTEMPTS (10) / AUTH_IP_WINDOW_MINUTES (15) / AUTH_IP_BLOCK_MINUTES (15) - wire into /login (block up front -> 429 + Retry-After BEFORE the password check; register on bad user/password; reset on correct password), /login-links/redeem, and the 2FA /verify endpoint - per-user lockout and the flask-limiter 5/min decorator kept (defense in depth) - tests: test_login_bruteforce.py (6) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
263 lines
8.1 KiB
Python
263 lines
8.1 KiB
Python
"""
|
|
Two-Factor Authentication API
|
|
|
|
Provides endpoints for enabling, disabling, and managing 2FA.
|
|
"""
|
|
|
|
import logging
|
|
from flask import Blueprint, request, jsonify
|
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
|
from app import limiter
|
|
from app.models import User
|
|
from app.services.totp_service import TOTPService, TwoFactorSetup
|
|
from app.services import auth_throttle_service
|
|
from app.utils.client_ip import get_client_ip
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
two_factor_bp = Blueprint('two_factor', __name__)
|
|
|
|
|
|
@two_factor_bp.route('/status', methods=['GET'])
|
|
@jwt_required()
|
|
def get_2fa_status():
|
|
"""Get the current 2FA status for the user."""
|
|
current_user_id = get_jwt_identity()
|
|
user = User.query.get(current_user_id)
|
|
|
|
if not user:
|
|
return jsonify({'error': 'User not found'}), 404
|
|
|
|
setup = TwoFactorSetup(user)
|
|
|
|
return jsonify({
|
|
'enabled': user.totp_enabled,
|
|
'confirmed_at': user.totp_confirmed_at.isoformat() if user.totp_confirmed_at else None,
|
|
'backup_codes_remaining': setup.get_remaining_backup_codes_count() if user.totp_enabled else 0
|
|
}), 200
|
|
|
|
|
|
@two_factor_bp.route('/setup', methods=['POST'])
|
|
@jwt_required()
|
|
def initiate_2fa_setup():
|
|
"""
|
|
Initiate 2FA setup. Generates a new secret and returns QR code.
|
|
User must verify with a code to complete setup.
|
|
"""
|
|
current_user_id = get_jwt_identity()
|
|
user = User.query.get(current_user_id)
|
|
|
|
if not user:
|
|
return jsonify({'error': 'User not found'}), 404
|
|
|
|
if user.totp_enabled:
|
|
return jsonify({'error': '2FA is already enabled'}), 400
|
|
|
|
setup = TwoFactorSetup(user)
|
|
setup_data = setup.initiate_setup()
|
|
|
|
return jsonify({
|
|
'message': '2FA setup initiated. Scan the QR code and enter a verification code.',
|
|
'secret': setup_data['secret'],
|
|
'qr_code': setup_data['qr_code'],
|
|
'uri': setup_data['uri'],
|
|
'issuer': setup_data['issuer']
|
|
}), 200
|
|
|
|
|
|
@two_factor_bp.route('/setup/confirm', methods=['POST'])
|
|
@jwt_required()
|
|
def confirm_2fa_setup():
|
|
"""
|
|
Confirm 2FA setup by verifying a code.
|
|
Returns backup codes on success (one-time display).
|
|
"""
|
|
current_user_id = get_jwt_identity()
|
|
user = User.query.get(current_user_id)
|
|
|
|
if not user:
|
|
return jsonify({'error': 'User not found'}), 404
|
|
|
|
if user.totp_enabled:
|
|
return jsonify({'error': '2FA is already enabled'}), 400
|
|
|
|
data = request.get_json()
|
|
if not data or 'code' not in data:
|
|
return jsonify({'error': 'Verification code is required'}), 400
|
|
|
|
setup = TwoFactorSetup(user)
|
|
success, result = setup.confirm_setup(data['code'])
|
|
|
|
if not success:
|
|
return jsonify({'error': result}), 400
|
|
|
|
return jsonify({
|
|
'message': '2FA has been enabled successfully',
|
|
'backup_codes': result, # One-time display
|
|
'backup_codes_warning': 'Save these backup codes in a secure location. They will not be shown again.'
|
|
}), 200
|
|
|
|
|
|
@two_factor_bp.route('/disable', methods=['POST'])
|
|
@jwt_required()
|
|
def disable_2fa():
|
|
"""
|
|
Disable 2FA. Requires a valid TOTP code or backup code.
|
|
"""
|
|
current_user_id = get_jwt_identity()
|
|
user = User.query.get(current_user_id)
|
|
|
|
if not user:
|
|
return jsonify({'error': 'User not found'}), 404
|
|
|
|
if not user.totp_enabled:
|
|
return jsonify({'error': '2FA is not enabled'}), 400
|
|
|
|
data = request.get_json()
|
|
if not data or 'code' not in data:
|
|
return jsonify({'error': 'Verification code is required'}), 400
|
|
|
|
setup = TwoFactorSetup(user)
|
|
success, error = setup.disable(data['code'])
|
|
|
|
if not success:
|
|
return jsonify({'error': error}), 400
|
|
|
|
logger.info(f"2FA disabled for user {user.id} - existing sessions remain valid (TODO: implement session invalidation)")
|
|
|
|
return jsonify({
|
|
'message': '2FA has been disabled successfully'
|
|
}), 200
|
|
|
|
|
|
@two_factor_bp.route('/backup-codes/regenerate', methods=['POST'])
|
|
@jwt_required()
|
|
def regenerate_backup_codes():
|
|
"""
|
|
Regenerate backup codes. Requires a valid TOTP code.
|
|
Old backup codes become invalid.
|
|
"""
|
|
current_user_id = get_jwt_identity()
|
|
user = User.query.get(current_user_id)
|
|
|
|
if not user:
|
|
return jsonify({'error': 'User not found'}), 404
|
|
|
|
if not user.totp_enabled:
|
|
return jsonify({'error': '2FA is not enabled'}), 400
|
|
|
|
data = request.get_json()
|
|
if not data or 'code' not in data:
|
|
return jsonify({'error': 'TOTP code is required'}), 400
|
|
|
|
setup = TwoFactorSetup(user)
|
|
success, result = setup.regenerate_backup_codes(data['code'])
|
|
|
|
if not success:
|
|
return jsonify({'error': result}), 400
|
|
|
|
return jsonify({
|
|
'message': 'Backup codes have been regenerated',
|
|
'backup_codes': result,
|
|
'backup_codes_warning': 'Save these backup codes in a secure location. They will not be shown again.'
|
|
}), 200
|
|
|
|
|
|
@two_factor_bp.route('/verify', methods=['POST'])
|
|
@limiter.limit("5 per minute")
|
|
def verify_2fa_code():
|
|
"""
|
|
Verify a 2FA code during login.
|
|
This endpoint is used when login returns requires_2fa=true.
|
|
Expects a temporary token from the login response.
|
|
"""
|
|
from flask_jwt_extended import create_access_token, create_refresh_token, decode_token
|
|
from app import db
|
|
|
|
# Per-IP brute-force throttle — 2FA codes are a small keyspace, so guessing
|
|
# from one IP is throttled up front, before any code check.
|
|
client_ip = get_client_ip()
|
|
blocked, retry_after = auth_throttle_service.is_blocked(client_ip)
|
|
if blocked:
|
|
return jsonify({
|
|
'error': 'Too many failed attempts. Try again later.'
|
|
}), 429, {'Retry-After': str(retry_after)}
|
|
|
|
data = request.get_json()
|
|
if not data:
|
|
return jsonify({'error': 'No data provided'}), 400
|
|
|
|
temp_token = data.get('temp_token')
|
|
code = data.get('code')
|
|
|
|
if not temp_token or not code:
|
|
return jsonify({'error': 'Temporary token and code are required'}), 400
|
|
|
|
# Decode the temporary token to get user ID
|
|
try:
|
|
token_data = decode_token(temp_token)
|
|
user_id = token_data['sub']
|
|
|
|
# Check if this is a 2FA pending token
|
|
if not token_data.get('2fa_pending'):
|
|
return jsonify({'error': 'Invalid token type'}), 400
|
|
|
|
except Exception as e:
|
|
return jsonify({'error': 'Invalid or expired token'}), 401
|
|
|
|
user = User.query.get(user_id)
|
|
if not user:
|
|
return jsonify({'error': 'User not found'}), 404
|
|
|
|
if not user.totp_enabled:
|
|
return jsonify({'error': '2FA is not enabled for this account'}), 400
|
|
|
|
# Try TOTP code first
|
|
if TOTPService.verify_code(user.totp_secret, code):
|
|
# Success - issue real tokens
|
|
auth_throttle_service.reset(client_ip)
|
|
user.reset_failed_login()
|
|
db.session.commit()
|
|
|
|
access_token = create_access_token(identity=user.id)
|
|
refresh_token = create_refresh_token(identity=user.id)
|
|
|
|
return jsonify({
|
|
'user': user.to_dict(),
|
|
'access_token': access_token,
|
|
'refresh_token': refresh_token
|
|
}), 200
|
|
|
|
# Try backup code
|
|
backup_codes = user.get_backup_codes()
|
|
matched_hash = TOTPService.verify_backup_code(code, backup_codes)
|
|
|
|
if matched_hash:
|
|
# Valid backup code - remove it (one-time use)
|
|
auth_throttle_service.reset(client_ip)
|
|
user.use_backup_code(matched_hash)
|
|
user.reset_failed_login()
|
|
db.session.commit()
|
|
|
|
access_token = create_access_token(identity=user.id)
|
|
refresh_token = create_refresh_token(identity=user.id)
|
|
|
|
# Warn about remaining backup codes
|
|
remaining = len(user.get_backup_codes())
|
|
|
|
return jsonify({
|
|
'user': user.to_dict(),
|
|
'access_token': access_token,
|
|
'refresh_token': refresh_token,
|
|
'backup_code_used': True,
|
|
'backup_codes_remaining': remaining,
|
|
'warning': f'Backup code used. {remaining} codes remaining.' if remaining <= 3 else None
|
|
}), 200
|
|
|
|
# Invalid code
|
|
auth_throttle_service.register_failure(client_ip)
|
|
user.record_failed_login()
|
|
db.session.commit()
|
|
|
|
return jsonify({'error': 'Invalid verification code'}), 401
|