Added authentication

This commit is contained in:
2025-11-29 14:19:34 +01:00
parent 47f4a22bef
commit 205b69713e
10 changed files with 274 additions and 18 deletions

82
backend/auth.py Normal file
View File

@@ -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

View File

@@ -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()])

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AOC Events</title>
<link rel="stylesheet" href="/style.css" />
<script src="https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
</head>
<body>
<div class="page">
@@ -15,6 +16,7 @@
<p class="lede">Filter Microsoft Entra audit events by user, app, time, action, and action type.</p>
</div>
<div class="cta">
<button id="authBtn" class="ghost" aria-label="Login">Login</button>
<button id="fetchBtn" aria-label="Fetch latest audit logs">Fetch new</button>
<button id="refreshBtn" aria-label="Refresh events">Refresh</button>
</div>
@@ -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();
</script>
</body>
</html>

View File

@@ -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.

View File

@@ -4,3 +4,4 @@ pymongo
python-dotenv
requests
PyYAML
python-jose[cryptography]

20
backend/routes/config.py Normal file
View File

@@ -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
}

View File

@@ -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")

View File

@@ -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):