Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 66 additions & 0 deletions backend/secuscan/auth.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions backend/secuscan/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 2 additions & 1 deletion backend/secuscan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion backend/secuscan/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
19 changes: 17 additions & 2 deletions backend/secuscan/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -40,9 +41,15 @@ export default function App() {
<I18nProvider>
<ToastProvider>
<Router>
<AppShell>
<AppRoutes />
</AppShell>
{localStorage.getItem('token') ? (
<AppShell>
<AppRoutes />
</AppShell>
) : (
<Routes>
<Route path="*" element={<Login />} />
</Routes>
)}
</Router>
</ToastProvider>
</I18nProvider>
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,23 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
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()
Expand Down
89 changes: 89 additions & 0 deletions frontend/src/pages/Login.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-100 dark:border-gray-700">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
SecuScan
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Sign in to your account
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
{error && (
<div className="text-red-500 text-sm text-center bg-red-50 dark:bg-red-900/30 p-2 rounded">
{error}
</div>
)}
<div className="rounded-md shadow-sm space-y-4">
<div>
<input
name="username"
type="text"
required
className="appearance-none rounded relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
/>
</div>
<div>
<input
name="password"
type="password"
required
className="appearance-none rounded relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</div>
</div>

<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Sign In
</button>
</div>
</form>
</div>
</div>
)
}
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions testing/backend/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading