diff --git a/README.md b/README.md index 4b803e8..d5b85fa 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,34 @@ docker-compose up -d 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 # Check backend health curl http://localhost:8000/health # 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 " +``` + +> **Important:** Change the default `admin123` password and `SECRET_KEY` in production. + ## Services | Service | Port | Description | @@ -72,31 +93,39 @@ Once the backend is running, access the interactive API documentation at: ``` Aegis/ -├── docker-compose.yml # Docker services configuration +├── docker-compose.yml # Docker services configuration ├── backend/ -│ ├── Dockerfile # Backend container definition -│ ├── requirements.txt # Python dependencies -│ ├── alembic.ini # Alembic configuration -│ ├── alembic/ # Database migrations +│ ├── Dockerfile # Backend container definition +│ ├── requirements.txt # Python dependencies +│ ├── alembic.ini # Alembic configuration +│ ├── alembic/ # Database migrations │ │ ├── env.py -│ │ ├── versions/ # Migration files +│ │ ├── versions/ # Migration files │ │ └── ... │ └── app/ │ ├── __init__.py -│ ├── main.py # FastAPI application entry point -│ ├── config.py # Application settings -│ ├── database.py # SQLAlchemy configuration -│ ├── models/ # SQLAlchemy models -│ │ ├── user.py # User authentication model -│ │ ├── technique.py # MITRE ATT&CK techniques -│ │ ├── test.py # Security tests -│ │ ├── evidence.py # Test evidence files -│ │ ├── intel.py # Threat intelligence items -│ │ ├── audit.py # Audit logging -│ │ └── enums.py # Shared enumerations -│ └── services/ # Business logic services +│ ├── main.py # FastAPI application entry point +│ ├── config.py # Application settings +│ ├── database.py # SQLAlchemy configuration +│ ├── auth.py # Password hashing & JWT utilities +│ ├── seed.py # Admin seed script (python -m app.seed) +│ ├── models/ # SQLAlchemy models +│ │ ├── user.py # User authentication model +│ │ ├── technique.py # MITRE ATT&CK techniques +│ │ ├── test.py # Security tests +│ │ ├── evidence.py # Test evidence files +│ │ ├── intel.py # Threat intelligence items +│ │ ├── 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 -└── frontend/ # React frontend (coming soon) +└── frontend/ # React frontend (coming soon) ``` ## 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 | | `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_ACCESS_KEY` | `minioadmin` | MinIO access key | | `MINIO_SECRET_KEY` | `minioadmin` | MinIO secret key | diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..5deaa33 --- /dev/null +++ b/backend/app/auth.py @@ -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) diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/dependencies/auth.py b/backend/app/dependencies/auth.py new file mode 100644 index 0000000..c1443a0 --- /dev/null +++ b/backend/app/dependencies/auth.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 0757620..c8e20e5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,22 @@ from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.routers import auth as auth_router 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") def health(): diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..3a10a96 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..719c0df --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/seed.py b/backend/app/seed.py new file mode 100644 index 0000000..ef695de --- /dev/null +++ b/backend/app/seed.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt index 20e391d..ecea93a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,6 +5,7 @@ psycopg2-binary alembic python-jose[cryptography] passlib[bcrypt] +bcrypt==4.0.1 boto3 apscheduler requests