04/18 Enhance app (security, performance)

This commit is contained in:
2026-04-18 13:20:04 -04:00
parent b51468661e
commit 4d3f9844f0
14 changed files with 529 additions and 7 deletions
+33 -1
View File
@@ -26,7 +26,39 @@ def create_app(config_name: str = 'development') -> Flask:
login_manager.init_app(app) login_manager.init_app(app)
csrf.init_app(app) csrf.init_app(app)
limiter.init_app(app) limiter.init_app(app)
CORS(app, resources={r'/api/*': {'origins': '*'}})
# Restrict CORS to the configured origin (locked to production domain in prod)
cors_origins = app.config.get('CORS_ORIGINS', '*')
CORS(app, resources={r'/api/*': {'origins': cors_origins}})
# Attach security headers to every response
@app.after_request
def set_security_headers(response):
# Strict-Transport-Security: enforce HTTPS for 1 year, include subdomains
response.headers['Strict-Transport-Security'] = (
'max-age=31536000; includeSubDomains'
)
# Prevent clickjacking
response.headers['X-Frame-Options'] = 'DENY'
# Prevent MIME-type sniffing
response.headers['X-Content-Type-Options'] = 'nosniff'
# Control referrer information leakage
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Permissions policy — disable features the app does not use
response.headers['Permissions-Policy'] = (
'geolocation=(), camera=(), microphone=()'
)
# CSP via HTTP header (authoritative — overrides the meta tag for all resources)
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"img-src 'self' data:; "
"font-src 'self'; "
"connect-src 'self'; "
"frame-ancestors 'none';"
)
return response
# Ensure all models are imported so SQLAlchemy knows about them # Ensure all models are imported so SQLAlchemy knows about them
from .models.user import User from .models.user import User
+11 -1
View File
@@ -29,7 +29,17 @@ class BaseConfig:
ARGON2_MEMORY_COST = int(os.environ.get('ARGON2_MEMORY_COST', 65536)) ARGON2_MEMORY_COST = int(os.environ.get('ARGON2_MEMORY_COST', 65536))
ARGON2_PARALLELISM = int(os.environ.get('ARGON2_PARALLELISM', 4)) ARGON2_PARALLELISM = int(os.environ.get('ARGON2_PARALLELISM', 4))
RATELIMIT_STORAGE_URI = 'memory://' # Server-side AES-256-GCM key for encrypting TOTP secrets at rest.
# Must be a 64-character hex string (32 bytes).
# Generate: python -c "import secrets; print(secrets.token_hex(32))"
TOTP_ENCRYPTION_KEY = os.environ.get('TOTP_ENCRYPTION_KEY', '')
# Rate limiting — use Redis in production so all Gunicorn workers share one counter.
# Falls back to memory (per-worker, dev only).
RATELIMIT_STORAGE_URI = os.environ.get('RATELIMIT_STORAGE_URI', 'memory://')
# CORS — restrict to the production origin in production config.
CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*')
class DevelopmentConfig(BaseConfig): class DevelopmentConfig(BaseConfig):
+5 -1
View File
@@ -22,7 +22,11 @@ class User(db.Model, UserMixin):
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
last_login = db.Column(db.DateTime, nullable=True) last_login = db.Column(db.DateTime, nullable=True)
# TOTP / MFA # TOTP / MFA
totp_secret = db.Column(db.String(64), nullable=True) # totp_secret: AES-256-GCM ciphertext of the base32 TOTP secret, base64-encoded.
# Encrypted server-side with the TOTP_ENCRYPTION_KEY from config.
# totp_iv: base64-encoded 12-byte GCM nonce for the above.
totp_secret = db.Column(db.String(255), nullable=True)
totp_iv = db.Column(db.String(64), nullable=True)
totp_enabled = db.Column(db.Boolean, default=False, nullable=False) totp_enabled = db.Column(db.Boolean, default=False, nullable=False)
# ECDH P-256 sharing keypair # ECDH P-256 sharing keypair
# Public key: raw uncompressed point (65 bytes), base64-encoded (~88 chars), stored plaintext # Public key: raw uncompressed point (65 bytes), base64-encoded (~88 chars), stored plaintext
+20 -3
View File
@@ -13,6 +13,8 @@ from app.services.auth_service import (
decode_token, decode_token,
blacklist_token, blacklist_token,
require_jwt, require_jwt,
encrypt_totp_secret,
decrypt_totp_secret,
) )
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
@@ -75,6 +77,16 @@ def login():
user = User.query.filter_by(email=email).first() user = User.query.filter_by(email=email).first()
if not user or not verify_auth_token(auth_hash, user.master_hash): if not user or not verify_auth_token(auth_hash, user.master_hash):
if user:
AuditLog.log(
user_id=user.id,
action='auth.login_failed',
resource_type='user',
resource_id=user.id,
detail='Failed login attempt — invalid password',
ip_address=_client_ip(),
)
db.session.commit()
return jsonify({'error': 'Invalid email or password'}), 401 return jsonify({'error': 'Invalid email or password'}), 401
from datetime import datetime from datetime import datetime
@@ -196,7 +208,9 @@ def mfa_enable():
if not pyotp.TOTP(secret).verify(totp_code, valid_window=1): if not pyotp.TOTP(secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400 return jsonify({'error': 'Invalid verification code'}), 400
user.totp_secret = secret totp_secret_enc, totp_iv = encrypt_totp_secret(secret)
user.totp_secret = totp_secret_enc
user.totp_iv = totp_iv
user.totp_enabled = True user.totp_enabled = True
AuditLog.log( AuditLog.log(
@@ -224,10 +238,12 @@ def mfa_disable():
totp_code = (data.get('totp_code') or '').strip() totp_code = (data.get('totp_code') or '').strip()
import pyotp import pyotp
if not pyotp.TOTP(user.totp_secret).verify(totp_code, valid_window=1): plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400 return jsonify({'error': 'Invalid verification code'}), 400
user.totp_secret = None user.totp_secret = None
user.totp_iv = None
user.totp_enabled = False user.totp_enabled = False
AuditLog.log( AuditLog.log(
@@ -264,7 +280,8 @@ def mfa_verify():
return jsonify({'error': 'MFA not configured for this account'}), 400 return jsonify({'error': 'MFA not configured for this account'}), 400
import pyotp import pyotp
if not pyotp.TOTP(user.totp_secret).verify(totp_code, valid_window=1): plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400 return jsonify({'error': 'Invalid verification code'}), 400
# One-time use: blacklist the mfa_token # One-time use: blacklist the mfa_token
+4 -1
View File
@@ -50,7 +50,10 @@ def create_emergency():
"""Grantor creates an emergency access invitation for a trusted contact.""" """Grantor creates an emergency access invitation for a trusted contact."""
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
grantee_email = (data.get('grantee_email') or '').strip().lower() grantee_email = (data.get('grantee_email') or '').strip().lower()
wait_days = int(data.get('wait_days', 7)) try:
wait_days = int(data.get('wait_days', 7))
except (TypeError, ValueError):
return jsonify({'error': 'wait_days must be an integer'}), 400
if not grantee_email: if not grantee_email:
return jsonify({'error': 'grantee_email is required'}), 400 return jsonify({'error': 'grantee_email is required'}), 400
+41
View File
@@ -1,9 +1,12 @@
import base64
import os
import uuid import uuid
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import wraps from functools import wraps
import jwt import jwt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from flask import current_app, request, g, jsonify from flask import current_app, request, g, jsonify
from argon2 import PasswordHasher from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
@@ -27,6 +30,44 @@ def verify_auth_token(auth_hash: str, stored_hash: str) -> bool:
return False return False
def _get_totp_key() -> bytes:
"""
Return the 32-byte AES key used for server-side TOTP secret encryption.
The key is stored as a 64-char hex string in TOTP_ENCRYPTION_KEY config.
"""
hex_key = current_app.config.get('TOTP_ENCRYPTION_KEY', '')
if not hex_key or len(hex_key) != 64:
raise RuntimeError(
'TOTP_ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes). '
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
)
return bytes.fromhex(hex_key)
def encrypt_totp_secret(plaintext_secret: str) -> tuple[str, str]:
"""
Encrypt a plaintext TOTP base32 secret with AES-256-GCM.
Returns (ciphertext_b64, iv_b64).
"""
key = _get_totp_key()
iv = os.urandom(12)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(iv, plaintext_secret.encode(), None)
return base64.b64encode(ciphertext).decode(), base64.b64encode(iv).decode()
def decrypt_totp_secret(ciphertext_b64: str, iv_b64: str) -> str:
"""
Decrypt a base64-encoded AES-256-GCM TOTP secret ciphertext.
Returns the plaintext base32 secret string.
"""
key = _get_totp_key()
iv = base64.b64decode(iv_b64)
ciphertext = base64.b64decode(ciphertext_b64)
aesgcm = AESGCM(key)
return aesgcm.decrypt(iv, ciphertext, None).decode()
def generate_tokens(user_id: int) -> dict: def generate_tokens(user_id: int) -> dict:
"""Return access_token and refresh_token JWTs, each with a unique jti.""" """Return access_token and refresh_token JWTs, each with a unique jti."""
now = datetime.utcnow() now = datetime.utcnow()
@@ -0,0 +1,49 @@
"""encrypt totp_secret at rest: widen column and add totp_iv
Revision ID: a1b2c3d4e5f6
Revises: 71d7158dd3b9
Create Date: 2026-04-18 00:00:00.000000
This migration:
1. Widens users.totp_secret from VARCHAR(64) to VARCHAR(255) to hold base64
AES-256-GCM ciphertext (plaintext secret ~32 chars → ciphertext ~64 bytes
→ base64 ~88 chars, plus GCM tag 16 bytes → up to ~120 chars; 255 is safe).
2. Adds users.totp_iv VARCHAR(64) for the base64 12-byte GCM nonce.
After running this migration you MUST run the one-time re-encryption script:
python scripts/reencrypt_totp_secrets.py
That script reads every existing plaintext totp_secret, encrypts it with the
TOTP_ENCRYPTION_KEY from .env, and writes back the ciphertext + iv.
"""
from alembic import op
import sqlalchemy as sa
revision = 'a1b2c3d4e5f6'
down_revision = '71d7158dd3b9'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.alter_column(
'totp_secret',
existing_type=sa.String(length=64),
type_=sa.String(length=255),
existing_nullable=True,
)
batch_op.add_column(sa.Column('totp_iv', sa.String(length=64), nullable=True))
def downgrade():
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('totp_iv')
batch_op.alter_column(
'totp_secret',
existing_type=sa.String(length=255),
type_=sa.String(length=64),
existing_nullable=True,
)
+2
View File
@@ -12,3 +12,5 @@ python-dotenv>=1.0
gunicorn>=21.0 gunicorn>=21.0
pyotp>=2.9.0 pyotp>=2.9.0
qrcode[pil]>=7.4.2 qrcode[pil]>=7.4.2
cryptography>=42.0 # AES-256-GCM server-side TOTP secret encryption
redis>=5.0 # Shared rate-limit storage across Gunicorn workers
+16
View File
@@ -0,0 +1,16 @@
# PassKeeper — automated database backup cron
#
# Install:
# crontab -e
# Paste the line below, then save.
#
# What it does:
# Runs backup_db.sh at 02:00 daily.
# stdout/stderr is redirected to backup.log; cron will also email on failure
# if MAILTO is configured in the system crontab.
#
# Adjust the path to match your deployment directory.
MAILTO=""
0 2 * * * /bin/bash /home/spuser/PassKeeper/scripts/backup_db.sh >> /home/spuser/backups/passkeeper/backup.log 2>&1
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# /home/spuser/PassKeeper/scripts/backup_db.sh
#
# Automated MySQL backup for PassKeeper.
# - Dumps the passkeeper database with mysqldump
# - Compresses with gzip
# - Retains the last 30 daily backups
# - Logs all actions with timestamps
# - Exits non-zero on any failure so cron can email on error
#
# Usage:
# chmod +x scripts/backup_db.sh
# # Test manually:
# bash scripts/backup_db.sh
# # Schedule via cron (see scripts/backup.cron)
set -euo pipefail
# ── Configuration ─────────────────────────────────────────────────────────────
ENV_FILE="$(dirname "$(realpath "$0")")/../.env"
# Load .env variables
if [[ -f "$ENV_FILE" ]]; then
set -o allexport
# shellcheck disable=SC1090
source "$ENV_FILE"
set +o allexport
else
echo "ERROR: .env file not found at $ENV_FILE" >&2
exit 1
fi
BACKUP_DIR="${BACKUP_DIR:-/home/spuser/backups/passkeeper}"
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP_FILE="${BACKUP_DIR}/passkeeper_${TIMESTAMP}.sql.gz"
LOG_FILE="${BACKUP_DIR}/backup.log"
# MySQL credentials from .env
DB_HOST="${MYSQL_HOST:-127.0.0.1}"
DB_PORT="${MYSQL_PORT:-3306}"
DB_USER="${MYSQL_USER:-passkeeper}"
DB_PASS="${MYSQL_PASSWORD}"
DB_NAME="${MYSQL_DB:-passkeeper}"
# ── Helpers ───────────────────────────────────────────────────────────────────
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
# ── Pre-flight ────────────────────────────────────────────────────────────────
mkdir -p "$BACKUP_DIR"
chmod 700 "$BACKUP_DIR"
log "INFO Starting backup → $BACKUP_FILE"
# ── Dump ─────────────────────────────────────────────────────────────────────
# --single-transaction: consistent snapshot without locking (InnoDB)
# --routines --events: include stored procedures/events if any
# --no-tablespaces: avoid PROCESS privilege requirement on MySQL 8+
mysqldump \
--host="$DB_HOST" \
--port="$DB_PORT" \
--user="$DB_USER" \
--password="$DB_PASS" \
--single-transaction \
--routines \
--events \
--no-tablespaces \
"$DB_NAME" \
| gzip -9 > "$BACKUP_FILE"
BACKUP_SIZE="$(du -sh "$BACKUP_FILE" | cut -f1)"
log "INFO Backup complete — size: $BACKUP_SIZE"
# ── Verify the file is non-empty ──────────────────────────────────────────────
if [[ ! -s "$BACKUP_FILE" ]]; then
log "ERROR Backup file is empty — aborting retention cleanup"
exit 1
fi
# ── Retention: delete backups older than RETENTION_DAYS ──────────────────────
DELETED=$(find "$BACKUP_DIR" -maxdepth 1 -name 'passkeeper_*.sql.gz' \
-mtime +"$RETENTION_DAYS" -print -delete | wc -l)
log "INFO Retention cleanup: removed $DELETED file(s) older than ${RETENTION_DAYS} days"
log "INFO Backup finished successfully"
+26
View File
@@ -0,0 +1,26 @@
# /etc/logrotate.d/passkeeper
#
# Install:
# sudo cp scripts/passkeeper-logrotate /etc/logrotate.d/passkeeper
# sudo logrotate -d /etc/logrotate.d/passkeeper # dry-run to verify
# sudo logrotate -f /etc/logrotate.d/passkeeper # force a rotation now
#
# Rotates Gunicorn access and error logs daily, retaining 30 days.
# Sends USR1 to Gunicorn after rotation so it reopens its log file handles
# without a full restart (zero downtime).
/home/spuser/logs/access.log
/home/spuser/logs/error.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0640 www-data www-data
sharedscripts
postrotate
# Signal Gunicorn to reopen log files
systemctl kill -s USR1 passkeeper.service 2>/dev/null || true
endscript
}
+114
View File
@@ -0,0 +1,114 @@
# /etc/nginx/sites-available/passkeeper
#
# Hardened production Nginx config for PassKeeper.
# Phase 5 additions vs original:
# - Strict-Transport-Security (HSTS) with preload
# - X-Frame-Options: DENY
# - X-Content-Type-Options: nosniff
# - Referrer-Policy
# - Permissions-Policy
# - Content-Security-Policy (HTTP header level — authoritative over meta tag)
# - Connection-level rate limiting zones for auth endpoints
# - Buffer and timeout hardening
# ── Rate limiting zones ────────────────────────────────────────────────────────
# auth_limit: 10 req/s per IP for auth endpoints (login, register, MFA verify)
# api_limit: 60 req/s per IP for all other API endpoints
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=60r/m;
server {
server_name pwkeeper.ngodanguyen.tech passkeeper.ngodanguyen.tech;
# ── TLS ───────────────────────────────────────────────────────────────────
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/pwkeeper.ngodanguyen.tech/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pwkeeper.ngodanguyen.tech/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Modern TLS only
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# ── Security headers ──────────────────────────────────────────────────────
# HSTS: enforce HTTPS for 1 year; include subdomains; allow preload submission
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Clickjacking protection
add_header X-Frame-Options "DENY" always;
# Prevent MIME sniffing
add_header X-Content-Type-Options "nosniff" always;
# Control referrer leakage
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Disable unused browser features
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
# Content-Security-Policy (HTTP header takes precedence over meta tag)
add_header Content-Security-Policy
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';"
always;
# ── Request hardening ─────────────────────────────────────────────────────
# Hide Nginx version
server_tokens off;
# Limit request body size (vault items are small; prevent large payload abuse)
client_max_body_size 1m;
# Timeouts
client_body_timeout 12s;
client_header_timeout 12s;
keepalive_timeout 15s;
send_timeout 10s;
# ── Static files ──────────────────────────────────────────────────────────
location /static/ {
alias /home/spuser/PassKeeper/app/static/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# ── Auth endpoints — stricter Nginx-level rate limit ──────────────────────
# Flask-Limiter (Redis-backed) is the primary guard; Nginx adds a network-level
# burst buffer of 5 before returning 429 directly, saving upstream connections.
location ~ ^/api/auth/(login|register|mfa/verify) {
limit_req zone=auth_limit burst=5 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ── All other requests ────────────────────────────────────────────────────
location / {
limit_req zone=api_limit burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# ── HTTP → HTTPS redirect ─────────────────────────────────────────────────────
server {
listen 80;
server_name pwkeeper.ngodanguyen.tech passkeeper.ngodanguyen.tech;
return 301 https://$host$request_uri;
}
+62
View File
@@ -0,0 +1,62 @@
# /etc/systemd/system/passkeeper.service
#
# Install / update:
# sudo cp scripts/passkeeper.service /etc/systemd/system/passkeeper.service
# sudo systemctl daemon-reload
# sudo systemctl enable passkeeper
# sudo systemctl restart passkeeper
# journalctl -xeu passkeeper.service
#
# Phase 5 additions vs original:
# - WatchdogSec: systemd kills and restarts a hung Gunicorn within 30 s
# - Gunicorn --timeout: workers that don't respond within 25 s are replaced
# - Gunicorn --graceful-timeout: allows in-flight requests to finish on reload
# - PrivateTmp, NoNewPrivileges, ProtectSystem: basic systemd sandboxing
# - StartLimitIntervalSec / StartLimitBurst: caps restart storm
[Unit]
Description=PassKeeper Gunicorn daemon
After=network.target mysql.service
Wants=mysql.service
# Restart policy: cap to 5 restarts in 60 s to prevent restart storms
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
User=www-data
Group=www-data
WorkingDirectory=/home/spuser/PassKeeper
EnvironmentFile=/home/spuser/PassKeeper/.env
ExecStart=/home/spuser/.venv/bin/gunicorn \
--workers 4 \
--bind 127.0.0.1:5000 \
--timeout 25 \
--graceful-timeout 20 \
--keep-alive 5 \
--access-logfile /home/spuser/logs/access.log \
--error-logfile /home/spuser/logs/error.log \
--log-level warning \
wsgi:app
# Reload (zero-downtime): send USR2 to Gunicorn master
ExecReload=/bin/kill -s USR2 $MAINPID
# Watchdog: systemd sends SIGKILL if Gunicorn doesn't send keepalives within 30 s.
# Requires gunicorn to be started with --preload OR the watchdog plugin; here we
# rely on the worker timeout (25 s) to recycle hung workers before the 30 s
# watchdog fires, which restarts the entire service.
WatchdogSec=30s
Restart=on-failure
RestartSec=5s
# Systemd sandboxing
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/home/spuser/PassKeeper /home/spuser/logs /home/spuser/backups
[Install]
WantedBy=multi-user.target
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python
"""
Re-encrypt existing plaintext TOTP secrets with AES-256-GCM.
Run ONCE after applying migration a1b2c3d4e5f6 and before restarting the app:
python scripts/reencrypt_totp_secrets.py
Requirements:
- TOTP_ENCRYPTION_KEY must be set in .env (64-char hex string)
- Run from the project root directory
This script is idempotent: it skips users who already have a totp_iv set,
so it is safe to re-run if interrupted.
"""
import os
import sys
# Allow running from project root
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv()
from app import create_app, db
from app.models.user import User
from app.services.auth_service import encrypt_totp_secret
app = create_app(os.environ.get('FLASK_ENV', 'production'))
with app.app_context():
users = User.query.filter(
User.totp_enabled == True,
User.totp_secret != None,
User.totp_iv == None, # skip already-encrypted rows
).all()
if not users:
print('No plaintext TOTP secrets found. Nothing to do.')
sys.exit(0)
print(f'Found {len(users)} user(s) with plaintext TOTP secrets. Re-encrypting...')
errors = 0
for user in users:
try:
plaintext = user.totp_secret
ciphertext_b64, iv_b64 = encrypt_totp_secret(plaintext)
user.totp_secret = ciphertext_b64
user.totp_iv = iv_b64
print(f' [OK] user_id={user.id} ({user.email})')
except Exception as e:
print(f' [ERROR] user_id={user.id} ({user.email}): {e}', file=sys.stderr)
errors += 1
if errors:
db.session.rollback()
print(f'\nAborted — {errors} error(s) encountered. No changes committed.', file=sys.stderr)
sys.exit(1)
db.session.commit()
print(f'\nDone. {len(users)} TOTP secret(s) re-encrypted successfully.')