04/16 Upload codebase
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import re
|
||||
import time
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app import db, limiter
|
||||
from app.models.user import User
|
||||
from app.services.auth_service import (
|
||||
hash_auth_token,
|
||||
verify_auth_token,
|
||||
generate_tokens,
|
||||
generate_mfa_token,
|
||||
decode_token,
|
||||
blacklist_token,
|
||||
require_jwt,
|
||||
)
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
||||
|
||||
|
||||
@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.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):
|
||||
return jsonify({'error': 'Invalid email or password'}), 401
|
||||
|
||||
from datetime import datetime
|
||||
user.last_login = datetime.utcnow()
|
||||
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 = User.query.get(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 = User.query.get(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
|
||||
|
||||
user.totp_secret = secret
|
||||
user.totp_enabled = True
|
||||
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 = User.query.get(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
|
||||
if not pyotp.TOTP(user.totp_secret).verify(totp_code, valid_window=1):
|
||||
return jsonify({'error': 'Invalid verification code'}), 400
|
||||
|
||||
user.totp_secret = None
|
||||
user.totp_enabled = False
|
||||
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 = User.query.get(int(payload['sub']))
|
||||
if not user or not user.totp_enabled:
|
||||
return jsonify({'error': 'MFA not configured for this account'}), 400
|
||||
|
||||
import pyotp
|
||||
if not pyotp.TOTP(user.totp_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')
|
||||
|
||||
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 = User.query.get(g.current_user_id)
|
||||
return jsonify({'totp_enabled': user.totp_enabled}), 200
|
||||
@@ -0,0 +1,216 @@
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.emergency_access import EmergencyAccess
|
||||
from app.services.auth_service import require_jwt
|
||||
|
||||
emergency_bp = Blueprint('emergency', __name__)
|
||||
|
||||
|
||||
@emergency_bp.route('', methods=['GET'])
|
||||
@require_jwt
|
||||
def list_emergency():
|
||||
"""Return emergency access records both as grantor and as grantee."""
|
||||
user = User.query.get(g.current_user_id)
|
||||
|
||||
grants = EmergencyAccess.query.filter_by(grantor_id=user.id).order_by(
|
||||
EmergencyAccess.created_at.desc()
|
||||
).all()
|
||||
|
||||
access = EmergencyAccess.query.filter(
|
||||
db.or_(
|
||||
EmergencyAccess.grantee_email == user.email,
|
||||
EmergencyAccess.grantee_id == user.id,
|
||||
)
|
||||
).order_by(EmergencyAccess.created_at.desc()).all()
|
||||
|
||||
return jsonify({
|
||||
'grants': [ea.to_dict(grantor_email=user.email) for ea in grants],
|
||||
'access': [_ea_as_grantee(ea) for ea in access],
|
||||
}), 200
|
||||
|
||||
|
||||
def _ea_as_grantee(ea: EmergencyAccess) -> dict:
|
||||
grantor = User.query.get(ea.grantor_id)
|
||||
d = ea.to_dict(grantor_email=grantor.email if grantor else None)
|
||||
d['grantor_public_key'] = grantor.sharing_public_key if grantor else None
|
||||
return d
|
||||
|
||||
|
||||
@emergency_bp.route('', methods=['POST'])
|
||||
@require_jwt
|
||||
def create_emergency():
|
||||
"""Grantor creates an emergency access invitation for a trusted contact."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
grantee_email = (data.get('grantee_email') or '').strip().lower()
|
||||
wait_days = int(data.get('wait_days', 7))
|
||||
|
||||
if not grantee_email:
|
||||
return jsonify({'error': 'grantee_email is required'}), 400
|
||||
if not (1 <= wait_days <= 90):
|
||||
return jsonify({'error': 'wait_days must be between 1 and 90'}), 400
|
||||
|
||||
owner = User.query.get(g.current_user_id)
|
||||
if owner.email == grantee_email:
|
||||
return jsonify({'error': 'Cannot designate yourself as emergency contact'}), 400
|
||||
|
||||
# No duplicate active grants
|
||||
existing = EmergencyAccess.query.filter(
|
||||
EmergencyAccess.grantor_id == g.current_user_id,
|
||||
EmergencyAccess.grantee_email == grantee_email,
|
||||
EmergencyAccess.status != 'denied',
|
||||
).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Emergency access already set up for this contact'}), 409
|
||||
|
||||
grantee = User.query.filter_by(email=grantee_email).first()
|
||||
ea = EmergencyAccess(
|
||||
grantor_id=g.current_user_id,
|
||||
grantee_email=grantee_email,
|
||||
grantee_id=grantee.id if grantee else None,
|
||||
wait_days=wait_days,
|
||||
)
|
||||
db.session.add(ea)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(ea.to_dict(grantor_email=owner.email)), 201
|
||||
|
||||
|
||||
@emergency_bp.route('/<int:ea_id>', methods=['DELETE'])
|
||||
@require_jwt
|
||||
def delete_emergency(ea_id):
|
||||
"""Grantor removes an emergency access grant."""
|
||||
ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first()
|
||||
if not ea:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
db.session.delete(ea)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Emergency access removed'}), 200
|
||||
|
||||
|
||||
@emergency_bp.route('/<int:ea_id>/accept', methods=['POST'])
|
||||
@require_jwt
|
||||
def accept_emergency(ea_id):
|
||||
"""Grantee accepts an emergency access invitation."""
|
||||
user = User.query.get(g.current_user_id)
|
||||
ea = EmergencyAccess.query.filter(
|
||||
EmergencyAccess.id == ea_id,
|
||||
EmergencyAccess.status == 'invited',
|
||||
db.or_(
|
||||
EmergencyAccess.grantee_email == user.email,
|
||||
EmergencyAccess.grantee_id == user.id,
|
||||
),
|
||||
).first()
|
||||
if not ea:
|
||||
return jsonify({'error': 'Not found or not in invited state'}), 404
|
||||
|
||||
ea.status = 'accepted'
|
||||
ea.grantee_id = user.id
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(_ea_as_grantee(ea)), 200
|
||||
|
||||
|
||||
@emergency_bp.route('/<int:ea_id>/provide', methods=['POST'])
|
||||
@require_jwt
|
||||
def provide_vault(ea_id):
|
||||
"""
|
||||
Grantor provides the ECDH-encrypted vault snapshot for emergency recovery.
|
||||
|
||||
The client re-encrypts each vault item's plaintext with the ECDH shared secret
|
||||
(grantor private key + grantee public key) and sends the JSON array as enc_vault.
|
||||
"""
|
||||
ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first()
|
||||
if not ea:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
if ea.status not in ('accepted', 'ready'):
|
||||
return jsonify({'error': 'Emergency access must be in accepted or ready state'}), 400
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
enc_vault = data.get('enc_vault', '')
|
||||
if not enc_vault:
|
||||
return jsonify({'error': 'enc_vault (JSON array) is required'}), 400
|
||||
|
||||
ea.enc_vault = enc_vault
|
||||
ea.status = 'ready'
|
||||
db.session.commit()
|
||||
|
||||
grantor = User.query.get(g.current_user_id)
|
||||
return jsonify(ea.to_dict(grantor_email=grantor.email)), 200
|
||||
|
||||
|
||||
@emergency_bp.route('/<int:ea_id>/request', methods=['POST'])
|
||||
@require_jwt
|
||||
def request_access(ea_id):
|
||||
"""Grantee initiates an access request, starting the wait-period clock."""
|
||||
user = User.query.get(g.current_user_id)
|
||||
ea = EmergencyAccess.query.filter(
|
||||
EmergencyAccess.id == ea_id,
|
||||
EmergencyAccess.status == 'ready',
|
||||
db.or_(
|
||||
EmergencyAccess.grantee_email == user.email,
|
||||
EmergencyAccess.grantee_id == user.id,
|
||||
),
|
||||
).first()
|
||||
if not ea:
|
||||
return jsonify({'error': 'Not found or not in ready state'}), 404
|
||||
|
||||
ea.status = 'pending'
|
||||
ea.request_initiated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(_ea_as_grantee(ea)), 200
|
||||
|
||||
|
||||
@emergency_bp.route('/<int:ea_id>/deny', methods=['POST'])
|
||||
@require_jwt
|
||||
def deny_access(ea_id):
|
||||
"""Grantor denies a pending access request (resets to ready)."""
|
||||
ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first()
|
||||
if not ea:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
if ea.status != 'pending':
|
||||
return jsonify({'error': 'No pending request to deny'}), 400
|
||||
|
||||
ea.status = 'ready'
|
||||
ea.request_initiated_at = None
|
||||
db.session.commit()
|
||||
|
||||
grantor = User.query.get(g.current_user_id)
|
||||
return jsonify(ea.to_dict(grantor_email=grantor.email)), 200
|
||||
|
||||
|
||||
@emergency_bp.route('/<int:ea_id>/vault', methods=['GET'])
|
||||
@require_jwt
|
||||
def get_emergency_vault(ea_id):
|
||||
"""
|
||||
Grantee retrieves the encrypted vault snapshot after the wait period has elapsed.
|
||||
Also returns the grantor's public key so the client can derive the ECDH secret.
|
||||
"""
|
||||
user = User.query.get(g.current_user_id)
|
||||
ea = EmergencyAccess.query.filter(
|
||||
EmergencyAccess.id == ea_id,
|
||||
db.or_(
|
||||
EmergencyAccess.grantee_email == user.email,
|
||||
EmergencyAccess.grantee_id == user.id,
|
||||
),
|
||||
).first()
|
||||
if not ea:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
if not ea.wait_elapsed:
|
||||
if ea.request_initiated_at:
|
||||
elapsed_secs = (datetime.utcnow() - ea.request_initiated_at).total_seconds()
|
||||
days_left = max(0, ea.wait_days - elapsed_secs / 86400)
|
||||
else:
|
||||
days_left = ea.wait_days
|
||||
return jsonify({
|
||||
'error': f'Wait period not yet elapsed ({days_left:.1f} day(s) remaining)'
|
||||
}), 403
|
||||
|
||||
grantor = User.query.get(ea.grantor_id)
|
||||
return jsonify({
|
||||
'enc_vault': ea.enc_vault,
|
||||
'grantor_public_key': grantor.sharing_public_key if grantor else None,
|
||||
}), 200
|
||||
@@ -0,0 +1,54 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app import db
|
||||
from app.models.folder import Folder
|
||||
from app.services.auth_service import require_jwt
|
||||
|
||||
folders_bp = Blueprint('folders', __name__)
|
||||
|
||||
|
||||
@folders_bp.route('', methods=['GET'])
|
||||
@require_jwt
|
||||
def list_folders():
|
||||
folders = Folder.query.filter_by(user_id=g.current_user_id).order_by(Folder.name.asc()).all()
|
||||
return jsonify([f.to_dict() for f in folders]), 200
|
||||
|
||||
|
||||
@folders_bp.route('', methods=['POST'])
|
||||
@require_jwt
|
||||
def create_folder():
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
|
||||
folder = Folder(user_id=g.current_user_id, name=name)
|
||||
db.session.add(folder)
|
||||
db.session.commit()
|
||||
return jsonify(folder.to_dict()), 201
|
||||
|
||||
|
||||
@folders_bp.route('/<int:folder_id>', methods=['PUT'])
|
||||
@require_jwt
|
||||
def update_folder(folder_id):
|
||||
folder = Folder.query.filter_by(id=folder_id, user_id=g.current_user_id).first()
|
||||
if not folder:
|
||||
return jsonify({'error': 'Folder not found'}), 404
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
folder.name = name
|
||||
db.session.commit()
|
||||
return jsonify(folder.to_dict()), 200
|
||||
|
||||
|
||||
@folders_bp.route('/<int:folder_id>', methods=['DELETE'])
|
||||
@require_jwt
|
||||
def delete_folder(folder_id):
|
||||
folder = Folder.query.filter_by(id=folder_id, user_id=g.current_user_id).first()
|
||||
if not folder:
|
||||
return jsonify({'error': 'Folder not found'}), 404
|
||||
db.session.delete(folder)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Folder deleted'}), 200
|
||||
@@ -0,0 +1,203 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.shared_item import SharedItem
|
||||
from app.services.auth_service import require_jwt
|
||||
|
||||
sharing_bp = Blueprint('sharing', __name__)
|
||||
|
||||
|
||||
# ── Sharing keypair management ────────────────────────────────────────────────
|
||||
|
||||
@sharing_bp.route('/keys', methods=['GET'])
|
||||
@require_jwt
|
||||
def get_my_keys():
|
||||
"""Return current user's encrypted sharing private key (to decrypt client-side)."""
|
||||
user = User.query.get(g.current_user_id)
|
||||
if not user.sharing_public_key:
|
||||
return jsonify({'keys_setup': False}), 200
|
||||
return jsonify({
|
||||
'keys_setup': True,
|
||||
'public_key': user.sharing_public_key,
|
||||
'private_key_enc': user.sharing_private_key_enc,
|
||||
'private_key_iv': user.sharing_private_key_iv,
|
||||
}), 200
|
||||
|
||||
|
||||
@sharing_bp.route('/keys', methods=['POST'])
|
||||
@require_jwt
|
||||
def store_my_keys():
|
||||
"""Store ECDH keypair. Public key plaintext; private key encrypted with vault key."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
public_key = data.get('public_key', '').strip()
|
||||
private_key_enc = data.get('private_key_enc', '').strip()
|
||||
private_key_iv = data.get('private_key_iv', '').strip()
|
||||
|
||||
if not public_key or not private_key_enc or not private_key_iv:
|
||||
return jsonify({'error': 'public_key, private_key_enc, and private_key_iv are required'}), 400
|
||||
|
||||
user = User.query.get(g.current_user_id)
|
||||
user.sharing_public_key = public_key
|
||||
user.sharing_private_key_enc = private_key_enc
|
||||
user.sharing_private_key_iv = private_key_iv
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Sharing keys stored'}), 200
|
||||
|
||||
|
||||
@sharing_bp.route('/public-key', methods=['GET'])
|
||||
@require_jwt
|
||||
def get_public_key():
|
||||
"""Look up another user's ECDH public key by email (needed to create a share)."""
|
||||
email = (request.args.get('email') or '').strip().lower()
|
||||
if not email:
|
||||
return jsonify({'error': 'email query param is required'}), 400
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found'}), 404
|
||||
if not user.sharing_public_key:
|
||||
return jsonify({'error': 'User has not set up sharing keys yet'}), 404
|
||||
|
||||
return jsonify({
|
||||
'user_id': user.id,
|
||||
'email': user.email,
|
||||
'public_key': user.sharing_public_key,
|
||||
}), 200
|
||||
|
||||
|
||||
# ── Outgoing shares ───────────────────────────────────────────────────────────
|
||||
|
||||
@sharing_bp.route('', methods=['GET'])
|
||||
@require_jwt
|
||||
def list_outgoing():
|
||||
"""List all items the current user has shared with others."""
|
||||
shares = (
|
||||
SharedItem.query
|
||||
.filter_by(owner_id=g.current_user_id)
|
||||
.order_by(SharedItem.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
result = []
|
||||
for s in shares:
|
||||
d = s.to_dict()
|
||||
recipient = User.query.get(s.recipient_id) if s.recipient_id else None
|
||||
d['recipient_name'] = recipient.email if recipient else s.recipient_email
|
||||
result.append(d)
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@sharing_bp.route('', methods=['POST'])
|
||||
@require_jwt
|
||||
def create_share():
|
||||
"""
|
||||
Share a vault item with another user.
|
||||
|
||||
The caller must already have:
|
||||
1. Fetched the recipient's public key via GET /api/sharing/public-key?email=...
|
||||
2. Loaded their own ECDH private key (decrypted client-side with vault key)
|
||||
3. Derived the ECDH shared secret
|
||||
4. Re-encrypted the item's plaintext with that shared secret → enc_data, iv
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
item_id = data.get('item_id')
|
||||
recipient_email = (data.get('recipient_email') or '').strip().lower()
|
||||
enc_data = data.get('enc_data', '')
|
||||
iv = data.get('iv', '')
|
||||
item_name = (data.get('item_name') or '').strip()
|
||||
item_type = data.get('item_type', 'password')
|
||||
|
||||
if not all([item_id, recipient_email, enc_data, iv, item_name]):
|
||||
return jsonify({'error': 'item_id, recipient_email, enc_data, iv, item_name are required'}), 400
|
||||
|
||||
owner = User.query.get(g.current_user_id)
|
||||
if owner.email == recipient_email:
|
||||
return jsonify({'error': 'Cannot share an item with yourself'}), 400
|
||||
|
||||
# Verify the item belongs to the current user
|
||||
from app.models.vault_item import VaultItem
|
||||
item = VaultItem.query.filter_by(id=item_id, user_id=g.current_user_id).first()
|
||||
if not item:
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
|
||||
recipient = User.query.filter_by(email=recipient_email).first()
|
||||
|
||||
share = SharedItem(
|
||||
item_id=item_id,
|
||||
owner_id=g.current_user_id,
|
||||
recipient_email=recipient_email,
|
||||
recipient_id=recipient.id if recipient else None,
|
||||
item_name=item_name,
|
||||
item_type=item_type,
|
||||
enc_data=enc_data,
|
||||
iv=iv,
|
||||
)
|
||||
db.session.add(share)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(share.to_dict()), 201
|
||||
|
||||
|
||||
@sharing_bp.route('/<int:share_id>', methods=['DELETE'])
|
||||
@require_jwt
|
||||
def delete_share(share_id):
|
||||
share = SharedItem.query.filter_by(id=share_id, owner_id=g.current_user_id).first()
|
||||
if not share:
|
||||
return jsonify({'error': 'Share not found'}), 404
|
||||
db.session.delete(share)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Share removed'}), 200
|
||||
|
||||
|
||||
# ── Inbox (received shares) ───────────────────────────────────────────────────
|
||||
|
||||
@sharing_bp.route('/inbox', methods=['GET'])
|
||||
@require_jwt
|
||||
def inbox():
|
||||
"""List all items shared with the current user."""
|
||||
user = User.query.get(g.current_user_id)
|
||||
shares = (
|
||||
SharedItem.query
|
||||
.filter(
|
||||
db.or_(
|
||||
SharedItem.recipient_email == user.email,
|
||||
SharedItem.recipient_id == user.id,
|
||||
)
|
||||
)
|
||||
.order_by(SharedItem.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
result = []
|
||||
for s in shares:
|
||||
d = s.to_dict()
|
||||
owner = User.query.get(s.owner_id)
|
||||
d['owner_email'] = owner.email if owner else 'Unknown'
|
||||
d['owner_public_key'] = owner.sharing_public_key if owner else None
|
||||
result.append(d)
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@sharing_bp.route('/inbox/<int:share_id>/accept', methods=['POST'])
|
||||
@require_jwt
|
||||
def accept_share(share_id):
|
||||
"""Mark a received share as accepted (links recipient_id if not already set)."""
|
||||
user = User.query.get(g.current_user_id)
|
||||
share = SharedItem.query.filter(
|
||||
SharedItem.id == share_id,
|
||||
db.or_(
|
||||
SharedItem.recipient_email == user.email,
|
||||
SharedItem.recipient_id == user.id,
|
||||
),
|
||||
).first()
|
||||
if not share:
|
||||
return jsonify({'error': 'Share not found'}), 404
|
||||
|
||||
share.accepted = True
|
||||
share.recipient_id = user.id
|
||||
db.session.commit()
|
||||
|
||||
d = share.to_dict()
|
||||
owner = User.query.get(share.owner_id)
|
||||
d['owner_email'] = owner.email if owner else 'Unknown'
|
||||
d['owner_public_key'] = owner.sharing_public_key if owner else None
|
||||
return jsonify(d), 200
|
||||
@@ -0,0 +1,95 @@
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app import db
|
||||
from app.models.vault_item import VaultItem, ItemType
|
||||
from app.services.auth_service import require_jwt
|
||||
|
||||
vault_bp = Blueprint('vault', __name__)
|
||||
|
||||
VALID_TYPES = {t.value for t in ItemType}
|
||||
|
||||
|
||||
@vault_bp.route('', methods=['GET'])
|
||||
@require_jwt
|
||||
def list_items():
|
||||
items = VaultItem.query.filter_by(user_id=g.current_user_id).order_by(
|
||||
VaultItem.name.asc()
|
||||
).all()
|
||||
return jsonify([item.to_dict() for item in items]), 200
|
||||
|
||||
|
||||
@vault_bp.route('', methods=['POST'])
|
||||
@require_jwt
|
||||
def create_item():
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get('name') or '').strip()
|
||||
item_type = data.get('item_type', 'password')
|
||||
enc_data = data.get('enc_data', '')
|
||||
iv = data.get('iv', '')
|
||||
folder_id = data.get('folder_id')
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
if item_type not in VALID_TYPES:
|
||||
return jsonify({'error': f'item_type must be one of {sorted(VALID_TYPES)}'}), 400
|
||||
if not enc_data or not iv:
|
||||
return jsonify({'error': 'enc_data and iv are required'}), 400
|
||||
|
||||
item = VaultItem(
|
||||
user_id=g.current_user_id,
|
||||
folder_id=folder_id,
|
||||
item_type=item_type, # stored as plain string
|
||||
name=name,
|
||||
enc_data=enc_data,
|
||||
iv=iv,
|
||||
)
|
||||
try:
|
||||
db.session.add(item)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': f'Database error: {str(e)}'}), 500
|
||||
return jsonify(item.to_dict()), 201
|
||||
|
||||
|
||||
@vault_bp.route('/<int:item_id>', methods=['GET'])
|
||||
@require_jwt
|
||||
def get_item(item_id):
|
||||
item = VaultItem.query.filter_by(id=item_id, user_id=g.current_user_id).first()
|
||||
if not item:
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
return jsonify(item.to_dict()), 200
|
||||
|
||||
|
||||
@vault_bp.route('/<int:item_id>', methods=['PUT'])
|
||||
@require_jwt
|
||||
def update_item(item_id):
|
||||
item = VaultItem.query.filter_by(id=item_id, user_id=g.current_user_id).first()
|
||||
if not item:
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if 'name' in data:
|
||||
name = data['name'].strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'name cannot be empty'}), 400
|
||||
item.name = name
|
||||
if 'folder_id' in data:
|
||||
item.folder_id = data['folder_id']
|
||||
if 'enc_data' in data:
|
||||
item.enc_data = data['enc_data']
|
||||
if 'iv' in data:
|
||||
item.iv = data['iv']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(item.to_dict()), 200
|
||||
|
||||
|
||||
@vault_bp.route('/<int:item_id>', methods=['DELETE'])
|
||||
@require_jwt
|
||||
def delete_item(item_id):
|
||||
item = VaultItem.query.filter_by(id=item_id, user_id=g.current_user_id).first()
|
||||
if not item:
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
db.session.delete(item)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Item deleted'}), 200
|
||||
Reference in New Issue
Block a user