CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
1161 lines
44 KiB
Python
1161 lines
44 KiB
Python
import logging
|
|
import re
|
|
import time
|
|
|
|
from flask import Blueprint, request, jsonify, g
|
|
|
|
_log = logging.getLogger(__name__)
|
|
from app import db, limiter, client_ip
|
|
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,
|
|
generate_recovery_nonce,
|
|
verify_recovery_proof,
|
|
generate_backup_codes,
|
|
verify_and_consume_backup_code,
|
|
is_totp_code_used,
|
|
mark_totp_code_used,
|
|
)
|
|
|
|
auth_bp = Blueprint('auth', __name__)
|
|
|
|
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
|
|
|
|
|
# Fixed-length hex validator for the client-supplied recovery verifier.
|
|
_HEX64_RE = re.compile(r'^[0-9a-f]{64}$')
|
|
|
|
|
|
def _recovery_proof_key(user) -> bytes:
|
|
"""
|
|
Return the HMAC key the recovery challenge-response is computed over.
|
|
|
|
Preferred: user.recovery_verifier — a 256-bit value derived client-side from
|
|
the recovery code alone. It is used for nothing else, so learning it grants
|
|
no decryption ability, and knowing enc_key_salt does not yield it.
|
|
|
|
Legacy fallback: user.enc_key_salt, for recovery codes created before the
|
|
verifier existed. This is weaker — enc_key_salt is also the vault-key PBKDF2
|
|
salt and is disclosed to the client on login, so anyone holding the master
|
|
password can forge a proof and pull the vault from /recovery/items without a
|
|
second factor. Accounts on this path are flagged via /recovery/status so the
|
|
settings UI can prompt the user to regenerate.
|
|
"""
|
|
if user.recovery_verifier:
|
|
return user.recovery_verifier.encode()
|
|
return user.enc_key_salt.encode()
|
|
|
|
|
|
class IncompleteReencryption(Exception):
|
|
"""
|
|
Raised when a key-rotation payload does not cover every vault item the user
|
|
owns. Rotating enc_key_salt while some ciphertext is still under the old key
|
|
renders those items permanently undecryptable, so the whole transaction is
|
|
refused unless the caller explicitly opts into partial coverage.
|
|
"""
|
|
|
|
def __init__(self, expected: int, received: int):
|
|
self.expected = expected
|
|
self.received = received
|
|
super().__init__(f'expected {expected} item(s), received {received}')
|
|
|
|
|
|
def _apply_reencrypted_items(user_id: int, items, allow_partial: bool = False) -> tuple[int, int]:
|
|
"""
|
|
Apply client-supplied re-encrypted ciphertext to the user's vault items.
|
|
|
|
Both the password-change and account-recovery flows rotate enc_key_salt,
|
|
which invalidates every ciphertext encrypted under the previous vault key.
|
|
The client is responsible for re-encrypting each item and sending it back;
|
|
any item missing from that payload is silently orphaned by the rotation.
|
|
|
|
This helper therefore counts what it actually wrote and compares it against
|
|
the number of items the user owns. On a shortfall it raises
|
|
IncompleteReencryption so the caller can roll back rather than commit a
|
|
rotation that destroys data.
|
|
|
|
allow_partial=True skips the guard. The web client only sets it after
|
|
showing the user exactly how many items will be lost and getting explicit
|
|
confirmation — it exists so a single corrupt item cannot lock someone out of
|
|
recovery entirely.
|
|
|
|
Returns (updated_count, total_count). Does not commit.
|
|
"""
|
|
from app.models.vault_item import VaultItem
|
|
|
|
total = VaultItem.query.filter_by(user_id=user_id).count()
|
|
|
|
item_ids = [i.get('id') for i in (items or []) if i.get('id')]
|
|
existing = {
|
|
v.id: v
|
|
for v in VaultItem.query.filter(
|
|
VaultItem.user_id == user_id,
|
|
VaultItem.id.in_(item_ids),
|
|
).all()
|
|
} if item_ids else {}
|
|
|
|
updated = 0
|
|
for item_data in (items or []):
|
|
item_id = item_data.get('id')
|
|
enc_data = item_data.get('enc_data', '')
|
|
iv = item_data.get('iv', '')
|
|
if not item_id or not enc_data or not iv:
|
|
continue
|
|
vault_item = existing.get(item_id)
|
|
if not vault_item:
|
|
continue
|
|
vault_item.enc_data = enc_data
|
|
vault_item.iv = iv
|
|
# Re-encrypt the name ciphertext if the client sent updated enc_name/iv_name.
|
|
if item_data.get('enc_name'):
|
|
vault_item.enc_name = item_data['enc_name']
|
|
if item_data.get('iv_name'):
|
|
vault_item.iv_name = item_data['iv_name']
|
|
updated += 1
|
|
|
|
if updated != total and not allow_partial:
|
|
raise IncompleteReencryption(expected=total, received=updated)
|
|
|
|
return updated, total
|
|
|
|
|
|
@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 len(email) > 254:
|
|
return jsonify({'error': 'Email address is too long'}), 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():
|
|
from datetime import datetime, timezone, timedelta
|
|
from sqlalchemy.exc import OperationalError
|
|
|
|
# Number of consecutive failures before a temporary lockout is applied.
|
|
MAX_FAILED_LOGINS = 5
|
|
LOCKOUT_MINUTES = 15
|
|
|
|
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()
|
|
|
|
# Per-account lockout check.
|
|
# Guarded with try/except so that a deployment where the migration has not
|
|
# yet been run (columns missing) degrades gracefully instead of returning
|
|
# an HTML 500 page that breaks JSON parsing in the extension.
|
|
try:
|
|
if user and user.locked_until:
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
if user.locked_until > now:
|
|
remaining = int((user.locked_until - now).total_seconds() // 60) + 1
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.login_blocked',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail=f'Login blocked — account locked for {remaining} more minute(s)',
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
return jsonify({
|
|
'error': f'Account temporarily locked. Try again in {remaining} minute(s).'
|
|
}), 429
|
|
else:
|
|
# Lockout has expired — reset the counter.
|
|
user.failed_login_count = 0
|
|
user.locked_until = None
|
|
except OperationalError:
|
|
# Columns do not exist yet — migration pending. Skip lockout check.
|
|
db.session.rollback()
|
|
|
|
if not user or not verify_auth_token(auth_hash, user.master_hash, user=user):
|
|
if user:
|
|
try:
|
|
user.failed_login_count = (user.failed_login_count or 0) + 1
|
|
if user.failed_login_count >= MAX_FAILED_LOGINS:
|
|
user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=LOCKOUT_MINUTES)
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.account_locked',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail=f'Account locked for {LOCKOUT_MINUTES} minutes after {user.failed_login_count} failed attempts',
|
|
ip_address=client_ip(),
|
|
)
|
|
else:
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.login_failed',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail=f'Failed login attempt — invalid password ({user.failed_login_count}/{MAX_FAILED_LOGINS})',
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
except OperationalError:
|
|
db.session.rollback()
|
|
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
|
|
|
|
# Successful authentication — reset lockout state.
|
|
try:
|
|
user.failed_login_count = 0
|
|
user.locked_until = None
|
|
except OperationalError:
|
|
db.session.rollback()
|
|
|
|
user.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
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.
|
|
#
|
|
# enc_key_salt is deliberately NOT returned here. It is the PBKDF2 salt for
|
|
# the vault key, and releasing it to a caller that has only cleared the
|
|
# password factor is a partial authentication result. The client receives it
|
|
# from /mfa/verify once the second factor is satisfied.
|
|
if user.totp_enabled:
|
|
mfa_token = generate_mfa_token(user.id)
|
|
return jsonify({
|
|
'mfa_required': True,
|
|
'mfa_token': mfa_token,
|
|
}), 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'])
|
|
@limiter.limit('60 per minute')
|
|
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'])
|
|
@limiter.limit('10 per minute')
|
|
@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'])
|
|
@limiter.limit('10 per minute')
|
|
@require_jwt
|
|
def mfa_enable():
|
|
"""Enable MFA after verifying the first TOTP code. Returns one-time backup codes."""
|
|
import json
|
|
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
|
|
|
|
# Encrypt the secret before replay check so totp_secret_enc/totp_iv are defined.
|
|
totp_secret_enc, totp_iv = encrypt_totp_secret(secret)
|
|
|
|
# Prevent replay: reject a code that was already consumed within the valid window.
|
|
# user.id is not yet persisted (MFA not enabled), so use g.current_user_id directly.
|
|
if is_totp_code_used(g.current_user_id, totp_code):
|
|
return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400
|
|
mark_totp_code_used(g.current_user_id, totp_code)
|
|
user.totp_secret = totp_secret_enc
|
|
user.totp_iv = totp_iv
|
|
user.totp_enabled = True
|
|
|
|
# Generate one-time backup codes — plaintext shown once, only hashes stored.
|
|
plaintext_codes, hashed_codes = generate_backup_codes()
|
|
user.mfa_backup_codes = json.dumps(hashed_codes)
|
|
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.mfa_enable',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail='TOTP two-factor authentication enabled; backup codes generated',
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'MFA enabled successfully',
|
|
'backup_codes': plaintext_codes,
|
|
}), 200
|
|
|
|
|
|
@auth_bp.route('/mfa/disable', methods=['POST'])
|
|
@limiter.limit('10 per minute')
|
|
@require_jwt
|
|
def mfa_disable():
|
|
"""Disable MFA after verifying the current TOTP code or a backup code."""
|
|
import json
|
|
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()
|
|
backup_code = (data.get('backup_code') or '').strip().lower().replace('-', '').replace(' ', '')
|
|
|
|
import pyotp
|
|
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
|
|
verified = False
|
|
|
|
if totp_code:
|
|
if is_totp_code_used(user.id, totp_code):
|
|
return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400
|
|
verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1)
|
|
if verified:
|
|
mark_totp_code_used(user.id, totp_code)
|
|
elif backup_code:
|
|
stored = json.loads(user.mfa_backup_codes or '[]')
|
|
matched, remaining = verify_and_consume_backup_code(stored, backup_code)
|
|
if matched:
|
|
user.mfa_backup_codes = json.dumps(remaining)
|
|
verified = True
|
|
|
|
if not verified:
|
|
return jsonify({'error': 'Invalid verification code'}), 400
|
|
|
|
user.totp_secret = None
|
|
user.totp_iv = None
|
|
user.totp_enabled = False
|
|
user.mfa_backup_codes = None
|
|
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.mfa_disable',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail='TOTP two-factor authentication disabled; backup codes cleared',
|
|
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 (or backup code) and exchange mfa_token for real tokens."""
|
|
import json
|
|
data = request.get_json(silent=True) or {}
|
|
mfa_token = data.get('mfa_token', '')
|
|
totp_code = (data.get('totp_code') or '').strip()
|
|
backup_code = (data.get('backup_code') or '').strip().lower().replace('-', '').replace(' ', '')
|
|
|
|
if not mfa_token or (not totp_code and not backup_code):
|
|
return jsonify({'error': 'mfa_token and either totp_code or backup_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)
|
|
verified = False
|
|
|
|
if totp_code:
|
|
if is_totp_code_used(user.id, totp_code):
|
|
return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400
|
|
verified = pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1)
|
|
if verified:
|
|
mark_totp_code_used(user.id, totp_code)
|
|
|
|
if not verified and backup_code:
|
|
stored = json.loads(user.mfa_backup_codes or '[]')
|
|
matched, remaining = verify_and_consume_backup_code(stored, backup_code)
|
|
if matched:
|
|
user.mfa_backup_codes = json.dumps(remaining)
|
|
verified = True
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.mfa_backup_code_used',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail=f'MFA backup code used; {len(remaining)} code(s) remaining',
|
|
ip_address=client_ip(),
|
|
)
|
|
|
|
if not verified:
|
|
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'],
|
|
# Released here rather than at /login — both factors are now proven.
|
|
'enc_key_salt': user.enc_key_salt,
|
|
}), 200
|
|
|
|
|
|
@auth_bp.route('/mfa/status', methods=['GET'])
|
|
@limiter.limit('60 per minute')
|
|
@require_jwt
|
|
def mfa_status():
|
|
user = db.session.get(User, g.current_user_id)
|
|
import json
|
|
stored = json.loads(user.mfa_backup_codes or '[]')
|
|
return jsonify({
|
|
'totp_enabled': user.totp_enabled,
|
|
'backup_codes_remaining': len(stored),
|
|
}), 200
|
|
|
|
|
|
@auth_bp.route('/mfa/backup-codes/regenerate', methods=['POST'])
|
|
@limiter.limit('5 per minute')
|
|
@require_jwt
|
|
def mfa_backup_codes_regenerate():
|
|
"""
|
|
Regenerate MFA backup codes. Requires a valid TOTP code to authorise.
|
|
All existing backup codes are invalidated and replaced.
|
|
Returns the new plaintext codes — shown once, never stored.
|
|
"""
|
|
import json
|
|
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()
|
|
if not totp_code:
|
|
return jsonify({'error': 'totp_code is required'}), 400
|
|
|
|
import pyotp
|
|
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
|
|
if is_totp_code_used(user.id, totp_code):
|
|
return jsonify({'error': 'Verification code already used. Wait for the next code.'}), 400
|
|
if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1):
|
|
return jsonify({'error': 'Invalid verification code'}), 400
|
|
mark_totp_code_used(user.id, totp_code)
|
|
|
|
plaintext_codes, hashed_codes = generate_backup_codes()
|
|
user.mfa_backup_codes = json.dumps(hashed_codes)
|
|
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.mfa_backup_codes_regenerated',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail='MFA backup codes regenerated — previous codes invalidated',
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'message': 'Backup codes regenerated. Save these — they will not be shown again.',
|
|
'backup_codes': plaintext_codes,
|
|
}), 200
|
|
|
|
|
|
@auth_bp.route('/me', methods=['GET'])
|
|
@limiter.limit('60 per minute')
|
|
@require_jwt
|
|
def me():
|
|
"""Return basic profile info for the authenticated user."""
|
|
import json
|
|
user = db.session.get(User, g.current_user_id)
|
|
stored_codes = json.loads(user.mfa_backup_codes or '[]')
|
|
return jsonify({
|
|
'id': user.id,
|
|
'email': user.email,
|
|
'created_at': user.created_at.isoformat() if user.created_at else None,
|
|
'last_login': user.last_login.isoformat() if user.last_login else None,
|
|
'totp_enabled': user.totp_enabled,
|
|
'backup_codes_remaining': len(stored_codes),
|
|
'recovery_configured': bool(user.recovery_enc_salt),
|
|
}), 200
|
|
|
|
|
|
@auth_bp.route('/audit-log', methods=['GET'])
|
|
@require_jwt
|
|
@limiter.limit('30 per minute')
|
|
def audit_log():
|
|
"""
|
|
Return the authenticated user's recent audit log entries.
|
|
|
|
Query params:
|
|
limit — max entries to return (default 50, max 200)
|
|
offset — pagination offset (default 0)
|
|
|
|
Sensitive field values are never logged — entries contain only action
|
|
types, resource IDs, timestamps, and IP addresses.
|
|
"""
|
|
try:
|
|
limit = min(int(request.args.get('limit', 50)), 200)
|
|
offset = max(int(request.args.get('offset', 0)), 0)
|
|
except (ValueError, TypeError):
|
|
return jsonify({'error': 'limit and offset must be integers'}), 400
|
|
|
|
entries = (
|
|
AuditLog.query
|
|
.filter_by(user_id=g.current_user_id)
|
|
.order_by(AuditLog.created_at.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.all()
|
|
)
|
|
total = AuditLog.query.filter_by(user_id=g.current_user_id).count()
|
|
|
|
return jsonify({
|
|
'total': total,
|
|
'limit': limit,
|
|
'offset': offset,
|
|
'entries': [e.to_dict() for e in entries],
|
|
}), 200
|
|
|
|
|
|
# ── Account management ────────────────────────────────────────────────────────
|
|
|
|
@auth_bp.route('/change-password', methods=['POST'])
|
|
@limiter.limit('5 per minute')
|
|
@require_jwt
|
|
def change_password():
|
|
"""
|
|
Change master password — zero-knowledge atomic re-encryption.
|
|
|
|
The client must:
|
|
1. Derive current auth_hash and verify it locally against what it knows.
|
|
2. Re-encrypt every vault item with the new vault key client-side.
|
|
3. POST the new credentials + all re-encrypted item blobs in one request.
|
|
|
|
The server verifies the current password, updates master_hash + enc_key_salt,
|
|
and bulk-replaces all vault item ciphertexts atomically. If any step fails,
|
|
the entire transaction is rolled back — the vault is never left in a split state.
|
|
"""
|
|
data = request.get_json(silent=True) or {}
|
|
current_auth_hash = data.get('current_auth_hash', '')
|
|
new_auth_hash = data.get('new_auth_hash', '')
|
|
new_enc_key_salt = data.get('new_enc_key_salt', '')
|
|
items = data.get('items', []) # [{id, enc_data, iv, enc_name?, iv_name?}, ...]
|
|
sharing_private_key_enc = data.get('sharing_private_key_enc', '')
|
|
sharing_private_key_iv = data.get('sharing_private_key_iv', '')
|
|
# Explicit opt-in to rotating the key while some items go un-re-encrypted.
|
|
# The client must have confirmed the resulting data loss with the user.
|
|
allow_partial = bool(data.get('allow_partial'))
|
|
|
|
if not current_auth_hash or not new_auth_hash or not new_enc_key_salt:
|
|
return jsonify({'error': 'current_auth_hash, new_auth_hash, and new_enc_key_salt are required'}), 400
|
|
|
|
user = db.session.get(User, g.current_user_id)
|
|
|
|
if not verify_auth_token(current_auth_hash, user.master_hash, user=user):
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.change_password_failed',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail='Password change rejected — current password incorrect',
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
return jsonify({'error': 'Current password is incorrect'}), 401
|
|
|
|
try:
|
|
# Refuse the rotation outright unless every item was re-encrypted —
|
|
# see _apply_reencrypted_items. Raises IncompleteReencryption otherwise.
|
|
updated, total = _apply_reencrypted_items(
|
|
user.id, items, allow_partial=allow_partial
|
|
)
|
|
|
|
# Update credentials
|
|
user.master_hash = hash_auth_token(new_auth_hash)
|
|
user.enc_key_salt = new_enc_key_salt
|
|
# Clear recovery data — it was encrypted with the old vault key and is now invalid
|
|
user.recovery_enc_salt = None
|
|
user.recovery_iv = None
|
|
user.recovery_verifier = None
|
|
# Re-encrypt sharing private key with new vault key if the client sent it.
|
|
# Without this update, the old ciphertext would be undecryptable after key rotation.
|
|
if sharing_private_key_enc and sharing_private_key_iv:
|
|
user.sharing_private_key_enc = sharing_private_key_enc
|
|
user.sharing_private_key_iv = sharing_private_key_iv
|
|
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.change_password',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail=(
|
|
f'Master password changed; {updated}/{total} vault item(s) re-encrypted'
|
|
f'{" (PARTIAL — user confirmed data loss)" if updated != total else ""}; '
|
|
'recovery code cleared'
|
|
),
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
except IncompleteReencryption as exc:
|
|
db.session.rollback()
|
|
AuditLog.log(
|
|
user_id=g.current_user_id,
|
|
action='auth.change_password_failed',
|
|
resource_type='user',
|
|
resource_id=g.current_user_id,
|
|
detail=(
|
|
f'Password change refused — re-encryption payload covered '
|
|
f'{exc.received} of {exc.expected} vault item(s)'
|
|
),
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
return jsonify({
|
|
'error': (
|
|
f'Password not changed: the re-encryption payload covered only '
|
|
f'{exc.received} of your {exc.expected} vault item(s). Completing '
|
|
'this would permanently lock the missing items. Reload the vault '
|
|
'and try again.'
|
|
),
|
|
'code': 'incomplete_reencryption',
|
|
'expected': exc.expected,
|
|
'received': exc.received,
|
|
}), 409
|
|
except Exception:
|
|
db.session.rollback()
|
|
_log.exception('change_password failed for user %s', g.current_user_id)
|
|
return jsonify({'error': 'Password change failed. Please try again.'}), 500
|
|
|
|
return jsonify({'message': 'Password changed successfully. Please log in again.'}), 200
|
|
|
|
|
|
@auth_bp.route('/account', methods=['DELETE'])
|
|
@limiter.limit('3 per minute')
|
|
@require_jwt
|
|
def delete_account():
|
|
"""
|
|
Permanently delete the authenticated user's account and all associated data.
|
|
Requires the current auth_hash for confirmation.
|
|
Cascading deletes handle vault_items, folders, shared_items, emergency_access.
|
|
"""
|
|
data = request.get_json(silent=True) or {}
|
|
auth_hash = data.get('auth_hash', '')
|
|
|
|
if not auth_hash:
|
|
return jsonify({'error': 'auth_hash is required for account deletion'}), 400
|
|
|
|
user = db.session.get(User, g.current_user_id)
|
|
|
|
if not verify_auth_token(auth_hash, user.master_hash):
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.delete_account_failed',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail='Account deletion rejected — password incorrect',
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
return jsonify({'error': 'Incorrect password'}), 401
|
|
|
|
user_id = user.id
|
|
user_email = user.email
|
|
try:
|
|
# Log before delete (user row will be gone after commit)
|
|
AuditLog.log(
|
|
user_id=user_id,
|
|
action='auth.delete_account',
|
|
resource_type='user',
|
|
resource_id=user_id,
|
|
detail=f'Account permanently deleted: {user_email}',
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.delete(user)
|
|
db.session.commit()
|
|
except Exception:
|
|
db.session.rollback()
|
|
_log.exception('delete_account failed for user %s', user_id)
|
|
return jsonify({'error': 'Account deletion failed. Please try again.'}), 500
|
|
|
|
return jsonify({'message': 'Account deleted'}), 200
|
|
|
|
|
|
# ── Account Recovery ──────────────────────────────────────────────────────────
|
|
|
|
@auth_bp.route('/recovery/setup', methods=['POST'])
|
|
@limiter.limit('10 per minute')
|
|
@require_jwt
|
|
def recovery_setup():
|
|
"""
|
|
Store a recovery-key-encrypted copy of enc_key_salt.
|
|
|
|
The client generates a random 128-bit recovery code, derives a recovery key
|
|
from it (PBKDF2), encrypts enc_key_salt with that key (AES-256-GCM), and
|
|
sends the ciphertext + iv. The server stores these blobs — it never sees the
|
|
recovery code or enc_key_salt plaintext.
|
|
|
|
The recovery code is displayed to the user once and never stored server-side.
|
|
"""
|
|
data = request.get_json(silent=True) or {}
|
|
recovery_enc_salt = data.get('recovery_enc_salt', '').strip()
|
|
recovery_iv = data.get('recovery_iv', '').strip()
|
|
# 64 lowercase hex chars, derived client-side from the recovery code alone.
|
|
recovery_verifier = (data.get('recovery_verifier') or '').strip().lower()
|
|
|
|
if not recovery_enc_salt or not recovery_iv:
|
|
return jsonify({'error': 'recovery_enc_salt and recovery_iv are required'}), 400
|
|
if not recovery_verifier or not _HEX64_RE.match(recovery_verifier):
|
|
return jsonify({
|
|
'error': 'recovery_verifier must be 64 hexadecimal characters'
|
|
}), 400
|
|
|
|
user = db.session.get(User, g.current_user_id)
|
|
user.recovery_enc_salt = recovery_enc_salt
|
|
user.recovery_iv = recovery_iv
|
|
user.recovery_verifier = recovery_verifier
|
|
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.recovery_setup',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail='Account recovery code configured',
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
|
|
return jsonify({'message': 'Recovery code saved'}), 200
|
|
|
|
|
|
@auth_bp.route('/recovery/status', methods=['GET'])
|
|
@limiter.limit('60 per minute')
|
|
@require_jwt
|
|
def recovery_status():
|
|
"""Return whether the user has a recovery code configured."""
|
|
user = db.session.get(User, g.current_user_id)
|
|
return jsonify({
|
|
'recovery_configured': bool(user.recovery_enc_salt),
|
|
# True when the stored recovery code predates recovery_verifier and so
|
|
# still relies on the weaker enc_key_salt-keyed proof. The settings UI
|
|
# surfaces this as a prompt to regenerate.
|
|
'recovery_is_legacy': bool(user.recovery_enc_salt and not user.recovery_verifier),
|
|
}), 200
|
|
|
|
|
|
@auth_bp.route('/recover', methods=['POST'])
|
|
@limiter.limit('5 per minute')
|
|
def recover_account():
|
|
"""
|
|
Recover account access using a recovery code.
|
|
|
|
Flow:
|
|
1. Client calls /recovery/data → receives enc_key_salt, recovery blobs, nonce.
|
|
2. Client decrypts recovery_enc_salt using the recovery key → gets enc_key_salt.
|
|
3. Client computes: recovery_proof = HMAC-SHA256(enc_key_salt_bytes, nonce).
|
|
4. Client derives new credentials and re-encrypts all vault items.
|
|
5. Client POSTs everything here in one atomic payload.
|
|
|
|
The server validates recovery_proof against the value stored in the DB
|
|
during /recovery/data — neither the recovery code nor the verifier is ever
|
|
sent in plaintext.
|
|
|
|
The re-encrypted `items` array must cover every vault item the user owns;
|
|
otherwise the rotation is refused with 409. Pass allow_partial=true to
|
|
override once the user has confirmed the resulting data loss.
|
|
The challenge row is consumed (deleted) on first use to prevent replay.
|
|
Challenge state is stored in the database, not the Flask session, so the
|
|
flow works correctly across all Gunicorn workers.
|
|
"""
|
|
from app.models.recovery_challenge import RecoveryChallenge
|
|
data = request.get_json(silent=True) or {}
|
|
email = (data.get('email') or '').strip().lower()
|
|
new_auth_hash = data.get('new_auth_hash', '')
|
|
new_enc_key_salt = data.get('new_enc_key_salt', '')
|
|
client_proof = data.get('recovery_proof', '')
|
|
items = data.get('items', [])
|
|
# Explicit opt-in to recovering with some items left un-re-encrypted.
|
|
# The client sets this only after telling the user how many items it could
|
|
# not decrypt and getting confirmation — without the escape hatch a single
|
|
# corrupt row would block recovery entirely.
|
|
allow_partial = bool(data.get('allow_partial'))
|
|
|
|
if not all([email, new_auth_hash, new_enc_key_salt, client_proof]):
|
|
return jsonify({'error': 'email, new_auth_hash, new_enc_key_salt, and recovery_proof are required'}), 400
|
|
|
|
time.sleep(0.1) # timing mitigation
|
|
|
|
user = User.query.filter_by(email=email).first()
|
|
if not user or not user.recovery_enc_salt:
|
|
return jsonify({'error': 'No recovery code found for this account'}), 404
|
|
|
|
# Consume the challenge — atomic read-and-delete from the DB.
|
|
# consume() returns None if the challenge is missing or expired.
|
|
challenge = RecoveryChallenge.consume(user.id)
|
|
if not challenge:
|
|
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
|
|
|
|
if not verify_recovery_proof(challenge.expected_proof, client_proof):
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.recovery_failed',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail='Recovery attempt failed — incorrect recovery proof',
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
return jsonify({'error': 'Invalid recovery code'}), 401
|
|
|
|
|
|
try:
|
|
# Refuse to rotate the key while items remain under the old one —
|
|
# see _apply_reencrypted_items. Raises IncompleteReencryption otherwise.
|
|
updated, total = _apply_reencrypted_items(
|
|
user.id, items, allow_partial=allow_partial
|
|
)
|
|
|
|
user.master_hash = hash_auth_token(new_auth_hash)
|
|
user.enc_key_salt = new_enc_key_salt
|
|
# Recovery code is consumed — clear it so it cannot be reused.
|
|
user.recovery_enc_salt = None
|
|
user.recovery_iv = None
|
|
user.recovery_verifier = None
|
|
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.recovery_success',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail=(
|
|
f'Account recovered; {updated}/{total} vault item(s) re-encrypted'
|
|
f'{" (PARTIAL — user confirmed data loss)" if updated != total else ""}; '
|
|
'recovery code consumed'
|
|
),
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
except IncompleteReencryption as exc:
|
|
# The challenge was already consumed above, so the client must restart
|
|
# from /recovery/data. That is the correct trade-off: better to repeat
|
|
# the flow than to commit a rotation that orphans ciphertext.
|
|
db.session.rollback()
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.recovery_failed',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail=(
|
|
f'Recovery refused — re-encryption payload covered '
|
|
f'{exc.received} of {exc.expected} vault item(s)'
|
|
),
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
return jsonify({
|
|
'error': (
|
|
f'Recovery stopped: only {exc.received} of your {exc.expected} '
|
|
'vault item(s) could be re-encrypted. Continuing would permanently '
|
|
'lock the rest.'
|
|
),
|
|
'code': 'incomplete_reencryption',
|
|
'expected': exc.expected,
|
|
'received': exc.received,
|
|
}), 409
|
|
except Exception:
|
|
db.session.rollback()
|
|
_log.exception('recover_account failed for user %s', user.id)
|
|
return jsonify({'error': 'Account recovery failed. Please try again.'}), 500
|
|
|
|
tokens = generate_tokens(user.id)
|
|
return jsonify({
|
|
'message': 'Account recovered successfully',
|
|
'access_token': tokens['access_token'],
|
|
'refresh_token': tokens['refresh_token'],
|
|
'enc_key_salt': user.enc_key_salt,
|
|
}), 200
|
|
|
|
|
|
@auth_bp.route('/recovery/data', methods=['GET'])
|
|
@limiter.limit('10 per minute')
|
|
def recovery_data():
|
|
"""
|
|
Return the data the client needs to attempt recovery (unauthenticated).
|
|
Exposes: enc_key_salt, recovery_enc_salt, recovery_iv, and a one-time nonce.
|
|
|
|
The nonce is used for the HMAC-SHA256 challenge-response proof:
|
|
- Client derives the recovery verifier from the recovery code:
|
|
PBKDF2(recovery_code, "passkeeper-recovery-verifier:" + email, 200k)
|
|
- Client computes: proof = HMAC-SHA256(key=verifier, msg=nonce)
|
|
- Server stores expected proof in the DB (recovery_challenges table),
|
|
verifying it on /recover and /recovery/items.
|
|
|
|
The response's `proof_scheme` field tells the client which key to use.
|
|
Accounts whose recovery code predates recovery_verifier get 'legacy' and
|
|
key the proof on the enc_key_salt decrypted out of the recovery blob.
|
|
|
|
Returns 404 if no recovery code is configured (prevents user enumeration).
|
|
The challenge is stored in the database (not the Flask session cookie) so
|
|
it works correctly across all Gunicorn workers.
|
|
"""
|
|
import hashlib, hmac as _hmac
|
|
from app.models.recovery_challenge import RecoveryChallenge
|
|
|
|
email = (request.args.get('email') or '').strip().lower()
|
|
if not email:
|
|
return jsonify({'error': 'email is required'}), 400
|
|
|
|
user = User.query.filter_by(email=email).first()
|
|
if not user or not user.recovery_enc_salt:
|
|
return jsonify({'error': 'No recovery data found'}), 404
|
|
|
|
# Generate a fresh nonce and precompute the expected HMAC using the stored
|
|
# enc_key_salt. The client must return HMAC-SHA256(enc_key_salt, nonce).
|
|
# This proves it decrypted the recovery blob correctly without sending
|
|
# enc_key_salt in plaintext.
|
|
nonce = generate_recovery_nonce()
|
|
expected_proof = _hmac.new(
|
|
_recovery_proof_key(user),
|
|
nonce.encode(),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
|
|
# Persist challenge in the DB — safe across all Gunicorn workers.
|
|
# RecoveryChallenge.create() deletes any previous challenge for this user
|
|
# before inserting, so a re-issued challenge always starts fresh.
|
|
RecoveryChallenge.create(
|
|
user_id=user.id,
|
|
nonce=nonce,
|
|
expected_proof=expected_proof,
|
|
)
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'recovery_enc_salt': user.recovery_enc_salt,
|
|
'recovery_iv': user.recovery_iv,
|
|
'nonce': nonce,
|
|
# Tells the client which value to key the HMAC proof with:
|
|
# 'verifier' → PBKDF2(recovery_code, "passkeeper-recovery-verifier:" + email)
|
|
# 'legacy' → the enc_key_salt decrypted out of the recovery blob
|
|
'proof_scheme': 'verifier' if user.recovery_verifier else 'legacy',
|
|
}), 200
|
|
|
|
|
|
@auth_bp.route('/recovery/items', methods=['GET'])
|
|
@limiter.limit('10 per minute')
|
|
def recovery_items():
|
|
"""
|
|
Return encrypted vault items for recovery re-encryption (unauthenticated).
|
|
|
|
Requires X-Recovery-Proof header containing the HMAC-SHA256 proof:
|
|
proof = HMAC-SHA256(key=recovery_verifier, msg=nonce_from_recovery_data)
|
|
|
|
The verifier is derived client-side from the recovery code alone and is
|
|
never returned by any endpoint, so only the holder of the recovery code can
|
|
compute the proof. It is deliberately not enc_key_salt: that value is also
|
|
the vault-key PBKDF2 salt and is released to the client on login, so keying
|
|
the proof with it allowed anyone holding the master password to forge a
|
|
proof and pull the whole vault here — bypassing MFA.
|
|
|
|
Replay prevention: the challenge is consumed (deleted) on success, then
|
|
immediately re-issued with the same expected_proof but a new nonce and a
|
|
fresh TTL. This means each call to /recovery/items rotates the challenge,
|
|
so a captured X-Recovery-Proof header cannot be replayed by a third party.
|
|
POST /recover will consume the rotated challenge on final commit.
|
|
Items are returned as encrypted ciphertext blobs only.
|
|
"""
|
|
from app.models.recovery_challenge import RecoveryChallenge
|
|
|
|
email = (request.args.get('email') or '').strip().lower()
|
|
client_proof = request.headers.get('X-Recovery-Proof', '').strip()
|
|
|
|
if not email or not client_proof:
|
|
return jsonify({'error': 'email and X-Recovery-Proof header are required'}), 400
|
|
|
|
user = User.query.filter_by(email=email).first()
|
|
if not user or not user.recovery_enc_salt:
|
|
return jsonify({'error': 'No recovery data found'}), 404
|
|
|
|
# Consume the current challenge atomically.
|
|
challenge = RecoveryChallenge.consume(user.id)
|
|
if not challenge:
|
|
return jsonify({'error': 'No active recovery challenge. Call /recovery/data first.'}), 400
|
|
|
|
if not verify_recovery_proof(challenge.expected_proof, client_proof):
|
|
AuditLog.log(
|
|
user_id=user.id,
|
|
action='auth.recovery_items_denied',
|
|
resource_type='user',
|
|
resource_id=user.id,
|
|
detail='Recovery items request denied — incorrect recovery proof',
|
|
ip_address=client_ip(),
|
|
)
|
|
db.session.commit()
|
|
return jsonify({'error': 'Invalid recovery proof'}), 401
|
|
|
|
# Re-issue a fresh challenge with the same expected_proof but a new nonce
|
|
# and TTL. POST /recover will consume this rotated challenge on final commit.
|
|
# The client continues to send the same proof value — no client change needed.
|
|
new_nonce = generate_recovery_nonce()
|
|
RecoveryChallenge.create(
|
|
user_id=user.id,
|
|
nonce=new_nonce,
|
|
expected_proof=challenge.expected_proof, # same proof, new nonce
|
|
)
|
|
db.session.commit()
|
|
|
|
from app.models.vault_item import VaultItem
|
|
items = VaultItem.query.filter_by(user_id=user.id).all()
|
|
return jsonify({
|
|
'items': [
|
|
{
|
|
'id': item.id,
|
|
'enc_data': item.enc_data,
|
|
'iv': item.iv,
|
|
'enc_name': item.enc_name,
|
|
'iv_name': item.iv_name,
|
|
}
|
|
for item in items
|
|
]
|
|
}), 200 |