04/18 Enhance app (security, performance)
This commit is contained in:
+33
-1
@@ -26,7 +26,39 @@ def create_app(config_name: str = 'development') -> Flask:
|
||||
login_manager.init_app(app)
|
||||
csrf.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
|
||||
from .models.user import User
|
||||
|
||||
+11
-1
@@ -29,7 +29,17 @@ class BaseConfig:
|
||||
ARGON2_MEMORY_COST = int(os.environ.get('ARGON2_MEMORY_COST', 65536))
|
||||
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):
|
||||
|
||||
+5
-1
@@ -22,7 +22,11 @@ class User(db.Model, UserMixin):
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
last_login = db.Column(db.DateTime, nullable=True)
|
||||
# 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)
|
||||
# ECDH P-256 sharing keypair
|
||||
# Public key: raw uncompressed point (65 bytes), base64-encoded (~88 chars), stored plaintext
|
||||
|
||||
+20
-3
@@ -13,6 +13,8 @@ from app.services.auth_service import (
|
||||
decode_token,
|
||||
blacklist_token,
|
||||
require_jwt,
|
||||
encrypt_totp_secret,
|
||||
decrypt_totp_secret,
|
||||
)
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
@@ -75,6 +77,16 @@ def login():
|
||||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
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
|
||||
|
||||
from datetime import datetime
|
||||
@@ -196,7 +208,9 @@ def mfa_enable():
|
||||
if not pyotp.TOTP(secret).verify(totp_code, valid_window=1):
|
||||
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
|
||||
|
||||
AuditLog.log(
|
||||
@@ -224,10 +238,12 @@ def mfa_disable():
|
||||
totp_code = (data.get('totp_code') or '').strip()
|
||||
|
||||
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
|
||||
|
||||
user.totp_secret = None
|
||||
user.totp_iv = None
|
||||
user.totp_enabled = False
|
||||
|
||||
AuditLog.log(
|
||||
@@ -264,7 +280,8 @@ def mfa_verify():
|
||||
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):
|
||||
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
|
||||
|
||||
# One-time use: blacklist the mfa_token
|
||||
|
||||
@@ -50,7 +50,10 @@ 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()
|
||||
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:
|
||||
return jsonify({'error': 'grantee_email is required'}), 400
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import base64
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from flask import current_app, request, g, jsonify
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||
@@ -27,6 +30,44 @@ def verify_auth_token(auth_hash: str, stored_hash: str) -> bool:
|
||||
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:
|
||||
"""Return access_token and refresh_token JWTs, each with a unique jti."""
|
||||
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,
|
||||
)
|
||||
@@ -12,3 +12,5 @@ python-dotenv>=1.0
|
||||
gunicorn>=21.0
|
||||
pyotp>=2.9.0
|
||||
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
|
||||
|
||||
@@ -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