auth: Keycloak OIDC — anonymous (truncated) vs manager (full) tiers
Backend (main.py + auth.py): - Validate Bearer JWT against the intense-sale realm JWKS (RS256, issuer checked, aud skipped). Missing/invalid token = anonymous, never an error, so the public map keeps working when Keycloak is down. - Anonymous truncation: /api/meta hides brand/manager/status facets; /api/bookings blanks brand/company_name/manager/task_id/task_status (bars stay, one neutral colour); /api/map keeps occupied_now but empties booking details. Hidden facet filters are ignored for anonymous callers. - boards stay visible to all (physical inventory: address/city/dimension). Frontend (keycloak-js): - Silent SSO (check-sso) — anonymous by default, Войти/Выйти button. - Bearer token attached + refreshed on every API call. - Anonymous UI hides Brand/Manager/Status filters, Brand/Company toggles, appearance menu, collisions and labels; board detail panel (Planfix deep-links) gated to managers. Test manager: user / 1234567890 (realm role `manager`).
This commit is contained in:
parent
4425bd394c
commit
b6b9c27cb0
@ -3,6 +3,7 @@ WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY main.py .
|
||||
COPY auth.py .
|
||||
COPY static ./static
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
59
auth.py
Normal file
59
auth.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Keycloak JWT validation for role-based response truncation.
|
||||
|
||||
The map dashboard has two tiers:
|
||||
* anonymous — no token (or an invalid/expired one). Sees a truncated view.
|
||||
* manager — a valid Keycloak access token carrying the realm role `manager`.
|
||||
Sees the full data.
|
||||
|
||||
Design rule: a MISSING or INVALID token is NOT an error — it simply means the
|
||||
caller is anonymous. So role_from_authorization() never raises; the worst case
|
||||
degrades to the anonymous tier. This keeps the public map working even when
|
||||
Keycloak is unreachable or a token has expired.
|
||||
|
||||
Validation checks the signature (RS256, keys fetched from the realm JWKS with
|
||||
in-process caching + automatic rotation on unknown kid) and the issuer. The
|
||||
access token's audience is `account` (Keycloak default), so we intentionally do
|
||||
NOT verify `aud`; authorization is decided by the presence of the realm role.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
|
||||
# Public realm URL — matches the `iss` Keycloak stamps into the token, so issuer
|
||||
# validation lines up without extra config. Overridable via env for other envs.
|
||||
KC_REALM_URL = os.environ.get(
|
||||
"KC_REALM_URL", "https://keycloak.intense-sale.ru/realms/intense-sale"
|
||||
)
|
||||
KC_ISSUER = KC_REALM_URL
|
||||
JWKS_URL = f"{KC_REALM_URL}/protocol/openid-connect/certs"
|
||||
MANAGER_ROLE = "manager"
|
||||
|
||||
# PyJWKClient caches signing keys and re-fetches when it sees an unknown `kid`
|
||||
# (key rotation), so we build it once at import time.
|
||||
_jwk_client = PyJWKClient(JWKS_URL)
|
||||
|
||||
|
||||
def role_from_authorization(authorization: str | None) -> str:
|
||||
"""Return "manager" for a valid token with the manager role, else "anonymous".
|
||||
|
||||
Never raises: any problem (no header, wrong scheme, bad signature, expired,
|
||||
JWKS fetch failure) falls through to "anonymous".
|
||||
"""
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
return "anonymous"
|
||||
token = authorization[7:].strip()
|
||||
try:
|
||||
signing_key = _jwk_client.get_signing_key_from_jwt(token)
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=KC_ISSUER,
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
except Exception:
|
||||
return "anonymous"
|
||||
roles = claims.get("realm_access", {}).get("roles", []) or []
|
||||
return MANAGER_ROLE if MANAGER_ROLE in roles else "anonymous"
|
||||
@ -33,6 +33,7 @@
|
||||
<button type="button" class="mode-btn" id="mode-split">Карта и график</button>
|
||||
</div>
|
||||
<button type="button" class="theme-toggle" id="theme-toggle" title="Переключить тему" aria-label="Переключить тему"></button>
|
||||
<button type="button" class="auth-btn" id="auth-btn" style="display:none">Войти</button>
|
||||
</div>
|
||||
|
||||
<!-- Group 2: fields (4-column grid; dates + search on the 2nd row) -->
|
||||
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||
"keycloak-js": "^26.2.4",
|
||||
"open-props": "^1.7.23",
|
||||
"vis-data": "^7.1.9",
|
||||
"vis-timeline": "^7.7.3"
|
||||
@ -880,6 +881,15 @@
|
||||
"license": "(Apache-2.0 OR MIT)",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/keycloak-js": {
|
||||
"version": "26.2.4",
|
||||
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz",
|
||||
"integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"test"
|
||||
]
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||
"keycloak-js": "^26.2.4",
|
||||
"open-props": "^1.7.23",
|
||||
"vis-data": "^7.1.9",
|
||||
"vis-timeline": "^7.7.3"
|
||||
|
||||
10
frontend/public/silent-check-sso.html
Normal file
10
frontend/public/silent-check-sso.html
Normal file
@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
// Keycloak silent SSO check: hand the redirect URL back to the app in the
|
||||
// parent window so keycloak-js can complete the check without a full reload.
|
||||
parent.postMessage(location.href, location.origin);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,6 +1,7 @@
|
||||
// Typed wrappers around the mapdash HTTP API. All network access for the app
|
||||
// goes through here, so response shapes stay in one typed place.
|
||||
import type { Meta, Board, Booking, Filters, MapSurface } from './types';
|
||||
import { authHeader } from './auth';
|
||||
|
||||
function buildQuery(f: Filters): string {
|
||||
const params = new URLSearchParams({
|
||||
@ -17,7 +18,7 @@ function buildQuery(f: Filters): string {
|
||||
}
|
||||
|
||||
async function getJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, { headers: await authHeader() });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed: ${url} -> HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
69
frontend/src/auth.ts
Normal file
69
frontend/src/auth.ts
Normal file
@ -0,0 +1,69 @@
|
||||
// Keycloak (OIDC) integration. The app is anonymous by default; logging in as a
|
||||
// user with the realm role `manager` unlocks the full data view. Everything here
|
||||
// degrades gracefully: if Keycloak is unreachable, initAuth() resolves anyway and
|
||||
// the app stays in the anonymous tier.
|
||||
import Keycloak from 'keycloak-js';
|
||||
|
||||
const keycloak = new Keycloak({
|
||||
url: 'https://keycloak.intense-sale.ru',
|
||||
realm: 'intense-sale',
|
||||
clientId: 'mapdash',
|
||||
});
|
||||
|
||||
let manager = false;
|
||||
|
||||
function computeManager(): boolean {
|
||||
const roles = (keycloak.tokenParsed as { realm_access?: { roles?: string[] } } | undefined)
|
||||
?.realm_access?.roles ?? [];
|
||||
return keycloak.authenticated === true && roles.includes('manager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise Keycloak with a silent SSO check (no forced login). Never rejects —
|
||||
* on any failure we log and continue as an anonymous visitor.
|
||||
*/
|
||||
export async function initAuth(): Promise<void> {
|
||||
try {
|
||||
await keycloak.init({
|
||||
onLoad: 'check-sso',
|
||||
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
|
||||
pkceMethod: 'S256',
|
||||
});
|
||||
manager = computeManager();
|
||||
} catch (e) {
|
||||
console.warn('Keycloak init failed — continuing as anonymous', e);
|
||||
manager = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isManager(): boolean {
|
||||
return manager;
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return keycloak.authenticated === true;
|
||||
}
|
||||
|
||||
export function login(): void {
|
||||
// Return to the current view (filters in the URL) after login.
|
||||
keycloak.login({ redirectUri: window.location.href });
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
keycloak.logout({ redirectUri: window.location.origin });
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization header for API calls. Refreshes the token when it is close to
|
||||
* expiry. Returns an empty object for anonymous callers (backend then serves the
|
||||
* truncated tier).
|
||||
*/
|
||||
export async function authHeader(): Promise<Record<string, string>> {
|
||||
if (!keycloak.authenticated) return {};
|
||||
try {
|
||||
await keycloak.updateToken(30);
|
||||
} catch {
|
||||
/* refresh failed — send the current token; backend falls back to anon if invalid */
|
||||
}
|
||||
return keycloak.token ? { Authorization: `Bearer ${keycloak.token}` } : {};
|
||||
}
|
||||
@ -19,6 +19,7 @@ import { loadAppearance, applyCssVars, buildAppearanceMenu, colorForStatus } fro
|
||||
import { createMapView } from './map';
|
||||
import { createViewModes, type ViewMode } from './viewmode';
|
||||
import { escapeHtml } from './util';
|
||||
import { initAuth, isManager, isAuthenticated, login, logout } from './auth';
|
||||
|
||||
const el = {
|
||||
search: document.getElementById('search') as HTMLInputElement,
|
||||
@ -50,8 +51,35 @@ const el = {
|
||||
loadBar: document.getElementById('load-bar') as HTMLElement,
|
||||
emptyResetTimeline: document.getElementById('empty-reset-timeline') as HTMLButtonElement,
|
||||
emptyResetMap: document.getElementById('empty-reset-map') as HTMLButtonElement,
|
||||
authBtn: document.getElementById('auth-btn') as HTMLButtonElement,
|
||||
};
|
||||
|
||||
// ---- role-based UI (anonymous vs manager) ----
|
||||
// Backend enforces truncation; here we just hide the controls that only make
|
||||
// sense for the full-data (manager) tier so the anonymous view stays clean.
|
||||
function applyRoleUi(): void {
|
||||
const manager = isManager();
|
||||
const hideField = (node: Element | null): void => {
|
||||
const f = node?.closest('.field') as HTMLElement | null;
|
||||
if (f) f.style.display = manager ? '' : 'none';
|
||||
};
|
||||
// Client-facing facets: Brand, Manager, Status. (Search stays — it filters by
|
||||
// address/board code, which anonymous visitors are allowed to use.)
|
||||
hideField(document.getElementById('brand-dropdown'));
|
||||
hideField(document.getElementById('manager-dropdown'));
|
||||
hideField(document.getElementById('status-dropdown'));
|
||||
// Brand/Company toggles and the appearance (colour) menu.
|
||||
const checks = document.querySelector('.checks') as HTMLElement | null;
|
||||
if (checks) checks.style.display = manager ? '' : 'none';
|
||||
const decor = document.querySelector('.decor-wrap') as HTMLElement | null;
|
||||
if (decor) decor.style.display = manager ? '' : 'none';
|
||||
|
||||
// Login / logout button.
|
||||
el.authBtn.style.display = '';
|
||||
el.authBtn.textContent = isAuthenticated() ? 'Выйти' : 'Войти';
|
||||
el.authBtn.onclick = () => (isAuthenticated() ? logout() : login());
|
||||
}
|
||||
|
||||
// ---- theme toggle (data-theme is set pre-paint by the inline head script) ----
|
||||
function currentTheme(): 'light' | 'dark' {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
@ -99,6 +127,11 @@ let lastBoards: Board[] = [];
|
||||
let lastBookings: Booking[] = [];
|
||||
|
||||
function flags(): RenderFlags {
|
||||
// Anonymous tier: no labels (data is blanked anyway) and no collision
|
||||
// highlighting, so every bar renders in the single neutral status colour.
|
||||
if (!isManager()) {
|
||||
return { withBrand: false, withCompany: false, withCollisions: false };
|
||||
}
|
||||
return {
|
||||
withBrand: el.showBrand.checked,
|
||||
withCompany: el.showCompany.checked,
|
||||
@ -363,6 +396,8 @@ function closeBoardPanel(): void {
|
||||
boardBackdrop.hidden = true;
|
||||
}
|
||||
function openBoardPanel(boardId: string): void {
|
||||
// Detail panel exposes client/brand/manager + Planfix deep-links — managers only.
|
||||
if (!isManager()) return;
|
||||
const board = lastBoards.find((b) => b.board_id === boardId);
|
||||
const bookings = lastBookings
|
||||
.filter((b) => b.board_id === boardId)
|
||||
@ -410,7 +445,14 @@ async function init(): Promise<void> {
|
||||
dimensionDropdown.setValues([], 'Все размеры');
|
||||
managerDropdown.setValues([], 'Все менеджеры');
|
||||
statusDropdown.setValues([], 'Все статусы');
|
||||
await initAuth(); // resolve role before first data load (degrades to anon on failure)
|
||||
applyRoleUi();
|
||||
applyUrlToFilters(); // restore filters from a shared link
|
||||
if (!isManager()) {
|
||||
// A shared link may carry hidden facets; drop them so the anonymous view
|
||||
// neither queries nor re-serialises Brand/Manager/Status.
|
||||
for (const d of [brandDropdown, managerDropdown, statusDropdown]) d.clear();
|
||||
}
|
||||
rebuildDecorMenu();
|
||||
view.applyLayout();
|
||||
await loadData(true);
|
||||
|
||||
@ -312,6 +312,15 @@ input::placeholder { color: var(--text-muted); }
|
||||
transition: border-color var(--transition), background var(--transition);
|
||||
}
|
||||
.theme-toggle:hover { border-color: var(--border-strong); background: var(--surface); }
|
||||
|
||||
/* Login / logout button (mirrors the toggle styling, sized to its text label) */
|
||||
.auth-btn {
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg);
|
||||
color: var(--text); height: 34px; padding: 0 14px; font-size: 13px; font-weight: 600;
|
||||
line-height: 1; cursor: pointer; display: inline-flex; align-items: center;
|
||||
transition: border-color var(--transition), background var(--transition);
|
||||
}
|
||||
.auth-btn:hover { border-color: var(--border-strong); background: var(--surface); }
|
||||
/* Dark theme temporarily hidden — toggle stays in the DOM, just not shown.
|
||||
Re-enable by removing this rule + restoring the head script in index.html. */
|
||||
.theme-toggle { display: none; }
|
||||
|
||||
54
main.py
54
main.py
@ -1,10 +1,18 @@
|
||||
import asyncio
|
||||
import os
|
||||
import httpx
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi import Depends, FastAPI, Header, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from auth import role_from_authorization
|
||||
|
||||
|
||||
def get_role(authorization: str | None = Header(default=None)) -> str:
|
||||
# Resolve the caller's tier from the Bearer token. "anonymous" is the safe
|
||||
# default (missing/invalid token); "manager" unlocks the full data view.
|
||||
return role_from_authorization(authorization)
|
||||
|
||||
CH_HOST = os.environ.get("CLICKHOUSE_HOST", "ClickHouse")
|
||||
CH_PORT = os.environ.get("CLICKHOUSE_PORT", "8123")
|
||||
CH_USER = os.environ.get("CLICKHOUSE_USER", "default")
|
||||
@ -156,7 +164,11 @@ def parse_filters(city: str, dimension: str, brand: str, manager: str, status: s
|
||||
|
||||
|
||||
@app.get("/api/meta")
|
||||
async def meta(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
|
||||
async def meta(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
|
||||
# Anonymous tier: the Brand/Manager/Status facets are hidden, so ignore those
|
||||
# incoming filters (a crafted request can't use them to probe hidden data).
|
||||
if role != "manager":
|
||||
brand = manager = status = ""
|
||||
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
|
||||
hds = bool(params["date_start"])
|
||||
hde = bool(params["date_end"])
|
||||
@ -185,6 +197,15 @@ async def meta(city: str = "", dimension: str = "", brand: str = "", manager: st
|
||||
params,
|
||||
),
|
||||
)
|
||||
if role != "manager":
|
||||
# Truncated tier: never expose client-facing facet values.
|
||||
return {
|
||||
"cities": [r["city"] for r in cities],
|
||||
"dimensions": [r["dimension"] for r in dimensions],
|
||||
"brands": [],
|
||||
"managers": [],
|
||||
"statuses": [],
|
||||
}
|
||||
return {
|
||||
"cities": [r["city"] for r in cities],
|
||||
"dimensions": [r["dimension"] for r in dimensions],
|
||||
@ -195,7 +216,11 @@ async def meta(city: str = "", dimension: str = "", brand: str = "", manager: st
|
||||
|
||||
|
||||
@app.get("/api/boards")
|
||||
async def boards(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
|
||||
async def boards(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
|
||||
# Board inventory (address/city/dimension) is visible to everyone; anonymous
|
||||
# just can't filter by the hidden facets.
|
||||
if role != "manager":
|
||||
brand = manager = status = ""
|
||||
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
|
||||
where = build_where(city=True, dimension=True, brand=True, manager=True, status=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
|
||||
sql = f"""
|
||||
@ -214,7 +239,9 @@ async def boards(city: str = "", dimension: str = "", brand: str = "", manager:
|
||||
|
||||
|
||||
@app.get("/api/bookings")
|
||||
async def bookings(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
|
||||
async def bookings(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
|
||||
if role != "manager":
|
||||
brand = manager = status = ""
|
||||
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
|
||||
where = build_where(city=True, dimension=True, brand=True, manager=True, status=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
|
||||
sql = f"""
|
||||
@ -236,11 +263,23 @@ async def bookings(city: str = "", dimension: str = "", brand: str = "", manager
|
||||
# collapse to canonical "Фамилия Имя" names for display.
|
||||
for r in rows:
|
||||
r["manager"] = sorted({canon_manager(m) for m in (r.get("manager") or []) if m})
|
||||
if role != "manager":
|
||||
# Truncated tier: keep the occupancy bar (board_id + dates) but strip every
|
||||
# client-facing field so the timeline shows neutral, unlabelled bars and the
|
||||
# board panel / Planfix deep-links have nothing to reveal.
|
||||
for r in rows:
|
||||
r["brand"] = ""
|
||||
r["company_name"] = ""
|
||||
r["manager"] = []
|
||||
r["task_id"] = ""
|
||||
r["task_status"] = ""
|
||||
return rows
|
||||
|
||||
|
||||
@app.get("/api/map")
|
||||
async def map_data(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""):
|
||||
async def map_data(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""):
|
||||
if role != "manager":
|
||||
brand = manager = status = ""
|
||||
# Surfaces come from board_info (has coordinates). Occupancy is computed "as of
|
||||
# today" and is intentionally NOT affected by the date-range filters — only the
|
||||
# Город/Бренд/Размер/Менеджер/Поиск facets narrow which surfaces are shown.
|
||||
@ -292,13 +331,16 @@ async def map_data(city: str = "", dimension: str = "", brand: str = "", manager
|
||||
for b in current:
|
||||
by_key.setdefault(b["board_key"], []).append(b)
|
||||
|
||||
anon = role != "manager"
|
||||
out = []
|
||||
for s in surfaces:
|
||||
active = by_key.get(s["board_key"], [])
|
||||
out.append({
|
||||
**s,
|
||||
"occupied_now": len(active) > 0,
|
||||
"bookings": active,
|
||||
# Truncated tier keeps the free/occupied marker colour but hides the
|
||||
# balloon's booking details (company/brand/status).
|
||||
"bookings": [] if anon else active,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.30.6
|
||||
httpx==0.27.2
|
||||
PyJWT[crypto]==2.10.1
|
||||
|
||||
10
static/silent-check-sso.html
Normal file
10
static/silent-check-sso.html
Normal file
@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
// Keycloak silent SSO check: hand the redirect URL back to the app in the
|
||||
// parent window so keycloak-js can complete the check without a full reload.
|
||||
parent.postMessage(location.href, location.origin);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user