Complete backend infrastructure and authentication system

Co-authored-by: mblanke <9078342+mblanke@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-09 14:29:06 +00:00
parent af23e610b2
commit 961946026a
47 changed files with 2337 additions and 1 deletions

67
.gitignore vendored Normal file
View File

@@ -0,0 +1,67 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnp
.pnp.js
# Testing
.coverage
.pytest_cache/
htmlcov/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS
.DS_Store
Thumbs.db
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
logs/
# Docker
*.pid

217
README.md
View File

@@ -1 +1,216 @@
# ThreatHunt
# VelociCompanion
A multi-tenant threat hunting companion for Velociraptor with JWT authentication and role-based access control.
## Features
- **JWT Authentication**: Secure token-based authentication system
- **Multi-Tenancy**: Complete data isolation between tenants
- **Role-Based Access Control**: Admin and user roles with different permissions
- **RESTful API**: FastAPI backend with automatic OpenAPI documentation
- **React Frontend**: Modern TypeScript React application with authentication
- **Database Migrations**: Alembic for database schema management
- **Docker Support**: Complete Docker Compose setup for easy deployment
## Project Structure
```
ThreatHunt/
├── backend/
│ ├── alembic/ # Database migrations
│ ├── app/
│ │ ├── api/routes/ # API endpoints
│ │ │ ├── auth.py # Authentication routes
│ │ │ ├── users.py # User management
│ │ │ ├── tenants.py # Tenant management
│ │ │ ├── hosts.py # Host management
│ │ │ ├── ingestion.py # Data ingestion
│ │ │ └── vt.py # VirusTotal integration
│ │ ├── core/ # Core functionality
│ │ │ ├── config.py # Configuration
│ │ │ ├── database.py # Database setup
│ │ │ ├── security.py # Password hashing, JWT
│ │ │ └── deps.py # FastAPI dependencies
│ │ ├── models/ # SQLAlchemy models
│ │ └── schemas/ # Pydantic schemas
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/
│ ├── public/
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── context/ # Auth context
│ │ ├── pages/ # Page components
│ │ ├── utils/ # API utilities
│ │ ├── App.tsx
│ │ └── index.tsx
│ ├── package.json
│ └── Dockerfile
└── docker-compose.yml
```
## Getting Started
### Prerequisites
- Docker and Docker Compose
- Python 3.11+ (for local development)
- Node.js 18+ (for local development)
### Quick Start with Docker
1. Clone the repository:
```bash
git clone https://github.com/mblanke/ThreatHunt.git
cd ThreatHunt
```
2. Start all services:
```bash
docker-compose up -d
```
3. Access the application:
- Frontend: http://localhost:3000
- Backend API: http://localhost:8000
- API Documentation: http://localhost:8000/docs
### Local Development
#### Backend
```bash
cd backend
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements.txt
# Set up environment variables
cp .env.example .env
# Edit .env with your settings
# Run migrations
alembic upgrade head
# Start development server
uvicorn app.main:app --reload
```
#### Frontend
```bash
cd frontend
npm install
npm start
```
## API Endpoints
### Authentication
- `POST /api/auth/register` - Register a new user
- `POST /api/auth/login` - Login and receive JWT token
- `GET /api/auth/me` - Get current user profile
- `PUT /api/auth/me` - Update current user profile
### User Management (Admin only)
- `GET /api/users` - List all users in tenant
- `GET /api/users/{user_id}` - Get user by ID
- `PUT /api/users/{user_id}` - Update user
- `DELETE /api/users/{user_id}` - Deactivate user
### Tenants
- `GET /api/tenants` - List tenants
- `POST /api/tenants` - Create tenant (admin)
- `GET /api/tenants/{tenant_id}` - Get tenant by ID
### Hosts
- `GET /api/hosts` - List hosts (scoped to tenant)
- `POST /api/hosts` - Create host
- `GET /api/hosts/{host_id}` - Get host by ID
### Ingestion
- `POST /api/ingestion/ingest` - Ingest data from Velociraptor
### VirusTotal
- `POST /api/vt/lookup` - Lookup hash in VirusTotal
## Authentication Flow
1. User registers or logs in via `/api/auth/login`
2. Backend returns JWT token with user_id, tenant_id, and role
3. Frontend stores token in localStorage
4. All subsequent API requests include token in Authorization header
5. Backend validates token and enforces tenant scoping
## Multi-Tenancy
- All data is scoped to tenant_id
- Users can only access data within their tenant
- Admin users have elevated permissions within their tenant
- Cross-tenant access requires explicit permissions
## Database Migrations
Create a new migration:
```bash
cd backend
alembic revision --autogenerate -m "Description of changes"
```
Apply migrations:
```bash
alembic upgrade head
```
Rollback migrations:
```bash
alembic downgrade -1
```
## Environment Variables
### Backend
- `DATABASE_URL` - PostgreSQL connection string
- `SECRET_KEY` - Secret key for JWT signing (min 32 characters)
- `ACCESS_TOKEN_EXPIRE_MINUTES` - JWT token expiration time (default: 30)
### Frontend
- `REACT_APP_API_URL` - Backend API URL (default: http://localhost:8000)
## Security
- Passwords are hashed using bcrypt
- JWT tokens include expiration time
- All API endpoints (except login/register) require authentication
- Role-based access control for admin operations
- Data isolation through tenant scoping
## Testing
### Backend
```bash
cd backend
pytest
```
### Frontend
```bash
cd frontend
npm test
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
## License
[Your License Here]
## Support
For issues and questions, please open an issue on GitHub.

3
backend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
DATABASE_URL=postgresql://postgres:postgres@db:5432/velocicompanion
SECRET_KEY=your-secret-key-change-in-production-min-32-chars-long
ACCESS_TOKEN_EXPIRE_MINUTES=30

13
backend/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Run migrations and start server
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"]

147
backend/alembic.ini Normal file
View File

@@ -0,0 +1,147 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
# sqlalchemy.url is configured in env.py from app.core.config
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

95
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,95 @@
from logging.config import fileConfig
import sys
from pathlib import Path
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Add app directory to Python path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# Import models and database
from app.core.database import Base
from app.core.config import settings
# Import all models to ensure they're registered with Base
from app.models.tenant import Tenant
from app.models.user import User
from app.models.host import Host
from app.models.case import Case
from app.models.artifact import Artifact
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Set the database URL from settings
config.set_main_option("sqlalchemy.url", settings.database_url)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,114 @@
"""Initial migration
Revision ID: f82b3092d056
Revises:
Create Date: 2025-12-09 14:25:47.222289
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f82b3092d056'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# Create tenants table
op.create_table(
'tenants',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tenants_id'), 'tenants', ['id'], unique=False)
op.create_index(op.f('ix_tenants_name'), 'tenants', ['name'], unique=True)
# Create users table
op.create_table(
'users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('password_hash', sa.String(), nullable=False),
sa.Column('role', sa.String(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
# Create hosts table
op.create_table(
'hosts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('hostname', sa.String(), nullable=False),
sa.Column('ip_address', sa.String(), nullable=True),
sa.Column('os', sa.String(), nullable=True),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('host_metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('last_seen', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_hosts_id'), 'hosts', ['id'], unique=False)
op.create_index(op.f('ix_hosts_hostname'), 'hosts', ['hostname'], unique=False)
# Create cases table
op.create_table(
'cases',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('severity', sa.String(), nullable=True),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_cases_id'), 'cases', ['id'], unique=False)
# Create artifacts table
op.create_table(
'artifacts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('artifact_type', sa.String(), nullable=False),
sa.Column('value', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('case_id', sa.Integer(), nullable=True),
sa.Column('artifact_metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_artifacts_id'), 'artifacts', ['id'], unique=False)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f('ix_artifacts_id'), table_name='artifacts')
op.drop_table('artifacts')
op.drop_index(op.f('ix_cases_id'), table_name='cases')
op.drop_table('cases')
op.drop_index(op.f('ix_hosts_hostname'), table_name='hosts')
op.drop_index(op.f('ix_hosts_id'), table_name='hosts')
op.drop_table('hosts')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_tenants_name'), table_name='tenants')
op.drop_index(op.f('ix_tenants_id'), table_name='tenants')
op.drop_table('tenants')

0
backend/app/__init__.py Normal file
View File

View File

View File

View File

@@ -0,0 +1,164 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import verify_password, get_password_hash, create_access_token
from app.core.deps import get_current_active_user
from app.models.user import User
from app.models.tenant import Tenant
from app.schemas.auth import Token, UserLogin, UserRegister
from app.schemas.user import UserRead, UserUpdate
router = APIRouter()
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserRegister,
db: Session = Depends(get_db)
):
"""
Register a new user
Creates a new user with hashed password. If tenant_id is not provided,
a default tenant is created or used.
"""
# Check if username already exists
existing_user = db.query(User).filter(User.username == user_data.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Handle tenant_id
tenant_id = user_data.tenant_id
if tenant_id is None:
# Create or get default tenant
default_tenant = db.query(Tenant).filter(Tenant.name == "default").first()
if not default_tenant:
default_tenant = Tenant(name="default", description="Default tenant")
db.add(default_tenant)
db.commit()
db.refresh(default_tenant)
tenant_id = default_tenant.id
else:
# Verify tenant exists
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
# Create new user with hashed password
hashed_password = get_password_hash(user_data.password)
new_user = User(
username=user_data.username,
password_hash=hashed_password,
role=user_data.role,
tenant_id=tenant_id
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""
Authenticate user and return JWT token
Uses OAuth2 password flow for compatibility with OpenAPI docs.
"""
# Find user by username
user = db.query(User).filter(User.username == form_data.username).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify password
if not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Check if user is active
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
# Create access token
access_token = create_access_token(
data={
"sub": user.id,
"tenant_id": user.tenant_id,
"role": user.role
}
)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/me", response_model=UserRead)
async def get_current_user_profile(
current_user: User = Depends(get_current_active_user)
):
"""
Get current user profile
Returns the profile of the authenticated user.
"""
return current_user
@router.put("/me", response_model=UserRead)
async def update_current_user_profile(
user_update: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Update current user profile
Allows users to update their own profile information.
"""
# Update username if provided
if user_update.username is not None:
# Check if new username is already taken
existing_user = db.query(User).filter(
User.username == user_update.username,
User.id != current_user.id
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken"
)
current_user.username = user_update.username
# Update password if provided
if user_update.password is not None:
current_user.password_hash = get_password_hash(user_update.password)
# Users cannot change their own role through this endpoint
# (admin users should use the admin endpoints in /users)
db.commit()
db.refresh(current_user)
return current_user

View File

@@ -0,0 +1,97 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime
from app.core.database import get_db
from app.core.deps import get_current_active_user, get_tenant_id
from app.models.user import User
from app.models.host import Host
router = APIRouter()
class HostCreate(BaseModel):
hostname: str
ip_address: Optional[str] = None
os: Optional[str] = None
host_metadata: Optional[dict] = None
class HostRead(BaseModel):
id: int
hostname: str
ip_address: Optional[str] = None
os: Optional[str] = None
tenant_id: int
host_metadata: Optional[dict] = None
created_at: datetime
last_seen: datetime
class Config:
from_attributes = True
@router.get("/", response_model=List[HostRead])
async def list_hosts(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""
List hosts scoped to user's tenant
"""
hosts = db.query(Host).filter(Host.tenant_id == tenant_id).offset(skip).limit(limit).all()
return hosts
@router.post("/", response_model=HostRead, status_code=status.HTTP_201_CREATED)
async def create_host(
host_data: HostCreate,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""
Create a new host
"""
new_host = Host(
hostname=host_data.hostname,
ip_address=host_data.ip_address,
os=host_data.os,
tenant_id=tenant_id,
host_metadata=host_data.host_metadata
)
db.add(new_host)
db.commit()
db.refresh(new_host)
return new_host
@router.get("/{host_id}", response_model=HostRead)
async def get_host(
host_id: int,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""
Get host by ID (scoped to tenant)
"""
host = db.query(Host).filter(
Host.id == host_id,
Host.tenant_id == tenant_id
).first()
if not host:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Host not found"
)
return host

View File

@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional, Dict, Any
from app.core.database import get_db
from app.core.deps import get_current_active_user, get_tenant_id
from app.models.user import User
from app.models.host import Host
router = APIRouter()
class IngestionData(BaseModel):
hostname: str
data: Dict[str, Any]
class IngestionResponse(BaseModel):
message: str
host_id: int
@router.post("/ingest", response_model=IngestionResponse)
async def ingest_data(
ingestion: IngestionData,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""
Ingest data from Velociraptor
Creates or updates host information scoped to the user's tenant.
"""
# Find or create host
host = db.query(Host).filter(
Host.hostname == ingestion.hostname,
Host.tenant_id == tenant_id
).first()
if host:
# Update existing host
host.host_metadata = ingestion.data
else:
# Create new host
host = Host(
hostname=ingestion.hostname,
tenant_id=tenant_id,
host_metadata=ingestion.data
)
db.add(host)
db.commit()
db.refresh(host)
return {
"message": "Data ingested successfully",
"host_id": host.id
}

View File

@@ -0,0 +1,103 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.deps import get_current_active_user, require_role
from app.models.user import User
from app.models.tenant import Tenant
from pydantic import BaseModel
router = APIRouter()
class TenantCreate(BaseModel):
name: str
description: str = None
class TenantRead(BaseModel):
id: int
name: str
description: str = None
class Config:
from_attributes = True
@router.get("/", response_model=List[TenantRead])
async def list_tenants(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
List tenants
Regular users can only see their own tenant.
Admins can see all tenants (cross-tenant access).
"""
if current_user.role == "admin":
# Admins can see all tenants
tenants = db.query(Tenant).all()
else:
# Regular users only see their tenant
tenants = db.query(Tenant).filter(Tenant.id == current_user.tenant_id).all()
return tenants
@router.post("/", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
async def create_tenant(
tenant_data: TenantCreate,
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Create a new tenant (admin only)
"""
# Check if tenant name already exists
existing_tenant = db.query(Tenant).filter(Tenant.name == tenant_data.name).first()
if existing_tenant:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant name already exists"
)
new_tenant = Tenant(
name=tenant_data.name,
description=tenant_data.description
)
db.add(new_tenant)
db.commit()
db.refresh(new_tenant)
return new_tenant
@router.get("/{tenant_id}", response_model=TenantRead)
async def get_tenant(
tenant_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Get tenant by ID
Users can only view their own tenant unless they are admin.
"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
# Check permissions
if current_user.role != "admin" and tenant.id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to view this tenant"
)
return tenant

View File

@@ -0,0 +1,154 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.deps import get_current_active_user, require_role
from app.core.security import get_password_hash
from app.models.user import User
from app.schemas.user import UserRead, UserUpdate, UserCreate
router = APIRouter()
@router.get("/", response_model=List[UserRead])
async def list_users(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
List all users (admin only, scoped to tenant)
Admins can only see users within their own tenant unless they have
cross-tenant access.
"""
# Scope to tenant
query = db.query(User).filter(User.tenant_id == current_user.tenant_id)
users = query.offset(skip).limit(limit).all()
return users
@router.get("/{user_id}", response_model=UserRead)
async def get_user(
user_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Get user by ID
Users can view their own profile or admins can view users in their tenant.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check permissions: user can view themselves or admin can view users in their tenant
if user.id != current_user.id and (
current_user.role != "admin" or user.tenant_id != current_user.tenant_id
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to view this user"
)
return user
@router.put("/{user_id}", response_model=UserRead)
async def update_user(
user_id: int,
user_update: UserUpdate,
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Update user (admin only)
Admins can update users within their tenant.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check tenant access
if user.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to update this user"
)
# Update fields
if user_update.username is not None:
# Check if new username is already taken
existing_user = db.query(User).filter(
User.username == user_update.username,
User.id != user_id
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken"
)
user.username = user_update.username
if user_update.password is not None:
user.password_hash = get_password_hash(user_update.password)
if user_update.role is not None:
user.role = user_update.role
if user_update.is_active is not None:
user.is_active = user_update.is_active
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Deactivate user (admin only)
Soft delete by setting is_active to False.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check tenant access
if user.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to delete this user"
)
# Prevent self-deletion
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account"
)
# Soft delete
user.is_active = False
db.commit()
return None

View File

@@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.user import User
router = APIRouter()
class VTLookupRequest(BaseModel):
hash: str
class VTLookupResponse(BaseModel):
hash: str
malicious: Optional[bool] = None
message: str
@router.post("/lookup", response_model=VTLookupResponse)
async def virustotal_lookup(
request: VTLookupRequest,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Lookup hash in VirusTotal
Requires authentication. In a real implementation, this would call
the VirusTotal API.
"""
# Placeholder implementation
return {
"hash": request.hash,
"malicious": None,
"message": "VirusTotal integration not yet implemented"
}

View File

View File

@@ -0,0 +1,16 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings"""
app_name: str = "VelociCompanion"
database_url: str = "postgresql://postgres:postgres@db:5432/velocicompanion"
secret_key: str = "your-secret-key-change-in-production-min-32-chars-long"
access_token_expire_minutes: int = 30
algorithm: str = "HS256"
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""Dependency for getting database session"""
db = SessionLocal()
try:
yield db
finally:
db.close()

108
backend/app/core/deps.py Normal file
View File

@@ -0,0 +1,108 @@
from typing import List, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import verify_token
from app.models.user import User
# OAuth2 scheme for token extraction
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> User:
"""
Extract and validate JWT from Authorization header
Args:
token: JWT token from Authorization header
db: Database session
Returns:
Current user object
Raises:
HTTPException: If token is invalid or user not found
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify and decode token
payload = verify_token(token)
if payload is None:
raise credentials_exception
user_id: Optional[int] = payload.get("sub")
if user_id is None:
raise credentials_exception
# Get user from database
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
Ensure user is active
Args:
current_user: Current user from get_current_user
Returns:
Active user object
Raises:
HTTPException: If user is inactive
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return current_user
def require_role(allowed_roles: List[str]):
"""
Role-based access control dependency factory
Args:
allowed_roles: List of roles that are allowed to access the endpoint
Returns:
Dependency function that checks user role
"""
async def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
return current_user
return role_checker
async def get_tenant_id(current_user: User = Depends(get_current_active_user)) -> int:
"""
Extract tenant_id from current user for scoping queries
Args:
current_user: Current active user
Returns:
Tenant ID
"""
return current_user.tenant_id

View File

@@ -0,0 +1,58 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hashed password"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password using bcrypt"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token
Args:
data: Dictionary containing user_id, tenant_id, role
expires_delta: Optional expiration time delta
Returns:
Encoded JWT token
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
return encoded_jwt
def verify_token(token: str) -> Optional[dict]:
"""
Verify and decode a JWT token
Args:
token: JWT token string
Returns:
Decoded token payload or None if invalid
"""
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
return payload
except JWTError:
return None

44
backend/app/main.py Normal file
View File

@@ -0,0 +1,44 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, users, tenants, hosts, ingestion, vt
from app.core.config import settings
app = FastAPI(
title=settings.app_name,
description="Multi-tenant threat hunting companion for Velociraptor",
version="0.1.0"
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:5173"], # Frontend URLs
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
app.include_router(users.router, prefix="/api/users", tags=["Users"])
app.include_router(tenants.router, prefix="/api/tenants", tags=["Tenants"])
app.include_router(hosts.router, prefix="/api/hosts", tags=["Hosts"])
app.include_router(ingestion.router, prefix="/api/ingestion", tags=["Ingestion"])
app.include_router(vt.router, prefix="/api/vt", tags=["VirusTotal"])
@app.get("/")
async def root():
"""Root endpoint"""
return {
"message": f"Welcome to {settings.app_name}",
"version": "0.1.0",
"docs": "/docs"
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy"}

View File

View File

@@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
from app.core.database import Base
class Artifact(Base):
__tablename__ = "artifacts"
id = Column(Integer, primary_key=True, index=True)
artifact_type = Column(String, nullable=False) # hash, ip, domain, email, etc.
value = Column(String, nullable=False)
description = Column(Text, nullable=True)
case_id = Column(Integer, ForeignKey("cases.id"), nullable=True)
artifact_metadata = Column(JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
case = relationship("Case", back_populates="artifacts")

View File

@@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime
from app.core.database import Base
class Case(Base):
__tablename__ = "cases"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
status = Column(String, default="open", nullable=False) # open, closed, investigating
severity = Column(String, nullable=True) # low, medium, high, critical
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
tenant = relationship("Tenant", back_populates="cases")
artifacts = relationship("Artifact", back_populates="case")

View File

@@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
from app.core.database import Base
class Host(Base):
__tablename__ = "hosts"
id = Column(Integer, primary_key=True, index=True)
hostname = Column(String, index=True, nullable=False)
ip_address = Column(String, nullable=True)
os = Column(String, nullable=True)
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
host_metadata = Column(JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
last_seen = Column(DateTime, default=datetime.utcnow)
# Relationships
tenant = relationship("Tenant", back_populates="hosts")

View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.orm import relationship
from datetime import datetime
from app.core.database import Base
class Tenant(Base):
__tablename__ = "tenants"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False)
description = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
users = relationship("User", back_populates="tenant")
hosts = relationship("Host", back_populates="tenant")
cases = relationship("Case", back_populates="tenant")

View File

@@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
password_hash = Column(String, nullable=False)
role = Column(String, default="user", nullable=False) # user, admin
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
tenant = relationship("Tenant", back_populates="users")

View File

View File

@@ -0,0 +1,29 @@
from pydantic import BaseModel
from typing import Optional
class Token(BaseModel):
"""Token response schema"""
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
"""Token payload data"""
user_id: Optional[int] = None
tenant_id: Optional[int] = None
role: Optional[str] = None
class UserLogin(BaseModel):
"""User login request schema"""
username: str
password: str
class UserRegister(BaseModel):
"""User registration request schema"""
username: str
password: str
tenant_id: Optional[int] = None
role: str = "user"

View File

@@ -0,0 +1,33 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
"""Base user schema"""
username: str
role: str = "user"
tenant_id: int
class UserCreate(UserBase):
"""Schema for creating a user"""
password: str
class UserUpdate(BaseModel):
"""Schema for updating a user"""
username: Optional[str] = None
password: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
class UserRead(UserBase):
"""Schema for reading user data (excludes password_hash)"""
id: int
is_active: bool
created_at: datetime
class Config:
from_attributes = True

10
backend/requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
alembic==1.13.1
pydantic==2.5.3
pydantic-settings==2.1.0

51
docker-compose.yml Normal file
View File

@@ -0,0 +1,51 @@
version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: velocicompanion
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/velocicompanion
- SECRET_KEY=your-secret-key-change-in-production-min-32-chars-long
- ACCESS_TOKEN_EXPIRE_MINUTES=30
depends_on:
db:
condition: service_healthy
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- REACT_APP_API_URL=http://localhost:8000
volumes:
- ./frontend:/app
- /app/node_modules
command: npm start
volumes:
postgres_data:

15
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy application code
COPY . .
# Start development server
CMD ["npm", "start"]

38
frontend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "velocicompanion-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"react-scripts": "5.0.1",
"typescript": "^5.3.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="VelociCompanion - Multi-tenant threat hunting companion for Velociraptor"
/>
<title>VelociCompanion</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

29
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,29 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import PrivateRoute from './components/PrivateRoute';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
const App: React.FC = () => {
return (
<Router>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<PrivateRoute>
<Dashboard />
</PrivateRoute>
}
/>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</AuthProvider>
</Router>
);
};
export default App;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
interface PrivateRouteProps {
children: React.ReactNode;
}
const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div style={styles.container}>
<div style={styles.spinner}>Loading...</div>
</div>
);
}
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
};
const styles: { [key: string]: React.CSSProperties } = {
container: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: '#f5f5f5',
},
spinner: {
fontSize: '18px',
color: '#666',
},
};
export default PrivateRoute;

View File

@@ -0,0 +1,85 @@
import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react';
import { authAPI } from '../utils/api';
interface User {
id: number;
username: string;
role: string;
tenant_id: number;
is_active: boolean;
created_at: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
// Check if user is already logged in on mount
const checkAuth = async () => {
const token = localStorage.getItem('access_token');
if (token) {
try {
const userData = await authAPI.getCurrentUser();
setUser(userData);
} catch (error) {
console.error('Failed to fetch user:', error);
localStorage.removeItem('access_token');
}
}
setLoading(false);
};
checkAuth();
}, []);
const login = async (username: string, password: string) => {
try {
const response = await authAPI.login(username, password);
localStorage.setItem('access_token', response.access_token);
// Fetch user data after login
const userData = await authAPI.getCurrentUser();
setUser(userData);
} catch (error) {
throw error;
}
};
const logout = () => {
localStorage.removeItem('access_token');
setUser(null);
};
const value: AuthContextType = {
user,
loading,
login,
logout,
isAuthenticated: !!user,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

13
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { useAuth } from '../context/AuthContext';
const Dashboard: React.FC = () => {
const { user, logout } = useAuth();
return (
<div style={styles.container}>
<div style={styles.header}>
<h1 style={styles.title}>VelociCompanion Dashboard</h1>
<button onClick={logout} style={styles.logoutButton}>
Logout
</button>
</div>
<div style={styles.content}>
<div style={styles.card}>
<h2>Welcome, {user?.username}!</h2>
<p><strong>Role:</strong> {user?.role}</p>
<p><strong>Tenant ID:</strong> {user?.tenant_id}</p>
<p><strong>Status:</strong> {user?.is_active ? 'Active' : 'Inactive'}</p>
</div>
<div style={styles.card}>
<h3>Getting Started</h3>
<p>Your authentication system is now set up and working!</p>
<ul>
<li> JWT authentication</li>
<li> Multi-tenancy support</li>
<li> Role-based access control</li>
<li> Protected routes</li>
</ul>
</div>
</div>
</div>
);
};
const styles: { [key: string]: React.CSSProperties } = {
container: {
minHeight: '100vh',
backgroundColor: '#f5f5f5',
},
header: {
backgroundColor: 'white',
padding: '20px 40px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
title: {
margin: 0,
fontSize: '24px',
color: '#333',
},
logoutButton: {
padding: '10px 20px',
fontSize: '14px',
fontWeight: '500',
color: 'white',
backgroundColor: '#dc3545',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
},
content: {
padding: '40px',
maxWidth: '1200px',
margin: '0 auto',
},
card: {
backgroundColor: 'white',
padding: '30px',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
marginBottom: '20px',
},
};
export default Dashboard;

View File

@@ -0,0 +1,148 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const Login: React.FC = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { login } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(username, password);
navigate('/');
} catch (err: any) {
setError(err.response?.data?.detail || 'Login failed. Please check your credentials.');
} finally {
setLoading(false);
}
};
return (
<div style={styles.container}>
<div style={styles.loginBox}>
<h1 style={styles.title}>VelociCompanion</h1>
<h2 style={styles.subtitle}>Login</h2>
<form onSubmit={handleSubmit} style={styles.form}>
{error && <div style={styles.error}>{error}</div>}
<div style={styles.formGroup}>
<label htmlFor="username" style={styles.label}>Username</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
style={styles.input}
disabled={loading}
/>
</div>
<div style={styles.formGroup}>
<label htmlFor="password" style={styles.label}>Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={styles.input}
disabled={loading}
/>
</div>
<button
type="submit"
style={styles.button}
disabled={loading}
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
</div>
);
};
const styles: { [key: string]: React.CSSProperties } = {
container: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: '#f5f5f5',
},
loginBox: {
backgroundColor: 'white',
padding: '40px',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
width: '100%',
maxWidth: '400px',
},
title: {
margin: '0 0 10px 0',
fontSize: '28px',
color: '#333',
textAlign: 'center',
},
subtitle: {
margin: '0 0 30px 0',
fontSize: '20px',
color: '#666',
textAlign: 'center',
fontWeight: 'normal',
},
form: {
display: 'flex',
flexDirection: 'column',
},
formGroup: {
marginBottom: '20px',
},
label: {
display: 'block',
marginBottom: '8px',
color: '#333',
fontSize: '14px',
fontWeight: '500',
},
input: {
width: '100%',
padding: '12px',
fontSize: '14px',
border: '1px solid #ddd',
borderRadius: '4px',
boxSizing: 'border-box',
},
button: {
padding: '12px',
fontSize: '16px',
fontWeight: '500',
color: 'white',
backgroundColor: '#007bff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginTop: '10px',
},
error: {
padding: '12px',
marginBottom: '20px',
backgroundColor: '#fee',
color: '#c33',
borderRadius: '4px',
fontSize: '14px',
},
};
export default Login;

1
frontend/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

75
frontend/src/utils/api.ts Normal file
View File

@@ -0,0 +1,75 @@
import axios, { AxiosInstance } from 'axios';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
// Create axios instance with default config
const api: AxiosInstance = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add JWT token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle 401 errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid, clear storage and redirect to login
localStorage.removeItem('access_token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
// Auth API functions
export const authAPI = {
login: async (username: string, password: string) => {
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const response = await api.post('/api/auth/login', formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
return response.data;
},
register: async (username: string, password: string, tenantId?: number) => {
const response = await api.post('/api/auth/register', {
username,
password,
tenant_id: tenantId,
});
return response.data;
},
getCurrentUser: async () => {
const response = await api.get('/api/auth/me');
return response.data;
},
updateProfile: async (data: { username?: string; password?: string }) => {
const response = await api.put('/api/auth/me', data);
return response.data;
},
};

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}