Skip to content

Commit 436565d

Browse files
committed
feat: implement secutiry check todo
1 parent 25d2a8f commit 436565d

26 files changed

Lines changed: 1269 additions & 23 deletions

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,8 @@ RATE_LIMIT="100/minute"
1919
CORS_ALLOW_ORIGINS=http://localhost:3000
2020
CORS_ALLOW_METHODS=*
2121
CORS_ALLOW_HEADERS=*
22+
SECURITY_CONTENT_SECURITY_POLICY=default-src 'self'; frame-ancestors 'none'
23+
IDEMPOTENCY_TTL_SECONDS=86400
24+
ACCOUNT_LOCKOUT_MAX_ATTEMPTS=5
25+
ACCOUNT_LOCKOUT_WINDOW_MINUTES=15
26+
ACCOUNT_LOCKOUT_DURATION_MINUTES=15

Makefile

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ COMPOSE_FILE := docker-compose.yml
1010

1111
.DEFAULT_GOAL := help
1212

13-
.PHONY: help install run test lint import-check check migrate downgrade revision db-up db-down db-logs clean
13+
.PHONY: help install run test lint import-check security-scan check migrate downgrade revision db-up db-down db-logs clean
1414

1515
help:
1616
@echo "[make:help] Available commands:"
@@ -19,6 +19,7 @@ help:
1919
@echo " [make:test] Run pytest"
2020
@echo " [make:lint] Run Ruff checks"
2121
@echo " [make:import-check] Verify src.main imports"
22+
@echo " [make:security-scan] Run dependency vulnerability scan with pip-audit"
2223
@echo " [make:check] Run tests, lint, and import check"
2324
@echo " [make:migrate] Apply Alembic migrations"
2425
@echo " [make:downgrade] Roll back one Alembic migration"
@@ -48,6 +49,14 @@ import-check:
4849
@echo "[make:import-check] Verifying src.main imports"
4950
@PYTHONDONTWRITEBYTECODE=1 $(PYTHON) -c "import src.main; print('import ok')"
5051

52+
security-scan:
53+
@echo "[make:security-scan] Running dependency vulnerability scan"
54+
@if ! command -v pip-audit >/dev/null 2>&1; then \
55+
echo "[make:security-scan] pip-audit is not installed. Install it with: pip install pip-audit"; \
56+
exit 1; \
57+
fi
58+
@pip-audit
59+
5160
check: test lint import-check
5261
@echo "[make:check] All checks completed"
5362

README.md

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -490,40 +490,40 @@ Legend: `Implemented` means code exists in the repository. `Partial` means code
490490
| Refresh Token Rotation | Required | Implemented | Refresh flow revokes the old refresh token and persists a new token. |
491491
| RBAC + Permissions | Required | Implemented | Casbin-backed role and permission checks are wired through route dependencies. |
492492
| Rate Limiting (Redis-backed) | Required | Implemented | Redis-backed limiter reads the configured `RATE_LIMIT` value. |
493-
| Security Headers Middleware | Required | Not Implemented | Add headers such as `X-Content-Type-Options`, `X-Frame-Options` or CSP `frame-ancestors`, `Referrer-Policy`, and production CSP. |
493+
| Security Headers Middleware | Required | Implemented | Adds `X-Content-Type-Options`, `X-Frame-Options`, CSP `frame-ancestors`, `Referrer-Policy`, and `Permissions-Policy`. |
494494
| CORS Configuration | Required | Implemented | CORS origins, methods, and headers are environment-driven through settings. |
495-
| Request ID Middleware | Required | Not Implemented | Add request/correlation ID generation and response header propagation. |
496-
| Audit Logging | Required | Not Implemented | Add audit events for sensitive auth, user, role, permission, and todo mutations. |
497-
| Structured Logging | Required | Not Implemented | Add structured application logs with request ID, method, path, status, latency, and user context when available. |
495+
| Request ID Middleware | Required | Implemented | Generates or propagates `X-Request-ID` and stores it on request state. |
496+
| Audit Logging | Required | Implemented | Adds global endpoint audit logging, domain audit events, and separate persisted error traces. |
497+
| Structured Logging | Required | Implemented | Logs request ID, method, path, status, latency, and user context when available. |
498498
| Global Exception Handling | Required | Implemented | Domain exceptions are registered explicitly and `Exception` is used only as the fallback handler. |
499499
| Input Validation | Required | Implemented | Pydantic schemas and application validation functions are used across user and todo flows. |
500500
| Password Hashing (Argon2 or bcrypt) | Required | Implemented | User auth service uses bcrypt hashing. |
501-
| Account Lockout | Required | Not Implemented | Add failed-login tracking and temporary lockout or throttling by account. |
501+
| Account Lockout | Required | Implemented | Tracks failed logins and temporarily locks accounts after configured thresholds. |
502502
| Token Revocation | Required | Implemented | Refresh tokens are revoked on rotation/logout, and access tokens are denylisted in Redis until expiry. |
503503
| OpenAPI Authentication | Required | Implemented | Swagger OAuth2 auth is configured, and docs/OpenAPI endpoints are disabled when `APP_ENV=production`. |
504504
| Health Check Endpoint | Required | Implemented | `/health` endpoint returns service health. |
505-
| Readiness/Liveness Endpoints | Required | Not Implemented | Add separate readiness and liveness endpoints for deployment orchestration. |
505+
| Readiness/Liveness Endpoints | Required | Implemented | Adds `/live` and `/ready` operational endpoints. |
506506
| Request Size Limiting | Required | Implemented | `LimitRequestSizeMiddleware` rejects oversized write requests. |
507-
| Idempotency Support (for applicable POST endpoints) | Optional but valuable | Not Implemented | Consider idempotency keys for retry-safe create/payment-like workflows. |
507+
| Idempotency Support (for applicable POST endpoints) | Optional but valuable | Implemented | Supports `Idempotency-Key` replay caching for POST responses. |
508508
| Database Migrations | Required | Implemented | Alembic is configured with migration commands in the README and Makefile. |
509509
| Dependency Injection | Required | Implemented | FastAPI dependencies wire repositories, handlers, auth, authorization, and database sessions. |
510510
| Configuration via Environment Variables | Required | Implemented | Pydantic settings read `.env` and reject the default secret key in production. |
511511

512512
### Next Implementation Checklist
513513

514514
- [x] Fix and verify rate limit configuration wiring.
515-
- [ ] Add security headers middleware.
516-
- [ ] Add request ID middleware.
517-
- [ ] Add structured request logging.
518-
- [ ] Add audit logging for sensitive actions.
519-
- [ ] Add account lockout or equivalent failed-login protection.
515+
- [x] Add security headers middleware.
516+
- [x] Add request ID middleware.
517+
- [x] Add structured request logging.
518+
- [x] Add audit logging for sensitive actions.
519+
- [x] Add account lockout or equivalent failed-login protection.
520520
- [x] Disable or authenticate `/docs`, `/redoc`, and `/openapi.json` in production.
521-
- [ ] Add readiness and liveness endpoints.
521+
- [x] Add readiness and liveness endpoints.
522522
- [x] Add production config validation for secrets and unsafe defaults.
523523
- [x] Harden CORS through environment-driven allowed origins, methods, and headers.
524-
- [ ] Review exception responses to avoid leaking token parsing details or internal exception messages.
525-
- [ ] Add automated tests for request size limits, rate limiting, auth failures, authorization failures, CORS, security headers, and request IDs.
526-
- [ ] Add dependency vulnerability scanning to local or CI checks, for example `pip-audit` or an equivalent Poetry-compatible scanner.
524+
- [x] Review exception responses to avoid leaking token parsing details or internal exception messages.
525+
- [x] Add automated tests for request size limits, rate limiting, auth failures, authorization failures, CORS, security headers, and request IDs.
526+
- [x] Add dependency vulnerability scanning to local or CI checks, for example `pip-audit` or an equivalent Poetry-compatible scanner.
527527

528528
## Known Notes
529529

alembic/env.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@
2323
UserHasRoleModel, # noqa: F401
2424
)
2525
from src.core.config.setting import get_settings
26+
from src.core.security.infrastructure.models.audit_log_model import (
27+
AuditLogModel, # noqa: F401
28+
)
29+
from src.core.security.infrastructure.models.error_trace_model import (
30+
ErrorTraceModel, # noqa: F401
31+
)
32+
from src.core.security.infrastructure.models.login_attempt_model import (
33+
LoginAttemptModel, # noqa: F401
34+
)
2635
from src.modules.todo.infrastructure.models.todo_model import TodoModel # noqa: F401
2736
from src.modules.user.infrastructure.models.refresh_token_model import (
2837
RefreshTokenModel, # noqa: F401
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""add security audit and login attempts
2+
3+
Revision ID: b2f4c7d9a1e0
4+
Revises: aa90557ef712
5+
Create Date: 2026-06-19 00:00:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
15+
revision: str = "b2f4c7d9a1e0"
16+
down_revision: Union[str, Sequence[str], None] = "aa90557ef712"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
op.create_table(
23+
"audit_logs",
24+
sa.Column("action", sa.String(length=120), nullable=False),
25+
sa.Column("actor_id", sa.String(length=64), nullable=True),
26+
sa.Column("resource_type", sa.String(length=80), nullable=True),
27+
sa.Column("resource_id", sa.String(length=64), nullable=True),
28+
sa.Column("request_id", sa.String(length=120), nullable=True),
29+
sa.Column("meta", sa.JSON(), nullable=False),
30+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
31+
sa.Column("id", sa.Uuid(), nullable=False),
32+
sa.PrimaryKeyConstraint("id"),
33+
)
34+
op.create_index("ix_audit_logs_action", "audit_logs", ["action"], unique=False)
35+
op.create_index("ix_audit_logs_actor_id", "audit_logs", ["actor_id"], unique=False)
36+
op.create_index(
37+
"ix_audit_logs_created_at", "audit_logs", ["created_at"], unique=False
38+
)
39+
op.create_index(
40+
"ix_audit_logs_request_id", "audit_logs", ["request_id"], unique=False
41+
)
42+
43+
op.create_table(
44+
"login_attempts",
45+
sa.Column("email", sa.String(length=255), nullable=False),
46+
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
47+
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
48+
sa.Column("id", sa.Uuid(), nullable=False),
49+
sa.PrimaryKeyConstraint("id"),
50+
)
51+
op.create_index("ix_login_attempts_email", "login_attempts", ["email"], unique=False)
52+
op.create_index(
53+
"ix_login_attempts_locked_until",
54+
"login_attempts",
55+
["locked_until"],
56+
unique=False,
57+
)
58+
op.create_index(
59+
"ix_login_attempts_occurred_at",
60+
"login_attempts",
61+
["occurred_at"],
62+
unique=False,
63+
)
64+
65+
op.create_table(
66+
"error_traces",
67+
sa.Column("error_type", sa.String(length=120), nullable=False),
68+
sa.Column("message", sa.Text(), nullable=False),
69+
sa.Column("traceback", sa.Text(), nullable=False),
70+
sa.Column("method", sa.String(length=12), nullable=False),
71+
sa.Column("path", sa.String(length=500), nullable=False),
72+
sa.Column("actor_id", sa.String(length=64), nullable=True),
73+
sa.Column("request_id", sa.String(length=120), nullable=True),
74+
sa.Column("meta", sa.JSON(), nullable=False),
75+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
76+
sa.Column("id", sa.Uuid(), nullable=False),
77+
sa.PrimaryKeyConstraint("id"),
78+
)
79+
op.create_index(
80+
"ix_error_traces_actor_id", "error_traces", ["actor_id"], unique=False
81+
)
82+
op.create_index(
83+
"ix_error_traces_created_at", "error_traces", ["created_at"], unique=False
84+
)
85+
op.create_index(
86+
"ix_error_traces_error_type", "error_traces", ["error_type"], unique=False
87+
)
88+
op.create_index("ix_error_traces_path", "error_traces", ["path"], unique=False)
89+
op.create_index(
90+
"ix_error_traces_request_id", "error_traces", ["request_id"], unique=False
91+
)
92+
93+
94+
def downgrade() -> None:
95+
op.drop_index("ix_error_traces_request_id", table_name="error_traces")
96+
op.drop_index("ix_error_traces_path", table_name="error_traces")
97+
op.drop_index("ix_error_traces_error_type", table_name="error_traces")
98+
op.drop_index("ix_error_traces_created_at", table_name="error_traces")
99+
op.drop_index("ix_error_traces_actor_id", table_name="error_traces")
100+
op.drop_table("error_traces")
101+
102+
op.drop_index("ix_login_attempts_occurred_at", table_name="login_attempts")
103+
op.drop_index("ix_login_attempts_locked_until", table_name="login_attempts")
104+
op.drop_index("ix_login_attempts_email", table_name="login_attempts")
105+
op.drop_table("login_attempts")
106+
107+
op.drop_index("ix_audit_logs_request_id", table_name="audit_logs")
108+
op.drop_index("ix_audit_logs_created_at", table_name="audit_logs")
109+
op.drop_index("ix_audit_logs_actor_id", table_name="audit_logs")
110+
op.drop_index("ix_audit_logs_action", table_name="audit_logs")
111+
op.drop_table("audit_logs")

src/core/bootstrap/middleware.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22
from fastapi.middleware.cors import CORSMiddleware
33

44
from src.core.config.setting import get_settings
5-
from src.core.middleware.request_size import LimitRequestSizeMiddleware
65
from src.core.middleware.auth import AuthenticationMiddleware
6+
from src.core.middleware.audit_logging import AuditLoggingMiddleware
7+
from src.core.middleware.idempotency import IdempotencyMiddleware
8+
from src.core.middleware.request_id import RequestIDMiddleware
9+
from src.core.middleware.request_size import LimitRequestSizeMiddleware
10+
from src.core.middleware.security_headers import SecurityHeadersMiddleware
11+
from src.core.middleware.structured_logging import StructuredLoggingMiddleware
712

813
settings = get_settings()
914

@@ -13,11 +18,16 @@ def register_middleware(app: FastAPI):
1318
LimitRequestSizeMiddleware,
1419
max_upload_size=settings.MAX_REQUEST_SIZE_MB,
1520
)
21+
app.add_middleware(SecurityHeadersMiddleware)
1622
app.add_middleware(
1723
CORSMiddleware,
1824
allow_origins=settings.cors_allow_origins,
1925
allow_credentials=True,
2026
allow_methods=settings.cors_allow_methods,
2127
allow_headers=settings.cors_allow_headers,
2228
)
29+
app.add_middleware(IdempotencyMiddleware)
30+
app.add_middleware(StructuredLoggingMiddleware)
31+
app.add_middleware(AuditLoggingMiddleware)
2332
app.add_middleware(AuthenticationMiddleware)
33+
app.add_middleware(RequestIDMiddleware)

src/core/config/setting.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ class Settings(BaseSettings):
3535
)
3636
CORS_ALLOW_METHODS: str = Field(alias="CORS_ALLOW_METHODS", default="*")
3737
CORS_ALLOW_HEADERS: str = Field(alias="CORS_ALLOW_HEADERS", default="*")
38+
SECURITY_CONTENT_SECURITY_POLICY: str = Field(
39+
alias="SECURITY_CONTENT_SECURITY_POLICY",
40+
default="default-src 'self'; frame-ancestors 'none'",
41+
)
42+
IDEMPOTENCY_TTL_SECONDS: int = Field(alias="IDEMPOTENCY_TTL_SECONDS", default=86400)
43+
ACCOUNT_LOCKOUT_MAX_ATTEMPTS: int = Field(
44+
alias="ACCOUNT_LOCKOUT_MAX_ATTEMPTS", default=5
45+
)
46+
ACCOUNT_LOCKOUT_WINDOW_MINUTES: int = Field(
47+
alias="ACCOUNT_LOCKOUT_WINDOW_MINUTES", default=15
48+
)
49+
ACCOUNT_LOCKOUT_DURATION_MINUTES: int = Field(
50+
alias="ACCOUNT_LOCKOUT_DURATION_MINUTES", default=15
51+
)
3852
MAX_REQUEST_SIZE_MB: int = Field(
3953
alias="MAX_REQUEST_SIZE_MB", default=5 * 1024 * 1024
4054
)

0 commit comments

Comments
 (0)