import re import time from flask import Blueprint, request, jsonify, g from app import db, limiter from app.models.user import User from app.models.audit_log import AuditLog from app.services.auth_service import ( hash_auth_token, verify_auth_token, generate_tokens, generate_mfa_token, decode_token, blacklist_token, require_jwt, encrypt_totp_secret, decrypt_totp_secret, ) auth_bp = Blueprint('auth', __name__) EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') def _client_ip(): """Return the best-effort client IP from the request context.""" return request.headers.get('X-Forwarded-For', request.remote_addr or '').split(',')[0].strip() @auth_bp.route('/register', methods=['POST']) @limiter.limit('10 per minute') def register(): data = request.get_json(silent=True) or {} email = (data.get('email') or '').strip().lower() auth_hash = data.get('auth_hash', '') enc_key_salt = data.get('enc_key_salt', '') if not email or not EMAIL_RE.match(email): return jsonify({'error': 'Invalid email address'}), 400 if not auth_hash: return jsonify({'error': 'auth_hash is required'}), 400 if not enc_key_salt: return jsonify({'error': 'enc_key_salt is required'}), 400 if User.query.filter_by(email=email).first(): return jsonify({'error': 'Email already registered'}), 409 master_hash = hash_auth_token(auth_hash) user = User(email=email, master_hash=master_hash, enc_key_salt=enc_key_salt) db.session.add(user) db.session.flush() # populate user.id before logging AuditLog.log( user_id=user.id, action='auth.register', resource_type='user', resource_id=user.id, detail=f'New account registered: {email}', ip_address=_client_ip(), ) db.session.commit() return jsonify({'message': 'Account created successfully'}), 201 @auth_bp.route('/login', methods=['POST']) @limiter.limit('10 per minute') def login(): data = request.get_json(silent=True) or {} email = (data.get('email') or '').strip().lower() auth_hash = data.get('auth_hash', '') time.sleep(0.1) # mitigate timing-based user enumeration if not email or not auth_hash: return jsonify({'error': 'Email and auth_hash are required'}), 400 user = User.query.filter_by(email=email).first() if not user or not verify_auth_token(auth_hash, user.master_hash): if user: AuditLog.log( user_id=user.id, action='auth.login_failed', resource_type='user', resource_id=user.id, detail='Failed login attempt — invalid password', ip_address=_client_ip(), ) db.session.commit() return jsonify({'error': 'Invalid email or password'}), 401 from datetime import datetime user.last_login = datetime.utcnow() AuditLog.log( user_id=user.id, action='auth.login', resource_type='user', resource_id=user.id, detail=f'Successful login{" (MFA pending)" if user.totp_enabled else ""}', ip_address=_client_ip(), ) db.session.commit() # MFA gate: if enabled, issue a short-lived mfa_token instead of full tokens if user.totp_enabled: mfa_token = generate_mfa_token(user.id) return jsonify({ 'mfa_required': True, 'mfa_token': mfa_token, 'enc_key_salt': user.enc_key_salt, }), 200 tokens = generate_tokens(user.id) return jsonify({ 'access_token': tokens['access_token'], 'refresh_token': tokens['refresh_token'], 'enc_key_salt': user.enc_key_salt, }), 200 @auth_bp.route('/logout', methods=['POST']) def logout(): """Blacklist both the access token (from header) and refresh token (from body).""" auth_header = request.headers.get('Authorization', '') if auth_header.startswith('Bearer '): blacklist_token(auth_header[7:], 'access') data = request.get_json(silent=True) or {} refresh_token = data.get('refresh_token', '') if refresh_token: blacklist_token(refresh_token, 'refresh') return jsonify({'message': 'Logged out'}), 200 @auth_bp.route('/refresh', methods=['POST']) @limiter.limit('30 per minute') def refresh(): data = request.get_json(silent=True) or {} refresh_token = data.get('refresh_token', '') if not refresh_token: return jsonify({'error': 'refresh_token is required'}), 400 try: payload = decode_token(refresh_token, expected_type='refresh') except Exception: return jsonify({'error': 'Invalid or expired refresh token'}), 401 # Rotate: blacklist old refresh token and issue fresh pair blacklist_token(refresh_token, 'refresh') tokens = generate_tokens(int(payload['sub'])) return jsonify({ 'access_token': tokens['access_token'], 'refresh_token': tokens['refresh_token'], }), 200 # ── MFA / TOTP endpoints ───────────────────────────────────────────────────── @auth_bp.route('/mfa/setup', methods=['GET']) @require_jwt def mfa_setup(): """Generate a new TOTP secret and return QR code (as base64 PNG data URI).""" user = db.session.get(User, g.current_user_id) if user.totp_enabled: return jsonify({'error': 'MFA is already enabled'}), 400 import pyotp import qrcode import io import base64 secret = pyotp.random_base32() uri = pyotp.TOTP(secret).provisioning_uri( name=user.email, issuer_name='PassKeeper', ) img = qrcode.make(uri) buf = io.BytesIO() img.save(buf, format='PNG') qr_b64 = base64.b64encode(buf.getvalue()).decode() return jsonify({ 'secret': secret, 'qr_code': f'data:image/png;base64,{qr_b64}', 'uri': uri, }), 200 @auth_bp.route('/mfa/enable', methods=['POST']) @require_jwt def mfa_enable(): """Enable MFA after verifying the first TOTP code.""" user = db.session.get(User, g.current_user_id) if user.totp_enabled: return jsonify({'error': 'MFA is already enabled'}), 400 data = request.get_json(silent=True) or {} secret = (data.get('secret') or '').strip() totp_code = (data.get('totp_code') or '').strip() if not secret or not totp_code: return jsonify({'error': 'secret and totp_code are required'}), 400 import pyotp if not pyotp.TOTP(secret).verify(totp_code, valid_window=1): return jsonify({'error': 'Invalid verification code'}), 400 totp_secret_enc, totp_iv = encrypt_totp_secret(secret) user.totp_secret = totp_secret_enc user.totp_iv = totp_iv user.totp_enabled = True AuditLog.log( user_id=user.id, action='auth.mfa_enable', resource_type='user', resource_id=user.id, detail='TOTP two-factor authentication enabled', ip_address=_client_ip(), ) db.session.commit() return jsonify({'message': 'MFA enabled successfully'}), 200 @auth_bp.route('/mfa/disable', methods=['POST']) @require_jwt def mfa_disable(): """Disable MFA after verifying the current TOTP code.""" user = db.session.get(User, g.current_user_id) if not user.totp_enabled: return jsonify({'error': 'MFA is not enabled'}), 400 data = request.get_json(silent=True) or {} totp_code = (data.get('totp_code') or '').strip() import pyotp plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv) if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1): return jsonify({'error': 'Invalid verification code'}), 400 user.totp_secret = None user.totp_iv = None user.totp_enabled = False AuditLog.log( user_id=user.id, action='auth.mfa_disable', resource_type='user', resource_id=user.id, detail='TOTP two-factor authentication disabled', ip_address=_client_ip(), ) db.session.commit() return jsonify({'message': 'MFA disabled'}), 200 @auth_bp.route('/mfa/verify', methods=['POST']) @limiter.limit('10 per minute') def mfa_verify(): """Complete MFA login: verify TOTP code and exchange mfa_token for real tokens.""" data = request.get_json(silent=True) or {} mfa_token = data.get('mfa_token', '') totp_code = (data.get('totp_code') or '').strip() if not mfa_token or not totp_code: return jsonify({'error': 'mfa_token and totp_code are required'}), 400 try: payload = decode_token(mfa_token, expected_type='mfa', check_blacklist=True) except Exception: return jsonify({'error': 'Invalid or expired MFA token'}), 401 user = db.session.get(User, int(payload['sub'])) if not user or not user.totp_enabled: return jsonify({'error': 'MFA not configured for this account'}), 400 import pyotp plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv) if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1): return jsonify({'error': 'Invalid verification code'}), 400 # One-time use: blacklist the mfa_token blacklist_token(mfa_token, 'mfa') AuditLog.log( user_id=user.id, action='auth.mfa_verify', resource_type='user', resource_id=user.id, detail='MFA verification successful — session tokens issued', ip_address=_client_ip(), ) db.session.commit() tokens = generate_tokens(user.id) return jsonify({ 'access_token': tokens['access_token'], 'refresh_token': tokens['refresh_token'], }), 200 @auth_bp.route('/mfa/status', methods=['GET']) @require_jwt def mfa_status(): user = db.session.get(User, g.current_user_id) return jsonify({'totp_enabled': user.totp_enabled}), 200