Equalize Access login failure handling

This commit is contained in:
2026-07-21 03:16:31 +02:00
parent 90d8f65835
commit 01d082e552
3 changed files with 102 additions and 4 deletions

View File

@@ -50,7 +50,7 @@ from govoplan_core.settings import settings
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.passwords import verify_password
from govoplan_access.backend.security.passwords import DUMMY_PASSWORD_HASH, verify_password
from govoplan_access.backend.security.sessions import (
authenticate_session_token,
collect_user_authorization_context,
@@ -253,7 +253,9 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
.one_or_none()
)
if account is None or not verify_password(payload.password, account.password_hash):
password_hash = account.password_hash if account is not None and account.password_hash else DUMMY_PASSWORD_HASH
password_matches = verify_password(payload.password, password_hash)
if account is None or not account.password_hash or not password_matches:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
query = (
@@ -269,7 +271,9 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
query = query.filter(Tenant.slug == payload.tenant_slug)
row = query.order_by(Tenant.name.asc()).first()
if row is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No active tenant membership")
# Keep every authentication failure generic so callers cannot infer
# whether an account exists but lacks an active tenant membership.
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
return account, row[0], row[1]

View File

@@ -9,6 +9,14 @@ _ALGORITHM = "pbkdf2_sha256"
_DEFAULT_ITERATIONS = 260_000
_SALT_BYTES = 16
# A valid, fixed-cost hash used when a login identity has no local password hash.
# Its plaintext value is intentionally irrelevant; the hash only keeps failed
# login attempts on the same verification path as existing local accounts.
DUMMY_PASSWORD_HASH = (
"pbkdf2_sha256$260000$Z292b3BsYW4tZHVtbXktdjE=" # noqa: S105 # nosec B105 - non-account timing equalizer.
"$uWgE7ht8wO6cotOqNKK2yNomPt57gstVss5ben5gTbw="
)
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
salt = os.urandom(_SALT_BYTES)
@@ -37,4 +45,3 @@ def verify_password(password: str, encoded: str | None) -> bool:
return False
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
return hmac.compare_digest(actual, expected)