04/18 Enhance app (security, performance)
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.')
|
||||
Reference in New Issue
Block a user