diff --git a/.env.example b/.env.example index 0b564aa..9360af1 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,6 @@ -# Copy to .env and fill values +# Copy to .env and fill values before running DATABASE_URL=postgres://user:password@localhost:5432/passwords_db PORT=4000 -JWT_SECRET=replace_with_strong_random -REFRESH_TOKEN_SECRET=replace_with_another_random +JWT_SECRET=replace_with_strong_random_32_char_minimum ACCESS_TOKEN_EXPIRES_IN=15m REFRESH_TOKEN_EXPIRES_IN=7d diff --git a/.env.production b/.env.production deleted file mode 100644 index e1e804a..0000000 --- a/.env.production +++ /dev/null @@ -1,16 +0,0 @@ -# Production Environment Variables -# Copy this to .env on your production server and fill with real values - -# Docker Hub username (for pulling images) -DOCKER_USERNAME=yourusername - -# Database credentials -DB_USER=pguser -DB_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD - -# JWT secrets (generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") -JWT_SECRET=CHANGE_ME_TO_LONG_RANDOM_STRING -REFRESH_TOKEN_SECRET=CHANGE_ME_TO_ANOTHER_RANDOM_STRING - -# Optional: Domain name for SSL -# DOMAIN=api.yourdomain.com diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a049b89..1cc63cc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -63,16 +63,16 @@ jobs: - uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . push: true diff --git a/.gitignore b/.gitignore index 1170717..9a3f977 100644 --- a/.gitignore +++ b/.gitignore @@ -1,136 +1,50 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +*.egg +*.egg-info/ +dist/ +build/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Testing / coverage +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ + +# Type checking +.mypy_cache/ +.dmypy.json +.pytype/ + # Logs -logs +logs/ *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files +# Environment files — never commit real secrets .env -.env.development.local -.env.test.local -.env.production.local .env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* +.env.production +.env.*.local + +# gstack security reports — keep local, never commit +.gstack/ + +# OS / editor +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*.swo diff --git a/Dockerfile b/Dockerfile index 0643215..f021de1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,13 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +RUN addgroup --system appgroup \ + && adduser --system --ingroup appgroup appuser \ + && mkdir -p /app/logs \ + && chown -R appuser:appgroup /app + +USER appuser + ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 diff --git a/app.py b/app.py index a879cb4..58af24c 100644 --- a/app.py +++ b/app.py @@ -4,7 +4,7 @@ """ import os -from datetime import datetime +from datetime import datetime, timezone from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from fastapi.exceptions import HTTPException @@ -94,7 +94,7 @@ async def security_headers(request: Request, call_next): @app.get("/health") def health(): - return {"status": "ok", "timestamp": datetime.utcnow().isoformat()} + return {"status": "ok", "timestamp": datetime.now(timezone.utc).isoformat()} if __name__ == "__main__": diff --git a/config.py b/config.py deleted file mode 100644 index a1e8a6d..0000000 --- a/config.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -Configuration management for the application. -""" - -import os -from datetime import timedelta -from dotenv import load_dotenv - -load_dotenv() - - -class Config: - """Base configuration.""" - - # Flask - DEBUG = os.getenv('DEBUG', 'false').lower() == 'true' - TESTING = False - - # Database - DATABASE_URL = os.getenv('DATABASE_URL', 'postgresql://user:password@localhost:5432/password_manager') - - # JWT - JWT_SECRET = os.getenv('JWT_SECRET', 'your-secret-key-change-me') - JWT_ALGORITHM = 'HS256' - ACCESS_TOKEN_EXPIRES = timedelta(minutes=15) - REFRESH_TOKEN_EXPIRES = timedelta(days=7) - - # Server - HOST = '0.0.0.0' - PORT = int(os.getenv('PORT', 4000)) - - # Rate Limiting - RATELIMIT_STORAGE_URL = os.getenv('RATELIMIT_STORAGE_URL', 'memory://') - - # Logging - LOG_DIR = 'logs' - LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO') - - -class DevelopmentConfig(Config): - """Development configuration.""" - DEBUG = True - LOG_LEVEL = 'DEBUG' - - -class ProductionConfig(Config): - """Production configuration.""" - DEBUG = False - LOG_LEVEL = 'INFO' - - -class TestingConfig(Config): - """Testing configuration.""" - TESTING = True - DATABASE_URL = 'postgresql://user:password@localhost:5432/password_manager_test' - - -# Configuration dictionary -config_by_name = { - 'development': DevelopmentConfig, - 'production': ProductionConfig, - 'testing': TestingConfig, - 'default': DevelopmentConfig -} - - -def get_config(config_name=None): - """Get configuration object.""" - if config_name is None: - config_name = os.getenv('FLASK_ENV', 'development') - - return config_by_name.get(config_name, Config) diff --git a/docker-compose.yml b/docker-compose.yml index e60c6b7..e780b81 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,7 @@ services: environment: DATABASE_URL: postgresql://${DB_USER:-pguser}:${DB_PASSWORD:-secret}@postgres:5432/passwords_db PORT: 4000 - JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set before running docker-compose} ACCESS_TOKEN_EXPIRES_IN: 15m REFRESH_TOKEN_EXPIRES_IN: 7d ports: diff --git a/src/db.py b/src/db.py index 4c60836..f1f78cf 100644 --- a/src/db.py +++ b/src/db.py @@ -1,31 +1,40 @@ -""" -Database connection and query utilities. -""" - import os +from typing import Optional import psycopg2 import psycopg2.extras +import psycopg2.pool from dotenv import load_dotenv load_dotenv() -DATABASE_URL = os.getenv('DATABASE_URL') +DATABASE_URL = os.getenv("DATABASE_URL") + +_pool: Optional[psycopg2.pool.ThreadedConnectionPool] = None + + +def _get_pool() -> psycopg2.pool.ThreadedConnectionPool: + global _pool + if _pool is None: + if not DATABASE_URL: + raise EnvironmentError("DATABASE_URL environment variable is required") + _pool = psycopg2.pool.ThreadedConnectionPool(2, 10, DATABASE_URL) + return _pool def get_connection(): - """Get a database connection.""" - return psycopg2.connect(DATABASE_URL) + return _get_pool().getconn() + + +def release_connection(conn): + _get_pool().putconn(conn) def query(sql, params=None): - """Execute a query and return results.""" conn = get_connection() try: cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(sql, params or ()) conn.commit() - - # Return results or None for INSERT/UPDATE/DELETE try: return cur.fetchall() except psycopg2.ProgrammingError: @@ -35,17 +44,15 @@ def query(sql, params=None): raise finally: cur.close() - conn.close() + release_connection(conn) def query_one(sql, params=None): - """Execute a query and return first result.""" results = query(sql, params) return results[0] if results else None def execute(sql, params=None): - """Execute a statement (for INSERT, UPDATE, DELETE).""" conn = get_connection() try: cur = conn.cursor() @@ -57,4 +64,4 @@ def execute(sql, params=None): raise finally: cur.close() - conn.close() + release_connection(conn) diff --git a/src/logger.py b/src/logger.py index 4c61437..3aa352a 100644 --- a/src/logger.py +++ b/src/logger.py @@ -1,39 +1,31 @@ -""" -Logging configuration using Python's logging module. -""" - import logging import os from logging.handlers import RotatingFileHandler -# Create logs directory if it doesn't exist -LOG_DIR = 'logs' -if not os.path.exists(LOG_DIR): - os.makedirs(LOG_DIR) +LOG_DIR = "logs" -# Configure logger -logger = logging.getLogger('SecurePasswordManager') -logger.setLevel(logging.DEBUG) +logger = logging.getLogger("SecurePasswordManager") -# File handler with rotation -file_handler = RotatingFileHandler( - os.path.join(LOG_DIR, 'app.log'), - maxBytes=10485760, # 10MB - backupCount=5 -) -file_handler.setLevel(logging.DEBUG) +if not logger.handlers: + level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO) + logger.setLevel(level) -# Console handler -console_handler = logging.StreamHandler() -console_handler.setLevel(logging.INFO) + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") -# Formatter -formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -file_handler.setFormatter(formatter) -console_handler.setFormatter(formatter) + console_handler = logging.StreamHandler() + console_handler.setLevel(level) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) -# Add handlers -logger.addHandler(file_handler) -logger.addHandler(console_handler) + try: + os.makedirs(LOG_DIR, exist_ok=True) + file_handler = RotatingFileHandler( + os.path.join(LOG_DIR, "app.log"), + maxBytes=10_485_760, + backupCount=5, + ) + file_handler.setLevel(level) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + except OSError: + pass diff --git a/src/middleware/audit.py b/src/middleware/audit.py index 2f38029..2ae6a04 100644 --- a/src/middleware/audit.py +++ b/src/middleware/audit.py @@ -1,29 +1,14 @@ -""" -Audit logging middleware to record all user actions. -""" - -from datetime import datetime +from datetime import datetime, timezone from src import db from src.logger import logger def audit_log(user_id=None, action=None, ip=None, success=True, message=None, meta=None): - """ - Log an audit event to the database. - - Args: - user_id: ID of the user performing the action - action: Action being performed (e.g., 'login', 'create_entry') - ip: IP address of the request - success: Whether the action succeeded - message: Optional message with additional details - meta: Optional metadata JSON - """ try: db.execute( - '''INSERT INTO audit_logs(user_id, action, ip, success, message, meta, created_at) - VALUES(%s, %s, %s, %s, %s, %s, %s)''', - (user_id, action, ip, success, message, meta, datetime.now()) + "INSERT INTO audit_logs(user_id, action, ip, success, message, meta, created_at) " + "VALUES(%s, %s, %s, %s, %s, %s, %s)", + (user_id, action, ip, success, message, meta, datetime.now(timezone.utc)), ) except Exception as err: - logger.error(f'Failed to write audit log: {err}') + logger.error(f"Failed to write audit log: {err}") diff --git a/src/middleware/auth.py b/src/middleware/auth.py index 726e317..9e10ef6 100644 --- a/src/middleware/auth.py +++ b/src/middleware/auth.py @@ -7,7 +7,9 @@ load_dotenv() -JWT_SECRET = os.getenv("JWT_SECRET", "your-secret-key-change-me") +JWT_SECRET = os.getenv("JWT_SECRET") +if not JWT_SECRET: + raise EnvironmentError("JWT_SECRET environment variable is required") security = HTTPBearer(auto_error=False) diff --git a/src/middleware/rate_limit.py b/src/middleware/rate_limit.py index 3327b9d..f7a5e12 100644 --- a/src/middleware/rate_limit.py +++ b/src/middleware/rate_limit.py @@ -1,7 +1,11 @@ +import os from slowapi import Limiter from slowapi.util import get_remote_address +_testing = os.getenv("TESTING", "false").lower() == "true" + limiter = Limiter( key_func=get_remote_address, - default_limits=["200/day", "50/hour"] + default_limits=["200/day", "50/hour"], + enabled=not _testing, ) diff --git a/src/routes/auth.py b/src/routes/auth.py index 6fe47c5..1babc6a 100644 --- a/src/routes/auth.py +++ b/src/routes/auth.py @@ -1,7 +1,7 @@ import os import re +import secrets import hashlib -import uuid from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Request, HTTPException @@ -13,10 +13,17 @@ from src import db from src.logger import logger from src.middleware.audit import audit_log +from src.middleware.rate_limit import limiter router = APIRouter(prefix="/auth", tags=["auth"]) ph = PasswordHasher() -JWT_SECRET = os.getenv("JWT_SECRET", "your-secret-key-change-me") +JWT_SECRET = os.getenv("JWT_SECRET") +if not JWT_SECRET: + raise EnvironmentError("JWT_SECRET environment variable is required") + +# Dummy hash used to make login timing constant whether the username exists or not. +# Computed once at startup so it doesn't add per-request overhead. +_DUMMY_HASH = ph.hash("dummy-timing-constant-value-never-valid") EXPIRY_UNITS = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days"} @@ -115,6 +122,7 @@ def make_access_token(user_id, username: str, now: datetime) -> str: @router.post("/register", status_code=201) +@limiter.limit("3/minute;10/hour") def register(body: RegisterRequest, request: Request): ip = request.client.host if request.client else None @@ -135,7 +143,7 @@ def register(body: RegisterRequest, request: Request): """INSERT INTO users(username, password_hash, encryption_salt) VALUES(%s, %s, %s) RETURNING id, username""", - (body.username, ph.hash(body.password), os.urandom(16).hex()), + (body.username, ph.hash(body.password), os.urandom(32).hex()), ) user = result[0] audit_log(user_id=user["id"], action="register", ip=ip, success=True) @@ -148,6 +156,7 @@ def register(body: RegisterRequest, request: Request): @router.post("/login") +@limiter.limit("5/minute;20/hour") def login(body: LoginRequest, request: Request): ip = request.client.host if request.client else None @@ -160,7 +169,14 @@ def login(body: LoginRequest, request: Request): "SELECT id, password_hash, encryption_salt FROM users WHERE username=%s", (body.username,), ) + + # Always run argon2 verify — even for unknown usernames — so response time + # is constant whether the username exists or not (prevents timing enumeration). if not result: + try: + ph.verify(_DUMMY_HASH, body.password) + except Exception: + pass audit_log(action="login", ip=ip, success=False, message="user not found") raise HTTPException(status_code=401, detail="Invalid credentials") @@ -176,7 +192,7 @@ def login(body: LoginRequest, request: Request): now = datetime.now(timezone.utc) access_token = make_access_token(user["id"], body.username, now) - refresh_token = str(uuid.uuid4()) + refresh_token = secrets.token_urlsafe(32) expires_at = now + parse_expiry(os.getenv("REFRESH_TOKEN_EXPIRES_IN", "7d")) db.execute( "INSERT INTO refresh_tokens(user_id, token_hash, expires_at) VALUES(%s, %s, %s)", @@ -224,11 +240,11 @@ def refresh_token(body: RefreshRequest, request: Request): user = db.query("SELECT username FROM users WHERE id=%s", (record["user_id"],)) if not user: - raise HTTPException(status_code=404, detail="User not found") + raise HTTPException(status_code=403, detail="Invalid refresh token") db.execute("DELETE FROM refresh_tokens WHERE token_hash=%s", (hash_token(body.refreshToken),)) - new_refresh_token = str(uuid.uuid4()) + new_refresh_token = secrets.token_urlsafe(32) db.execute( "INSERT INTO refresh_tokens(user_id, token_hash, expires_at) VALUES(%s, %s, %s)", (record["user_id"], hash_token(new_refresh_token), diff --git a/src/routes/entries.py b/src/routes/entries.py index 6145c46..a91fe75 100644 --- a/src/routes/entries.py +++ b/src/routes/entries.py @@ -38,11 +38,17 @@ class EntryRequest(BaseModel): def decode_entry_fields(body: EntryRequest): - return ( - base64.b64decode(body.ciphertext), - base64.b64decode(body.iv), - base64.b64decode(body.tag), - ) + try: + ciphertext = base64.b64decode(body.ciphertext) + iv = base64.b64decode(body.iv) + tag = base64.b64decode(body.tag) + except Exception: + raise HTTPException(status_code=400, detail="ciphertext, iv, and tag must be valid base64") + if len(iv) != 12: + raise HTTPException(status_code=400, detail="iv must be exactly 12 bytes for AES-GCM") + if len(tag) != 16: + raise HTTPException(status_code=400, detail="tag must be exactly 16 bytes for AES-GCM") + return ciphertext, iv, tag @router.post("/", status_code=201) @@ -62,12 +68,13 @@ def create_entry( try: ciphertext, iv, tag = decode_entry_fields(body) meta = json.dumps(body.meta) if body.meta is not None else None - db.execute( - "INSERT INTO password_entries(user_id, name, ciphertext, iv, tag, meta) VALUES(%s,%s,%s,%s,%s,%s)", + result = db.query( + "INSERT INTO password_entries(user_id, name, ciphertext, iv, tag, meta) " + "VALUES(%s,%s,%s,%s,%s,%s) RETURNING id", (user_id, body.name, ciphertext, iv, tag, meta), ) audit_log(user_id=user_id, action="create_entry", ip=ip, success=True) - return {"ok": True} + return {"ok": True, "id": str(result[0]["id"])} except HTTPException: raise diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a820043 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import os + +os.environ.setdefault("JWT_SECRET", "test-secret-key-ci-only") +# Disable rate limiting in tests — validation tests reuse the same client IP +os.environ.setdefault("TESTING", "true") diff --git a/tests/test_api.py b/tests/test_api.py index ec8a791..ebf1686 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -195,6 +195,7 @@ def test_create_entry(self, logged_in): }) assert response.status_code == 201 assert response.json()['ok'] is True + assert 'id' in response.json() @requires_db def test_list_entries(self, logged_in):