Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Automatisierung und Sicherheitslage. Ende-zu-Ende gegen einen Test-Stack und drei Test-Firewalls geprüft, siehe Handbuch „Sicherheitslage“ und „Automatisierung“. - **API-Tokens** (System › API-Tokens): persönliche Tokens für Skripte, CI, Prometheus und KI-Assistenten, Bereiche *Lesen*, *Ausrollen*, *Admin*. Nur als Hash gespeichert, einmal angezeigt, widerrufbar, mit Ablaufdatum; Anlegen verlangt das Passwort. Ein Token überschreitet nie die Rechte seines Besitzers. Tokens verwalten und das eigene Konto geht nur in einer normalen Anmeldung. Geheimnisse (Roh-Backups, Benachrichtigungseinstellungen) liest nur der Bereich *Admin*. Das Audit nennt das Token. Neue Tabelle `api_tokens` (Migration `0008`). - **Prometheus-Metriken** unter `GET /api/metrics` (Host erreichbar, Last, Speicher, Platte, CARP-Rolle, offene Aktionen und Probleme, Sicherheitswert), nur aus der Datenbank, ohne Anfragen an die Firewalls. - **MCP-Endpunkt** `POST /api/mcp` (nur lesend) für KI-Assistenten: Flottenübersicht, Hosts, Auslastung, CARP-Ereignisse, Audit, Suche und ein Leser für jeden GET-Endpunkt, mit den Rechten des Tokens. - **OpenAPI-Schema** jetzt unter `/api/openapi.json` (bisher unter `/openapi.json`, vom Proxy nicht erreichbar); die Swagger-/ReDoc-Seiten entfallen (ihre CDN-Skripte verbietet die CSP). - **Sicherheitslage** (Monitoring › Sicherheitslage): Härtungs- und Regel-Hygiene-Prüfungen je Firewall mit Wert 0–100: Allow-all am WAN, HTTPS, SSH, SNMP, verdeckte/doppelte/unbeschriftete Regeln, Zeitserver, externe Logs, Firmware, Backup-Alter. Liest die Konfiguration nur lesend, speichert sie nicht, alle 6 Stunden und auf Knopfdruck. Neue Tabelle `compliance_results` (Migration `0009`). - Behoben: Änderungen am Benutzer in einer Anfrage (z. B. 2FA aktivieren) gingen verloren, wenn die Middleware den Benutzer aus einer anderen Datenbank-Sitzung lieferte; er wird jetzt in der Sitzung der Anfrage neu geladen.
313 lines
13 KiB
Python
313 lines
13 KiB
Python
import logging
|
|
|
|
from fastapi import APIRouter, Depends, Request, Response, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.errors import ErrorCode, api_error
|
|
from app.config import settings
|
|
from app.database import get_db
|
|
from app.models.user import AdminUser
|
|
from app.services import api_token_service, auth_service, totp_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
COOKIE_NAME = "opnexus-session"
|
|
|
|
|
|
def client_ip(request: Request) -> str:
|
|
if settings.trust_proxy_headers:
|
|
forwarded = request.headers.get("x-forwarded-for")
|
|
if forwarded:
|
|
# First entry is the original client; a trusted proxy
|
|
# overwrites (not appends blindly to) this header, so this
|
|
# is safe only under the condition documented on the config
|
|
# flag itself.
|
|
return forwarded.split(",")[0].strip()
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
|
|
def _set_session_cookie(response: Response, token: str) -> None:
|
|
response.set_cookie(
|
|
key=COOKIE_NAME,
|
|
value=token,
|
|
max_age=settings.session_lifetime,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=settings.cookie_secure,
|
|
path="/",
|
|
)
|
|
|
|
|
|
def _clear_session_cookie(response: Response) -> None:
|
|
response.delete_cookie(key=COOKIE_NAME, path="/")
|
|
|
|
|
|
def _check_account_budget(user: AdminUser, action: str) -> None:
|
|
"""429 once too many failed password/code attempts piled up for this account
|
|
and action (see auth_service.account_throttle)."""
|
|
wait = auth_service.account_throttle.retry_after(str(user.id), action)
|
|
if wait > 0:
|
|
raise api_error(status.HTTP_429_TOO_MANY_REQUESTS, ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, wait=wait)
|
|
|
|
|
|
def _account_failure(user: AdminUser, action: str) -> None:
|
|
auth_service.account_throttle.record_failure(str(user.id), action)
|
|
|
|
|
|
def _account_success(user: AdminUser, action: str) -> None:
|
|
auth_service.account_throttle.reset(str(user.id), action)
|
|
|
|
|
|
async def resolve_user(request: Request, db: AsyncSession) -> AdminUser:
|
|
# AuthMiddleware already resolved the caller (session cookie or API token), but
|
|
# in its own, closed DB session: re-load into this request's session so that
|
|
# changes made to the user (2FA, password...) are actually persisted.
|
|
resolved = getattr(request.state, "user", None)
|
|
if resolved is not None:
|
|
user = await db.get(AdminUser, resolved.id)
|
|
if user is not None:
|
|
return user
|
|
token = request.cookies.get(COOKIE_NAME, "")
|
|
user = await auth_service.resolve_session(db, token)
|
|
if user is None:
|
|
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.NICHT_AUTHENTIFIZIERT)
|
|
return user
|
|
|
|
|
|
async def current_user(request: Request, db: AsyncSession = Depends(get_db)) -> AdminUser:
|
|
return await resolve_user(request, db)
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str = Field(min_length=1, max_length=150)
|
|
password: str = Field(min_length=1, max_length=1024)
|
|
# Authenticator code or one-time recovery code; only needed (and only
|
|
# requested) once the password was right and the account has 2FA enabled.
|
|
totp_code: str | None = Field(default=None, max_length=32)
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
current_password: str = Field(min_length=1, max_length=1024)
|
|
new_password: str = Field(min_length=10, max_length=1024)
|
|
|
|
|
|
@router.post("/login")
|
|
async def login(payload: LoginRequest, request: Request, response: Response, db: AsyncSession = Depends(get_db)):
|
|
ip = client_ip(request)
|
|
|
|
wait = auth_service.throttle.retry_after(ip, payload.username)
|
|
if wait > 0:
|
|
raise api_error(status.HTTP_429_TOO_MANY_REQUESTS, ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, wait=wait)
|
|
|
|
user = await auth_service.authenticate(db, payload.username, payload.password, ip, finalize=False)
|
|
if user is None:
|
|
auth_service.throttle.record_failure(ip, payload.username)
|
|
logger.info("Failed login for '%s' from %s", payload.username, ip)
|
|
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.UNGUELTIGER_BENUTZERNAME_ODER_PASSWORT)
|
|
|
|
verified_hash = user.password_hash
|
|
if user.totp_enabled:
|
|
# Only revealed after the password was correct, so it cannot be used
|
|
# to find out which usernames exist or have 2FA.
|
|
if not payload.totp_code:
|
|
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.TOTP_REQUIRED)
|
|
if not await _accept_second_factor(db, user, payload.totp_code):
|
|
auth_service.throttle.record_failure(ip, payload.username)
|
|
logger.info("Failed second factor for '%s' from %s", payload.username, ip)
|
|
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.TOTP_INVALID)
|
|
await auth_service.finalize_login(db, user, ip)
|
|
|
|
auth_service.throttle.reset(ip, payload.username)
|
|
failed_since_last_login = auth_service.throttle.pop_failures_since_success(payload.username)
|
|
token, expires_at = await auth_service.create_session(
|
|
db, user, user_agent=request.headers.get("User-Agent"), ip_address=ip, verified_hash=verified_hash
|
|
)
|
|
_set_session_cookie(response, token)
|
|
logger.info("Successful login for '%s' from %s", user.username, ip)
|
|
return {
|
|
"authenticated": True,
|
|
"username": user.username,
|
|
"role": user.role,
|
|
"previous_login_at": getattr(user, "previous_login_at", None),
|
|
"previous_login_ip": getattr(user, "previous_login_ip", None),
|
|
"failed_attempts_since_last_login": failed_since_last_login,
|
|
"session_expires_at": expires_at,
|
|
}
|
|
|
|
|
|
async def _accept_second_factor(db: AsyncSession, user: AdminUser, code: str) -> bool:
|
|
"""Authenticator code (replay-protected) or, failing that, a one-time recovery code.
|
|
|
|
A code is single-use only if "check, then mark as used" cannot interleave
|
|
with a second request carrying the same code. The user row is therefore
|
|
re-read under a row lock (FOR UPDATE, fresh values) and the used step /
|
|
consumed recovery hash is committed while holding it; a concurrent attempt
|
|
blocks here and then sees the updated state.
|
|
"""
|
|
locked = await db.scalar(
|
|
select(AdminUser).where(AdminUser.id == user.id).with_for_update().execution_options(populate_existing=True)
|
|
)
|
|
if locked is None:
|
|
return False
|
|
secret = totp_service.read_secret(locked.id)
|
|
if secret:
|
|
step = totp_service.verify_code(secret, code, locked.totp_last_step)
|
|
if step is not None:
|
|
locked.totp_last_step = step
|
|
await db.commit()
|
|
return True
|
|
remaining = totp_service.consume_recovery(locked.totp_recovery_hashes, code)
|
|
if remaining is not None:
|
|
locked.totp_recovery_hashes = remaining
|
|
await db.commit()
|
|
logger.warning("Recovery code used by '%s' (%d left)", locked.username, len(remaining))
|
|
return True
|
|
await db.commit() # nothing changed — just release the row lock
|
|
return False
|
|
|
|
|
|
class TotpSetupRequest(BaseModel):
|
|
password: str = Field(min_length=1, max_length=1024)
|
|
|
|
|
|
class TotpEnableRequest(BaseModel):
|
|
code: str = Field(min_length=1, max_length=32)
|
|
|
|
|
|
class TotpDisableRequest(BaseModel):
|
|
password: str = Field(min_length=1, max_length=1024)
|
|
code: str = Field(min_length=1, max_length=32)
|
|
|
|
|
|
@router.get("/2fa")
|
|
async def totp_status(user: AdminUser = Depends(current_user)):
|
|
return {"enabled": user.totp_enabled, "recovery_codes_left": len(user.totp_recovery_hashes or [])}
|
|
|
|
|
|
@router.post("/2fa/setup")
|
|
async def totp_setup(payload: TotpSetupRequest, user: AdminUser = Depends(current_user)):
|
|
"""Step 1: needs the current password (a stolen session alone must not be able
|
|
to add an attacker's authenticator). Returns the secret to scan; nothing is
|
|
active until /2fa/enable proves the authenticator works."""
|
|
if user.totp_enabled:
|
|
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.TOTP_ALREADY_ENABLED)
|
|
_check_account_budget(user, "2fa-setup")
|
|
if not await auth_service.verify_password_async(user.password_hash, payload.password):
|
|
_account_failure(user, "2fa-setup")
|
|
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH)
|
|
_account_success(user, "2fa-setup")
|
|
secret = totp_service.generate_secret()
|
|
totp_service.save_secret(user.id, secret, pending=True)
|
|
return {"secret": secret, "otpauth_uri": totp_service.otpauth_uri(user.username, secret)}
|
|
|
|
|
|
@router.post("/2fa/enable")
|
|
async def totp_enable(
|
|
payload: TotpEnableRequest, request: Request,
|
|
db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user),
|
|
):
|
|
if user.totp_enabled:
|
|
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.TOTP_ALREADY_ENABLED)
|
|
_check_account_budget(user, "2fa-enable")
|
|
pending = totp_service.read_secret(user.id, pending=True)
|
|
if not pending:
|
|
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.TOTP_SETUP_MISSING)
|
|
step = totp_service.verify_code(pending, payload.code)
|
|
if step is None:
|
|
# Not reset by a fresh /2fa/setup: recreating the pending enrolment
|
|
# must not hand out a new guessing budget.
|
|
_account_failure(user, "2fa-enable")
|
|
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.TOTP_INVALID)
|
|
_account_success(user, "2fa-enable")
|
|
plain, hashes = totp_service.new_recovery_codes()
|
|
totp_service.promote_pending(user.id)
|
|
user.totp_enabled = True
|
|
user.totp_last_step = step
|
|
user.totp_recovery_hashes = hashes
|
|
# Other sessions were established without the second factor — end them.
|
|
await auth_service.revoke_sessions(db, user.id, keep_token=request.cookies.get(COOKIE_NAME, ""), commit=False)
|
|
await db.commit()
|
|
logger.info("Two-factor enabled for '%s'", user.username)
|
|
return {"enabled": True, "recovery_codes": plain}
|
|
|
|
|
|
@router.post("/2fa/disable")
|
|
async def totp_disable(
|
|
payload: TotpDisableRequest, request: Request,
|
|
db: AsyncSession = Depends(get_db), user: AdminUser = Depends(current_user),
|
|
):
|
|
if not user.totp_enabled:
|
|
raise api_error(status.HTTP_409_CONFLICT, ErrorCode.TOTP_NOT_ENABLED)
|
|
_check_account_budget(user, "2fa-disable")
|
|
if not await auth_service.verify_password_async(user.password_hash, payload.password):
|
|
_account_failure(user, "2fa-disable")
|
|
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH)
|
|
if not await _accept_second_factor(db, user, payload.code):
|
|
_account_failure(user, "2fa-disable")
|
|
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.TOTP_INVALID)
|
|
_account_success(user, "2fa-disable")
|
|
totp_service.delete_secrets(user.id)
|
|
user.totp_enabled = False
|
|
user.totp_recovery_hashes = None
|
|
user.totp_last_step = 0
|
|
await auth_service.revoke_sessions(db, user.id, keep_token=request.cookies.get(COOKIE_NAME, ""), commit=False)
|
|
await db.commit()
|
|
logger.info("Two-factor disabled for '%s'", user.username)
|
|
return {"enabled": False}
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
|
|
token = request.cookies.get(COOKIE_NAME, "")
|
|
await auth_service.revoke_session(db, token)
|
|
_clear_session_cookie(response)
|
|
return {"authenticated": False}
|
|
|
|
|
|
@router.get("/me")
|
|
async def me(request: Request, db: AsyncSession = Depends(get_db)):
|
|
raw = api_token_service.bearer_from_header(request.headers.get("authorization", ""))
|
|
if raw is not None:
|
|
resolved = await api_token_service.resolve(db, raw, client_ip(request))
|
|
if resolved is None:
|
|
return {"authenticated": False}
|
|
user, api_token = resolved
|
|
return {"authenticated": True, "username": user.username, "role": user.role,
|
|
"via": "token", "scope": api_token.scope, "token_name": api_token.name}
|
|
token = request.cookies.get(COOKIE_NAME, "")
|
|
user = await auth_service.resolve_session(db, token)
|
|
if user is None:
|
|
return {"authenticated": False}
|
|
return {"authenticated": True, "username": user.username, "role": user.role}
|
|
|
|
|
|
@router.post("/change-password")
|
|
async def change_password(
|
|
payload: ChangePasswordRequest,
|
|
response: Response,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
user: AdminUser = Depends(current_user),
|
|
):
|
|
_check_account_budget(user, "change-password")
|
|
if not await auth_service.verify_password_async(user.password_hash, payload.current_password):
|
|
_account_failure(user, "change-password")
|
|
raise api_error(status.HTTP_401_UNAUTHORIZED, ErrorCode.AKTUELLES_PASSWORT_IST_FALSCH)
|
|
_account_success(user, "change-password")
|
|
if payload.new_password == payload.current_password:
|
|
raise api_error(status.HTTP_400_BAD_REQUEST, ErrorCode.DAS_NEUE_PASSWORT_MUSS_SICH_VOM_AKTUELLEN_UNTERSCHEIDEN)
|
|
|
|
# New hash, revocation of EVERY existing session (including other devices
|
|
# an attacker may hold) and the replacement session commit as one unit.
|
|
user.password_hash = await auth_service.hash_password_async(payload.new_password)
|
|
await auth_service.revoke_sessions(db, user.id, commit=False)
|
|
token, _ = await auth_service.create_session(
|
|
db, user, user_agent=request.headers.get("User-Agent"), ip_address=client_ip(request)
|
|
)
|
|
_set_session_cookie(response, token)
|
|
return {"ok": True}
|