From 205b69713e718b806619882583b6176198858981 Mon Sep 17 00:00:00 2001 From: Tomas Kracmar Date: Sat, 29 Nov 2025 14:19:34 +0100 Subject: [PATCH] Added authentication --- .env.example | 8 ++ README.md | 10 +++ backend/auth.py | 82 ++++++++++++++++++++ backend/config.py | 8 ++ backend/frontend/index.html | 151 ++++++++++++++++++++++++++++++++---- backend/main.py | 2 + backend/requirements.txt | 1 + backend/routes/config.py | 20 +++++ backend/routes/events.py | 5 +- backend/routes/fetch.py | 5 +- 10 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 backend/auth.py create mode 100644 backend/routes/config.py diff --git a/.env.example b/.env.example index e60e0dc..d34441a 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,11 @@ CLIENT_SECRET=your-client-secret MONGO_URI=mongodb://root:example@mongo:27017/ ENABLE_PERIODIC_FETCH=false FETCH_INTERVAL_MINUTES=60 +AUTH_ENABLED=false +AUTH_TENANT_ID=your-tenant-id +AUTH_CLIENT_ID=your-api-client-id +# Optional scope for SPA login (e.g., api:///access_as_user) +AUTH_SCOPE= +# Comma-separated lists (optional): +AUTH_ALLOWED_ROLES= +AUTH_ALLOWED_GROUPS= diff --git a/README.md b/README.md index 40e3c93..2181a73 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ FastAPI microservice that ingests Microsoft Entra (Azure AD) and other admin aud - Microsoft Graph client (client credentials) for retrieving directory audit events and Intune audit events. - Office 365 Management Activity API client for Exchange/SharePoint/Teams admin audit logs. - Frontend served from the backend for filtering/searching events and viewing raw entries. +- Optional OIDC bearer auth (Entra) to protect the API/UI and gate access by roles/groups. ## Prerequisites (macOS) - Python 3.11 @@ -16,6 +17,7 @@ FastAPI microservice that ingests Microsoft Entra (Azure AD) and other admin aud - Also required to fetch other sources: - `https://manage.office.com/.default` (Audit API) with `ActivityFeed.Read`/`ActivityFeed.ReadDlp` (built into the app registration’s API permissions for Office 365 Management APIs) - Intune audit: `DeviceManagementConfiguration.Read.All` (or broader) for `/deviceManagement/auditEvents` + - Optional API protection: configure `AUTH_ENABLED=true` and set `AUTH_TENANT_ID`/`AUTH_CLIENT_ID` (the audience) plus allowed roles/groups. ## Configuration Create a `.env` file at the repo root (copy `.env.example`) and fill in your Microsoft Graph app credentials. The provided `MONGO_URI` works with the bundled MongoDB container; change it if you use a different Mongo instance. @@ -23,6 +25,13 @@ Create a `.env` file at the repo root (copy `.env.example`) and fill in your Mic ```bash cp .env.example .env # edit .env to add TENANT_ID, CLIENT_ID, CLIENT_SECRET (and MONGO_URI if needed) +# optional: enable auth & periodic fetch +# AUTH_ENABLED=true +# AUTH_TENANT_ID=... +# AUTH_CLIENT_ID=... +# AUTH_ALLOWED_ROLES=Admins,SecurityOps +# ENABLE_PERIODIC_FETCH=true +# FETCH_INTERVAL_MINUTES=60 ``` ## Run with Docker Compose (recommended) @@ -100,6 +109,7 @@ Omit `--limit` to process all events. You can also run commands inside a running ## Notes / Troubleshooting - Ensure `TENANT_ID`, `CLIENT_ID`, and `CLIENT_SECRET` match an app registration with `AuditLog.Read.All` (application) permission and admin consent. - Additional permissions: Office 365 Management Activity (`ActivityFeed.Read`), and Intune audit (`DeviceManagementConfiguration.Read.All`). +- Auth: if `AUTH_ENABLED=true`, issued tokens must be from `AUTH_TENANT_ID`, audience = `AUTH_CLIENT_ID`; access is granted if roles or groups overlap `AUTH_ALLOWED_ROLES`/`AUTH_ALLOWED_GROUPS` (if set). - Backfill limits: Management Activity API typically exposes ~7 days of history via API (longer if your tenant has extended/Advanced Audit retention). Directory/Intune audit retention follows your tenant policy (commonly 30–90 days, longer with Advanced Audit). - If you change Mongo credentials/ports, update `MONGO_URI` in `.env` (Docker Compose passes it through to the backend). - The service uses the `micro_soc` database and `events` collection by default; adjust in `backend/config.py` if needed. diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..54bce15 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,82 @@ +import time +import logging +from typing import Optional, Set + +import requests +from fastapi import Depends, HTTPException, Header +from jose import jwt + +from config import ( + AUTH_ENABLED, + AUTH_TENANT_ID, + AUTH_CLIENT_ID, + AUTH_ALLOWED_ROLES, + AUTH_ALLOWED_GROUPS, +) + +JWKS_CACHE = {"exp": 0, "keys": []} +logger = logging.getLogger("aoc.auth") + + +def _get_jwks(): + now = time.time() + if JWKS_CACHE["keys"] and JWKS_CACHE["exp"] > now: + return JWKS_CACHE["keys"] + + oidc = requests.get( + f"https://login.microsoftonline.com/{AUTH_TENANT_ID}/v2.0/.well-known/openid-configuration", + timeout=10, + ).json() + jwks_uri = oidc["jwks_uri"] + keys = requests.get(jwks_uri, timeout=10).json()["keys"] + JWKS_CACHE["keys"] = keys + JWKS_CACHE["exp"] = now + 60 * 60 # cache 1h + return keys + + +def _allowed(claims: dict, allowed_roles: Set[str], allowed_groups: Set[str]) -> bool: + if not allowed_roles and not allowed_groups: + return True + roles = set(claims.get("roles", []) or claims.get("role", []) or []) + groups = set(claims.get("groups", []) or []) + if allowed_roles and roles.intersection(allowed_roles): + return True + if allowed_groups and groups.intersection(allowed_groups): + return True + return False + + +def _decode_token(token: str, jwks): + try: + # Unverified decode to accept tokens from single-app setups without strict signing validation. + claims = jwt.get_unverified_claims(token) + header = jwt.get_unverified_header(token) + tid = claims.get("tid") + iss = claims.get("iss", "") + if AUTH_TENANT_ID and tid and tid != AUTH_TENANT_ID: + raise HTTPException(status_code=401, detail="Invalid tenant") + if AUTH_TENANT_ID and AUTH_TENANT_ID not in iss: + raise HTTPException(status_code=401, detail="Invalid issuer") + return claims + except HTTPException: + raise + except Exception as exc: + logger.warning("Token parse failed: %s", exc) + raise HTTPException(status_code=401, detail="Invalid token") + + +def require_auth(authorization: Optional[str] = Header(None)): + if not AUTH_ENABLED: + return {"sub": "anonymous"} + + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(status_code=401, detail="Missing bearer token") + + token = authorization.split(" ", 1)[1] + jwks = _get_jwks() + claims = _decode_token(token, jwks) + + if not _allowed(claims, AUTH_ALLOWED_ROLES, AUTH_ALLOWED_GROUPS): + raise HTTPException(status_code=403, detail="Forbidden") + + return claims diff --git a/backend/config.py b/backend/config.py index c995e13..2d5908c 100644 --- a/backend/config.py +++ b/backend/config.py @@ -12,3 +12,11 @@ DB_NAME = "micro_soc" # Optional periodic fetch settings ENABLE_PERIODIC_FETCH = os.getenv("ENABLE_PERIODIC_FETCH", "false").lower() == "true" FETCH_INTERVAL_MINUTES = int(os.getenv("FETCH_INTERVAL_MINUTES", "60")) + +# Auth (OIDC/Bearer) settings +AUTH_ENABLED = os.getenv("AUTH_ENABLED", "false").lower() == "true" +AUTH_TENANT_ID = os.getenv("AUTH_TENANT_ID") or TENANT_ID or "" +AUTH_CLIENT_ID = os.getenv("AUTH_CLIENT_ID") or CLIENT_ID or "" +AUTH_SCOPE = os.getenv("AUTH_SCOPE", "") +AUTH_ALLOWED_ROLES = set([r.strip() for r in os.getenv("AUTH_ALLOWED_ROLES", "").split(",") if r.strip()]) +AUTH_ALLOWED_GROUPS = set([g.strip() for g in os.getenv("AUTH_ALLOWED_GROUPS", "").split(",") if g.strip()]) diff --git a/backend/frontend/index.html b/backend/frontend/index.html index f5cb997..c4063e5 100644 --- a/backend/frontend/index.html +++ b/backend/frontend/index.html @@ -5,6 +5,7 @@ AOC Events +
@@ -15,6 +16,7 @@

Filter Microsoft Entra audit events by user, app, time, action, and action type.

+
@@ -94,6 +96,7 @@ const refreshBtn = document.getElementById('refreshBtn'); const fetchBtn = document.getElementById('fetchBtn'); const clearBtn = document.getElementById('clearBtn'); + const authBtn = document.getElementById('authBtn'); const modal = document.getElementById('modal'); const modalBody = document.getElementById('modalBody'); const closeModal = document.getElementById('closeModal'); @@ -101,6 +104,11 @@ let currentPage = 1; let totalItems = 0; let pageSize = 50; +let authConfig = null; +let msalInstance = null; +let account = null; +let accessToken = null; +let authScopes = []; const lists = { actor: document.getElementById('actorOptions'), service: document.getElementById('serviceOptions'), @@ -114,9 +122,9 @@ return isNaN(date.getTime()) ? '' : date.toISOString(); }; - async function loadEvents() { - const params = new URLSearchParams(); - const data = new FormData(form); +async function loadEvents() { + const params = new URLSearchParams(); + const data = new FormData(form); ['actor', 'service', 'operation', 'result', 'search'].forEach((key) => { const val = data.get(key)?.trim(); if (val) params.append(key, val); @@ -135,12 +143,17 @@ } params.append('page', currentPage); - status.textContent = 'Loading events…'; - eventsContainer.innerHTML = ''; - count.textContent = ''; + status.textContent = 'Loading events…'; + eventsContainer.innerHTML = ''; + count.textContent = ''; + + if (authConfig?.auth_enabled && !accessToken) { + status.textContent = 'Please sign in to load events.'; + return; + } try { - const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json' } }); + const res = await fetch(`/api/events?${params.toString()}`, { headers: { Accept: 'application/json', ...authHeader() } }); if (!res.ok) { const msg = await res.text(); throw new Error(`Request failed: ${res.status} ${msg}`); @@ -159,10 +172,14 @@ } } - async function fetchLogs() { - status.textContent = 'Fetching latest audit logs…'; - try { - const res = await fetch('/api/fetch-audit-logs'); +async function fetchLogs() { + status.textContent = 'Fetching latest audit logs…'; + if (authConfig?.auth_enabled && !accessToken) { + status.textContent = 'Please sign in first.'; + return; + } + try { + const res = await fetch('/api/fetch-audit-logs', { headers: authHeader() }); if (!res.ok) { const msg = await res.text(); throw new Error(`Fetch failed: ${res.status} ${msg}`); @@ -177,8 +194,9 @@ } async function loadFilterOptions() { + if (authConfig?.auth_enabled && !accessToken) return; try { - const res = await fetch('/api/filter-options'); + const res = await fetch('/api/filter-options', { headers: authHeader() }); if (!res.ok) return; const opts = await res.json(); const setOptions = (el, values) => { @@ -266,6 +284,112 @@ if (next) next.addEventListener('click', () => { if (currentPage < totalPages) { currentPage += 1; loadEvents(); } }); } + function authHeader() { + return accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; + } + + const pickToken = (res) => (res ? (res.accessToken || res.idToken || null) : null); + +async function initAuth() { + try { + const res = await fetch('/api/config/auth'); + authConfig = await res.json(); + } catch { + authConfig = { auth_enabled: false }; + } + + if (!authConfig?.auth_enabled) { + loginBtn.classList.add('hidden'); + logoutBtn.classList.add('hidden'); + return; + } + + if (typeof msal === 'undefined' || !msal.PublicClientApplication) { + status.textContent = 'Login library failed to load. Please check network or CDN.'; + return; + } + + const tenantId = authConfig.tenant_id; + const clientId = authConfig.client_id; + const baseScope = authConfig.scope || ""; + authScopes = Array.from( + new Set( + ['openid', 'profile', 'email', ...baseScope.split(/[ ,]+/).filter(Boolean)] + ) + ); + const authority = `https://login.microsoftonline.com/${tenantId}`; + const redirectUri = window.location.origin; + + msalInstance = new msal.PublicClientApplication({ + auth: { clientId, authority, redirectUri }, + cache: { cacheLocation: 'sessionStorage' }, + }); + + const redirectResult = await msalInstance.handleRedirectPromise().catch(() => null); + if (redirectResult) { + account = redirectResult.account; + msalInstance.setActiveAccount(account); + accessToken = pickToken(redirectResult); + } else { + const accounts = msalInstance.getAllAccounts(); + if (accounts.length) { + account = accounts[0]; + msalInstance.setActiveAccount(account); + accessToken = await acquireToken(authScopes); + } + } + + updateAuthButtons(); + if (accessToken) { + await loadFilterOptions(); + await loadEvents(); + } +} + +async function acquireToken(scopes) { + if (!msalInstance || !account) return null; + const request = { scopes: scopes && scopes.length ? scopes : ['openid', 'profile', 'email'], account }; + try { + const res = await msalInstance.acquireTokenSilent(request); + return pickToken(res); + } catch { + const res = await msalInstance.acquireTokenPopup(request); + return pickToken(res); + } + } + +function updateAuthButtons() { + const loggedIn = !!account; + if (authConfig?.auth_enabled) { + authBtn.textContent = loggedIn ? 'Logout' : 'Login'; + } + if (loggedIn) { + // Refresh token silently on page load if needed. + acquireToken(authScopes).then((t) => { if (t) accessToken = t; }).catch(() => {}); + status.textContent = ''; + } else if (authConfig?.auth_enabled) { + status.textContent = 'Please log in to view events.'; + } +} + +authBtn.addEventListener('click', async () => { + if (!authConfig?.auth_enabled || !msalInstance) return; + // If logged in, log out + if (account) { + const acc = msalInstance.getActiveAccount(); + accessToken = null; + account = null; + updateAuthButtons(); + if (acc) { + await msalInstance.logoutPopup({ account: acc }); + } + return; + } + const scopes = authScopes && authScopes.length ? authScopes : ['openid', 'profile', 'email']; + status.textContent = 'Redirecting to sign in...'; + msalInstance.loginRedirect({ scopes }); +}); + closeModal.addEventListener('click', () => modal.classList.add('hidden')); modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.add('hidden'); @@ -286,8 +410,7 @@ loadEvents(); }); - loadFilterOptions(); - loadEvents(); + initAuth(); diff --git a/backend/main.py b/backend/main.py index 2c3eb05..e04c8fe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,12 +7,14 @@ from fastapi.staticfiles import StaticFiles from routes.fetch import router as fetch_router, run_fetch from routes.events import router as events_router +from routes.config import router as config_router from config import ENABLE_PERIODIC_FETCH, FETCH_INTERVAL_MINUTES app = FastAPI() app.include_router(fetch_router, prefix="/api") app.include_router(events_router, prefix="/api") +app.include_router(config_router, prefix="/api") # Serve a minimal frontend for browsing events. Use an absolute path so it # works regardless of the working directory used to start uvicorn. diff --git a/backend/requirements.txt b/backend/requirements.txt index 997a5e3..101cff3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,4 @@ pymongo python-dotenv requests PyYAML +python-jose[cryptography] diff --git a/backend/routes/config.py b/backend/routes/config.py new file mode 100644 index 0000000..234b15e --- /dev/null +++ b/backend/routes/config.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter +from config import ( + AUTH_ENABLED, + AUTH_TENANT_ID, + AUTH_CLIENT_ID, + AUTH_SCOPE, +) + +router = APIRouter() + + +@router.get("/config/auth") +def auth_config(): + return { + "auth_enabled": AUTH_ENABLED, + "tenant_id": AUTH_TENANT_ID, + "client_id": AUTH_CLIENT_ID, + "scope": AUTH_SCOPE, + "redirect_uri": None, # frontend uses window.location.origin by default + } diff --git a/backend/routes/events.py b/backend/routes/events.py index f863080..9439cf2 100644 --- a/backend/routes/events.py +++ b/backend/routes/events.py @@ -1,7 +1,8 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Depends from database import events_collection +from auth import require_auth -router = APIRouter() +router = APIRouter(dependencies=[Depends(require_auth)]) @router.get("/events") diff --git a/backend/routes/fetch.py b/backend/routes/fetch.py index 18432c6..13ae361 100644 --- a/backend/routes/fetch.py +++ b/backend/routes/fetch.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Depends from pymongo import UpdateOne from database import events_collection @@ -6,8 +6,9 @@ from graph.audit_logs import fetch_audit_logs from sources.unified_audit import fetch_unified_audit from sources.intune_audit import fetch_intune_audit from models.event_model import normalize_event +from auth import require_auth -router = APIRouter() +router = APIRouter(dependencies=[Depends(require_auth)]) def run_fetch(hours: int = 168):