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)
|
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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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