diff --git a/config/api.example.toml b/config/api.example.toml index 67bbc91..0e7d7f4 100644 --- a/config/api.example.toml +++ b/config/api.example.toml @@ -3,6 +3,11 @@ workspace = "../service-data/service" host = "127.0.0.1" port = 8080 +[auth] +session_seconds = 3600 +# Production Linux: root-owned file, read-only for the service group. +# users_database = "/etc/viano-bug-analysis/users.sqlite3" + [[projects]] id = "viano-core" type = "wow" @@ -21,10 +26,10 @@ id = "wow-capture-worker" token_env = "VBA_INTAKE_TOKEN" projects = ["viano-core"] -# Separate operator identity/token. Submission clients remain non-operators. +# Optional privileged machine integration, not a human login. # [[clients]] -# id = "human-operator" +# id = "automation-operator" # token_env = "VBA_OPERATOR_TOKEN" # projects = ["viano-core"] # operator = true -# tester = true +# Human accounts are created only by the privileged create_user console command. diff --git a/docs/test-results/web-login-20261022.md b/docs/test-results/web-login-20261022.md new file mode 100644 index 0000000..7166085 --- /dev/null +++ b/docs/test-results/web-login-20261022.md @@ -0,0 +1,40 @@ +# Benutzeranmeldung und Bearbeitungssperren – 2026-10-22 + +Umfang: SQLite-Benutzer mit gesalzenen scrypt-Passworthashes, getrennte +Sitzungs-/Sperrendatenbank, Cookie-Anmeldung, CSRF-Schutz, Abmeldung, +Konsolenbefehle create_user/delete_user/change_pw_user mit Privilegienprüfung. +Alle Webbenutzer besitzen dieselben Rechte für alle Projekte. Keine Webverwaltung +von Benutzern und keine Benutzerklassen. Bearer-Tokens bleiben für Maschinen. + +Konsole: Linux-root/sudo oder Windows-elevierte Administratorberechtigung. +Die Privilegienprüfung wurde mit simuliertem unprivilegiertem Aufrufer getestet; +Benutzerbefehle mit temporärer Datenbank unter kontrollierter Testfreigabe geprüft. +Keine realen Benutzerkonten wurden im Dienst-Workspace erzeugt. + +Prüfungen: + +- 57 lokale Tests bestanden: API, Benutzer/Sitzungen, menschliches Feedback, + Dashboard, Worker und vcore-Übergabe. +- Gleichzeitige SQLite-Übernahmen aus zwei Threads/Sitzungen: genau ein Gewinner. +- HTTP: ohne Bearbeiten kein Feedback, fremde Sitzung weder Übernahme noch Antwort. +- Abmeldung/Sitzungsablauf geben Sperren frei; Passwortwechsel/Löschung sperren + alte Sitzungen. Falsches altes Passwort wird abgewiesen. +- Alle Webbenutzer sehen alle Projekte/Betriebsaktionen. Fehlender CSRF-Header + wird abgewiesen; fremder Login-Origin wird abgewiesen; Anmeldeversuche begrenzt. +- Beide Feedbackausgänge bleiben möglich; unveränderte Budgets/Revision und + idempotente Antworten erhalten. Maschinen-Bearer können menschliche Antworten + nicht ohne Benutzersitzung bestätigen. +- Echter Headless-Edge mit ausschließlich temporärem Testbenutzer: normale + Anmeldung, UI-Projektsteuerung, Testanfrage zuerst schreibgeschützt, Bearbeiten + aktiviert Formular, Navigation/Diagramme/Mobilansicht/Logout: PASS. + Keine JS-Ausnahmen. Die erwartete 401 beim Prüfen einer noch nicht bestehenden + Sitzung wird separat vom Browserfehlercheck ausgenommen. + +Testdaten vor Ausführung: Trials 16.939.129.428 Bytes, kleine Fixtures/temporäre +Dateien mit Reserve innerhalb des 20-GB-Budgets. Keine kostenpflichtigen +LLM-Anfragen, keine Änderung an Analysebudgets oder der Designspezifikation. + +Deployment: Benutzerdatei in root-/administratorgeschütztem Verzeichnis mit +passenden Dienst-Leserechten betreiben; SQLite allein ersetzt keine OS-Dateirechte. +Native Linux-Dateirechte/Chown und reale Windows-UAC/NTFS-ACLs sind beim Server- +Deployment zu prüfen. Die API öffnet die Benutzerdatei ausschließlich read-only. diff --git a/docs/user-accounts.md b/docs/user-accounts.md new file mode 100644 index 0000000..55c463e --- /dev/null +++ b/docs/user-accounts.md @@ -0,0 +1,103 @@ +# Benutzeranmeldung und exklusive Bearbeitung + +Webbenutzer melden sich mit Benutzername und Passwort an. Alle Benutzer besitzen +dieselben Rechte für alle Projekte. Es gibt weder Administrator-/Normalbenutzer- +Klassen noch eine Benutzerverwaltung im Web. Bestehende Bearer-Tokens bleiben +ausschließlich für externe API-Integrationen nutzbar und behalten deren bisherigen +Zugriffsgrenzen. Fehlende Token-Umgebungsvariablen deaktivieren die jeweilige +Integration; die Benutzeranmeldung benötigt keine Token-Zufallsgenerierung. + +## Konsolenverwaltung + +Die Befehle erfordern unter Linux effektive root-Rechte (normalerweise `sudo`), +unter Windows eine als Administrator gestartete Konsole. Sie benötigen keinen +Gemini-Schlüssel und senden keine Modellanfrage. + +```bash +sudo .venv/bin/python -m viano_bug_analysis.api create_user nico --config config/api.local.toml --service-group buganalysis +sudo .venv/bin/python -m viano_bug_analysis.api change_pw_user nico --config config/api.local.toml +sudo .venv/bin/python -m viano_bug_analysis.api delete_user nico --config config/api.local.toml +``` + +Ohne Passwortargumente erfolgt eine verdeckte Abfrage. Die gewünschten expliziten +Argumentformen funktionieren ebenfalls: + +```text +create_user NAME PASSWORT --config DATEI +delete_user NAME --config DATEI +change_pw_user NAME ALTES_PASSWORT NEUES_PASSWORT --config DATEI +``` + +Explizite Passwortargumente können in Shellhistorie/Prozessargumenten auftauchen; +für den normalen Betrieb die verdeckte Eingabe verwenden. Benutzernamen bestehen +aus 1–64 ASCII-Buchstaben/Ziffern sowie Punkt, Unterstrich oder Bindestrich und +beginnen mit einem Buchstaben oder einer Ziffer. Namen sind nicht case-sensitiv. +Beim Passwortwechsel wird das alte Passwort geprüft. Passwortwechsel und Löschung +entwerten bestehende Sitzungen und deren Bearbeitungssperren ohne API-Neustart. + +Windows, in einer Administrator-PowerShell: + +```powershell +.\.venv\Scripts\python.exe -m viano_bug_analysis.api create_user nico --config config/api.local.toml +``` + +## SQLite-Dateien und Betriebssystemrechte + +Benutzer liegen standardmäßig in `/auth/users.sqlite3`. Die API öffnet +diese Datei ausschließlich schreibgeschützt. Gesalzene scrypt-Hashes ersetzen +Klartextpasswörter. Sitzungen, Anmeldebegrenzung und Bearbeitungssperren liegen +separat in `/runtime/web-sessions/sessions.sqlite3`; Sitzungstokens +werden dort nur als Hash gespeichert. + +Produktionskonfiguration, Pfade relativ zur Konfigurationsdatei oder absolut: + +```toml +[auth] +users_database = "/etc/viano-bug-analysis/users.sqlite3" +session_seconds = 3600 +``` + +Die Benutzerdatei und ihr Verzeichnis gehören unter Linux root; CLI-Befehle setzen +0640 bzw. 0750. `--service-group buganalysis` erlaubt dem dedizierten Dienstkonto +das Lesen, nicht die Benutzerverwaltung. Verzeichnisvorfahren dürfen nicht durch +unvertrauenswürdige Benutzer austauschbar sein. Der Dienst benötigt Schreibzugriff +auf seinen Workspace und die Sitzungsdatei. Unter Windows entsprechend geschützte +NTFS-Verzeichnisse/ACLs verwenden: Administratoren verwalten die Benutzerdatei, +das Dienstkonto liest sie. Die Konsolenbefehle prüfen auch dort die erhöhte +Administratorberechtigung. Wer Programmcode oder Authentifizierungsdateien selbst +ändern kann, liegt außerhalb der Web-Zugriffsgrenze; deshalb sind diese OS-Rechte +Bestandteil der Servereinrichtung. + +SQLite benötigt keinen MariaDB-Dienst. Für einen Umzug die Benutzerdatei und die +Projekt-/Workspace-Daten sichern; die separate Sitzungsdatei kann bei gestoppter +API bewusst verworfen werden, um alle Sitzungen und Sperren zu beenden. + +## Sitzungen und Bearbeiten + +Die Anmeldung erzeugt ein HttpOnly-/SameSite-Strict-Cookie; unter HTTPS zusätzlich +Secure. Entfernte Zugriffe über HTTPS betreiben. Mutationen benötigen den an die +Sitzung gebundenen CSRF-Header. Die Oberfläche hält nur diesen CSRF-Wert im +Arbeitsspeicher; Passwörter und Sitzungstokens werden nicht im Local Storage +gespeichert. Sitzungen gelten standardmäßig absolut eine Stunde, konfigurierbar +von 60 Sekunden bis 24 Stunden; regelmäßige Seitenaktualisierungen verlängern sie +nicht endlos. Nach einem Neuladen kann eine gültige Sitzung weiterverwendet werden. +Anmeldeversuche werden serverseitig zeitlich begrenzt. + +Eine Testanfrage lässt sich zunächst nur ansehen. Erst **Bearbeiten** übernimmt +die Anfrage atomar für die aktuelle Sitzung. Eine zweite Sitzung erhält +`API_TEST_CLAIMED` und eine verständliche Meldung. Erst dann erscheinen Eingabefelder. +Die Serverprüfung verlangt dieselbe Sperre auch beim Absenden; das Ausblenden +von Feldern allein ist nicht die Zugriffskontrolle. Identische Wiederholungen +bleiben idempotent, abweichende zweite Antworten werden zurückgewiesen. + +**Bearbeitung freigeben**, Abmeldung oder Sitzungsablauf machen die Anfrage für +andere Sitzungen verfügbar. Der Ablauf wird bei erneuter Übernahme anhand der +serverseitigen Zeit geprüft; es wird kein Hintergrundtimer benötigt. Zwei Tabs +derselben Sitzung gehören derselben Bearbeitung; zwei getrennte Sitzungen auch +desselben Benutzers konkurrieren um die Sperre. + +HTTP: `POST /v1/auth/login`, `GET /v1/auth/session`, `POST /v1/auth/logout`. +Testübernahme/-freigabe: `POST /v1/test-requests/{key}/claim` mit +`{request_id,analysis_revision,action:"edit"|"release"}`. Feedback bestätigt wie +bisher die konkrete Anfrage und führt dieselbe Analyse-Revision fort. Bearer- +Clients können keine menschliche Bearbeitungssperre oder Antwort übernehmen. diff --git a/docs/web-console.md b/docs/web-console.md index 67ae730..7dba303 100644 --- a/docs/web-console.md +++ b/docs/web-console.md @@ -6,10 +6,12 @@ Die Betriebsübersicht wird durch die bestehende Übergabe-API ausgeliefert: .\.venv\Scripts\python.exe -m viano_bug_analysis.api serve --config config/api.local.toml ``` -Anschließend `http://127.0.0.1:8080/` öffnen und mit einem freigegebenen **API-Token** -anmelden. Dies ist nicht der Gemini-Schlüssel. Der Token bleibt ausschließlich im -Arbeitsspeicher des Tabs, nicht in Cookies, Local Storage oder URLs. Für entfernte -Zugriffe HTTPS verwenden. Der Analyseworker läuft unabhängig davon wie unter +Anschließend `http://127.0.0.1:8080/` öffnen und mit **Benutzername und Passwort** +anmelden. Benutzer werden ausschließlich über privilegierte Konsolenbefehle +angelegt, geändert und gelöscht, siehe [Benutzerverwaltung](user-accounts.md). +Alle Webbenutzer haben dieselben Rechte für alle Projekte. Die unten beschriebenen +Client-/Projektberechtigungen gelten weiterhin für externe API-Integrationen. +Für entfernte Zugriffe HTTPS verwenden. Der Analyseworker läuft unabhängig davon wie unter [Analyseworker](analysis-worker.md) beschrieben. Das Öffnen der Übersicht löst keine Modellanfragen aus. @@ -93,7 +95,8 @@ Fortsetzung angefordert werden; Budgets werden dabei nicht zurückgesetzt. ## Betriebssteuerung -Clients erhalten standardmäßig keine Betreiberrechte. Ein eigener Client mit +Alle angemeldeten Webbenutzer können die Betriebssteuerung verwenden. Externe +API-Clients erhalten standardmäßig keine Betreiberrechte. Ein eigener Client mit eigenem Token und `operator = true` in der API-Konfiguration erhält Aktionen für seine freigegebenen Projekte (Beispiel in `config/api.example.toml`). API nach einer Rechteänderung neu starten. Der Gemini-Schlüssel ist dafür nicht geeignet. @@ -140,24 +143,15 @@ Beobachtungen und „Test durchgeführt“ oder „Nicht durchführbar“. Die a Bestätigung ist erforderlich. Textdaten bleiben untrusted observations, keine auszuführenden Befehle. Diese erste Oberfläche nimmt Text, keine Dateianhänge an. -Clients brauchen `tester = true` oder Betreiberrechte und Projektzugriff, um eine -Antwort zu bestätigen. Ein reiner Uploadclient erhält diese Rechte nicht automatisch. -Beispiel eines separaten menschlichen Clients in der API-Konfiguration: - -```toml -[[clients]] -id = "human-dev" -token_env = "VBA_HUMAN_TOKEN" -projects = ["viano-core"] -tester = true -``` - -Eigenen API-Token über diese Umgebungsvariable bereitstellen und API neu starten. +Menschliche Antworten erfordern eine angemeldete Benutzersitzung und die exklusive +Übernahme über **Bearbeiten**, siehe [Benutzeranmeldung](user-accounts.md). +Es gibt keine gesonderten Testerrollen für Webbenutzer. Menschliche Zugänge +ausschließlich mit `create_user` anlegen. Die bestätigende Identität und Zeit stammen vom Server, nicht aus frei editierbaren Formularfeldern. `GET /v1/test-requests` liefert ausschließlich freigegebene Projekte; `POST /v1/test-requests/{case-key}` bestätigt die konkrete Anfrage/Revision. Kollisionen, veraltete Anfragen und widersprüchliche Wiederholungen werden abgewiesen. -Eine identische erneute Übermittlung desselben Clients bleibt ohne doppelte Wirkung. +Eine identische erneute Übermittlung derselben Sitzung bleibt ohne doppelte Wirkung. Eine bestätigte Antwort setzt `AWAITING_TEST_FEEDBACK` über den vorhandenen Coordinator auf `INVESTIGATING_HYPOTHESES` zurück. Revision, Budget und ursprüngliche @@ -185,7 +179,8 @@ LLM-Aufrufe aus. ## Datenschutz und Betrieb -`GET /v1/overview` benötigt einen Bearer-Token und liefert nur freigegebene Projekte. +`GET /v1/overview` benötigt eine Benutzersitzung oder einen Maschinen-Bearer-Token. +Webbenutzer sehen alle Projekte; Maschinenclients nur ihre freigegebenen Projekte. Prompts, vollständige Modellantworten, lokale Pfade und Zugangsdaten werden nicht an den Browser übergeben. Fallnamen werden als Text dargestellt. Die Seite lädt keine externen Fonts, Chartbibliotheken oder Analysedienste; Diagramme werden lokal diff --git a/src/viano_bug_analysis/api/__main__.py b/src/viano_bug_analysis/api/__main__.py index a10ee35..a69358b 100644 --- a/src/viano_bug_analysis/api/__main__.py +++ b/src/viano_bug_analysis/api/__main__.py @@ -1,15 +1,46 @@ import argparse import json +import getpass +import os +import sqlite3 from .config import APIConfig, APIError def main(): parser = argparse.ArgumentParser(description='Authenticated BugAnalysis intake; no analysis worker') - parser.add_argument('command', choices=['check', 'serve']) + parser.add_argument('command', choices=['check', 'serve','create_user','delete_user','change_pw_user']) + parser.add_argument('name',nargs='?') + parser.add_argument('password',nargs='?') + parser.add_argument('new_password',nargs='?') + parser.add_argument('--service-group',help='Linux group allowed to read the root-owned users database') parser.add_argument('--config', required=True) args = parser.parse_args() try: config = APIConfig.load(args.config) + if args.command in {'create_user','delete_user','change_pw_user'}: + from .users import Users,require_privileged + require_privileged() + gid=None + if args.service_group and os.name!='nt': + import grp + gid=grp.getgrnam(args.service_group).gr_gid + if not args.name:raise APIError('AUTH_USER_INVALID') + if args.command=='delete_user' and (args.password or args.new_password):raise APIError('AUTH_ARGUMENTS') + if args.command=='create_user' and args.new_password:raise APIError('AUTH_ARGUMENTS') + users=Users(config.users_database or config.workspace/'auth/users.sqlite3') + if args.command=='create_user': + users.manage(args.command,args.name,args.password if args.password is not None else getpass.getpass('Password: ')) + elif args.command=='change_pw_user': + old=args.password if args.password is not None else getpass.getpass('Old password: ') + new=args.new_password if args.new_password is not None else getpass.getpass('New password: ') + users.manage(args.command,args.name,new,old) + else:users.manage(args.command,args.name) + if os.name!='nt': + os.chown(users.path,0,gid if gid is not None else -1) + os.chown(users.path.parent,0,gid if gid is not None else -1) + os.chmod(users.path,0o640);os.chmod(users.path.parent,0o750) + print(json.dumps({'status':'OK','command':args.command,'username':args.name})) + return 0 config.tokens() if args.command == 'check': print(json.dumps({'status': 'OK', 'projects': [p.id for p in config.projects]})) @@ -21,6 +52,8 @@ def main(): except APIError as error: print(json.dumps({'status': 'ERROR', 'code': error.code})) return 1 + except (OSError,sqlite3.Error,KeyError): + print(json.dumps({'status':'ERROR','code':'AUTH_STORAGE'}));return 1 if __name__ == '__main__': diff --git a/src/viano_bug_analysis/api/app.py b/src/viano_bug_analysis/api/app.py index 421861a..b4650a8 100644 --- a/src/viano_bug_analysis/api/app.py +++ b/src/viano_bug_analysis/api/app.py @@ -2,6 +2,7 @@ import asyncio import functools import hmac import json +import sqlite3 import shutil import tempfile from pathlib import Path @@ -22,6 +23,7 @@ from .dashboard import Dashboard from .cases import Cases from .operations import Operations from .test_requests import TestRequests +from .sessions import Sessions,COOKIE from viano_bug_analysis.lifecycle.errors import LockBusy from viano_bug_analysis.lifecycle.errors import LifecycleError from viano_bug_analysis.package import PackageReadError @@ -34,18 +36,51 @@ def create_app(config): cases = Cases(service) operations = Operations(service) test_requests = TestRequests(service) + sessions=Sessions(config) tokens = config.tokens() service.recover() active = 0 def authenticate(request): header = request.headers.get('authorization', '') - if not header.startswith('Bearer ') or not header.isascii(): - raise APIError('API_UNAUTHORIZED', 401) - for client, token in tokens: - if hmac.compare_digest(header[7:], token): - return client - raise APIError('API_UNAUTHORIZED', 401) + if header: + if header.startswith('Bearer ') and header.isascii(): + for client, token in tokens: + if hmac.compare_digest(header[7:], token):return client + raise APIError('API_UNAUTHORIZED',401) + return sessions.authenticate(request) + + async def small_json(request,maximum=4096): + if request.headers.get('content-type','').split(';')[0]!='application/json': + raise APIError('API_CONTENT_TYPE',415) + body=bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if len(body)>maximum:raise APIError('API_BODY_LIMIT',413) + try:return json.loads(body) + except ValueError:raise APIError('API_BAD_REQUEST',422) from None + + async def auth(request): + origin=request.headers.get('origin') + if origin and origin!=str(request.base_url).rstrip('/'): + raise APIError('AUTH_ORIGIN',403) + action=request.path_params['action'] + if action=='login' and request.method=='POST': + data=await small_json(request,16384) + if not isinstance(data,dict) or set(data)!={'username','password'}:raise APIError('AUTH_LOGIN_INVALID',401) + token,info=await run_in_threadpool(sessions.login,data['username'],data['password'],request.client.host if request.client else 'unknown') + response=JSONResponse(info,headers={'Cache-Control':'no-store'}) + response.set_cookie(COOKIE,token,max_age=config.session_seconds,httponly=True, + secure=request.url.scheme=='https',samesite='strict',path='/') + return response + if action not in {'session','logout'} or (action=='session' and request.method!='GET') or (action=='logout' and request.method!='POST'): + raise APIError('API_NOT_FOUND',404) + await run_in_threadpool(sessions.authenticate,request) + info=request.state.session_info + if action=='logout': + await run_in_threadpool(sessions.logout,request.state.session_id) + response=JSONResponse({'status':'OK'},headers={'Cache-Control':'no-store'});response.delete_cookie(COOKIE,path='/');return response + return JSONResponse({'username':info['username'],'csrf':info['csrf'],'expires_at':info['expires']},headers={'Cache-Control':'no-store'}) async def health(request): return JSONResponse({'status': 'OK'}) @@ -127,6 +162,8 @@ def create_app(config): if request.method=='GET': result=await run_in_threadpool(test_requests.listing,client) else: + identifier=getattr(request.state,'session_id',None) + if not identifier:raise APIError('AUTH_SESSION_REQUIRED',403) if not (client.operator or client.tester): raise APIError('API_FORBIDDEN',403) if request.headers.get('content-type','').split(';')[0]!='application/json': @@ -140,7 +177,19 @@ def create_app(config): data=json.loads(body) except ValueError: raise APIError('API_TEST_FEEDBACK_INVALID',422) from None - result=await run_in_threadpool(test_requests.answer,client,request.path_params['handle'],data) + handle=request.path_params['handle'] + if (not isinstance(data,dict) or not isinstance(data.get('request_id'),str) + or type(data.get('analysis_revision')) is not int): + raise APIError('API_TEST_FEEDBACK_INVALID',422) + if request.url.path.endswith('/claim'): + if set(data)!={'request_id','analysis_revision','action'} or not isinstance(data['action'],str) or data['action'] not in {'edit','release'}: + raise APIError('API_TEST_FEEDBACK_INVALID',422) + await run_in_threadpool(test_requests.pending,client,handle,data['request_id'],data['analysis_revision']) + await run_in_threadpool(functools.partial(sessions.claim,identifier,handle,data['request_id'],data['analysis_revision'],release=data['action']=='release')) + result={'status':'RELEASED' if data['action']=='release' else 'EDITING'} + else: + await run_in_threadpool(functools.partial(sessions.claim,identifier,handle,data['request_id'],data['analysis_revision'],check=True)) + result=await run_in_threadpool(test_requests.answer,client,handle,data) except LockBusy: raise APIError('API_TEST_BUSY',409) from None except (LifecycleError,PackageReadError) as exc: @@ -227,14 +276,17 @@ def create_app(config): return JSONResponse({'status': 'ERROR', 'code': 'API_STORAGE_ERROR'}, status_code=503) app = Starlette(routes=[Route('/', web), Route('/assets/{asset}', web), + Route('/v1/auth/{action}',auth,methods=['GET','POST']), Route('/v1/overview', overview), Route('/healthz', health), Route('/v1/profiles', profiles), Route('/v1/operations',operation,methods=['GET','POST']), Route('/v1/test-requests',tests),Route('/v1/test-requests/{handle}',tests,methods=['POST']), + Route('/v1/test-requests/{handle}/claim',tests,methods=['POST']), Route('/v1/cases/{handle}', case_detail), Route('/v1/cases/{handle}/package', case_package), Route('/v1/schema', contract), Route('/v1/submissions', submit, methods=['POST']), Route('/v1/submissions/{submission}', status), Route('/v1/submissions/{submission}/package', package)], - exception_handlers={APIError: error, HTTPException: http_error, OSError: storage_error}) + exception_handlers={APIError: error, HTTPException: http_error, OSError: storage_error,sqlite3.Error:storage_error}) app.state.intake = service + app.state.sessions=sessions return BodyLimit(app, config.limits.max_body_bytes) diff --git a/src/viano_bug_analysis/api/config.py b/src/viano_bug_analysis/api/config.py index 974b8be..df983b5 100644 --- a/src/viano_bug_analysis/api/config.py +++ b/src/viano_bug_analysis/api/config.py @@ -78,13 +78,16 @@ class APIConfig: host: str = '127.0.0.1' port: int = 8080 limits: IntakeLimits = field(default_factory=IntakeLimits) + users_database: Path | None = None + session_seconds: int = 3600 def __post_init__(self): ids = {p.id for p in self.projects} - if (not ids or len(ids) != len(self.projects) or not self.clients + if (not ids or len(ids) != len(self.projects) or len({c.id for c in self.clients}) != len(self.clients) or type(self.port) is not int or not 0 < self.port < 65536 - or not isinstance(self.host, str) or not self.host): + or not isinstance(self.host, str) or not self.host + or type(self.session_seconds) is not int or not 60<=self.session_seconds<=86400): raise APIError('API_CONFIG', 500) for c in self.clients: if (type(c.operator) is not bool or type(c.tester) is not bool or not isinstance(c.id, str) or not IDENTIFIER.fullmatch(c.id) @@ -97,6 +100,8 @@ class APIConfig: result = [] for client in self.clients: token = os.environ.get(client.token_env, '') + if not token: + continue # Machine integrations are optional; web users need no token env. if not re.fullmatch(r'[A-Za-z0-9_-]{32,256}', token): raise APIError('API_CREDENTIALS', 500) if any(token == existing for _, existing in result): @@ -109,7 +114,7 @@ class APIConfig: try: path = Path(path).resolve() data = tomllib.loads(path.read_text(encoding='utf-8')) - if set(data) - {'api', 'projects', 'clients', 'limits'}: + if set(data) - {'api', 'projects', 'clients', 'limits','auth'}: raise ValueError() api = data['api'] if set(api) - {'workspace', 'host', 'port'}: @@ -127,9 +132,13 @@ class APIConfig: raise ValueError() resolved[key] = target projects.append(Project(**row, settings=resolved)) - clients = [Client(**(dict(c) | {'projects': tuple(c['projects'])})) for c in data['clients']] + clients = [Client(**(dict(c) | {'projects': tuple(c['projects'])})) for c in data.get('clients',[])] + auth=data.get('auth',{}) + if set(auth)-{'users_database','session_seconds'}:raise ValueError() return cls(workspace=(path.parent / api['workspace']).resolve(), projects=tuple(projects), clients=tuple(clients), host=api.get('host', '127.0.0.1'), - port=api.get('port', 8080), limits=IntakeLimits(**data.get('limits', {}))) + port=api.get('port', 8080), limits=IntakeLimits(**data.get('limits', {})), + users_database=(path.parent/auth['users_database']).resolve() if 'users_database' in auth else None, + session_seconds=auth.get('session_seconds',3600)) except (OSError, ValueError, TypeError, KeyError): raise APIError('API_CONFIG', 500) from None diff --git a/src/viano_bug_analysis/api/sessions.py b/src/viano_bug_analysis/api/sessions.py new file mode 100644 index 0000000..d02f4ef --- /dev/null +++ b/src/viano_bug_analysis/api/sessions.py @@ -0,0 +1,97 @@ +import hashlib +import hmac +import os +import secrets +import time + +from .config import APIError,Client,directory +from .users import Users,database + +COOKIE='vba_session' + + +class Sessions: + def __init__(self,config,*,clock=time.time): + self.config=config;self.clock=clock + self.users=Users(config.users_database or config.workspace/'auth/users.sqlite3') + root=directory(config.workspace/'runtime/web-sessions') + self.path=root/'sessions.sqlite3' + if not self.path.exists(): + try: + descriptor=os.open(self.path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600);os.close(descriptor) + except FileExistsError:pass + with database(self.path) as db: + db.executescript(''' + CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, username TEXT NOT NULL, version TEXT NOT NULL, csrf TEXT NOT NULL, expires REAL NOT NULL); + CREATE TABLE IF NOT EXISTS claims (handle TEXT NOT NULL, request_id TEXT NOT NULL, revision INTEGER NOT NULL, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, PRIMARY KEY(handle,request_id,revision)); + CREATE TABLE IF NOT EXISTS attempts (id TEXT PRIMARY KEY, count INTEGER NOT NULL, until REAL NOT NULL); + ''') + + @staticmethod + def digest(value):return hashlib.sha256(value.encode()).hexdigest() + + def _prune(self,db): + db.execute('DELETE FROM sessions WHERE expires<=?',(self.clock(),)) + # Password changes/deletion invalidate sessions and their editing claims. + for row in db.execute('SELECT id,username,version FROM sessions').fetchall(): + user=self.users.get(row['username']) + if not user or user['version']!=row['version']: + db.execute('DELETE FROM sessions WHERE id=?',(row['id'],)) + + def login(self,name,password,remote): + if not isinstance(name,str) or not isinstance(password,str) or not 1<=len(name)<=64 or not 1<=len(password)<=1024: + raise APIError('AUTH_LOGIN_INVALID',401) + now=self.clock() + with database(self.path) as db: + db.execute('BEGIN IMMEDIATE') + db.execute('DELETE FROM attempts WHERE until<=?',(now,)) + keys=[('user:'+name.casefold(),10),('ip:'+remote,30)] + for value,maximum in keys: + identifier=self.digest(value) + row=db.execute('SELECT count FROM attempts WHERE id=?',(identifier,)).fetchone() + if row and row['count']>=maximum:raise APIError('AUTH_LOGIN_LIMIT',429) + for value,_ in keys: + db.execute('INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(id) DO UPDATE SET count=count+1', + (self.digest(value),now+900)) + user=self.users.verify(name,password) + if not user:raise APIError('AUTH_LOGIN_INVALID',401) + token=secrets.token_urlsafe(32);csrf=secrets.token_urlsafe(32);expires=now+self.config.session_seconds + with database(self.path) as db: + db.execute('BEGIN IMMEDIATE');self._prune(db) + db.execute('DELETE FROM attempts WHERE id=?',(self.digest('user:'+name.casefold()),)) + db.execute('INSERT INTO sessions VALUES (?,?,?,?,?)',(self.digest(token),user['name'],user['version'],csrf,expires)) + return token,{'username':user['name'],'csrf':csrf,'expires_at':expires} + + def authenticate(self,request): + token=request.cookies.get(COOKIE,'') + if not token or len(token)>128:raise APIError('API_UNAUTHORIZED',401) + identifier=self.digest(token) + with database(self.path) as db: + row=db.execute('SELECT * FROM sessions WHERE id=? AND expires>?',(identifier,self.clock())).fetchone() + user=self.users.get(row['username']) if row else None + if not user or user['version']!=row['version']:raise APIError('API_UNAUTHORIZED',401) + if request.method not in {'GET','HEAD','OPTIONS'}: + csrf=request.headers.get('x-csrf-token','') + if not csrf.isascii() or not hmac.compare_digest(csrf,row['csrf']): + raise APIError('AUTH_CSRF',403) + request.state.session_id=identifier + request.state.session_info=dict(row) + # Human accounts deliberately have the same complete web permissions. + return Client('user-'+user['name'],'WEB_SESSION',tuple(p.id for p in self.config.projects),operator=True,tester=True) + + def logout(self,identifier): + with database(self.path) as db:db.execute('DELETE FROM sessions WHERE id=?',(identifier,)) + + def claim(self,identifier,handle,request_id,revision,*,check=False,release=False): + with database(self.path) as db: + db.execute('BEGIN IMMEDIATE');self._prune(db) + if not db.execute('SELECT 1 FROM sessions WHERE id=?',(identifier,)).fetchone(): + raise APIError('API_UNAUTHORIZED',401) + identity=(handle,request_id,revision) + row=db.execute('SELECT session_id FROM claims WHERE handle=? AND request_id=? AND revision=?',identity).fetchone() + if row and row['session_id']!=identifier:raise APIError('API_TEST_CLAIMED',409) + if check and not row:raise APIError('API_TEST_CLAIM_REQUIRED',409) + if release: + db.execute('DELETE FROM claims WHERE handle=? AND request_id=? AND revision=? AND session_id=?',identity+(identifier,)) + elif not row: + db.execute('INSERT INTO claims VALUES (?,?,?,?)',identity+(identifier,)) diff --git a/src/viano_bug_analysis/api/test_requests.py b/src/viano_bug_analysis/api/test_requests.py index 26caae4..4a96aa8 100644 --- a/src/viano_bug_analysis/api/test_requests.py +++ b/src/viano_bug_analysis/api/test_requests.py @@ -23,6 +23,13 @@ class TestRequests: return None return value + def pending(self,client,handle,request_id,revision): + with PackageReader(self.cases.head(handle,client)) as reader: + value=self.request(reader) + if (reader.manifest['state']!='BLOCKED' or reader.manifest['routing']['reason']!='AWAITING_TEST_FEEDBACK' + or not value or (value['request_id'],value['analysis_revision'])!=(request_id,revision)): + raise APIError('API_TEST_REQUEST_STALE',409) + def listing(self,client): rows=[];errors=0 for folder in self.service.workspace.cases.glob('*/*'): diff --git a/src/viano_bug_analysis/api/users.py b/src/viano_bug_analysis/api/users.py new file mode 100644 index 0000000..a969c41 --- /dev/null +++ b/src/viano_bug_analysis/api/users.py @@ -0,0 +1,84 @@ +"""Portable password database. Only privileged console commands mutate users.""" +import hashlib +import hmac +import os +import re +import secrets +import sqlite3 +from contextlib import contextmanager +from pathlib import Path + +from .config import APIError,directory + + +@contextmanager +def database(path, *, readonly=False): + path=Path(path).absolute() + for part in [*path.parents,path]: + if part.is_symlink() or (part.exists() and getattr(part.lstat(),'st_file_attributes',0)&0x400): + raise APIError('AUTH_UNSAFE_STORAGE',500) + connection=sqlite3.connect(path.as_uri()+'?mode=ro' if readonly else str(path),uri=readonly,timeout=5) + connection.row_factory=sqlite3.Row + connection.execute('PRAGMA foreign_keys=ON') + try: + with connection: + yield connection + finally: + connection.close() + + +def password_hash(password,salt): + return hashlib.scrypt(password.encode('utf-8'),salt=salt,n=32768,r=8,p=1,maxmem=64*1024*1024).hex() + + +class Users: + def __init__(self,path): + self.path=Path(path) + + def get(self,name): + if not self.path.exists():return None + with database(self.path,readonly=True) as db: + row=db.execute('SELECT * FROM users WHERE name=? COLLATE NOCASE',(name,)).fetchone() + return dict(row) if row else None + + def verify(self,name,password): + row=self.get(name) + actual=password_hash(password,bytes.fromhex(row['salt']) if row else b'\0'*16) + return row if row and hmac.compare_digest(actual,row['password_hash']) else None + + def manage(self,action,name,password=None,old_password=None): + if not isinstance(name,str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,63}',name): + raise APIError('AUTH_USER_INVALID',400) + if action!='delete_user' and (not isinstance(password,str) or not 1<=len(password)<=1024): + raise APIError('AUTH_PASSWORD_INVALID',400) + if action=='change_pw_user' and (not isinstance(old_password,str) or len(old_password)>1024): + raise APIError('AUTH_PASSWORD_INVALID',400) + directory(self.path.parent) + if not self.path.exists(): + descriptor=os.open(self.path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o640);os.close(descriptor) + with database(self.path) as db: + db.execute('CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY COLLATE NOCASE, salt TEXT NOT NULL, password_hash TEXT NOT NULL, version TEXT NOT NULL)') + db.execute('BEGIN IMMEDIATE') + row=db.execute('SELECT * FROM users WHERE name=? COLLATE NOCASE',(name,)).fetchone() + if action=='create_user' and row:raise APIError('AUTH_USER_EXISTS',409) + if action!='create_user' and not row:raise APIError('AUTH_USER_NOT_FOUND',404) + if action=='delete_user': + db.execute('DELETE FROM users WHERE name=? COLLATE NOCASE',(name,));return + if action=='change_pw_user' and not hmac.compare_digest(password_hash(old_password,bytes.fromhex(row['salt'])),row['password_hash']): + raise APIError('AUTH_OLD_PASSWORD',403) + salt=secrets.token_bytes(16) + digest=password_hash(password,salt) + if action=='create_user': + db.execute('INSERT INTO users VALUES (?,?,?,?)',(name,salt.hex(),digest,secrets.token_hex(16))) + else: + db.execute('UPDATE users SET salt=?,password_hash=?,version=? WHERE name=? COLLATE NOCASE', + (salt.hex(),digest,secrets.token_hex(16),name)) + + +def require_privileged(): + if os.name=='nt': + import ctypes + allowed=bool(ctypes.windll.shell32.IsUserAnAdmin()) + else: + allowed=os.geteuid()==0 + if not allowed:raise APIError('AUTH_PRIVILEGED_REQUIRED',403) diff --git a/src/viano_bug_analysis/web/case-detail.js b/src/viano_bug_analysis/web/case-detail.js index 8104d9d..dcbb482 100644 --- a/src/viano_bug_analysis/web/case-detail.js +++ b/src/viano_bug_analysis/web/case-detail.js @@ -39,8 +39,8 @@ function renderArchive(){ function clearDetails(){selectedCase=null;detailHash=null;detailLoading=false;$('result-search').value='';} async function downloadCase(data,button){ const generation=session,credential=token;button.disabled=true;error(); - try{const response=await fetch(`/v1/cases/${data.key}/package`,{headers:{Authorization:`Bearer ${credential}`},cache:'no-store'}); - if(!response.ok){const issue=await response.json();if(response.status===401&&credential===token)logout();throw new Error(issue.code||`HTTP ${response.status}`);} + try{const response=await fetch(`/v1/cases/${data.key}/package`,{credentials:'same-origin',cache:'no-store'}); + if(!response.ok){const issue=await response.json();if(response.status===401&&credential===token&&generation===session)logout(false);throw new Error(issue.code||`HTTP ${response.status}`);} const blob=await response.blob();if(generation!==session)return; const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download=`${data.case_id.replace(/[^A-Za-z0-9_.-]/g,'_')}-Fallakte.zip`;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),30000); }catch(e){if(generation===session)error(`Download fehlgeschlagen: ${e.message}`);}finally{button.disabled=false;} @@ -110,7 +110,7 @@ function route(){ if(typeof testRoute==='function'&&testRoute())return; if($('test-page'))$('test-page').hidden=true; const detail=location.hash.startsWith('#case/'),results=location.hash==='#results',submissionPage=location.hash==='#submit'; - $('overview-page').hidden=detail||results||submissionPage;$('results').hidden=!results;$('case-detail').hidden=!detail;$('submit').hidden=detail||results; + $('overview-page').hidden=detail||results||submissionPage;$('results').hidden=!results;$('case-detail').hidden=!detail;$('submit').hidden=!submissionPage; document.querySelector('header h1').textContent=detail?'Die Fallakte.':results?'Abgeschlossene Fälle.':submissionPage?'Auftrag übergeben.':'Analyse im Blick.'; document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('selected',detail?a.hash==='#cases':a.hash===(results?'#results':submissionPage?'#submit':location.hash==='#cases'?'#cases':'#overview'))); if(detail)loadDetail(location.hash);if(results)renderArchive(); diff --git a/src/viano_bug_analysis/web/dashboard.js b/src/viano_bug_analysis/web/dashboard.js index 65af892..a6f7f7b 100644 --- a/src/viano_bug_analysis/web/dashboard.js +++ b/src/viano_bug_analysis/web/dashboard.js @@ -5,13 +5,14 @@ const names = {NEW:'Eingang',VALIDATED:'Angenommen',BUILDING_CASE:'Fallaufbau',I const finished = new Set(['OUTPUT_READY','FAILED_TO_ANALYZE','HANDED_OFF']); const number = n => new Intl.NumberFormat('de-DE').format(n); let token = '', timer = null, overview = null, projects = [], sending = false, submission = null, session = 0; +let csrfToken='',logoutPending=Promise.resolve(),loginBusy=false; const text = (tag, value, cls) => { const e=document.createElement(tag); e.textContent=value; if(cls)e.className=cls; return e; }; function error(message='') { $('error').hidden=!message; $('error').textContent=message; } async function api(path, options={}) { - const credential=token; - const response=await fetch(path,{...options,headers:{...options.headers,Authorization:`Bearer ${credential}`},cache:'no-store'}); + const credential=token,generation=session; + const response=await fetch(path,{...options,headers:{...options.headers,...(csrfToken?{'X-CSRF-Token':csrfToken}:{})},credentials:'same-origin',cache:'no-store'}); const data=await response.json(); - if(!response.ok) { if(response.status===401 && credential===token) logout(); throw new Error(data.code || `HTTP ${response.status}`); } + if(!response.ok) { if(response.status===401 && credential===token&&generation===session&&path!=='/v1/auth/login') logout(false); throw new Error(data.code || `HTTP ${response.status}`); } return data; } function progress(value, label) { @@ -19,8 +20,9 @@ function progress(value, label) { outer.setAttribute('aria-label',label); outer.setAttribute('aria-valuemin','0'); outer.setAttribute('aria-valuemax','100'); outer.setAttribute('aria-valuenow',String(value)); const inner=text('div','','progress-fill'); inner.style.width=`${value}%`; outer.append(inner); return outer; } -function logout() { - session++;token=''; clearTimeout(timer); $('token').value=''; $('content').hidden=true; $('login').hidden=false; +function logout(remote=true) { + if(remote&&token)logoutPending=fetch('/v1/auth/logout',{method:'POST',headers:{'X-CSRF-Token':csrfToken},credentials:'same-origin'}).catch(()=>{}); + session++;token='';csrfToken=''; clearTimeout(timer); $('password').value=''; $('content').hidden=true; $('login').hidden=false; $('logout').hidden=true; $('connection').textContent='Nicht verbunden'; document.body.classList.remove('connected'); $('updated').textContent='Warte auf Verbindung'; overview=null;projects=[];submission=null; $('submit-form').reset();$('receipt').textContent='';$('new-files').hidden=false;$('return-files').hidden=true; @@ -113,16 +115,24 @@ async function refresh() { } catch(e) {if(generation===session)error(`Aktualisierung fehlgeschlagen: ${e.message}`);} if(token && generation===session)timer=setTimeout(refresh,4000); } -$('login-form').addEventListener('submit',async e=>{ - e.preventDefault();const generation=++session;token=$('token').value.trim();error(); - try {const response=await api('/v1/profiles');if(generation!==session)return;projects=response.projects; +async function connected(info,generation){ + if(generation!==session)return;token='session';csrfToken=info.csrf; + const response=await api('/v1/profiles');if(generation!==session)return;projects=response.projects; $('project').replaceChildren();$('project-filter').replaceChildren(new Option('Alle freigegebenen Projekte',''));$('result-project').replaceChildren(new Option('Alle freigegebenen Projekte','')); projects.forEach(p=>{$('project').add(new Option(p.project_id,p.project_id));$('project-filter').add(new Option(p.project_id,p.project_id));$('result-project').add(new Option(p.project_id,p.project_id));}); - $('login').hidden=true;$('content').hidden=false;$('logout').hidden=false;$('token').value=''; - $('connection').textContent='Verbunden';document.body.classList.add('connected');if(typeof route==='function')route();await refresh(); - } catch(e) {if(generation===session){token='';error(`Verbindung fehlgeschlagen: ${e.message}`);}else if(!token)error('Verbindung fehlgeschlagen: API-Zugangsschlüssel prüfen.');} + $('login').hidden=true;$('content').hidden=false;$('logout').hidden=false;$('password').value=''; + $('connection').textContent=info.username;document.body.classList.add('connected');if(typeof route==='function')route();await refresh(); +} +$('login-form').addEventListener('submit',async e=>{ + e.preventDefault();if(loginBusy)return;loginBusy=true;const generation=++session;error(); + const password=$('password').value;$('password').value=''; + try{await logoutPending;const info=await api('/v1/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('username').value,password})});await connected(info,generation); + }catch(e){if(generation===session){token='';error(`Anmeldung fehlgeschlagen: ${e.message}`);}}finally{loginBusy=false;} }); -$('logout').addEventListener('click',logout);$('project-filter').addEventListener('change',render); +$('logout').addEventListener('click',()=>logout());$('project-filter').addEventListener('change',render); +fetch('/v1/auth/session',{credentials:'same-origin',cache:'no-store'}).then(async response=>{ + if(response.ok&&session===0){const info=await response.json();await connected(info,0);} +}).catch(()=>{}); $('kind').addEventListener('change',()=>{$('new-files').hidden=$('kind').value!=='new_case';$('return-files').hidden=$('kind').value!=='case_return';}); $('submit-form').addEventListener('submit',async e=>{ e.preventDefault();if(sending)return;error(); diff --git a/src/viano_bug_analysis/web/index.html b/src/viano_bug_analysis/web/index.html index 560aab8..75a99f3 100644 --- a/src/viano_bug_analysis/web/index.html +++ b/src/viano_bug_analysis/web/index.html @@ -10,8 +10,7 @@
ANALYSEBETRIEB
Nicht verbunden
BETRIEBSÜBERSICHT

Analyse im Blick.

Aktuelle Arbeit und messbarer Verbrauch.

Warte auf Verbindung
- +