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
- **Fehlende API-Rechte werden sichtbar:** Darf der API-Schlüssel einer Firewall den Gateway- oder Dienststatus nicht lesen (OPNsense antwortet
mit 403), fiel das bisher niemandem auf, und der standardmäßig aktive Gateway-Alarm konnte nie auslösen. Jetzt zeigen die Host-Seite
(Abschnitt „Gateways“) und die Dashboard-Karte „Fleet-/CARP-Gesundheit“ ein Warn-Badge „API-Rechte fehlen: Gateways, Dienste“; der Tooltip nennt die
OPNsense-Berechtigung, die beim Benutzer oder bei der Gruppe fehlt (System: Gateways, Status: Services, Lobby: Dashboard). Zusätzlich gibt es
den neuen Alarm **API-Rechte fehlen** (standardmäßig an, abschaltbar unter Einstellungen › Benachrichtigungen, gleiche Entprellung wie die
anderen Messungs-Alarme) und die Prometheus-Metrik `opnexus_host_api_rights_missing{host,read}`. Nur eine ausdrückliche Ablehnung zählt;
Zeitüberschreitungen oder Serverfehler bleiben „unbekannt“.
- **Gateway-Alarm pro Gateway abschaltbar:** In der Gateway-Liste der Host-Seite schaltet eine Glocke den Alarm für ein einzelnes Gateway aus
(z. B. ruhendes Zweit-WAN oder tote IPv6-Gateways); ein „Alarm aus“-Badge zeigt es an. Ein offener Alarm dieses Gateways löst sich dabei
auf. Nur Administratoren, die Änderung steht im Audit-Log. Das Dashboard zeigt bei mehr als acht Gateways „+ N weitere“.
- **Firewall-Oberfläche (GUI-Zugriff) härter:** Ein frei gewordener Platz wird für eine **andere** Firewall erst 5 Minuten nach dem Ende der letzten
Sitzung vergeben (Schutz, falls dort noch ein Tab offen ist, der die Cookies des Platzes teilt); dieselbe Firewall bekommt ihn sofort wieder.
Sind alle Plätze belegt oder noch in der Abkühlzeit, nennt die Meldung den Grund. Ändern sich Adresse, festgesetztes Zertifikat oder Tunnelweg
einer Firewall, während ihre Oberfläche offen ist, endet die Sitzung spätestens nach 20 Sekunden. Das Limit von 64 gleichzeitigen Sitzungen
verdrängt keine laufende Sitzung mehr: eine neue wird abgelehnt (mit Meldung), bis eine endet. **Hinweis:** Das GUI-Gateway-Image muss zur
Backend-Version passen (wie bisher beim Update beider Container).
- **Anmeldung (Cookies) härter:** Das alte Sitzungs-Cookie `opnexus-session` wird bei HTTPS **nicht mehr akzeptiert**. **Alle, die sich seit der
Umstellung auf 1.27 nicht neu angemeldet haben, müssen sich einmal neu anmelden**; Skripte mit rohem Sitzungs-Cookie brauchen den Namen
`__Host-opnexus-session` (API-Tokens sind nicht betroffen, ohne HTTPS bleibt `opnexus-session` der normale Name). Beim Abmelden wird ein altes
Cookie weiterhin gelöscht. Das Cookie, das die SSO-Anmeldung an den Browser bindet, heißt bei HTTPS jetzt `__Host-opnexus-oidc-state`; der
Rückweg vom Anbieter wird nur noch als Seitenaufruf (Navigation) von einer fremden Seite zugelassen, und ohne dieses Cookie scheitert er immer.
- **Webhook, Benachrichtigungskanäle und SSO-Anbieter: Schutz vor DNS-Rebinding:** Bisher prüfte OPNexus die Adresse, auf die ein Name zeigt, und
der HTTP-Client löste den Namen danach noch einmal auf; ein Server unter Fremdkontrolle konnte beim zweiten Mal eine interne Adresse liefern. Jetzt
verbindet OPNexus genau zu der geprüften Adresse (Host-Name, TLS-Zertifikatsprüfung und Anfrage bleiben beim Namen); zeigt eine Antwort auch nur
teilweise auf Loopback, Link-Local oder einen Cloud-Metadaten-Dienst (auch AWS-IPv6 und Alibaba), kommt keine Verbindung zustande und die Zustellung meldet „Zieladresse nicht
erlaubt“. Private Netze bleiben erlaubt (eigener ntfy, Gotify, Matrix). Proxy-Umgebungsvariablen werden für diese Aufrufe nicht mehr beachtet.
- **Benachrichtigungen parallel zugestellt:** Webhook, E-Mail und alle Kanäle werden gleichzeitig bedient, jeder mit eigener Zeitgrenze (20 Sekunden),
und ein Prüfdurchlauf sendet insgesamt höchstens 90 Sekunden. Ein hängender Kanal verzögert weder die anderen noch die nächsten Prüfungen; im Log
steht dann „timed out“ (ohne Adresse oder Token). Wie bisher wird einmal je Ereignis zugestellt, ohne Wiederholung, **außer** der Durchlauf war so voll, dass
für ein Ereignis noch gar kein Versuch stattfand: es wird im nächsten Durchlauf zugestellt. Die Zustellung beginnt jetzt nach allen Prüfungen
eines Durchlaufs statt mitten darin.
- **Rollouts: Fingerabdruck, verantwortliche Person, überlappende Termine:** Der Fingerabdruck eines aktivierten Plans umfasst jetzt auch die
ausgelassenen und verschobenen Termine seines Fensters: **wer einen Termin auslässt oder verschiebt, muss einen automatischen Plan danach neu
aktivieren** (ohne solche Ausnahmen bleiben bestehende Aktivierungen gültig; **Pläne, deren Fenster schon Ausnahmen haben, nach dem Update
einmal neu aktivieren**, sonst meldet der Plan „Plan geändert“ und startet nicht automatisch). Die verantwortliche Person (Aktivierende, Startende, Freigebende)
wird zusätzlich als Konto-ID gespeichert (Migration 0026, bestehende Einträge werden über den Namen zugeordnet): ein gelöschtes und unter gleichem
Namen neu angelegtes Konto gilt nicht mehr als dieselbe Person, der Lauf pausiert bzw. der automatische Start unterbleibt mit „Person darf nicht mehr
ausrollen“. Angezeigt wird weiter der Benutzername. Überlappen sich zwei Termine eines Wartungsfensters, zählt der zuletzt begonnene (bisher der
früheste), sodass ein älterer, längst abgeschnittener Termin keinen neuen Start mehr blockiert.
- **Kleinigkeiten:** Ressourcen-Alarme zeigen bei fehlendem Messwert keine Zahl mehr („None %“ entfällt). Der Dialog „Von Firewall übernehmen“ zeigt ohne aktive
Firewall einen Hinweis statt dauerhaft „Lädt…“. Fehlermeldungen beim Übernehmen und Verknüpfen nennen die Gründe in Klartext (Deutsch/Englisch) statt
roher Codes wie `name_exists`; dasselbe gilt für „GUI-Zugriff nicht eingerichtet“ und blockierte Netzwerkänderungen. Der Test-Knopf der
Benachrichtigungskanäle ist für Nicht-Admins ausgeblendet (der Server verweigert den Test ohnehin). Der Tooltip der aktiven Plätze nennt keine
Umgebungsvariable mehr. Eine Gateway-Sitzung, die der Gateway-Container nicht mehr bestätigt (Absturz), zählt für die Abkühlzeit der Plätze als
beendet.
- **Grafana-Dashboard erweitert:** neue Felder für Gateways (erreichbar, Paketverlust, Latenz), Swap, gestoppte Dienste, fehlende API-Rechte und
Rollouts nach Status; dazu die Metrik `opnexus_rollout_runs{status}`.
346 lines
15 KiB
Python
346 lines
15 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"])
|
|
|
|
# Plain name: the session cookie of HTTP-only (lab) deployments. On HTTPS deployments it is NOT accepted any
|
|
# more (1.29): it is only still deleted (login, logout) so that a pre-1.27 cookie does not linger.
|
|
COOKIE_NAME = "opnexus-session"
|
|
# HTTPS deployments: the __Host- prefix makes the browser refuse any cookie of
|
|
# this name that is not Secure, has a Domain attribute or a Path other than
|
|
# "/". A page on another port of the same host (the GUI gateway slots, any
|
|
# other service on that machine) therefore cannot toss a narrower-path cookie
|
|
# that shadows the real session, and cannot overwrite it from script (HttpOnly).
|
|
SECURE_COOKIE_NAME = "__Host-opnexus-session"
|
|
|
|
|
|
def session_cookie_name() -> str:
|
|
return SECURE_COOKIE_NAME if settings.cookie_secure else COOKIE_NAME
|
|
|
|
|
|
def session_token(request: Request) -> str:
|
|
"""The session token the browser sent: the __Host- cookie on HTTPS deployments, the plain one otherwise.
|
|
Never the other name (1.29): a plain-name cookie can be planted by any plain-HTTP or sibling-subdomain page and
|
|
must not be able to carry a session on an HTTPS deployment (the __Host- prefix does not separate PORTS, see
|
|
BACKLOG S8)."""
|
|
return request.cookies.get(session_cookie_name(), "")
|
|
|
|
|
|
def has_session_cookie(request: Request) -> bool:
|
|
return bool(request.cookies.get(SECURE_COOKIE_NAME) or request.cookies.get(COOKIE_NAME))
|
|
|
|
|
|
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, max_age: int | None = None) -> None:
|
|
response.set_cookie(
|
|
key=session_cookie_name(),
|
|
value=token,
|
|
max_age=max_age or settings.session_lifetime,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=settings.cookie_secure,
|
|
path="/",
|
|
)
|
|
if settings.cookie_secure:
|
|
# A pre-1.27 plain-name cookie must not linger next to the new one.
|
|
response.delete_cookie(key=COOKIE_NAME, path="/", secure=True, httponly=True, samesite="lax")
|
|
|
|
|
|
def _clear_session_cookie(response: Response) -> None:
|
|
response.delete_cookie(key=COOKIE_NAME, path="/", secure=settings.cookie_secure, httponly=True, samesite="lax")
|
|
if settings.cookie_secure:
|
|
response.delete_cookie(key=SECURE_COOKIE_NAME, path="/", secure=True, httponly=True, samesite="lax")
|
|
|
|
|
|
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 = session_token(request)
|
|
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=session_token(request), 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=session_token(request), 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 = session_token(request)
|
|
await auth_service.revoke_session(db, token)
|
|
legacy = request.cookies.get(COOKIE_NAME, "")
|
|
if settings.cookie_secure and legacy and legacy != token:
|
|
await auth_service.revoke_session(db, legacy) # an old plain-name cookie ends with the logout too
|
|
_clear_session_cookie(response)
|
|
return {"authenticated": False}
|
|
|
|
|
|
@router.get("/me")
|
|
async def me(request: Request, response: Response, 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 = session_token(request)
|
|
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)
|
|
await api_token_service.revoke_all(db, user.id, commit=False) # a changed password answers a suspected leak: tokens too
|
|
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}
|