diff --git a/backend/requirements.txt b/backend/requirements.txt index b7d7a851..895abb39 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,3 +10,5 @@ python-multipart>=0.0.9 xhtml2pdf>=0.2.17 aiosqlite>=0.20.0 python-whois>=0.9.4 +bcrypt>=4.0.0 +PyJWT>=2.8.0 diff --git a/backend/secuscan/auth.py b/backend/secuscan/auth.py new file mode 100644 index 00000000..08ac9b3f --- /dev/null +++ b/backend/secuscan/auth.py @@ -0,0 +1,66 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +import jwt +import bcrypt + +from .config import settings +from .database import get_db +from .models import User, UserRole + +# Secret key for JWT. In production, this should be a secure random string. +SECRET_KEY = getattr(settings, "secret_key", "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + +def verify_password(plain_password, hashed_password): + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) + +def get_password_hash(password): + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except jwt.InvalidTokenError: + raise credentials_exception + + db = await get_db() + user_row = await db.fetchone("SELECT username, role FROM users WHERE username = ?", (username,)) + if user_row is None: + raise credentials_exception + + return User(username=user_row["username"], role=UserRole(user_row["role"])) + +class RoleChecker: + def __init__(self, allowed_roles: list[UserRole]): + self.allowed_roles = allowed_roles + + def __call__(self, user: User = Depends(get_current_user)): + if user.role not in self.allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Operation not permitted" + ) + return user diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index 588121d3..78bb1c03 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -154,6 +154,13 @@ async def _create_schema(self): UNIQUE(plugin_id, name) ); + CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + role TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS credential_vault ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -204,6 +211,17 @@ async def _create_schema(self): """ ) + # Seed default admin user if none exist + from .auth import get_password_hash + user_count = await self.fetchone("SELECT COUNT(*) as count FROM users") + + if user_count and user_count["count"] == 0: + default_hash = get_password_hash("admin") + await self.execute( + "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", + ("admin", default_hash, "admin") + ) + # Migration logic: ensure latest columns exist in 'tasks' table tasks_columns = await self.fetchall("PRAGMA table_info(tasks)") existing_cols = {col["name"] for col in tasks_columns} diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index ebf57f60..b5a543c1 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -17,7 +17,7 @@ from .cache import init_cache, cache as global_cache from .database import init_db, db as global_db from .plugins import init_plugins -from .routes import router +from .routes import router, auth_router from .workflows import scheduler @@ -124,6 +124,7 @@ async def redirect_api_openapi(): app.add_middleware(RequestIDMiddleware) # Include API routes +app.include_router(auth_router) app.include_router(router) # Health check endpoint diff --git a/backend/secuscan/models.py b/backend/secuscan/models.py index f1792be2..c61efae6 100644 --- a/backend/secuscan/models.py +++ b/backend/secuscan/models.py @@ -181,4 +181,22 @@ class ErrorResponse(BaseModel): class BulkDeleteRequest(RootModel[Annotated[List[str], Field(max_length=MAX_BULK_DELETE)]]): """Accepts a JSON array of task IDs directly. Max 500 per request.""" - pass \ No newline at end of file + pass + + +class UserRole(str, Enum): + ADMIN = "admin" + VIEWER = "viewer" + +class User(BaseModel): + username: str + role: UserRole + +class Token(BaseModel): + access_token: str + token_type: str + +class WebhookConfig(BaseModel): + slack_url: Optional[str] = None + discord_url: Optional[str] = None + custom_url: Optional[str] = None diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index 6d985f6a..3603aa1c 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -111,8 +111,23 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str: from sse_starlette.sse import EventSourceResponse -router = APIRouter(prefix="/api/v1") -SSE_RAW_OUTPUT_CHUNK_SIZE = 64 * 1024 +from fastapi.security import OAuth2PasswordRequestForm +from .auth import get_current_user, RoleChecker, create_access_token, verify_password +from .models import Token, UserRole, User + +router = APIRouter(prefix="/api/v1", dependencies=[Depends(get_current_user)]) + +auth_router = APIRouter(prefix="/api/v1/auth") + +@auth_router.post("/login", response_model=Token) +async def login(form_data: OAuth2PasswordRequestForm = Depends()): + db = await get_db() + user_row = await db.fetchone("SELECT username, password_hash, role FROM users WHERE username = ?", (form_data.username,)) + if not user_row or not verify_password(form_data.password, user_row["password_hash"]): + raise HTTPException(status_code=400, detail="Incorrect username or password") + access_token = create_access_token(data={"sub": user_row["username"], "role": user_row["role"]}) + return Token(access_token=access_token, token_type="bearer") + async def get_or_set_cached(key: str, builder): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4f6b39f..6a1e7e6b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import { ThemeProvider } from './components/ThemeContext' import { ToastProvider, ToastContainer } from './components/ToastContext' import { I18nProvider } from './components/I18nContext' import { routes } from './routes' +import Login from './pages/Login' export function AppRoutes() { return ( @@ -40,9 +41,15 @@ export default function App() { - - - + {localStorage.getItem('token') ? ( + + + + ) : ( + + } /> + + )} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6b4b7fcc..6c04b447 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -87,12 +87,23 @@ async function request(path: string, init?: RequestInit): Promise { const controller = new AbortController() const timeoutId = window.setTimeout(() => controller.abort(), 10000) + const token = localStorage.getItem('token') + const headers = new Headers(init?.headers) + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + const response = await fetch(`${API_BASE}${path}`, { ...init, + headers, signal: controller.signal, }) window.clearTimeout(timeoutId) if (!response.ok) { + if (response.status === 401) { + localStorage.removeItem('token') + window.location.href = '/login' + } throw new Error(`Request failed: ${response.status}`) } return response.json() diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 00000000..6b637c1a --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react' +import { API_BASE } from '../api' + +export default function Login() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + try { + const formData = new URLSearchParams() + formData.append('username', username) + formData.append('password', password) + + const res = await fetch(`${API_BASE}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formData, + }) + + if (!res.ok) { + throw new Error('Invalid credentials') + } + + const data = await res.json() + localStorage.setItem('token', data.access_token) + window.location.href = '/' + } catch (err: any) { + setError(err.message) + } + } + + return ( + + + + + SecuScan + + + Sign in to your account + + + + {error && ( + + {error} + + )} + + + setUsername(e.target.value)} + /> + + + setPassword(e.target.value)} + /> + + + + + + Sign In + + + + + + ) +} diff --git a/pyproject.toml b/pyproject.toml index 8a0908e7..8582b23c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,9 @@ dependencies = [ "python-multipart>=0.0.9", "xhtml2pdf>=0.2.17", "aiosqlite>=0.20.0", - "python-whois>=0.9.4" + "python-whois>=0.9.4", + "PyJWT>=2.8.0", + "bcrypt>=4.0.0" ] [project.scripts] diff --git a/testing/backend/conftest.py b/testing/backend/conftest.py index ad7707a2..8865f05a 100644 --- a/testing/backend/conftest.py +++ b/testing/backend/conftest.py @@ -61,6 +61,10 @@ async def setup(): asyncio.run(setup()) + + from backend.secuscan.auth import get_current_user + from backend.secuscan.models import User, UserRole + app.dependency_overrides[get_current_user] = lambda: User(username="test_admin", role=UserRole.ADMIN) with TestClient(app) as client: yield client
+ Sign in to your account +