feat: Phase 2 - Authentication and authorization (T-010 to T-013)

This commit is contained in:
2026-02-06 13:15:25 +01:00
parent ec65991ac1
commit 508f0723af
11 changed files with 321 additions and 20 deletions

View File

@@ -45,13 +45,34 @@ docker-compose up -d
docker exec -w /app aegis-backend-1 alembic upgrade head docker exec -w /app aegis-backend-1 alembic upgrade head
``` ```
4. Verify the installation: 4. Seed the admin user:
```bash
docker exec -w /app aegis-backend-1 python -m app.seed
```
5. Verify the installation:
```bash ```bash
# Check backend health # Check backend health
curl http://localhost:8000/health curl http://localhost:8000/health
# Expected: {"status":"ok"} # Expected: {"status":"ok"}
``` ```
### Authentication
The platform uses JWT-based authentication. After seeding, log in with the default admin credentials:
```bash
# Obtain a token
curl -X POST http://localhost:8000/api/v1/auth/login \
-d "username=admin&password=admin123"
# Use the token to access protected endpoints
curl http://localhost:8000/api/v1/auth/me \
-H "Authorization: Bearer <your-token>"
```
> **Important:** Change the default `admin123` password and `SECRET_KEY` in production.
## Services ## Services
| Service | Port | Description | | Service | Port | Description |
@@ -72,31 +93,39 @@ Once the backend is running, access the interactive API documentation at:
``` ```
Aegis/ Aegis/
├── docker-compose.yml # Docker services configuration ├── docker-compose.yml # Docker services configuration
├── backend/ ├── backend/
│ ├── Dockerfile # Backend container definition │ ├── Dockerfile # Backend container definition
│ ├── requirements.txt # Python dependencies │ ├── requirements.txt # Python dependencies
│ ├── alembic.ini # Alembic configuration │ ├── alembic.ini # Alembic configuration
│ ├── alembic/ # Database migrations │ ├── alembic/ # Database migrations
│ │ ├── env.py │ │ ├── env.py
│ │ ├── versions/ # Migration files │ │ ├── versions/ # Migration files
│ │ └── ... │ │ └── ...
│ └── app/ │ └── app/
│ ├── __init__.py │ ├── __init__.py
│ ├── main.py # FastAPI application entry point │ ├── main.py # FastAPI application entry point
│ ├── config.py # Application settings │ ├── config.py # Application settings
│ ├── database.py # SQLAlchemy configuration │ ├── database.py # SQLAlchemy configuration
│ ├── models/ # SQLAlchemy models │ ├── auth.py # Password hashing & JWT utilities
├── user.py # User authentication model │ ├── seed.py # Admin seed script (python -m app.seed)
│ ├── technique.py # MITRE ATT&CK techniques ├── models/ # SQLAlchemy models
│ │ ├── test.py # Security tests │ │ ├── user.py # User authentication model
│ │ ├── evidence.py # Test evidence files │ │ ├── technique.py # MITRE ATT&CK techniques
│ │ ├── intel.py # Threat intelligence items │ │ ├── test.py # Security tests
│ │ ├── audit.py # Audit logging │ │ ├── evidence.py # Test evidence files
│ │ ── enums.py # Shared enumerations │ │ ── intel.py # Threat intelligence items
└── services/ # Business logic services │ ├── audit.py # Audit logging
│ │ └── enums.py # Shared enumerations
│ ├── schemas/ # Pydantic request/response schemas
│ │ └── auth.py # LoginRequest, TokenResponse, UserOut
│ ├── routers/ # API endpoint routers
│ │ └── auth.py # POST /auth/login, GET /auth/me
│ ├── dependencies/ # FastAPI dependencies (DI)
│ │ └── auth.py # get_current_user, require_role (RBAC)
│ └── services/ # Business logic services
│ └── audit_service.py │ └── audit_service.py
└── frontend/ # React frontend (coming soon) └── frontend/ # React frontend (coming soon)
``` ```
## Database Schema ## Database Schema
@@ -120,6 +149,8 @@ The application can be configured via environment variables:
|----------|---------|-------------| |----------|---------|-------------|
| `DATABASE_URL` | `postgresql://postgres:postgres@postgres:5432/attackdb` | PostgreSQL connection string | | `DATABASE_URL` | `postgresql://postgres:postgres@postgres:5432/attackdb` | PostgreSQL connection string |
| `SECRET_KEY` | `change-me-in-production` | JWT signing key | | `SECRET_KEY` | `change-me-in-production` | JWT signing key |
| `ALGORITHM` | `HS256` | JWT signing algorithm |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `60` | JWT token lifetime in minutes |
| `MINIO_ENDPOINT` | `minio:9000` | MinIO server endpoint | | `MINIO_ENDPOINT` | `minio:9000` | MinIO server endpoint |
| `MINIO_ACCESS_KEY` | `minioadmin` | MinIO access key | | `MINIO_ACCESS_KEY` | `minioadmin` | MinIO access key |
| `MINIO_SECRET_KEY` | `minioadmin` | MinIO secret key | | `MINIO_SECRET_KEY` | `minioadmin` | MinIO secret key |

50
backend/app/auth.py Normal file
View File

@@ -0,0 +1,50 @@
"""
Security utilities: password hashing and JWT token management.
This module provides pure functions for:
- Hashing and verifying passwords using bcrypt via passlib.
- Creating JWT access tokens using python-jose.
No endpoints are defined here.
"""
from datetime import datetime, timedelta, timezone
from jose import jwt
from passlib.context import CryptContext
from app.config import settings
# ---------------------------------------------------------------------------
# Password hashing
# ---------------------------------------------------------------------------
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""Return a bcrypt hash of *password*."""
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
"""Return ``True`` if *plain* matches the bcrypt *hashed* value."""
return pwd_context.verify(plain, hashed)
# ---------------------------------------------------------------------------
# JWT tokens
# ---------------------------------------------------------------------------
def create_access_token(data: dict) -> str:
"""Create a signed JWT containing *data* plus an ``exp`` claim.
The token expires after ``ACCESS_TOKEN_EXPIRE_MINUTES`` (from settings).
"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)

View File

View File

@@ -0,0 +1,89 @@
"""
Authentication and RBAC dependencies for FastAPI.
Provides:
- ``get_current_user``: decodes JWT, fetches user from DB, raises 401 on failure.
- ``require_role``: factory that returns a dependency enforcing a specific role
(admins always pass).
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
# ---------------------------------------------------------------------------
# OAuth2 scheme
# ---------------------------------------------------------------------------
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
# ---------------------------------------------------------------------------
# Current-user dependency
# ---------------------------------------------------------------------------
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
"""Decode the JWT *token*, look up the user in *db*, and return it.
Raises :class:`~fastapi.HTTPException` **401** when:
- the token cannot be decoded,
- the ``sub`` claim is missing, or
- no matching active user exists in the database.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
username: str | None = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
return user
# ---------------------------------------------------------------------------
# Role-based access control dependency
# ---------------------------------------------------------------------------
def require_role(required_role: str):
"""Return a FastAPI dependency that enforces *required_role*.
The dependency allows the request to proceed when
``user.role == required_role`` **or** ``user.role == "admin"``.
Otherwise it raises :class:`~fastapi.HTTPException` **403**.
"""
async def role_checker(
current_user: User = Depends(get_current_user),
) -> User:
if current_user.role != required_role and current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
return current_user
return role_checker

View File

@@ -1,7 +1,22 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import auth as auth_router
app = FastAPI(title="Attack Coverage Platform") app = FastAPI(title="Attack Coverage Platform")
# ── CORS ──────────────────────────────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Routers ──────────────────────────────────────────────────────────────
app.include_router(auth_router.router, prefix="/api/v1")
@app.get("/health") @app.get("/health")
def health(): def health():

View File

View File

@@ -0,0 +1,47 @@
"""Authentication router: login and current-user endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.auth import verify_password, create_access_token
from app.database import get_db
from app.dependencies.auth import get_current_user
from app.models.user import User
from app.schemas.auth import TokenResponse, UserOut
router = APIRouter(prefix="/auth", tags=["auth"])
# ---------------------------------------------------------------------------
# POST /auth/login
# ---------------------------------------------------------------------------
@router.post("/login", response_model=TokenResponse)
def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
):
"""Authenticate a user and return a JWT access token."""
user = db.query(User).filter(User.username == form_data.username).first()
if user is None or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect username or password",
)
access_token = create_access_token(data={"sub": user.username})
return TokenResponse(access_token=access_token)
# ---------------------------------------------------------------------------
# GET /auth/me
# ---------------------------------------------------------------------------
@router.get("/me", response_model=UserOut)
def read_current_user(current_user: User = Depends(get_current_user)):
"""Return the profile of the currently authenticated user."""
return current_user

View File

View File

@@ -0,0 +1,33 @@
"""Pydantic schemas for authentication endpoints."""
import uuid
from pydantic import BaseModel
class LoginRequest(BaseModel):
"""Body for the login endpoint (unused directly — we rely on
``OAuth2PasswordRequestForm``, but kept for documentation / testing)."""
username: str
password: str
class TokenResponse(BaseModel):
"""Response returned after a successful login."""
access_token: str
token_type: str = "bearer"
class UserOut(BaseModel):
"""Public representation of a user (no password hash)."""
id: uuid.UUID
username: str
email: str | None = None
role: str
is_active: bool
class Config:
from_attributes = True

35
backend/app/seed.py Normal file
View File

@@ -0,0 +1,35 @@
"""
Seed script — creates the initial admin user if it does not already exist.
Usage:
python -m app.seed
"""
from app.auth import hash_password
from app.database import SessionLocal
from app.models.user import User
def seed_admin() -> None:
"""Create the default admin user when it is missing."""
db = SessionLocal()
try:
existing = db.query(User).filter(User.username == "admin").first()
if existing:
print("Admin user already exists — skipping.")
return
admin = User(
username="admin",
hashed_password=hash_password("admin123"),
role="admin",
)
db.add(admin)
db.commit()
print("Admin user created successfully.")
finally:
db.close()
if __name__ == "__main__":
seed_admin()

View File

@@ -5,6 +5,7 @@ psycopg2-binary
alembic alembic
python-jose[cryptography] python-jose[cryptography]
passlib[bcrypt] passlib[bcrypt]
bcrypt==4.0.1
boto3 boto3
apscheduler apscheduler
requests requests