05/09 Update: security enhanced
This commit is contained in:
+33
-9
@@ -1,4 +1,4 @@
|
|||||||
from flask import Flask, render_template
|
from flask import Flask, render_template, request as _flask_request
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_login import LoginManager
|
from flask_login import LoginManager
|
||||||
@@ -6,6 +6,7 @@ from flask_wtf.csrf import CSRFProtect
|
|||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
|
||||||
from .config import config
|
from .config import config
|
||||||
|
|
||||||
@@ -15,6 +16,16 @@ login_manager = LoginManager()
|
|||||||
csrf = CSRFProtect()
|
csrf = CSRFProtect()
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip() -> str:
|
||||||
|
"""
|
||||||
|
Return the real client IP address, trusted only from one upstream proxy
|
||||||
|
(Nginx). ProxyFix — applied in create_app() — ensures request.remote_addr
|
||||||
|
is already set to the correct value; we do NOT parse X-Forwarded-For
|
||||||
|
manually here, which would be spoofable.
|
||||||
|
"""
|
||||||
|
return _flask_request.remote_addr or ''
|
||||||
|
|
||||||
# APScheduler is used for the background token-blacklist cleanup job.
|
# APScheduler is used for the background token-blacklist cleanup job.
|
||||||
# Imported here so it is available at module level; started inside create_app().
|
# Imported here so it is available at module level; started inside create_app().
|
||||||
try:
|
try:
|
||||||
@@ -26,6 +37,11 @@ except ImportError: # pragma: no cover — optional dependency
|
|||||||
|
|
||||||
def create_app(config_name: str = 'development') -> Flask:
|
def create_app(config_name: str = 'development') -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
# Trust exactly one upstream proxy (Nginx) for X-Forwarded-For / X-Forwarded-Proto.
|
||||||
|
# x_for=1 means only the rightmost hop in XFF is trusted, making rate-limit
|
||||||
|
# IP keys spoof-resistant — a client cannot bypass per-IP limits by injecting
|
||||||
|
# an arbitrary IP into the XFF header.
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||||
app.config.from_object(config[config_name])
|
app.config.from_object(config[config_name])
|
||||||
|
|
||||||
# Extensions
|
# Extensions
|
||||||
@@ -46,21 +62,27 @@ def create_app(config_name: str = 'development') -> Flask:
|
|||||||
# Attach security headers to every response
|
# Attach security headers to every response
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def set_security_headers(response):
|
def set_security_headers(response):
|
||||||
# Strict-Transport-Security: enforce HTTPS for 1 year, include subdomains
|
# HSTS: enforce HTTPS for 1 year across all subdomains.
|
||||||
|
# 'preload' enables browser preload-list submission so first-time
|
||||||
|
# HTTP visitors are also protected before the first redirect.
|
||||||
response.headers['Strict-Transport-Security'] = (
|
response.headers['Strict-Transport-Security'] = (
|
||||||
'max-age=31536000; includeSubDomains'
|
'max-age=31536000; includeSubDomains; preload'
|
||||||
)
|
)
|
||||||
# Prevent clickjacking
|
# Prevent clickjacking (belt-and-suspenders alongside CSP frame-ancestors)
|
||||||
response.headers['X-Frame-Options'] = 'DENY'
|
response.headers['X-Frame-Options'] = 'DENY'
|
||||||
# Prevent MIME-type sniffing
|
# Prevent MIME-type sniffing attacks
|
||||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
# Control referrer information leakage
|
# Limit referrer information sent on navigation
|
||||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||||
# Permissions policy — disable features the app does not use
|
# Permissions policy — disable browser features the app does not use
|
||||||
response.headers['Permissions-Policy'] = (
|
response.headers['Permissions-Policy'] = (
|
||||||
'geolocation=(), camera=(), microphone=()'
|
'geolocation=(), camera=(), microphone=()'
|
||||||
)
|
)
|
||||||
# CSP via HTTP header (authoritative — overrides the meta tag for all resources)
|
# Content-Security-Policy (HTTP header is authoritative — overrides meta tag).
|
||||||
|
# base-uri 'self' — blocks <base href> injection that would redirect all
|
||||||
|
# relative URLs to an attacker-controlled origin.
|
||||||
|
# upgrade-insecure-requests — instructs browsers to rewrite http:// sub-resource
|
||||||
|
# requests to https:// to avoid mixed-content warnings.
|
||||||
response.headers['Content-Security-Policy'] = (
|
response.headers['Content-Security-Policy'] = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"script-src 'self'; "
|
"script-src 'self'; "
|
||||||
@@ -68,7 +90,9 @@ def create_app(config_name: str = 'development') -> Flask:
|
|||||||
"img-src 'self' data:; "
|
"img-src 'self' data:; "
|
||||||
"font-src 'self'; "
|
"font-src 'self'; "
|
||||||
"connect-src 'self' https://api.pwnedpasswords.com; "
|
"connect-src 'self' https://api.pwnedpasswords.com; "
|
||||||
"frame-ancestors 'none';"
|
"frame-ancestors 'none'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"upgrade-insecure-requests;"
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ from dotenv import load_dotenv
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
# Shared set of known-insecure placeholder values that must never reach production.
|
||||||
|
_INSECURE_SECRET_DEFAULTS = {'dev-secret-change-me', 'jwt-secret-change-me', '', 'change-me'}
|
||||||
|
|
||||||
|
|
||||||
class BaseConfig:
|
class BaseConfig:
|
||||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-change-me')
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-change-me')
|
||||||
@@ -79,6 +82,38 @@ class ProductionConfig(BaseConfig):
|
|||||||
SESSION_COOKIE_HTTPONLY = True
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||||
|
|
||||||
|
# ── Critical security checks — fail loudly at startup, not silently at runtime ──
|
||||||
|
# These checks run at class definition time (i.e. at import / app startup).
|
||||||
|
# Any misconfiguration raises RuntimeError immediately so the process never
|
||||||
|
# serves a single request with an insecure configuration.
|
||||||
|
|
||||||
|
_secret_key = os.environ.get('SECRET_KEY', '')
|
||||||
|
if not _secret_key or _secret_key in _INSECURE_SECRET_DEFAULTS:
|
||||||
|
raise RuntimeError(
|
||||||
|
'[PassKeeper] SECRET_KEY is not set or uses an insecure placeholder. '
|
||||||
|
'Generate a strong key with: python -c "import secrets; print(secrets.token_hex(32))" '
|
||||||
|
'and add SECRET_KEY=<value> to your production .env file.'
|
||||||
|
)
|
||||||
|
SECRET_KEY = _secret_key
|
||||||
|
|
||||||
|
_jwt_secret = os.environ.get('JWT_SECRET_KEY', '')
|
||||||
|
if not _jwt_secret or _jwt_secret in _INSECURE_SECRET_DEFAULTS:
|
||||||
|
raise RuntimeError(
|
||||||
|
'[PassKeeper] JWT_SECRET_KEY is not set or uses an insecure placeholder. '
|
||||||
|
'Generate a strong key with: python -c "import secrets; print(secrets.token_hex(32))" '
|
||||||
|
'and add JWT_SECRET_KEY=<value> to your production .env file.'
|
||||||
|
)
|
||||||
|
JWT_SECRET_KEY = _jwt_secret
|
||||||
|
|
||||||
|
_cors = os.environ.get('CORS_ORIGINS', '')
|
||||||
|
if not _cors or _cors.strip() == '*':
|
||||||
|
raise RuntimeError(
|
||||||
|
'[PassKeeper] CORS_ORIGINS must be set to a specific origin in production '
|
||||||
|
'(e.g. CORS_ORIGINS=https://pwkeeper.ngodanguyen.tech). '
|
||||||
|
'A wildcard "*" is not permitted in production.'
|
||||||
|
)
|
||||||
|
CORS_ORIGINS = _cors
|
||||||
|
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
'development': DevelopmentConfig,
|
'development': DevelopmentConfig,
|
||||||
|
|||||||
+22
-25
@@ -2,7 +2,7 @@ import re
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify, g, session
|
from flask import Blueprint, request, jsonify, g, session
|
||||||
from app import db, limiter
|
from app import db, limiter, client_ip
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.services.auth_service import (
|
from app.services.auth_service import (
|
||||||
@@ -26,11 +26,6 @@ auth_bp = Blueprint('auth', __name__)
|
|||||||
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
|
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'])
|
@auth_bp.route('/register', methods=['POST'])
|
||||||
@limiter.limit('10 per minute')
|
@limiter.limit('10 per minute')
|
||||||
def register():
|
def register():
|
||||||
@@ -41,6 +36,8 @@ def register():
|
|||||||
|
|
||||||
if not email or not EMAIL_RE.match(email):
|
if not email or not EMAIL_RE.match(email):
|
||||||
return jsonify({'error': 'Invalid email address'}), 400
|
return jsonify({'error': 'Invalid email address'}), 400
|
||||||
|
if len(email) > 254:
|
||||||
|
return jsonify({'error': 'Email address is too long'}), 400
|
||||||
if not auth_hash:
|
if not auth_hash:
|
||||||
return jsonify({'error': 'auth_hash is required'}), 400
|
return jsonify({'error': 'auth_hash is required'}), 400
|
||||||
if not enc_key_salt:
|
if not enc_key_salt:
|
||||||
@@ -60,7 +57,7 @@ def register():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail=f'New account registered: {email}',
|
detail=f'New account registered: {email}',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -103,7 +100,7 @@ def login():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail=f'Login blocked — account locked for {remaining} more minute(s)',
|
detail=f'Login blocked — account locked for {remaining} more minute(s)',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -129,7 +126,7 @@ def login():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail=f'Account locked for {LOCKOUT_MINUTES} minutes after {user.failed_login_count} failed attempts',
|
detail=f'Account locked for {LOCKOUT_MINUTES} minutes after {user.failed_login_count} failed attempts',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
AuditLog.log(
|
AuditLog.log(
|
||||||
@@ -138,7 +135,7 @@ def login():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail=f'Failed login attempt — invalid password ({user.failed_login_count}/{MAX_FAILED_LOGINS})',
|
detail=f'Failed login attempt — invalid password ({user.failed_login_count}/{MAX_FAILED_LOGINS})',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
@@ -149,7 +146,7 @@ def login():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail='Failed login attempt — invalid password',
|
detail='Failed login attempt — invalid password',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'error': 'Invalid email or password'}), 401
|
return jsonify({'error': 'Invalid email or password'}), 401
|
||||||
@@ -169,7 +166,7 @@ def login():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail=f'Successful login{" (MFA pending)" if user.totp_enabled else ""}',
|
detail=f'Successful login{" (MFA pending)" if user.totp_enabled else ""}',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -298,7 +295,7 @@ def mfa_enable():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail='TOTP two-factor authentication enabled; backup codes generated',
|
detail='TOTP two-factor authentication enabled; backup codes generated',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -349,7 +346,7 @@ def mfa_disable():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail='TOTP two-factor authentication disabled; backup codes cleared',
|
detail='TOTP two-factor authentication disabled; backup codes cleared',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -397,7 +394,7 @@ def mfa_verify():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail=f'MFA backup code used; {len(remaining)} code(s) remaining',
|
detail=f'MFA backup code used; {len(remaining)} code(s) remaining',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not verified:
|
if not verified:
|
||||||
@@ -412,7 +409,7 @@ def mfa_verify():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail='MFA verification successful — session tokens issued',
|
detail='MFA verification successful — session tokens issued',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -469,7 +466,7 @@ def mfa_backup_codes_regenerate():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail='MFA backup codes regenerated — previous codes invalidated',
|
detail='MFA backup codes regenerated — previous codes invalidated',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -572,7 +569,7 @@ def change_password():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail='Password change rejected — current password incorrect',
|
detail='Password change rejected — current password incorrect',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'error': 'Current password is incorrect'}), 401
|
return jsonify({'error': 'Current password is incorrect'}), 401
|
||||||
@@ -619,7 +616,7 @@ def change_password():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail=f'Master password changed; {len(existing)} vault item(s) re-encrypted; recovery code cleared',
|
detail=f'Master password changed; {len(existing)} vault item(s) re-encrypted; recovery code cleared',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -653,7 +650,7 @@ def delete_account():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail='Account deletion rejected — password incorrect',
|
detail='Account deletion rejected — password incorrect',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'error': 'Incorrect password'}), 401
|
return jsonify({'error': 'Incorrect password'}), 401
|
||||||
@@ -668,7 +665,7 @@ def delete_account():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user_id,
|
resource_id=user_id,
|
||||||
detail=f'Account permanently deleted: {user_email}',
|
detail=f'Account permanently deleted: {user_email}',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.delete(user)
|
db.session.delete(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -712,7 +709,7 @@ def recovery_setup():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail='Account recovery code configured',
|
detail='Account recovery code configured',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -775,7 +772,7 @@ def recover_account():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail='Recovery attempt failed — incorrect recovery proof',
|
detail='Recovery attempt failed — incorrect recovery proof',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'error': 'Invalid recovery code'}), 401
|
return jsonify({'error': 'Invalid recovery code'}), 401
|
||||||
@@ -824,7 +821,7 @@ def recover_account():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail=f'Account recovered; {len(existing)} vault item(s) re-encrypted; recovery code consumed',
|
detail=f'Account recovered; {len(existing)} vault item(s) re-encrypted; recovery code consumed',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -926,7 +923,7 @@ def recovery_items():
|
|||||||
resource_type='user',
|
resource_type='user',
|
||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
detail='Recovery items request denied — incorrect recovery proof',
|
detail='Recovery items request denied — incorrect recovery proof',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'error': 'Invalid recovery proof'}), 401
|
return jsonify({'error': 'Invalid recovery proof'}), 401
|
||||||
|
|||||||
+8
-11
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from app import db, limiter
|
from app import db, limiter, client_ip
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.emergency_access import EmergencyAccess
|
from app.models.emergency_access import EmergencyAccess
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
@@ -10,9 +10,6 @@ from app.services.auth_service import require_jwt
|
|||||||
emergency_bp = Blueprint('emergency', __name__)
|
emergency_bp = Blueprint('emergency', __name__)
|
||||||
|
|
||||||
|
|
||||||
def _client_ip():
|
|
||||||
return request.headers.get('X-Forwarded-For', request.remote_addr or '').split(',')[0].strip()
|
|
||||||
|
|
||||||
|
|
||||||
@emergency_bp.route('', methods=['GET'])
|
@emergency_bp.route('', methods=['GET'])
|
||||||
@limiter.limit('30 per minute')
|
@limiter.limit('30 per minute')
|
||||||
@@ -95,7 +92,7 @@ def create_emergency():
|
|||||||
resource_type='emergency_access',
|
resource_type='emergency_access',
|
||||||
resource_id=ea.id,
|
resource_id=ea.id,
|
||||||
detail=f'Created emergency access invitation for {grantee_email} (wait: {wait_days}d)',
|
detail=f'Created emergency access invitation for {grantee_email} (wait: {wait_days}d)',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -121,7 +118,7 @@ def delete_emergency(ea_id):
|
|||||||
resource_type='emergency_access',
|
resource_type='emergency_access',
|
||||||
resource_id=ea_id,
|
resource_id=ea_id,
|
||||||
detail=f'Removed emergency access grant for {grantee_email}',
|
detail=f'Removed emergency access grant for {grantee_email}',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'message': 'Emergency access removed'}), 200
|
return jsonify({'message': 'Emergency access removed'}), 200
|
||||||
@@ -153,7 +150,7 @@ def accept_emergency(ea_id):
|
|||||||
resource_type='emergency_access',
|
resource_type='emergency_access',
|
||||||
resource_id=ea.id,
|
resource_id=ea.id,
|
||||||
detail=f'Accepted emergency access invitation from grantor_id={ea.grantor_id}',
|
detail=f'Accepted emergency access invitation from grantor_id={ea.grantor_id}',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -190,7 +187,7 @@ def provide_vault(ea_id):
|
|||||||
resource_type='emergency_access',
|
resource_type='emergency_access',
|
||||||
resource_id=ea.id,
|
resource_id=ea.id,
|
||||||
detail=f'Provided encrypted vault snapshot for grantee {ea.grantee_email}',
|
detail=f'Provided encrypted vault snapshot for grantee {ea.grantee_email}',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -224,7 +221,7 @@ def request_access(ea_id):
|
|||||||
resource_type='emergency_access',
|
resource_type='emergency_access',
|
||||||
resource_id=ea.id,
|
resource_id=ea.id,
|
||||||
detail=f'Requested emergency vault access from grantor_id={ea.grantor_id} (wait: {ea.wait_days}d)',
|
detail=f'Requested emergency vault access from grantor_id={ea.grantor_id} (wait: {ea.wait_days}d)',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -251,7 +248,7 @@ def deny_access(ea_id):
|
|||||||
resource_type='emergency_access',
|
resource_type='emergency_access',
|
||||||
resource_id=ea.id,
|
resource_id=ea.id,
|
||||||
detail=f'Denied emergency access request from grantee {ea.grantee_email}',
|
detail=f'Denied emergency access request from grantee {ea.grantee_email}',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -293,7 +290,7 @@ def get_emergency_vault(ea_id):
|
|||||||
resource_type='emergency_access',
|
resource_type='emergency_access',
|
||||||
resource_id=ea.id,
|
resource_id=ea.id,
|
||||||
detail=f'Retrieved emergency vault from grantor_id={ea.grantor_id}',
|
detail=f'Retrieved emergency vault from grantor_id={ea.grantor_id}',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from app import db, limiter
|
from app import db, limiter, client_ip
|
||||||
from app.models.folder import Folder
|
from app.models.folder import Folder
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.services.auth_service import require_jwt
|
from app.services.auth_service import require_jwt
|
||||||
@@ -7,10 +7,6 @@ from app.services.auth_service import require_jwt
|
|||||||
folders_bp = Blueprint('folders', __name__)
|
folders_bp = Blueprint('folders', __name__)
|
||||||
|
|
||||||
|
|
||||||
def _client_ip():
|
|
||||||
from flask import request as _req
|
|
||||||
return _req.headers.get('X-Forwarded-For', _req.remote_addr or '').split(',')[0].strip()
|
|
||||||
|
|
||||||
|
|
||||||
@folders_bp.route('', methods=['GET'])
|
@folders_bp.route('', methods=['GET'])
|
||||||
@limiter.limit('120 per minute')
|
@limiter.limit('120 per minute')
|
||||||
@@ -38,7 +34,7 @@ def create_folder():
|
|||||||
resource_type='folder',
|
resource_type='folder',
|
||||||
resource_id=folder.id,
|
resource_id=folder.id,
|
||||||
detail=f'Created folder: "{name}"',
|
detail=f'Created folder: "{name}"',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify(folder.to_dict()), 201
|
return jsonify(folder.to_dict()), 201
|
||||||
@@ -64,7 +60,7 @@ def update_folder(folder_id):
|
|||||||
resource_type='folder',
|
resource_type='folder',
|
||||||
resource_id=folder.id,
|
resource_id=folder.id,
|
||||||
detail=f'Renamed folder: "{old_name}" → "{name}"',
|
detail=f'Renamed folder: "{old_name}" → "{name}"',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify(folder.to_dict()), 200
|
return jsonify(folder.to_dict()), 200
|
||||||
@@ -87,7 +83,7 @@ def delete_folder(folder_id):
|
|||||||
resource_type='folder',
|
resource_type='folder',
|
||||||
resource_id=folder_id,
|
resource_id=folder_id,
|
||||||
detail=f'Deleted folder: "{folder_name}"',
|
detail=f'Deleted folder: "{folder_name}"',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'message': 'Folder deleted'}), 200
|
return jsonify({'message': 'Folder deleted'}), 200
|
||||||
+9
-12
@@ -1,5 +1,5 @@
|
|||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from app import db, limiter
|
from app import db, limiter, client_ip
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.shared_item import SharedItem
|
from app.models.shared_item import SharedItem
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
@@ -8,9 +8,6 @@ from app.services.auth_service import require_jwt
|
|||||||
sharing_bp = Blueprint('sharing', __name__)
|
sharing_bp = Blueprint('sharing', __name__)
|
||||||
|
|
||||||
|
|
||||||
def _client_ip():
|
|
||||||
return request.headers.get('X-Forwarded-For', request.remote_addr or '').split(',')[0].strip()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Sharing keypair management ────────────────────────────────────────────────
|
# ── Sharing keypair management ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -55,7 +52,7 @@ def store_my_keys():
|
|||||||
resource_type='sharing_keys',
|
resource_type='sharing_keys',
|
||||||
resource_id=g.current_user_id,
|
resource_id=g.current_user_id,
|
||||||
detail='ECDH sharing keypair stored/updated',
|
detail='ECDH sharing keypair stored/updated',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -72,10 +69,10 @@ def get_public_key():
|
|||||||
return jsonify({'error': 'email query param is required'}), 400
|
return jsonify({'error': 'email query param is required'}), 400
|
||||||
|
|
||||||
user = User.query.filter_by(email=email).first()
|
user = User.query.filter_by(email=email).first()
|
||||||
if not user:
|
# Return the same 404 regardless of whether the email is registered,
|
||||||
return jsonify({'error': 'User not found'}), 404
|
# to prevent user enumeration by authenticated clients.
|
||||||
if not user.sharing_public_key:
|
if not user or not user.sharing_public_key:
|
||||||
return jsonify({'error': 'User has not set up sharing keys yet'}), 404
|
return jsonify({'error': 'User or sharing key not found'}), 404
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
@@ -161,7 +158,7 @@ def create_share():
|
|||||||
resource_type='shared_item',
|
resource_type='shared_item',
|
||||||
resource_id=share.id,
|
resource_id=share.id,
|
||||||
detail=f'Shared item "{item_name}" ({item_type}) with {recipient_email}',
|
detail=f'Shared item "{item_name}" ({item_type}) with {recipient_email}',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -187,7 +184,7 @@ def delete_share(share_id):
|
|||||||
resource_type='shared_item',
|
resource_type='shared_item',
|
||||||
resource_id=share_id,
|
resource_id=share_id,
|
||||||
detail=f'Revoked share of "{item_name}" with {recipient_email}',
|
detail=f'Revoked share of "{item_name}" with {recipient_email}',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'message': 'Share removed'}), 200
|
return jsonify({'message': 'Share removed'}), 200
|
||||||
@@ -251,7 +248,7 @@ def accept_share(share_id):
|
|||||||
resource_type='shared_item',
|
resource_type='shared_item',
|
||||||
resource_id=share.id,
|
resource_id=share.id,
|
||||||
detail=f'Accepted shared item "{share.item_name}" from {owner_email}',
|
detail=f'Accepted shared item "{share.item_name}" from {owner_email}',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|||||||
+6
-11
@@ -1,5 +1,5 @@
|
|||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from app import db, limiter
|
from app import db, limiter, client_ip
|
||||||
from app.models.vault_item import VaultItem, ItemType
|
from app.models.vault_item import VaultItem, ItemType
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.services.auth_service import require_jwt
|
from app.services.auth_service import require_jwt
|
||||||
@@ -9,11 +9,6 @@ vault_bp = Blueprint('vault', __name__)
|
|||||||
VALID_TYPES = {t.value for t in ItemType}
|
VALID_TYPES = {t.value for t in ItemType}
|
||||||
|
|
||||||
|
|
||||||
def _client_ip():
|
|
||||||
from flask import request as _req
|
|
||||||
return _req.headers.get('X-Forwarded-For', _req.remote_addr or '').split(',')[0].strip()
|
|
||||||
|
|
||||||
|
|
||||||
@vault_bp.route('', methods=['GET'])
|
@vault_bp.route('', methods=['GET'])
|
||||||
@limiter.limit('120 per minute')
|
@limiter.limit('120 per minute')
|
||||||
@require_jwt
|
@require_jwt
|
||||||
@@ -64,7 +59,7 @@ def create_item():
|
|||||||
resource_type='vault_item',
|
resource_type='vault_item',
|
||||||
resource_id=item.id,
|
resource_id=item.id,
|
||||||
detail=f'Created {item_type} item: "{name}"',
|
detail=f'Created {item_type} item: "{name}"',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -122,7 +117,7 @@ def update_item(item_id):
|
|||||||
resource_type='vault_item',
|
resource_type='vault_item',
|
||||||
resource_id=item.id,
|
resource_id=item.id,
|
||||||
detail=f'Updated item: "{item.name}"',
|
detail=f'Updated item: "{item.name}"',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -149,7 +144,7 @@ def delete_item(item_id):
|
|||||||
resource_type='vault_item',
|
resource_type='vault_item',
|
||||||
resource_id=item_id,
|
resource_id=item_id,
|
||||||
detail=f'Deleted item: "{item_name}"',
|
detail=f'Deleted item: "{item_name}"',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'message': 'Item deleted'}), 200
|
return jsonify({'message': 'Item deleted'}), 200
|
||||||
@@ -177,7 +172,7 @@ def export_items():
|
|||||||
resource_type='vault_item',
|
resource_type='vault_item',
|
||||||
resource_id=None,
|
resource_id=None,
|
||||||
detail=f'Exported {len(items)} vault item(s)',
|
detail=f'Exported {len(items)} vault item(s)',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify([item.to_dict() for item in items]), 200
|
return jsonify([item.to_dict() for item in items]), 200
|
||||||
@@ -232,7 +227,7 @@ def import_items():
|
|||||||
resource_type='vault_item',
|
resource_type='vault_item',
|
||||||
resource_id=None,
|
resource_id=None,
|
||||||
detail=f'Imported {imported} item(s), skipped {skipped} malformed row(s)',
|
detail=f'Imported {imported} item(s), skipped {skipped} malformed row(s)',
|
||||||
ip_address=_client_ip(),
|
ip_address=client_ip(),
|
||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'imported': imported, 'skipped': skipped}), 200
|
return jsonify({'imported': imported, 'skipped': skipped}), 200
|
||||||
Reference in New Issue
Block a user