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
+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.')