mirror of
https://github.com/mblanke/ThreatHunt.git
synced 2026-03-01 14:00:20 -05:00
Complete backend infrastructure and authentication system
Co-authored-by: mblanke <9078342+mblanke@users.noreply.github.com>
This commit is contained in:
67
.gitignore
vendored
Normal file
67
.gitignore
vendored
Normal 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
217
README.md
@@ -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
3
backend/.env.example
Normal 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
13
backend/Dockerfile
Normal 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
147
backend/alembic.ini
Normal 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
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
95
backend/alembic/env.py
Normal file
95
backend/alembic/env.py
Normal 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()
|
||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal 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"}
|
||||
114
backend/alembic/versions/f82b3092d056_initial_migration.py
Normal file
114
backend/alembic/versions/f82b3092d056_initial_migration.py
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
164
backend/app/api/routes/auth.py
Normal file
164
backend/app/api/routes/auth.py
Normal 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
|
||||
97
backend/app/api/routes/hosts.py
Normal file
97
backend/app/api/routes/hosts.py
Normal 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
|
||||
60
backend/app/api/routes/ingestion.py
Normal file
60
backend/app/api/routes/ingestion.py
Normal 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
|
||||
}
|
||||
103
backend/app/api/routes/tenants.py
Normal file
103
backend/app/api/routes/tenants.py
Normal 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
|
||||
154
backend/app/api/routes/users.py
Normal file
154
backend/app/api/routes/users.py
Normal 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
|
||||
40
backend/app/api/routes/vt.py
Normal file
40
backend/app/api/routes/vt.py
Normal 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"
|
||||
}
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
16
backend/app/core/config.py
Normal file
16
backend/app/core/config.py
Normal 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()
|
||||
19
backend/app/core/database.py
Normal file
19
backend/app/core/database.py
Normal 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
108
backend/app/core/deps.py
Normal 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
|
||||
58
backend/app/core/security.py
Normal file
58
backend/app/core/security.py
Normal 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
44
backend/app/main.py
Normal 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"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
20
backend/app/models/artifact.py
Normal file
20
backend/app/models/artifact.py
Normal 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")
|
||||
22
backend/app/models/case.py
Normal file
22
backend/app/models/case.py
Normal 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")
|
||||
21
backend/app/models/host.py
Normal file
21
backend/app/models/host.py
Normal 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")
|
||||
19
backend/app/models/tenant.py
Normal file
19
backend/app/models/tenant.py
Normal 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")
|
||||
20
backend/app/models/user.py
Normal file
20
backend/app/models/user.py
Normal 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")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
29
backend/app/schemas/auth.py
Normal file
29
backend/app/schemas/auth.py
Normal 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"
|
||||
33
backend/app/schemas/user.py
Normal file
33
backend/app/schemas/user.py
Normal 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
10
backend/requirements.txt
Normal 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
51
docker-compose.yml
Normal 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
15
frontend/Dockerfile
Normal 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
38
frontend/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
17
frontend/public/index.html
Normal file
17
frontend/public/index.html
Normal 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
29
frontend/src/App.tsx
Normal 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;
|
||||
37
frontend/src/components/PrivateRoute.tsx
Normal file
37
frontend/src/components/PrivateRoute.tsx
Normal 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;
|
||||
85
frontend/src/context/AuthContext.tsx
Normal file
85
frontend/src/context/AuthContext.tsx
Normal 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
13
frontend/src/index.tsx
Normal 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>
|
||||
);
|
||||
81
frontend/src/pages/Dashboard.tsx
Normal file
81
frontend/src/pages/Dashboard.tsx
Normal 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;
|
||||
148
frontend/src/pages/Login.tsx
Normal file
148
frontend/src/pages/Login.tsx
Normal 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
1
frontend/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
75
frontend/src/utils/api.ts
Normal file
75
frontend/src/utils/api.ts
Normal 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
26
frontend/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user