feat(access): add guarded local password lifecycle and recovery
This commit is contained in:
@@ -146,6 +146,10 @@ cd /mnt/DATA/git/govoplan-core
|
||||
|
||||
## Login Throttling
|
||||
|
||||
Optional PostgreSQL password-recovery race checks and their disposable-database
|
||||
setup are documented in English and German in
|
||||
[docs/PASSWORD_RECOVERY_POSTGRES_TESTS.md](docs/PASSWORD_RECOVERY_POSTGRES_TESTS.md).
|
||||
|
||||
Interactive password login is throttled by normalized global login identity and by
|
||||
the directly connected client address. The deployment defaults are 10 identity
|
||||
failures and 100 client failures in a 15-minute window. Counters use
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Password recovery: PostgreSQL concurrency verification
|
||||
|
||||
## English
|
||||
|
||||
`tests/test_password_recovery_postgres.py` contains four optional real-transaction
|
||||
regressions: simultaneous redemption of one recovery code, simultaneous changes
|
||||
using one old password/session, competing recovery-code issuance, and issuer
|
||||
password revocation during redemption. They reuse the synthetic Access HTTP
|
||||
fixture with real PostgreSQL account locks and compare-and-swap updates.
|
||||
|
||||
Provision a **disposable test database**, preferably in a temporary PostgreSQL
|
||||
cluster with only a private Unix socket. Never use a production or shared
|
||||
development database. The supplied role needs permission to create and drop
|
||||
schemas. The tests create a random `access_password_race_*` schema per case and
|
||||
drop only that schema afterward, including on test failure. Audit emission is
|
||||
mocked as in the ordinary fixture; these are not audit-sink integration tests.
|
||||
|
||||
From the Access checkout, using the workspace Python environment with the current
|
||||
Core/Access sources, pytest, and psycopg installed:
|
||||
|
||||
```sh
|
||||
GOVOPLAN_ACCESS_TEST_POSTGRES_URL='postgresql+psycopg://test_role@/disposable_test?host=/absolute/private/socket&port=55439' \
|
||||
../govoplan/.venv/bin/python -m pytest -q tests/test_password_recovery_postgres.py
|
||||
```
|
||||
|
||||
Replace the synthetic URL with the explicitly provisioned test fixture. Without
|
||||
this variable all four tests are skipped; there is no fallback to application
|
||||
database settings. The tests do not provision or stop PostgreSQL: the fixture
|
||||
owner must stop its temporary cluster and verify cleanup after the run. Existing
|
||||
portable password-recovery tests continue to run without PostgreSQL.
|
||||
|
||||
## Deutsch
|
||||
|
||||
`tests/test_password_recovery_postgres.py` enthält vier optionale Regressionstests
|
||||
mit echten Transaktionen: gleichzeitige Einlösung desselben Wiederherstellungscodes,
|
||||
gleichzeitige Änderungen mit demselben alten Passwort und derselben Sitzung,
|
||||
konkurrierende Code-Ausstellung sowie Passwortwechsel des ausstellenden
|
||||
Administrators während einer Einlösung. Die synthetische Access-HTTP-Testumgebung
|
||||
nutzt dabei echte PostgreSQL-Kontosperren und bedingte Datenbankaktualisierungen.
|
||||
|
||||
Nur eine **wegwerfbare Testdatenbank** verwenden, möglichst in einem temporären
|
||||
PostgreSQL-Cluster mit privatem Unix-Socket. Produktionsdatenbanken und gemeinsam
|
||||
genutzte Entwicklungsdatenbanken sind ausgeschlossen. Die Testrolle benötigt
|
||||
Berechtigungen zum Erstellen und Löschen von Schemas. Jeder Test erstellt ein
|
||||
zufälliges Schema `access_password_race_*` und entfernt ausschließlich dieses
|
||||
Schema auch bei Fehlern. Die Audit-Ausgabe bleibt wie im bestehenden Testaufbau
|
||||
simuliert; ein externer Audit-Dienst wird damit nicht geprüft.
|
||||
|
||||
Der obige Aufruf gilt aus dem Access-Checkout mit der aktuellen
|
||||
Workspace-Python-Umgebung. Die Beispiel-URL muss durch die ausdrücklich
|
||||
bereitgestellte Testumgebung ersetzt werden. Ohne
|
||||
`GOVOPLAN_ACCESS_TEST_POSTGRES_URL` werden alle vier Tests übersprungen; es gibt
|
||||
keinen Rückgriff auf die Datenbankeinstellungen der Anwendung. Die Tests starten
|
||||
und stoppen PostgreSQL nicht selbst: Der Betreiber der Testumgebung muss den
|
||||
temporären Cluster anschließend stoppen und die Bereinigung prüfen. Die
|
||||
bestehenden portablen Tests laufen weiterhin ohne PostgreSQL.
|
||||
@@ -539,6 +539,7 @@ def _system_account_response_item(
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
is_active=account.is_active,
|
||||
local_password=account.auth_provider == "local",
|
||||
memberships=[
|
||||
_system_membership_item(
|
||||
user,
|
||||
|
||||
@@ -660,6 +660,7 @@ class SystemAccountItem(BaseModel):
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
is_active: bool
|
||||
local_password: bool = False
|
||||
memberships: list[dict[str, Any]] = Field(default_factory=list)
|
||||
roles: list[RoleSummary] = Field(default_factory=list)
|
||||
last_login_at: datetime | None = None
|
||||
|
||||
@@ -6,7 +6,10 @@ from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.api.v1.schemas import (
|
||||
@@ -58,12 +61,19 @@ from govoplan_core.i18n import (
|
||||
user_enabled_language_codes,
|
||||
)
|
||||
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes, normalize_email, scopes_grant
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
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 DUMMY_PASSWORD_HASH, verify_password
|
||||
from govoplan_access.backend.security.password_change import (
|
||||
MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, RECOVERY_MINUTES,
|
||||
enforce_password_change, issue_recovery, local_password_account, locked_local_account,
|
||||
password_change_required, recovery_issuer_authorized, replace_password,
|
||||
)
|
||||
from govoplan_access.backend.auth.tokens import hash_secret
|
||||
from govoplan_access.backend.db.models import PasswordRecovery
|
||||
from govoplan_access.backend.security.login_throttle import (
|
||||
LoginThrottle,
|
||||
LoginThrottleDecision,
|
||||
@@ -89,7 +99,34 @@ from govoplan_access.backend.session_management import (
|
||||
session_summary,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
class AuthRoute(APIRoute):
|
||||
def get_route_handler(self):
|
||||
handler = super().get_route_handler()
|
||||
|
||||
async def without_secret_validation_inputs(request: Request):
|
||||
try:
|
||||
return await handler(request)
|
||||
except RequestValidationError as exc:
|
||||
# FastAPI's default validation response echoes rejected input,
|
||||
# including a password/code whose length validation failed.
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"detail": [
|
||||
{
|
||||
key: error[key]
|
||||
for key in ("type", "loc", "msg")
|
||||
if key in error
|
||||
}
|
||||
for error in exc.errors()
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
return without_secret_validation_inputs
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"], route_class=AuthRoute)
|
||||
|
||||
|
||||
class ActingContextInfo(BaseModel):
|
||||
@@ -131,6 +168,27 @@ class OtherSessionRevocationResponse(BaseModel):
|
||||
revoked_count: int
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
current_password: SecretStr = Field(min_length=1, max_length=MAX_PASSWORD_LENGTH)
|
||||
new_password: SecretStr = Field(min_length=MIN_PASSWORD_LENGTH, max_length=MAX_PASSWORD_LENGTH)
|
||||
|
||||
|
||||
class PasswordRecoveryIssueRequest(BaseModel):
|
||||
current_password: SecretStr = Field(min_length=1, max_length=MAX_PASSWORD_LENGTH)
|
||||
identity_verified: Literal[True]
|
||||
|
||||
|
||||
class PasswordRecoveryCompleteRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
recovery_code: SecretStr = Field(min_length=1, max_length=256)
|
||||
new_password: SecretStr = Field(min_length=MIN_PASSWORD_LENGTH, max_length=MAX_PASSWORD_LENGTH)
|
||||
|
||||
|
||||
class PasswordRecoveryIssueResponse(BaseModel):
|
||||
recovery_code: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
def _account_session_info(item: SessionSummary) -> AccountSessionInfo:
|
||||
return AccountSessionInfo(**asdict(item))
|
||||
|
||||
@@ -217,7 +275,7 @@ def _cookie_samesite() -> str:
|
||||
|
||||
|
||||
def _set_auth_cookies(response: Response, created) -> None:
|
||||
max_age = max(0, int((created.model.expires_at - utc_now()).total_seconds()))
|
||||
max_age = max(0, int((ensure_aware_utc(created.model.expires_at) - utc_now()).total_seconds()))
|
||||
common = {
|
||||
"secure": settings.auth_cookie_secure,
|
||||
"samesite": _cookie_samesite(),
|
||||
@@ -283,6 +341,8 @@ def _user_info(
|
||||
tenant_display_name=user.display_name,
|
||||
is_tenant_admin=user.is_tenant_admin,
|
||||
password_reset_required=account.password_reset_required,
|
||||
required_auth_action="change_password" if password_change_required(account) else None,
|
||||
local_password=local_password_account(account),
|
||||
preferred_language=preferred_language,
|
||||
enabled_language_codes=enabled_language_codes or [],
|
||||
ui_preferences=_user_ui_preferences(user.settings),
|
||||
@@ -299,6 +359,8 @@ def _session_user_info(user: User, account: Account) -> AuthSessionUserInfo:
|
||||
tenant_display_name=user.display_name,
|
||||
is_tenant_admin=user.is_tenant_admin,
|
||||
password_reset_required=account.password_reset_required,
|
||||
required_auth_action="change_password" if password_change_required(account) else None,
|
||||
local_password=local_password_account(account),
|
||||
)
|
||||
|
||||
|
||||
@@ -393,11 +455,13 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
|
||||
account = (
|
||||
session.query(Account)
|
||||
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
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:
|
||||
if account is None or not local_password_account(account) or not account.password_hash or not password_matches:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
|
||||
|
||||
query = (
|
||||
@@ -533,7 +597,7 @@ def _session_response(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
|
||||
def _resolve_auth_context(request: Request, session: Session, *, allow_password_change: bool = False) -> AuthContext:
|
||||
token, source = _extract_auth_token(request)
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
||||
@@ -549,6 +613,7 @@ def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
|
||||
tenant=tenant,
|
||||
expected_tenant_id=api_key.tenant_id,
|
||||
)
|
||||
enforce_password_change(account)
|
||||
return AuthContext(
|
||||
account=account,
|
||||
user=user,
|
||||
@@ -570,6 +635,8 @@ def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
|
||||
tenant=tenant,
|
||||
expected_tenant_id=auth_session.tenant_id,
|
||||
)
|
||||
if not allow_password_change:
|
||||
enforce_password_change(account)
|
||||
return AuthContext(
|
||||
account=account,
|
||||
user=user,
|
||||
@@ -633,7 +700,7 @@ def _shell_response(
|
||||
|
||||
|
||||
def _resolve_lightweight_session(request: Request, session: Session) -> AuthSessionResponse:
|
||||
context = _resolve_auth_context(request, session)
|
||||
context = _resolve_auth_context(request, session, allow_password_change=True)
|
||||
return _session_response(
|
||||
account=context.account,
|
||||
user=context.user,
|
||||
@@ -645,7 +712,7 @@ def _resolve_lightweight_session(request: Request, session: Session) -> AuthSess
|
||||
|
||||
|
||||
def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse:
|
||||
context = _resolve_auth_context(request, session)
|
||||
context = _resolve_auth_context(request, session, allow_password_change=True)
|
||||
if context.api_key is not None:
|
||||
user_scopes = collect_user_scopes(session, context.user, include_system=False)
|
||||
scopes = intersect_api_key_scopes(user_scopes, context.api_key.scopes or [])
|
||||
@@ -660,6 +727,8 @@ def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse
|
||||
)
|
||||
|
||||
scopes = collect_user_scopes(session, context.user, include_system=True)
|
||||
if password_change_required(context.account):
|
||||
scopes = []
|
||||
return _shell_response(
|
||||
session,
|
||||
account=context.account,
|
||||
@@ -852,8 +921,13 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
|
||||
system_roles = authorization_context.system_roles
|
||||
groups = authorization_context.groups
|
||||
effective_scopes = authorization_context.scopes
|
||||
if password_change_required(account):
|
||||
effective_scopes = []
|
||||
tenant_roles = []
|
||||
system_roles = []
|
||||
groups = []
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
if maintenance_mode.enabled and not scopes_grant(effective_scopes, MAINTENANCE_ACCESS_SCOPE):
|
||||
if maintenance_mode.enabled and not scopes_grant(authorization_context.scopes, MAINTENANCE_ACCESS_SCOPE):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=maintenance_response_detail(maintenance_mode),
|
||||
@@ -895,6 +969,333 @@ def auth_session(request: Request, session: Session = Depends(get_session)):
|
||||
return _resolve_lightweight_session(request, session)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _password_operation_throttle() -> LoginThrottle:
|
||||
# Sensitive re-authorization and public recovery remain bounded even if an
|
||||
# operator disables ordinary login throttling for a test installation.
|
||||
return build_login_throttle(
|
||||
redis_url=settings.redis_url,
|
||||
identity_limit=settings.auth_login_throttle_identity_limit,
|
||||
client_limit=settings.auth_login_throttle_client_limit,
|
||||
window_seconds=settings.auth_login_throttle_window_seconds,
|
||||
redis_retry_seconds=settings.auth_login_throttle_redis_retry_seconds,
|
||||
key_prefix="govoplan:access:password:v1",
|
||||
)
|
||||
|
||||
|
||||
def _consume_password_attempt(request: Request, *, identity: str) -> None:
|
||||
throttle = _password_operation_throttle()
|
||||
context = {
|
||||
"normalized_email": identity,
|
||||
"tenant_slug": None,
|
||||
"client_address": request.client.host if request.client else None,
|
||||
}
|
||||
decision = throttle.check(**context)
|
||||
if decision.allowed:
|
||||
# Reserve before verification, so simultaneous requests cannot all
|
||||
# check an empty bucket and then evade the limit. Count successes too.
|
||||
decision = throttle.record_failure(**context)
|
||||
if not decision.allowed:
|
||||
raise HTTPException(
|
||||
429,
|
||||
detail={
|
||||
"code": "password_rate_limited",
|
||||
"message": "Too many password attempts. Try again later.",
|
||||
},
|
||||
headers={"Retry-After": str(max(1, decision.retry_after_seconds))},
|
||||
)
|
||||
|
||||
|
||||
def _password_context(request: Request, session: Session) -> AuthContext:
|
||||
# Authentication may schedule a last-seen touch. Do not flush that session
|
||||
# row before locking the account: password replacement takes those locks in
|
||||
# account-then-session order and the reverse order could deadlock.
|
||||
with session.no_autoflush:
|
||||
context = _resolve_auth_context(request, session, allow_password_change=True)
|
||||
_verify_profile_mutation_allowed(request, context)
|
||||
context.account = locked_local_account(session, context.account.id)
|
||||
# The initial auth read can precede a concurrent password change. Check
|
||||
# credential lifetime and membership again after obtaining the lock.
|
||||
session.refresh(context.auth_session)
|
||||
session.refresh(context.user)
|
||||
session.refresh(context.tenant)
|
||||
current = context.auth_session
|
||||
if (
|
||||
current.revoked_at is not None
|
||||
or ensure_aware_utc(current.expires_at) <= utc_now()
|
||||
):
|
||||
raise HTTPException(401, detail="Invalid session")
|
||||
_active_context_or_401(
|
||||
user=context.user,
|
||||
account=context.account,
|
||||
tenant=context.tenant,
|
||||
expected_tenant_id=current.tenant_id,
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def _require_recovery_enabled() -> None:
|
||||
if not settings.auth_local_password_recovery_enabled:
|
||||
raise HTTPException(
|
||||
409,
|
||||
detail={
|
||||
"code": "password_recovery_disabled",
|
||||
"message": "Administrator-assisted local password recovery has not been enabled.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _password_audit(
|
||||
session: Session, *, account: Account, user: User, action: str, details: dict
|
||||
) -> None:
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
action=action,
|
||||
object_type="access_account",
|
||||
object_id=account.id,
|
||||
details=details,
|
||||
)
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=None,
|
||||
source_module="access",
|
||||
resource_type="access_account",
|
||||
resource_id=account.id,
|
||||
reason=action,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/password/policy")
|
||||
def password_policy():
|
||||
return {
|
||||
"recovery_enabled": settings.auth_local_password_recovery_enabled,
|
||||
"min_length": MIN_PASSWORD_LENGTH,
|
||||
"max_length": MAX_PASSWORD_LENGTH,
|
||||
"recovery_minutes": RECOVERY_MINUTES,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/password/change", response_model=LoginResponse)
|
||||
def change_local_password(
|
||||
payload: PasswordChangeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
context = _password_context(request, session)
|
||||
_consume_password_attempt(
|
||||
request, identity="change:" + context.account.normalized_email
|
||||
)
|
||||
if not verify_password(
|
||||
payload.current_password.get_secret_value(), context.account.password_hash
|
||||
):
|
||||
raise HTTPException(
|
||||
403,
|
||||
detail={
|
||||
"code": "current_password_invalid",
|
||||
"message": "Current password re-authorization failed.",
|
||||
},
|
||||
)
|
||||
counts = replace_password(
|
||||
session,
|
||||
account=context.account,
|
||||
password=payload.new_password.get_secret_value(),
|
||||
)
|
||||
created = create_auth_session(
|
||||
session,
|
||||
user=context.user,
|
||||
hours=settings.auth_session_hours,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
ip_address=request.client.host if request.client else None,
|
||||
)
|
||||
_password_audit(
|
||||
session,
|
||||
account=context.account,
|
||||
user=context.user,
|
||||
action="access.password.changed",
|
||||
details={"authorization": "current_password", **counts},
|
||||
)
|
||||
result = LoginResponse(
|
||||
access_token=created.token,
|
||||
expires_at=created.model.expires_at,
|
||||
**_me_response(
|
||||
session,
|
||||
account=context.account,
|
||||
user=context.user,
|
||||
tenant=context.tenant,
|
||||
effective_scopes=collect_user_scopes(
|
||||
session, context.user, include_system=True
|
||||
),
|
||||
auth_method="session",
|
||||
session_id=created.model.id,
|
||||
).model_dump(),
|
||||
)
|
||||
session.commit()
|
||||
principal_summary_cache.clear()
|
||||
_set_auth_cookies(response, created)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return result
|
||||
|
||||
|
||||
@router.post(
|
||||
"/password/recovery/{account_id}", response_model=PasswordRecoveryIssueResponse
|
||||
)
|
||||
def issue_password_recovery(
|
||||
account_id: str,
|
||||
payload: PasswordRecoveryIssueRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_recovery_enabled()
|
||||
context = _password_context(request, session)
|
||||
enforce_password_change(context.account)
|
||||
if not recovery_issuer_authorized(
|
||||
session, account_id=context.account.id, membership_id=context.user.id
|
||||
):
|
||||
raise HTTPException(
|
||||
403, detail={"code": "recovery_issuer_required", "message": "Only a current System owner can issue password recovery codes."}
|
||||
)
|
||||
_consume_password_attempt(
|
||||
request, identity="issue:" + context.account.normalized_email
|
||||
)
|
||||
if not verify_password(
|
||||
payload.current_password.get_secret_value(), context.account.password_hash
|
||||
):
|
||||
raise HTTPException(
|
||||
403,
|
||||
detail={
|
||||
"code": "current_password_invalid",
|
||||
"message": "Current password re-authorization failed.",
|
||||
},
|
||||
)
|
||||
# Global account takeover must not be granted by tenant or ordinary account
|
||||
# editor permissions. Ownership is checked from current roles above.
|
||||
target = locked_local_account(session, account_id)
|
||||
membership = (
|
||||
session.query(User)
|
||||
.join(Tenant, Tenant.id == User.tenant_id)
|
||||
.filter(
|
||||
User.account_id == target.id,
|
||||
User.is_active.is_(True),
|
||||
Tenant.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if membership is None:
|
||||
raise HTTPException(
|
||||
409, detail={"code": "recovery_membership_required", "message": "The account needs an active tenant membership before recovery."}
|
||||
)
|
||||
code, recovery = issue_recovery(
|
||||
session, account=target, issuer=context.account, membership=context.user
|
||||
)
|
||||
_password_audit(
|
||||
session,
|
||||
account=target,
|
||||
user=context.user,
|
||||
action="access.password.recovery_issued",
|
||||
details={
|
||||
"identity_verified": True,
|
||||
"expires_at": recovery.expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return PasswordRecoveryIssueResponse(
|
||||
recovery_code=code, expires_at=recovery.expires_at
|
||||
)
|
||||
|
||||
|
||||
@router.post("/password/recover")
|
||||
def complete_password_recovery(
|
||||
payload: PasswordRecoveryCompleteRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_recovery_enabled()
|
||||
normalized = normalize_email(payload.email)
|
||||
_consume_password_attempt(request, identity="recover:" + normalized)
|
||||
invalid = HTTPException(
|
||||
400,
|
||||
detail={
|
||||
"code": "recovery_invalid",
|
||||
"message": "The recovery code is invalid, expired, or no longer authorized.",
|
||||
},
|
||||
)
|
||||
recovery = (
|
||||
session.query(PasswordRecovery)
|
||||
.filter(
|
||||
PasswordRecovery.code_hash
|
||||
== hash_secret(payload.recovery_code.get_secret_value())
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if recovery is None:
|
||||
raise invalid
|
||||
try:
|
||||
account = locked_local_account(session, recovery.account_id)
|
||||
except HTTPException:
|
||||
raise invalid from None
|
||||
session.refresh(recovery)
|
||||
if (
|
||||
account.normalized_email != normalized
|
||||
or recovery.consumed_at is not None
|
||||
or ensure_aware_utc(recovery.expires_at) <= utc_now()
|
||||
or not recovery_issuer_authorized(
|
||||
session,
|
||||
account_id=recovery.issuer_account_id,
|
||||
membership_id=recovery.issuer_membership_id,
|
||||
)
|
||||
):
|
||||
raise invalid
|
||||
user = (
|
||||
session.query(User)
|
||||
.join(Tenant, Tenant.id == User.tenant_id)
|
||||
.filter(
|
||||
User.account_id == account.id,
|
||||
User.is_active.is_(True),
|
||||
Tenant.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if user is None:
|
||||
raise invalid
|
||||
# CAS supplements the account lock on databases where FOR UPDATE is absent.
|
||||
consumed = (
|
||||
session.query(PasswordRecovery)
|
||||
.filter(
|
||||
PasswordRecovery.id == recovery.id,
|
||||
PasswordRecovery.consumed_at.is_(None),
|
||||
PasswordRecovery.expires_at > utc_now(),
|
||||
)
|
||||
.update({PasswordRecovery.consumed_at: utc_now()}, synchronize_session="fetch")
|
||||
)
|
||||
if consumed != 1:
|
||||
raise invalid
|
||||
counts = replace_password(
|
||||
session, account=account, password=payload.new_password.get_secret_value()
|
||||
)
|
||||
_password_audit(
|
||||
session,
|
||||
account=account,
|
||||
user=user,
|
||||
action="access.password.recovered",
|
||||
details={
|
||||
"authorization": "administrator_code",
|
||||
"issuer_account_id": recovery.issuer_account_id,
|
||||
**counts,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
principal_summary_cache.clear()
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
# Recovery authorizes password replacement only, never a browser session.
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/shell", response_model=AuthShellResponse)
|
||||
def auth_shell(request: Request, session: Session = Depends(get_session)):
|
||||
return _resolve_shell_auth(request, session)
|
||||
@@ -1268,12 +1669,14 @@ def switch_acting_context(
|
||||
@router.post("/logout")
|
||||
def logout(
|
||||
response: Response,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
if principal.auth_session is not None:
|
||||
principal.auth_session.revoked_at = utc_now()
|
||||
session.add(principal.auth_session)
|
||||
context = _resolve_auth_context(request, session, allow_password_change=True)
|
||||
if context.auth_session is not None:
|
||||
_verify_profile_mutation_allowed(request, context)
|
||||
context.auth_session.revoked_at = utc_now()
|
||||
session.add(context.auth_session)
|
||||
session.commit()
|
||||
_clear_auth_cookies(response)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -44,6 +44,7 @@ from govoplan_access.backend.semantic import collect_external_function_roles, id
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.auth.tokens import hash_secret
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.password_change import enforce_password_change, password_change_required
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
authenticate_session_token,
|
||||
collect_user_authorization_context,
|
||||
@@ -159,6 +160,7 @@ def _api_principal_from_ref(
|
||||
|
||||
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
||||
auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None
|
||||
enforce_password_change(account)
|
||||
return ApiPrincipal(
|
||||
principal=principal,
|
||||
account=account,
|
||||
@@ -226,6 +228,7 @@ def _resolve_legacy_principal_context(
|
||||
source=source,
|
||||
)
|
||||
if cached is not None:
|
||||
enforce_password_change(cached.account)
|
||||
return cached
|
||||
|
||||
if source != "cookie":
|
||||
@@ -237,6 +240,7 @@ def _resolve_legacy_principal_context(
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
if context is not None:
|
||||
enforce_password_change(context.account)
|
||||
return _cache_resolved_principal_context(
|
||||
session,
|
||||
token=token,
|
||||
@@ -256,6 +260,7 @@ def _resolve_legacy_principal_context(
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
if context is not None:
|
||||
enforce_password_change(context.account)
|
||||
return _cache_resolved_principal_context(
|
||||
session,
|
||||
token=token,
|
||||
@@ -1004,6 +1009,12 @@ def _resolve_delegated_user_automation(
|
||||
"belongs to the tenant."
|
||||
),
|
||||
)
|
||||
if password_change_required(account):
|
||||
return _automation_denied(
|
||||
request,
|
||||
status="password_change_required",
|
||||
reason="The automation owner must complete the required local password change.",
|
||||
)
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
|
||||
@@ -51,6 +51,26 @@ class Account(AccessBase, TimestampMixin):
|
||||
)
|
||||
|
||||
|
||||
class PasswordRecovery(AccessBase, TimestampMixin):
|
||||
"""Bounded, administrator-issued authorization; never retain the code."""
|
||||
|
||||
__tablename__ = "access_password_recoveries"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
account_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
issuer_account_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
issuer_membership_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
code_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Identity(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_identities"
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from govoplan_core.core.modules import (
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
PublicFrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
@@ -883,7 +884,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
title="Authentication and password fields",
|
||||
summary="Understand which credentials are used for interactive sign-in, initial account enrollment, administrative re-authorization, and automation.",
|
||||
body=(
|
||||
"The sign-in email identifies the account and the password authenticates only that account. Initial passwords entered by administrators are transmitted only for account creation; leaving the field empty asks the server to generate an initial password disclosed once. The password-change-required flag is currently advisory metadata: the self-service password-change workflow and server-side enforcement are not implemented. Do not treat a generated initial password as expiring, single-use for sign-in, or automatically replaced because this flag is set. Current-password prompts re-authorize a sensitive action and never target the selected user's password. Applying an automation API key in local settings explicitly selects that key's identity. Interactive sign-in and sign-out remove the stored key; automation keys should be narrowly scoped, revocable, and never shared with other users. Generated passwords are not applied until Use password is selected."
|
||||
"The sign-in email identifies the account and the password authenticates only that account. Initial passwords entered by administrators are transmitted only for account creation; leaving the field empty asks the server to generate an initial password disclosed once. Local users can change their own password with their current password in account settings. AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED defaults to false: while disabled, the password-change-required flag remains advisory metadata and administrator-assisted recovery is unavailable. After the complete recovery policy is adopted and this setting is enabled, a flagged local account can sign in only to change its password or sign out; normal authenticated routes and human API keys are blocked until the change is completed. Initial passwords are not given an expiry or a single-use lifetime by the flag. Password replacement rotates the current browser session and CSRF token, signs out every other browser session across tenants, and revokes all human API keys owned by the account. External-provider passwords must be changed or recovered at that provider; service accounts have no local interactive password. Current-password prompts re-authorize the current person's action and never target the selected user's password. Applying an automation API key in local settings explicitly selects that key's identity. Interactive sign-in and sign-out remove the stored key. Generated passwords are not applied until Use password is selected."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -900,7 +901,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
"title": "Authentifizierungs- und Passwortfelder",
|
||||
"summary": "Einordnen, welche Zugangsdaten für die interaktive Anmeldung, die erste Kontoeinrichtung, die erneute administrative Autorisierung und Automatisierung verwendet werden.",
|
||||
"body": (
|
||||
"Die Anmelde-E-Mail identifiziert das Konto; das Passwort authentifiziert ausschließlich dieses Konto. Von Administrierenden eingegebene Anfangspasswörter werden nur zur Kontoerstellung übertragen. Bleibt das Feld leer, erzeugt der Server ein einmal angezeigtes Anfangspasswort. Das Kennzeichen für einen erforderlichen Passwortwechsel ist derzeit nur ein Hinweis in den Metadaten: Der selbstständige Passwortwechsel und die serverseitige Durchsetzung sind noch nicht umgesetzt. Ein generiertes Anfangspasswort läuft durch dieses Kennzeichen weder ab noch ist es nur einmal zur Anmeldung verwendbar oder wird automatisch ersetzt. Die Abfrage des aktuellen Passworts autorisiert eine sensible Aktion erneut und meint niemals das Passwort der ausgewählten Person. Das Anwenden eines Automatisierungs-API-Schlüssels in den lokalen Einstellungen wählt ausdrücklich dessen Identität. Interaktives An- und Abmelden entfernt den gespeicherten Schlüssel. Automatisierungsschlüssel sollen eng begrenzt, widerrufbar und niemals mit anderen Personen geteilt sein. Generierte Passwörter werden erst mit „Passwort verwenden“ übernommen."
|
||||
"Die Anmelde-E-Mail identifiziert das Konto; das Passwort authentifiziert ausschließlich dieses Konto. Von Administrierenden eingegebene Anfangspasswörter werden nur zur Kontoerstellung übertragen. Bleibt das Feld leer, erzeugt der Server ein einmal angezeigtes Anfangspasswort. Lokale Benutzer können ihr Passwort in den Kontoeinstellungen mit dem aktuellen Passwort ändern. AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED ist standardmäßig false: Solange die Einstellung deaktiviert ist, bleibt das Kennzeichen für den Passwortwechsel ein unverbindlicher Hinweis und die administrativ unterstützte Wiederherstellung ist nicht verfügbar. Erst nach Einführung der vollständigen Wiederherstellungsrichtlinie und Aktivierung dieser Einstellung kann sich ein gekennzeichnetes lokales Konto ausschließlich zum Passwortwechsel oder Abmelden anmelden; normale authentifizierte Routen und persönliche API-Schlüssel sind bis zum Wechsel gesperrt. Das Kennzeichen verleiht Anfangspasswörtern weder eine Ablaufzeit noch eine einmalige Nutzungsdauer. Der Passwortwechsel erneuert die aktuelle Browsersitzung und das CSRF-Token, meldet alle anderen Sitzungen mandantenübergreifend ab und widerruft alle persönlichen API-Schlüssel des Kontos. Passwörter externer Anbieter müssen dort geändert oder wiederhergestellt werden; Dienstkonten haben kein lokales interaktives Passwort. Die Abfrage des aktuellen Passworts autorisiert die Aktion der aktuellen Person erneut und meint niemals das Passwort der ausgewählten Person. Das Anwenden eines Automatisierungs-API-Schlüssels in den lokalen Einstellungen wählt ausdrücklich dessen Identität. Interaktives An- und Abmelden entfernt den gespeicherten Schlüssel. Generierte Passwörter werden erst mit „Passwort verwenden“ übernommen."
|
||||
),
|
||||
}
|
||||
},
|
||||
@@ -916,6 +917,96 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.workflow.local-password-recovery",
|
||||
title="Change or recover a local password",
|
||||
summary="Use current-password authorization or an explicitly enabled, bounded System-owner recovery handoff.",
|
||||
body=(
|
||||
"For a normal or required change, open the Password section in account settings, enter your current or initial password, and choose a different password of 10–1024 characters. The same action is shown immediately after initial sign-in when enforcement is enabled. Your current browser receives a new session and CSRF cookie; every previous session and human API key for your global account is revoked across all tenants. Update affected automation with newly authorized credentials; dedicated service-account credentials are unaffected. Passwords and recovery codes must not be placed in tickets, URLs, browser storage, or audit notes. "
|
||||
"When a forced change is pending, delegated automation owned by that local account also receives a password_change_required denial until the account completes the change. "
|
||||
"If the password is lost, contact a System owner through your organization's approved identity-verification channel. When administrator-assisted recovery is enabled, the owner opens the central account's recovery action, verifies the person's identity outside GovOPlaN, confirms that verification, and re-authorizes with the owner's own current local password. The server displays a random recovery code once, valid for 15 minutes. The owner hands it to the verified person through an approved channel; GovOPlaN does not send email. A new code supersedes every older code for that account without changing its password. At /password-recovery, enter the account email, the code, and a new password; then sign in normally. Redemption is single-use, revokes all previous sessions and human API keys, and also invalidates unused recovery codes. Both normal password changes and recovery invalidate outstanding codes for the changed account and codes issued by that account for other people. Those people must obtain newly authorized codes, even after a routine System-owner password change. "
|
||||
"Before enabling AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED=true, deploy matching Access/Core WebUI assets, apply the Access e9a2c5f8b1d4 migration through the normal upgrade process, document who verifies identity and how codes are handed over, ensure an available local System owner, and verify first-login and recovery in a test environment. The default false preserves the existing advisory flag behavior; do not claim forced password change is enabled until this setting is active. Recovery issuance requires current system:* authority and an interactive local session; ordinary account editors, tenant administrators, and API keys cannot authorize account takeover. Redemption rechecks the target's local provider, active account and active membership/tenant, and the issuer's current active membership and System-owner authority. Disabled or externally managed accounts are not reactivated or converted by recovery. If every System owner loses access, escalate to the installation's operator recovery process; first-admin enrollment is not a reset mechanism for a populated installation. "
|
||||
"Password operations reserve attempts before verification using the login identity/client limits and window in an independent throttle namespace, even when ordinary login throttling is disabled. Redis shares limits across workers; a bounded process-local fallback applies during outages and cannot provide cluster-wide limits. A 429 response includes Retry-After. Current-password changes and recovery write secret-free audit evidence and revoke credentials in the same transaction; the database stores only password hashes and recovery-code hashes. The recovery table retains consumed/expired hash records until the corresponding account is removed; include it in the installation's retention review. Downgrading the migration removes recovery authorizations, so disable the feature first."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "access_admin", "operator"),
|
||||
order=30,
|
||||
conditions=(DocumentationCondition(required_modules=("access",)),),
|
||||
links=(DocumentationLink(label="Password recovery", href="/password-recovery", kind="runtime"), DocumentationLink(label="Account settings", href="/settings", kind="runtime")),
|
||||
translations={"de": {
|
||||
"title": "Lokales Passwort ändern oder wiederherstellen",
|
||||
"summary": "Das aktuelle Passwort oder eine ausdrücklich aktivierte, zeitlich begrenzte Wiederherstellung durch einen Systemverantwortlichen verwenden.",
|
||||
"body": (
|
||||
"Für einen freiwilligen oder erforderlichen Wechsel öffnen Sie den Abschnitt Passwort in den Kontoeinstellungen, geben das aktuelle beziehungsweise anfängliche Passwort ein und wählen ein anderes Passwort mit 10–1024 Zeichen. Bei aktivierter Durchsetzung erscheint dieselbe Aktion unmittelbar nach der ersten Anmeldung. Der aktuelle Browser erhält eine neue Sitzung und ein neues CSRF-Cookie; alle bisherigen Sitzungen und persönlichen API-Schlüssel des globalen Kontos werden mandantenübergreifend widerrufen. Betroffene Automatisierungen benötigen neu autorisierte Zugangsdaten; Zugangsdaten eigenständiger Dienstkonten bleiben unberührt. Passwörter und Wiederherstellungscodes gehören nicht in Tickets, URLs, Browserspeicher oder Auditnotizen. "
|
||||
"Solange ein erzwungener Wechsel aussteht, erhält auch delegierte Automatisierung dieses lokalen Kontos die Ablehnung password_change_required, bis das Konto den Wechsel abgeschlossen hat. "
|
||||
"Bei verlorenem Passwort wenden Sie sich über den freigegebenen Identitätsprüfungsweg Ihrer Organisation an einen Systemverantwortlichen. Ist die administrativ unterstützte Wiederherstellung aktiviert, öffnet dieser die Wiederherstellungsaktion des zentralen Kontos, prüft die Identität außerhalb von GovOPlaN, bestätigt diese Prüfung und autorisiert sich erneut mit seinem eigenen aktuellen lokalen Passwort. Der Server zeigt einen zufälligen, 15 Minuten gültigen Wiederherstellungscode einmal an. Der Systemverantwortliche übergibt ihn der geprüften Person über einen freigegebenen Kanal; GovOPlaN versendet keine E-Mail. Ein neuer Code ersetzt alle älteren Codes für das Konto, ohne dessen Passwort zu ändern. Unter /password-recovery geben Sie Konto-E-Mail, Code und neues Passwort ein und melden sich anschließend normal an. Die einmalige Einlösung widerruft alle bisherigen Sitzungen und persönlichen API-Schlüssel sowie ungenutzte Wiederherstellungscodes. Sowohl regulärer Passwortwechsel als auch Wiederherstellung machen ausstehende Codes für das geänderte Konto und von diesem Konto für andere Personen ausgestellte Codes ungültig. Diese Personen benötigen neu autorisierte Codes, auch nach einem routinemäßigen Passwortwechsel eines Systemverantwortlichen. "
|
||||
"Vor AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED=true müssen passende Access/Core-WebUI-Dateien bereitgestellt, die Access-Migration e9a2c5f8b1d4 über den normalen Upgradeprozess angewandt, Zuständigkeit und Übergabekanal für Identitätsprüfung dokumentiert, ein erreichbarer lokaler Systemverantwortlicher sichergestellt und erste Anmeldung sowie Wiederherstellung in einer Testumgebung geprüft werden. Der Standard false erhält das bisherige unverbindliche Kennzeichen; behaupten Sie keine Durchsetzung, bevor die Einstellung aktiv ist. Die Codeausgabe verlangt aktuelle system:*-Berechtigung und eine interaktive lokale Sitzung; normale Kontobearbeiter, Mandantenadministratoren und API-Schlüssel dürfen keine Kontoübernahme autorisieren. Bei Einlösung werden lokaler Anbieter, aktives Zielkonto, aktive Mitgliedschaft und aktiver Mandant sowie die aktuelle aktive Mitgliedschaft und Systemverantwortung des Ausstellers erneut geprüft. Deaktivierte oder extern verwaltete Konten werden nicht reaktiviert oder umgewandelt. Verlieren sämtliche Systemverantwortlichen den Zugang, ist das Wiederherstellungsverfahren des Installationsbetreibers einzuschalten; die Ersteinrichtung ist kein Rücksetzverfahren für bestehende Installationen. "
|
||||
"Passwortaktionen reservieren Versuche vor der Prüfung mit den Identitäts-/Clientgrenzen und dem Zeitfenster der Anmeldung in einem unabhängigen Drosselungsbereich, auch bei deaktivierter normaler Anmeldedrosselung. Redis teilt Grenzen zwischen Prozessen; bei Ausfällen gilt ein begrenzter lokaler Ersatz ohne clusterweite Garantie. HTTP 429 enthält Retry-After. Passwortwechsel und Wiederherstellung schreiben Auditnachweise ohne Geheimnisse und widerrufen Zugangsdaten in derselben Transaktion; gespeichert werden ausschließlich Passwort- und Code-Hashes. Verbrauchte oder abgelaufene Hashdatensätze verbleiben bis zur Entfernung des jeweiligen Kontos in der Wiederherstellungstabelle; berücksichtigen Sie diese bei der Aufbewahrungsprüfung. Ein Downgrade der Migration entfernt Wiederherstellungsberechtigungen; deaktivieren Sie die Funktion vorher."
|
||||
),
|
||||
}},
|
||||
metadata={"kind": "operator_workflow"},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.help.password-change",
|
||||
title="Change your local password",
|
||||
summary="Authorize your own password change and understand credential rotation.",
|
||||
body=(
|
||||
"Use your own current or initial password, not another person's password. Choose a different new password of 10–1024 Unicode characters and confirm it exactly. This requires an interactive local-account session; external accounts use their identity provider. Self-service remains available when recovery is disabled. "
|
||||
"A successful change rotates this browser's session and CSRF cookie, ends other sessions across tenants, revokes all account API keys, and invalidates unused recovery codes for this account and codes it issued for others. Update affected integrations. Required-change accounts cannot enter the workspace until this succeeds; signing out leaves the requirement unchanged. Reload only refreshes policy information. Never put passwords or codes in URLs, tickets, browser storage, or logs."
|
||||
),
|
||||
layer="always", documentation_types=("admin", "user"),
|
||||
audience=("user", "access_admin", "operator"), order=31,
|
||||
translations={"de": {
|
||||
"title": "Eigenes lokales Passwort ändern",
|
||||
"summary": "Die eigene Passwortänderung autorisieren und den Austausch der Zugangsdaten verstehen.",
|
||||
"body": (
|
||||
"Geben Sie Ihr eigenes aktuelles oder initiales Passwort ein, niemals das Passwort einer anderen Person. Wählen Sie ein anderes neues Passwort mit 10–1024 Unicode-Zeichen und bestätigen Sie es exakt. Dies benötigt eine interaktive Sitzung eines lokalen Kontos; externe Konten verwenden ihren Identitätsanbieter. Der freiwillige Wechsel bleibt bei deaktivierter Wiederherstellung verfügbar. "
|
||||
"Der erfolgreiche Wechsel erneuert Sitzung und CSRF-Cookie dieses Browsers, beendet andere Sitzungen mandantenübergreifend, widerruft alle Konto-API-Schlüssel und macht ungenutzte Wiederherstellungscodes für dieses Konto sowie von ihm für andere Personen ausgestellte Codes ungültig. Aktualisieren Sie betroffene Integrationen. Bei erforderlichem Wechsel bleibt der Arbeitsbereich bis zum Erfolg gesperrt; Abmelden hebt die Anforderung nicht auf. Neuladen aktualisiert nur die Richtlinieninformation. Passwörter und Codes gehören niemals in URLs, Tickets, Browserspeicher oder Protokolle."
|
||||
),
|
||||
}},
|
||||
metadata={"kind": "reference", "help_contexts": ["access.password.change"]},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.help.password-recovery",
|
||||
title="Recover a local password",
|
||||
summary="Use an authorized one-time code, then sign in normally.",
|
||||
body=(
|
||||
"Recovery is disabled by default. When enabled, contact a System owner through your organization's independent identity-verification process; GovOPlaN sends no recovery email. Enter the target account email, the confidential code, and matching new passwords of 10–1024 Unicode characters. The code expires after 15 minutes and works once; invalid, expired, used, or no-longer-authorized codes require a new handoff. "
|
||||
"Successful recovery ends all target-account sessions, revokes its API keys, and invalidates unused codes for it and codes it issued for others. It does not sign you in: Return to sign in and use the new password. External and inactive accounts are not converted or reactivated. Reload only refreshes policy information. Never share credentials through URLs, tickets, browser storage, or logs."
|
||||
),
|
||||
layer="always", documentation_types=("admin", "user"),
|
||||
audience=("user", "access_admin", "operator"), order=32,
|
||||
translations={"de": {
|
||||
"title": "Lokales Passwort wiederherstellen",
|
||||
"summary": "Einen autorisierten Einmalcode verwenden und sich anschließend normal anmelden.",
|
||||
"body": (
|
||||
"Die Wiederherstellung ist standardmäßig deaktiviert. Ist sie aktiviert, wenden Sie sich über das unabhängige Identitätsprüfungsverfahren Ihrer Organisation an einen Systemverantwortlichen; GovOPlaN versendet keine Wiederherstellungs-E-Mail. Geben Sie Konto-E-Mail, vertraulichen Code und übereinstimmende neue Passwörter mit 10–1024 Unicode-Zeichen ein. Der Code gilt 15 Minuten und funktioniert einmal; ungültige, abgelaufene, verbrauchte oder nicht mehr autorisierte Codes benötigen eine neue Übergabe. "
|
||||
"Der Erfolg beendet alle Sitzungen des Zielkontos, widerruft dessen API-Schlüssel und macht ungenutzte Codes für dieses Konto sowie von ihm für andere Personen ausgestellte Codes ungültig. Es erfolgt keine automatische Anmeldung: Kehren Sie zur Anmeldung zurück und verwenden Sie das neue Passwort. Externe und inaktive Konten werden weder umgewandelt noch reaktiviert. Neuladen aktualisiert nur die Richtlinieninformation. Zugangsdaten gehören niemals in URLs, Tickets, Browserspeicher oder Protokolle."
|
||||
),
|
||||
}},
|
||||
metadata={"kind": "reference", "help_contexts": ["access.password.recover"]},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.help.password-issue-recovery",
|
||||
title="Issue and hand over a recovery code",
|
||||
summary="A current local System owner verifies identity and authorizes a bounded recovery.",
|
||||
body=(
|
||||
"Only a current System owner with system:* and an interactive local session may issue a code when recovery is enabled. Confirm the selected person's identity independently outside GovOPlaN, then enter your own current password, never the target person's password. The target must be active and local with an active tenant membership. "
|
||||
"Issuing replaces earlier unused target codes without changing the password yet. The code is displayed once, expires after 15 minutes, and can be used once. Deliver it only through an agreed confidential channel. Closing clears the display; passwords and codes must not enter URLs, tickets, browser storage, or logs. Redemption replaces the password, ends target sessions, revokes its API keys, and invalidates codes it issued for others. Authority is rechecked at redemption."
|
||||
),
|
||||
layer="always", documentation_types=("admin", "user"),
|
||||
audience=("user", "access_admin", "operator"), order=33,
|
||||
translations={"de": {
|
||||
"title": "Wiederherstellungscode ausstellen und übergeben",
|
||||
"summary": "Ein aktueller lokaler Systemverantwortlicher prüft die Identität und autorisiert eine begrenzte Wiederherstellung.",
|
||||
"body": (
|
||||
"Nur ein aktueller Systemverantwortlicher mit system:* und interaktiver lokaler Sitzung darf bei aktivierter Wiederherstellung einen Code ausstellen. Prüfen Sie die Identität der ausgewählten Person unabhängig außerhalb von GovOPlaN und geben Sie Ihr eigenes aktuelles Passwort ein, niemals das Passwort der Zielperson. Das Zielkonto muss aktiv und lokal sein und eine aktive Mandantenmitgliedschaft besitzen. "
|
||||
"Die Ausstellung ersetzt frühere ungenutzte Zielcodes, ändert aber noch kein Passwort. Der Code wird einmal angezeigt, gilt 15 Minuten und ist einmal verwendbar. Übergeben Sie ihn ausschließlich über einen vereinbarten vertraulichen Kanal. Schließen leert die Anzeige; Passwörter und Codes gehören nicht in URLs, Tickets, Browserspeicher oder Protokolle. Die Einlösung ersetzt das Passwort, beendet Zielsitzungen, widerruft dessen API-Schlüssel und macht von ihm für andere Personen ausgestellte Codes ungültig. Die Berechtigung wird bei Einlösung erneut geprüft."
|
||||
),
|
||||
}},
|
||||
metadata={"kind": "reference", "help_contexts": ["access.password.issue-recovery"]},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.workflow.grant-user-access",
|
||||
title="Grant a person access",
|
||||
@@ -2066,6 +2157,17 @@ def _people_search(context: ModuleContext) -> object:
|
||||
return people_search_capability(context)
|
||||
|
||||
|
||||
def _public_auth_tenant_resolver(_request: object, _session: object) -> str | None:
|
||||
"""Authentication is global: unverified email/code never select a tenant.
|
||||
|
||||
None intentionally denotes no public tenant admission. The password
|
||||
recovery handler first verifies its bounded account authorization, then
|
||||
requires a current active membership in an active tenant before mutation.
|
||||
This resolver grants neither a tenant principal nor module permissions.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
@@ -2086,6 +2188,7 @@ manifest = ModuleManifest(
|
||||
permissions=ACCESS_PERMISSIONS,
|
||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||
route_factory=_route_factory,
|
||||
public_tenant_resolver=_public_auth_tenant_resolver,
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="access",
|
||||
metadata=AccessBase.metadata,
|
||||
@@ -2094,6 +2197,7 @@ manifest = ModuleManifest(
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
access_models.Account,
|
||||
access_models.PasswordRecovery,
|
||||
access_models.Identity,
|
||||
access_models.IdentityAccountLink,
|
||||
access_models.User,
|
||||
@@ -2127,6 +2231,7 @@ manifest = ModuleManifest(
|
||||
frontend=FrontendModule(
|
||||
module_id="access",
|
||||
package_name="@govoplan/access-webui",
|
||||
public_routes=(PublicFrontendRoute(path="/password-recovery", component="PasswordRecoveryPage", order=20),),
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/admin",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Add bounded administrator-assisted local password recovery.
|
||||
|
||||
Revision ID: e9a2c5f8b1d4
|
||||
Revises: d8f1b4e7a0c3
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "e9a2c5f8b1d4"
|
||||
down_revision = "d8f1b4e7a0c3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"access_password_recoveries",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("account_id", sa.String(36), nullable=False),
|
||||
sa.Column("issuer_account_id", sa.String(36), nullable=False),
|
||||
sa.Column("issuer_membership_id", sa.String(36), nullable=False),
|
||||
sa.Column("code_hash", sa.String(64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("code_hash"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"], ["access_accounts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["issuer_account_id"], ["access_accounts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["issuer_membership_id"], ["access_users.id"], ondelete="CASCADE"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_access_password_recoveries_account_id",
|
||||
"access_password_recoveries",
|
||||
["account_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_access_password_recoveries_issuer_account_id",
|
||||
"access_password_recoveries",
|
||||
["issuer_account_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("access_password_recoveries")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Add bounded administrator-assisted local password recovery.
|
||||
|
||||
Revision ID: e9a2c5f8b1d4
|
||||
Revises: d8f1b4e7a0c3
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "e9a2c5f8b1d4"
|
||||
down_revision = "d8f1b4e7a0c3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"access_password_recoveries",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("account_id", sa.String(36), nullable=False),
|
||||
sa.Column("issuer_account_id", sa.String(36), nullable=False),
|
||||
sa.Column("issuer_membership_id", sa.String(36), nullable=False),
|
||||
sa.Column("code_hash", sa.String(64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("code_hash"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"], ["access_accounts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["issuer_account_id"], ["access_accounts.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["issuer_membership_id"], ["access_users.id"], ondelete="CASCADE"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_access_password_recoveries_account_id",
|
||||
"access_password_recoveries",
|
||||
["account_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_access_password_recoveries_issuer_account_id",
|
||||
"access_password_recoveries",
|
||||
["issuer_account_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("access_password_recoveries")
|
||||
@@ -343,6 +343,7 @@ def build_login_throttle(
|
||||
client_limit: int,
|
||||
window_seconds: int,
|
||||
redis_retry_seconds: int,
|
||||
key_prefix: str = "govoplan:access:login:v1",
|
||||
) -> LoginThrottle:
|
||||
redis_store = RedisLoginAttemptStore(redis_url) if redis_url and redis_url.strip() else None
|
||||
resilient_store = ResilientLoginAttemptStore(
|
||||
@@ -355,4 +356,5 @@ def build_login_throttle(
|
||||
identity_limit=identity_limit,
|
||||
client_limit=client_limit,
|
||||
window_seconds=window_seconds,
|
||||
key_prefix=key_prefix,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
AuthSession,
|
||||
PasswordRecovery,
|
||||
Tenant,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.permissions.catalog import scopes_grant
|
||||
from govoplan_access.backend.security.passwords import hash_password, verify_password
|
||||
from govoplan_access.backend.security.sessions import collect_user_scopes
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
MIN_PASSWORD_LENGTH = 10
|
||||
MAX_PASSWORD_LENGTH = 1024
|
||||
RECOVERY_MINUTES = 15
|
||||
|
||||
|
||||
def local_password_account(account: Account) -> bool:
|
||||
return account.auth_provider == "local"
|
||||
|
||||
|
||||
def password_change_required(account: Account) -> bool:
|
||||
return bool(
|
||||
settings.auth_local_password_recovery_enabled
|
||||
and local_password_account(account)
|
||||
and account.password_reset_required
|
||||
)
|
||||
|
||||
|
||||
def enforce_password_change(account: Account) -> None:
|
||||
if password_change_required(account):
|
||||
raise HTTPException(
|
||||
403,
|
||||
detail={
|
||||
"code": "password_change_required",
|
||||
"message": "Change your initial local password to continue.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def validate_new_password(password: str, account: Account) -> None:
|
||||
if not MIN_PASSWORD_LENGTH <= len(password) <= MAX_PASSWORD_LENGTH:
|
||||
raise HTTPException(
|
||||
422,
|
||||
detail={
|
||||
"code": "invalid_new_password",
|
||||
"message": "Use a password between 10 and 1024 characters.",
|
||||
},
|
||||
)
|
||||
if verify_password(password, account.password_hash):
|
||||
raise HTTPException(
|
||||
422,
|
||||
detail={
|
||||
"code": "password_unchanged",
|
||||
"message": "Choose a different password.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def locked_local_account(session: Session, account_id: str) -> Account:
|
||||
account = (
|
||||
session.query(Account)
|
||||
.filter(Account.id == account_id)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if account is None or not account.is_active or not local_password_account(account):
|
||||
raise HTTPException(
|
||||
403,
|
||||
detail={
|
||||
"code": "local_password_unavailable",
|
||||
"message": "Local password changes are unavailable for this account.",
|
||||
},
|
||||
)
|
||||
return account
|
||||
|
||||
|
||||
def recovery_issuer_authorized(
|
||||
session: Session, *, account_id: str, membership_id: str
|
||||
) -> bool:
|
||||
account = session.get(Account, account_id)
|
||||
user = session.get(User, membership_id)
|
||||
tenant = session.get(Tenant, user.tenant_id) if user else None
|
||||
return bool(
|
||||
account
|
||||
and user
|
||||
and tenant
|
||||
and account.is_active
|
||||
and user.is_active
|
||||
and tenant.is_active
|
||||
and user.account_id == account.id
|
||||
and local_password_account(account)
|
||||
and not password_change_required(account)
|
||||
and scopes_grant(
|
||||
collect_user_scopes(session, user, include_system=True), "system:*"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def issue_recovery(
|
||||
session: Session, *, account: Account, issuer: Account, membership: User
|
||||
) -> tuple[str, PasswordRecovery]:
|
||||
# Caller holds the target account lock: issue/redeem/change operations for
|
||||
# that account serialize, and only the latest issued code remains usable.
|
||||
now = utc_now()
|
||||
session.query(PasswordRecovery).filter(
|
||||
PasswordRecovery.account_id == account.id,
|
||||
PasswordRecovery.consumed_at.is_(None),
|
||||
).update({PasswordRecovery.consumed_at: now}, synchronize_session="fetch")
|
||||
code = generate_secret("pr_", random_bytes=32)
|
||||
model = PasswordRecovery(
|
||||
account_id=account.id,
|
||||
issuer_account_id=issuer.id,
|
||||
issuer_membership_id=membership.id,
|
||||
code_hash=hash_secret(code),
|
||||
expires_at=now + timedelta(minutes=RECOVERY_MINUTES),
|
||||
)
|
||||
session.add(model)
|
||||
session.flush()
|
||||
return code, model
|
||||
|
||||
|
||||
def replace_password(
|
||||
session: Session, *, account: Account, password: str
|
||||
) -> dict[str, int]:
|
||||
"""Caller must hold the account lock and commit audit + changes together."""
|
||||
validate_new_password(password, account)
|
||||
now = utc_now()
|
||||
encoded = hash_password(password)
|
||||
replaced = (
|
||||
session.query(Account)
|
||||
.filter(
|
||||
Account.id == account.id,
|
||||
Account.password_hash == account.password_hash,
|
||||
Account.auth_provider == "local",
|
||||
Account.is_active.is_(True),
|
||||
)
|
||||
.update(
|
||||
{Account.password_hash: encoded, Account.password_reset_required: False},
|
||||
synchronize_session="fetch",
|
||||
)
|
||||
)
|
||||
if replaced != 1:
|
||||
raise HTTPException(
|
||||
409,
|
||||
detail={
|
||||
"code": "password_changed_concurrently",
|
||||
"message": "The account changed during authorization. Sign in again.",
|
||||
},
|
||||
)
|
||||
# Keep compatibility membership hashes synchronized; account remains the
|
||||
# only interactive password authority.
|
||||
session.query(User).filter(User.account_id == account.id).update(
|
||||
{User.password_hash: encoded}, synchronize_session="fetch"
|
||||
)
|
||||
sessions = (
|
||||
session.query(AuthSession)
|
||||
.filter(AuthSession.account_id == account.id, AuthSession.revoked_at.is_(None))
|
||||
.update({AuthSession.revoked_at: now}, synchronize_session="fetch")
|
||||
)
|
||||
membership_ids = session.query(User.id).filter(User.account_id == account.id)
|
||||
keys = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id.in_(membership_ids), ApiKey.revoked_at.is_(None))
|
||||
.update({ApiKey.revoked_at: now}, synchronize_session="fetch")
|
||||
)
|
||||
# Treat recovery handoffs as credentials delegated by the issuer too. A
|
||||
# compromised owner's password replacement must not leave previously
|
||||
# issued takeover authorizations for other accounts usable.
|
||||
recoveries = session.query(PasswordRecovery).filter(
|
||||
or_(
|
||||
PasswordRecovery.account_id == account.id,
|
||||
PasswordRecovery.issuer_account_id == account.id,
|
||||
),
|
||||
PasswordRecovery.consumed_at.is_(None),
|
||||
).update({PasswordRecovery.consumed_at: now}, synchronize_session="fetch")
|
||||
return {
|
||||
"revoked_sessions": sessions,
|
||||
"revoked_api_keys": keys,
|
||||
"revoked_password_recoveries": recoveries,
|
||||
}
|
||||
@@ -29,6 +29,7 @@ from govoplan_core.tenancy.scope import (
|
||||
create_scope_tables,
|
||||
scope_registry,
|
||||
)
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
class AutomationPrincipalTests(unittest.TestCase):
|
||||
@@ -189,6 +190,14 @@ class AutomationPrincipalTests(unittest.TestCase):
|
||||
suspended.provenance["status"],
|
||||
)
|
||||
|
||||
def test_required_local_password_change_denies_delegated_automation(self) -> None:
|
||||
self.account.password_reset_required = True
|
||||
self.session.commit()
|
||||
with patch.object(settings, "auth_local_password_recovery_enabled", True):
|
||||
result = self.provider.resolve_automation_principal(self.session, request=self._request())
|
||||
self.assertFalse(result.allowed)
|
||||
self.assertEqual("password_change_required", result.provenance["status"])
|
||||
|
||||
def test_service_account_resolution_uses_current_scope_ceiling(self) -> None:
|
||||
account = Account(
|
||||
id="service-account-backing",
|
||||
|
||||
@@ -6,11 +6,36 @@ from govoplan_access.backend.manifest import manifest
|
||||
|
||||
|
||||
class InterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_password_change_flag_is_documented_as_unenforced(self) -> None:
|
||||
def test_password_f1_contexts_resolve_once_to_concise_bilingual_topics(self) -> None:
|
||||
expected = {
|
||||
"access.password.change": "access.help.password-change",
|
||||
"access.password.recover": "access.help.password-recovery",
|
||||
"access.password.issue-recovery": "access.help.password-issue-recovery",
|
||||
}
|
||||
for context, topic_id in expected.items():
|
||||
with self.subTest(context=context):
|
||||
matches = [topic for topic in manifest.documentation if context in (topic.metadata or {}).get("help_contexts", ())]
|
||||
self.assertEqual([topic_id], [topic.id for topic in matches])
|
||||
topic = matches[0]
|
||||
self.assertEqual({"admin", "user"}, set(topic.documentation_types))
|
||||
for body in (topic.body, topic.translations["de"]["body"]):
|
||||
self.assertLessEqual(len(body.split()), 180)
|
||||
self.assertIn("API", body)
|
||||
self.assertIn("URLs", body)
|
||||
if context != "access.password.change":
|
||||
self.assertIn("15 minutes", topic.body)
|
||||
self.assertIn("15 Minuten", topic.translations["de"]["body"])
|
||||
|
||||
def test_password_change_flag_documents_opt_in_and_complete_recovery(self) -> None:
|
||||
topic = next(item for item in manifest.documentation if item.id == "access.reference.authentication-fields")
|
||||
self.assertIn("currently advisory metadata", topic.body)
|
||||
self.assertIn("server-side enforcement are not implemented", topic.body)
|
||||
self.assertIn("serverseitige Durchsetzung sind noch nicht umgesetzt", topic.translations["de"]["body"])
|
||||
self.assertIn("defaults to false", topic.body)
|
||||
self.assertIn("remains advisory metadata", topic.body)
|
||||
self.assertIn("standardmäßig false", topic.translations["de"]["body"])
|
||||
recovery = next(item for item in manifest.documentation if item.id == "access.workflow.local-password-recovery")
|
||||
for required in ("15 minutes", "single-use", "system:*", "human API keys", "e9a2c5f8b1d4", "does not send email"):
|
||||
self.assertIn(required, recovery.body)
|
||||
self.assertIn("issued by that account for other people", recovery.body)
|
||||
self.assertIn("von diesem Konto für andere Personen ausgestellte", recovery.translations["de"]["body"])
|
||||
|
||||
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||
for topic in manifest.documentation:
|
||||
|
||||
@@ -18,7 +18,7 @@ class LoginSecurityTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _session_with_account(account: object | None) -> MagicMock:
|
||||
session = MagicMock()
|
||||
session.query.return_value.filter.return_value.one_or_none.return_value = (
|
||||
session.query.return_value.filter.return_value.populate_existing.return_value.with_for_update.return_value.one_or_none.return_value = (
|
||||
account
|
||||
)
|
||||
return session
|
||||
@@ -43,7 +43,7 @@ class LoginSecurityTests(unittest.TestCase):
|
||||
self,
|
||||
) -> None:
|
||||
account_hash = "pbkdf2_sha256$260000$account-salt$account-digest"
|
||||
account = SimpleNamespace(password_hash=account_hash)
|
||||
account = SimpleNamespace(password_hash=account_hash, auth_provider="local")
|
||||
payload = LoginRequest(email="known@example.test", password="wrong-password")
|
||||
|
||||
with patch.object(auth, "verify_password", return_value=False) as verifier:
|
||||
@@ -55,7 +55,7 @@ class LoginSecurityTests(unittest.TestCase):
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
|
||||
def test_passwordless_account_cannot_authenticate_with_dummy_password(self) -> None:
|
||||
account = SimpleNamespace(password_hash=None)
|
||||
account = SimpleNamespace(password_hash=None, auth_provider="local")
|
||||
payload = LoginRequest(
|
||||
email="passwordless@example.test", password="not-a-user-password"
|
||||
)
|
||||
@@ -69,6 +69,7 @@ class LoginSecurityTests(unittest.TestCase):
|
||||
def test_account_without_active_membership_uses_same_generic_failure(self) -> None:
|
||||
account = SimpleNamespace(
|
||||
id="account-1",
|
||||
auth_provider="local",
|
||||
password_hash="pbkdf2_sha256$260000$account-salt$account-digest",
|
||||
)
|
||||
session = self._session_with_account(account)
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from govoplan_access.backend.api.v1 import auth
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
AccessApiPrincipalProvider,
|
||||
get_api_principal,
|
||||
)
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
AuthSession,
|
||||
PasswordRecovery,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.security.api_keys import create_api_key
|
||||
from govoplan_access.backend.security.login_throttle import (
|
||||
InMemoryLoginAttemptStore,
|
||||
LoginThrottle,
|
||||
)
|
||||
from govoplan_access.backend.security.passwords import hash_password, verify_password
|
||||
from govoplan_access.backend.security.password_change import replace_password
|
||||
from govoplan_access.backend.security.sessions import create_auth_session
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.auth import get_api_principal as get_core_api_principal
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.change_sequence import (
|
||||
ChangeSequenceEntry,
|
||||
ChangeSequenceRetentionFloor,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
|
||||
|
||||
class PasswordRecoveryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine(
|
||||
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||
)
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(self.engine)
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
SystemSettings.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
ChangeSequenceRetentionFloor.__table__,
|
||||
],
|
||||
)
|
||||
self.factory = sessionmaker(bind=self.engine)
|
||||
self.db = self.factory()
|
||||
self.tenant = Tenant(id="tenant", slug="tenant", name="Tenant")
|
||||
self.account = Account(
|
||||
id="person",
|
||||
email="person@example.test",
|
||||
normalized_email="person@example.test",
|
||||
password_hash=hash_password("Initial-password"),
|
||||
password_reset_required=True,
|
||||
)
|
||||
self.owner = Account(
|
||||
id="owner",
|
||||
email="owner@example.test",
|
||||
normalized_email="owner@example.test",
|
||||
password_hash=hash_password("Owner-password"),
|
||||
)
|
||||
self.user = User(
|
||||
id="person-member",
|
||||
account_id="person",
|
||||
tenant_id="tenant",
|
||||
email=self.account.email,
|
||||
)
|
||||
self.owner_user = User(
|
||||
id="owner-member",
|
||||
account_id="owner",
|
||||
tenant_id="tenant",
|
||||
email=self.owner.email,
|
||||
)
|
||||
role = Role(
|
||||
id="owner-role",
|
||||
tenant_id=None,
|
||||
slug="system_owner",
|
||||
name="System owner",
|
||||
permissions=["system:*"],
|
||||
)
|
||||
self.owner_assignment = SystemRoleAssignment(
|
||||
id="owner-assignment", account_id="owner", role_id=role.id
|
||||
)
|
||||
self.db.add_all(
|
||||
[
|
||||
self.tenant,
|
||||
self.account,
|
||||
self.owner,
|
||||
self.user,
|
||||
self.owner_user,
|
||||
role,
|
||||
self.owner_assignment,
|
||||
]
|
||||
)
|
||||
self.db.flush()
|
||||
self.current = create_auth_session(self.db, user=self.user)
|
||||
self.other = create_auth_session(self.db, user=self.user)
|
||||
self.owner_session = create_auth_session(self.db, user=self.owner_user)
|
||||
self.key = create_api_key(
|
||||
self.db, user=self.user, name="Human automation", scopes=[]
|
||||
)
|
||||
self.db.commit()
|
||||
self.audit = patch.object(auth, "audit_event").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
patch.object(settings, "auth_local_password_recovery_enabled", True).start()
|
||||
patch.object(settings, "auth_principal_cache_enabled", True).start()
|
||||
patch.object(settings, "auth_login_throttle_enabled", False).start()
|
||||
self.throttle = LoginThrottle(
|
||||
InMemoryLoginAttemptStore(),
|
||||
identity_limit=50,
|
||||
client_limit=100,
|
||||
window_seconds=900,
|
||||
)
|
||||
patch.object(
|
||||
auth, "_password_operation_throttle", return_value=self.throttle
|
||||
).start()
|
||||
app = FastAPI()
|
||||
registry = PlatformRegistry()
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=settings)
|
||||
)
|
||||
registry.register_capability_factory(
|
||||
"access",
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
lambda _context: AccessApiPrincipalProvider(),
|
||||
)
|
||||
app.state.govoplan_registry = registry
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
|
||||
@app.get("/protected")
|
||||
def protected(principal=Depends(get_api_principal)):
|
||||
return {"account": principal.account_id}
|
||||
|
||||
@app.get("/core-protected")
|
||||
def core_protected(principal=Depends(get_core_api_principal)):
|
||||
return {"account": principal.account_id}
|
||||
|
||||
def session_dependency():
|
||||
with self.factory() as session:
|
||||
try:
|
||||
yield session
|
||||
except BaseException:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
app.dependency_overrides[get_session] = session_dependency
|
||||
self.client = TestClient(app)
|
||||
principal_summary_cache.clear()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.client.close()
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
principal_summary_cache.clear()
|
||||
|
||||
def headers(self, created=None):
|
||||
return {"authorization": "Bearer " + (created or self.current).token}
|
||||
|
||||
def change(self, **overrides):
|
||||
return self.client.post(
|
||||
"/api/v1/auth/password/change",
|
||||
headers=self.headers(),
|
||||
json={
|
||||
"current_password": "Initial-password",
|
||||
"new_password": "New-secret-password",
|
||||
**overrides,
|
||||
},
|
||||
)
|
||||
|
||||
def issue(self):
|
||||
return self.client.post(
|
||||
"/api/v1/auth/password/recovery/person",
|
||||
headers=self.headers(self.owner_session),
|
||||
json={"current_password": "Owner-password", "identity_verified": True},
|
||||
)
|
||||
|
||||
def recover(self, code, **overrides):
|
||||
return self.client.post(
|
||||
"/api/v1/auth/password/recover",
|
||||
json={
|
||||
"email": "person@example.test",
|
||||
"recovery_code": code,
|
||||
"new_password": "Recovered-password",
|
||||
**overrides,
|
||||
},
|
||||
)
|
||||
|
||||
def test_first_login_restricts_then_rotates_and_revokes_all_credentials(self):
|
||||
login = self.client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": self.account.email, "password": "Initial-password"},
|
||||
)
|
||||
self.assertEqual(200, login.status_code, login.text)
|
||||
self.assertEqual(
|
||||
"change_password", login.json()["user"]["required_auth_action"]
|
||||
)
|
||||
self.assertEqual([], login.json()["scopes"])
|
||||
self.assertEqual(
|
||||
403, self.client.get("/protected", headers=self.headers()).status_code
|
||||
)
|
||||
self.assertEqual(
|
||||
403,
|
||||
self.client.patch(
|
||||
"/api/v1/auth/profile",
|
||||
headers=self.headers(),
|
||||
json={"display_name": "Escaped"},
|
||||
).status_code,
|
||||
)
|
||||
for path in ("session", "shell"):
|
||||
result = self.client.get("/api/v1/auth/" + path, headers=self.headers())
|
||||
self.assertEqual(200, result.status_code, result.text)
|
||||
self.assertEqual(
|
||||
"change_password", result.json()["user"]["required_auth_action"]
|
||||
)
|
||||
changed = self.change()
|
||||
self.assertEqual(200, changed.status_code, changed.text)
|
||||
self.assertIsNone(changed.json()["user"]["required_auth_action"])
|
||||
self.assertNotEqual(self.current.token, changed.json()["access_token"])
|
||||
for old in (self.current.token, self.other.token, self.key.secret):
|
||||
self.assertEqual(
|
||||
401,
|
||||
self.client.get(
|
||||
"/protected", headers={"authorization": "Bearer " + old}
|
||||
).status_code,
|
||||
)
|
||||
self.assertEqual(
|
||||
200,
|
||||
self.client.get(
|
||||
"/protected",
|
||||
headers={"authorization": "Bearer " + changed.json()["access_token"]},
|
||||
).status_code,
|
||||
)
|
||||
self.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password("New-secret-password", self.account.password_hash)
|
||||
)
|
||||
self.assertTrue(verify_password("New-secret-password", self.user.password_hash))
|
||||
self.assertFalse(self.account.password_reset_required)
|
||||
self.assertNotIn("New-secret-password", str(self.audit.call_args_list))
|
||||
self.assertNotIn("Initial-password", str(self.audit.call_args_list))
|
||||
|
||||
def test_failed_current_password_leaves_credentials_and_flag_unchanged(self):
|
||||
result = self.change(current_password="wrong-password")
|
||||
self.assertEqual(403, result.status_code, result.text)
|
||||
self.db.expire_all()
|
||||
self.assertTrue(self.account.password_reset_required)
|
||||
self.assertIsNone(self.db.get(AuthSession, self.current.model.id).revoked_at)
|
||||
self.assertTrue(verify_password("Initial-password", self.account.password_hash))
|
||||
self.assertEqual(422, self.change(new_password="Initial-password").status_code)
|
||||
|
||||
def test_core_api_provider_enforces_current_flag_for_sessions_and_keys(self):
|
||||
for headers in (self.headers(), {"x-api-key": self.key.secret}):
|
||||
with patch.object(settings, "auth_local_password_recovery_enabled", False):
|
||||
self.assertEqual(
|
||||
200, self.client.get("/core-protected", headers=headers).status_code
|
||||
)
|
||||
result = self.client.get("/core-protected", headers=headers)
|
||||
self.assertEqual(403, result.status_code)
|
||||
self.assertEqual(
|
||||
"password_change_required", result.json()["detail"]["code"]
|
||||
)
|
||||
|
||||
def test_validation_responses_do_not_echo_rejected_secrets(self):
|
||||
response = self.change(new_password="tiny")
|
||||
self.assertEqual(422, response.status_code)
|
||||
self.assertNotIn("tiny", response.text)
|
||||
self.assertNotIn("Initial-password", response.text)
|
||||
self.assertNotIn("input", response.json()["detail"][0])
|
||||
|
||||
def test_cookie_change_requires_matching_csrf_and_replaces_csrf(self):
|
||||
self.client.cookies.set(settings.auth_session_cookie_name, self.current.token)
|
||||
self.client.cookies.set(settings.auth_csrf_cookie_name, self.current.csrf_token)
|
||||
payload = {
|
||||
"current_password": "Initial-password",
|
||||
"new_password": "New-secret-password",
|
||||
}
|
||||
self.assertEqual(
|
||||
403,
|
||||
self.client.post("/api/v1/auth/password/change", json=payload).status_code,
|
||||
)
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/password/change",
|
||||
headers={"x-csrf-token": self.current.csrf_token},
|
||||
json=payload,
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertIn("HttpOnly", response.headers["set-cookie"])
|
||||
self.assertNotIn(self.current.csrf_token, response.headers["set-cookie"])
|
||||
|
||||
def test_opt_in_preserves_advisory_behavior_and_still_allows_change(self):
|
||||
with patch.object(settings, "auth_local_password_recovery_enabled", False):
|
||||
self.assertEqual(
|
||||
200, self.client.get("/protected", headers=self.headers()).status_code
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.client.get("/api/v1/auth/session", headers=self.headers()).json()[
|
||||
"user"
|
||||
]["required_auth_action"]
|
||||
)
|
||||
self.assertEqual(409, self.issue().status_code)
|
||||
self.assertEqual(409, self.recover("unknown").status_code)
|
||||
self.assertEqual(200, self.change().status_code)
|
||||
|
||||
def test_warm_cache_and_human_api_keys_do_not_bypass_flag(self):
|
||||
self.account.password_reset_required = False
|
||||
self.db.commit()
|
||||
self.assertEqual(
|
||||
200, self.client.get("/protected", headers=self.headers()).status_code
|
||||
)
|
||||
self.assertEqual(
|
||||
200,
|
||||
self.client.get(
|
||||
"/protected", headers={"x-api-key": self.key.secret}
|
||||
).status_code,
|
||||
)
|
||||
self.account.password_reset_required = True
|
||||
self.db.commit()
|
||||
self.assertEqual(
|
||||
403, self.client.get("/protected", headers=self.headers()).status_code
|
||||
)
|
||||
self.assertEqual(
|
||||
403,
|
||||
self.client.get(
|
||||
"/protected", headers={"x-api-key": self.key.secret}
|
||||
).status_code,
|
||||
)
|
||||
self.assertEqual(
|
||||
403,
|
||||
self.client.get(
|
||||
"/api/v1/auth/session", headers={"x-api-key": self.key.secret}
|
||||
).status_code,
|
||||
)
|
||||
self.assertEqual(
|
||||
403,
|
||||
self.client.post(
|
||||
"/api/v1/auth/password/change",
|
||||
headers={"x-api-key": self.key.secret},
|
||||
json={
|
||||
"current_password": "Initial-password",
|
||||
"new_password": "New-secret-password",
|
||||
},
|
||||
).status_code,
|
||||
)
|
||||
self.account.password_reset_required = False
|
||||
self.db.commit()
|
||||
self.assertEqual(
|
||||
400,
|
||||
self.client.post(
|
||||
"/api/v1/auth/password/change",
|
||||
headers={"x-api-key": self.key.secret},
|
||||
json={
|
||||
"current_password": "Initial-password",
|
||||
"new_password": "New-secret-password",
|
||||
},
|
||||
).status_code,
|
||||
)
|
||||
|
||||
def test_external_provider_is_not_forced_or_allowed_to_change_local_password(self):
|
||||
for provider in ("oidc", "service_account"):
|
||||
with self.subTest(provider=provider):
|
||||
self.account.auth_provider = provider
|
||||
self.db.commit()
|
||||
session_info = self.client.get(
|
||||
"/api/v1/auth/session", headers=self.headers()
|
||||
)
|
||||
self.assertIsNone(session_info.json()["user"]["required_auth_action"])
|
||||
self.assertFalse(session_info.json()["user"]["local_password"])
|
||||
self.assertEqual(403, self.change().status_code)
|
||||
self.assertEqual(
|
||||
401,
|
||||
self.client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": self.account.email,
|
||||
"password": "Initial-password",
|
||||
},
|
||||
).status_code,
|
||||
)
|
||||
|
||||
def test_recovery_code_is_hashed_one_use_and_revokes_sessions_and_keys(self):
|
||||
response = self.issue()
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
code = response.json()["recovery_code"]
|
||||
stored = self.db.query(PasswordRecovery).one()
|
||||
self.assertNotEqual(code, stored.code_hash)
|
||||
self.assertNotIn(code, str(self.audit.call_args_list))
|
||||
self.assertEqual("no-store", response.headers["cache-control"])
|
||||
self.assertEqual(200, self.recover(code).status_code)
|
||||
self.assertEqual(
|
||||
400, self.recover(code, new_password="Another-secret").status_code
|
||||
)
|
||||
self.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password("Recovered-password", self.account.password_hash)
|
||||
)
|
||||
self.assertFalse(self.account.password_reset_required)
|
||||
self.assertEqual(
|
||||
0,
|
||||
self.db.query(AuthSession)
|
||||
.filter(
|
||||
AuthSession.account_id == self.account.id,
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
.count(),
|
||||
)
|
||||
self.assertIsNotNone(self.db.get(ApiKey, self.key.model.id).revoked_at)
|
||||
|
||||
def test_expiry_supersession_and_current_issuer_authority(self):
|
||||
first = self.issue().json()["recovery_code"]
|
||||
second = self.issue().json()["recovery_code"]
|
||||
self.assertEqual(400, self.recover(first).status_code)
|
||||
latest = (
|
||||
self.db.query(PasswordRecovery)
|
||||
.filter(PasswordRecovery.consumed_at.is_(None))
|
||||
.one()
|
||||
)
|
||||
latest.expires_at = utc_now() - timedelta(seconds=1)
|
||||
self.db.commit()
|
||||
self.assertEqual(400, self.recover(second).status_code)
|
||||
third = self.issue().json()["recovery_code"]
|
||||
self.db.delete(self.owner_assignment)
|
||||
self.db.commit()
|
||||
self.assertEqual(400, self.recover(third).status_code)
|
||||
self.assertEqual(403, self.issue().status_code)
|
||||
|
||||
def test_recovery_rechecks_account_and_membership_state(self):
|
||||
code = self.issue().json()["recovery_code"]
|
||||
self.user.is_active = False
|
||||
self.db.commit()
|
||||
self.assertEqual(400, self.recover(code).status_code)
|
||||
self.user.is_active = True
|
||||
self.account.auth_provider = "oidc"
|
||||
self.db.commit()
|
||||
self.assertEqual(400, self.recover(code).status_code)
|
||||
|
||||
def test_recovery_rechecks_tenant_and_issuer_activation(self):
|
||||
code = self.issue().json()["recovery_code"]
|
||||
self.tenant.is_active = False
|
||||
self.db.commit()
|
||||
self.assertEqual(400, self.recover(code).status_code)
|
||||
self.tenant.is_active = True
|
||||
self.owner.is_active = False
|
||||
self.db.commit()
|
||||
self.assertEqual(400, self.recover(code).status_code)
|
||||
|
||||
def test_recovery_requires_current_owner_password_and_identity_verification(self):
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/password/recovery/person",
|
||||
headers=self.headers(self.owner_session),
|
||||
json={"current_password": "wrong-password", "identity_verified": True},
|
||||
)
|
||||
self.assertEqual(403, response.status_code)
|
||||
response = self.client.post(
|
||||
"/api/v1/auth/password/recovery/person",
|
||||
headers=self.headers(self.owner_session),
|
||||
json={"current_password": "Owner-password", "identity_verified": False},
|
||||
)
|
||||
self.assertEqual(422, response.status_code)
|
||||
self.assertEqual(0, self.db.query(PasswordRecovery).count())
|
||||
role = self.db.get(Role, "owner-role")
|
||||
role.permissions = ["system:accounts:update"]
|
||||
self.db.commit()
|
||||
self.assertEqual(403, self.issue().status_code)
|
||||
|
||||
def test_invalid_recovery_email_or_unchanged_password_does_not_consume_code(self):
|
||||
code = self.issue().json()["recovery_code"]
|
||||
response = self.recover(code, email="different@example.test")
|
||||
self.assertEqual(400, response.status_code)
|
||||
self.assertEqual(
|
||||
422, self.recover(code, new_password="Initial-password").status_code
|
||||
)
|
||||
self.assertEqual(200, self.recover(code).status_code)
|
||||
|
||||
def test_recovery_abuse_is_bounded_even_with_login_throttle_disabled(self):
|
||||
throttle = LoginThrottle(
|
||||
InMemoryLoginAttemptStore(),
|
||||
identity_limit=2,
|
||||
client_limit=100,
|
||||
window_seconds=900,
|
||||
)
|
||||
with patch.object(auth, "_password_operation_throttle", return_value=throttle):
|
||||
self.assertEqual(400, self.recover("wrong-code").status_code)
|
||||
response = self.recover("different-wrong-code")
|
||||
self.assertEqual(429, response.status_code)
|
||||
self.assertIn("retry-after", response.headers)
|
||||
|
||||
def test_failed_audit_rolls_back_password_and_session_rotation(self):
|
||||
with patch.object(
|
||||
auth, "audit_event", side_effect=RuntimeError("audit unavailable")
|
||||
):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.change()
|
||||
self.db.expire_all()
|
||||
self.assertTrue(verify_password("Initial-password", self.account.password_hash))
|
||||
self.assertIsNone(self.db.get(AuthSession, self.current.model.id).revoked_at)
|
||||
|
||||
def test_stale_password_authorization_cannot_overwrite_concurrent_change(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
with self.factory() as competing:
|
||||
current = competing.get(Account, self.account.id)
|
||||
replace_password(competing, account=current, password="Concurrent-password")
|
||||
competing.commit()
|
||||
with self.assertRaises(HTTPException) as conflict:
|
||||
# This intentionally uses the account state read before the other
|
||||
# transaction, modelling a database without row-lock support.
|
||||
replace_password(self.db, account=self.account, password="Stale-password")
|
||||
self.assertEqual(409, conflict.exception.status_code)
|
||||
self.db.rollback()
|
||||
self.db.refresh(self.account)
|
||||
self.assertTrue(
|
||||
verify_password("Concurrent-password", self.account.password_hash)
|
||||
)
|
||||
|
||||
def test_current_password_change_invalidates_outstanding_recovery(self):
|
||||
code = self.issue().json()["recovery_code"]
|
||||
self.assertEqual(200, self.change().status_code)
|
||||
self.assertEqual(400, self.recover(code).status_code)
|
||||
|
||||
def test_owner_password_change_invalidates_codes_issued_for_other_accounts(self):
|
||||
code = self.issue().json()["recovery_code"]
|
||||
changed = self.client.post(
|
||||
"/api/v1/auth/password/change",
|
||||
headers=self.headers(self.owner_session),
|
||||
json={
|
||||
"current_password": "Owner-password",
|
||||
"new_password": "Owner-new-password",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, changed.status_code, changed.text)
|
||||
rejected = self.recover(code)
|
||||
self.assertEqual(400, rejected.status_code, rejected.text)
|
||||
self.assertEqual("recovery_invalid", rejected.json()["detail"]["code"])
|
||||
self.db.expire_all()
|
||||
self.assertIsNotNone(self.db.query(PasswordRecovery).one().consumed_at)
|
||||
self.assertTrue(verify_password("Initial-password", self.account.password_hash))
|
||||
self.assertEqual(
|
||||
1, self.audit.call_args.kwargs["details"]["revoked_password_recoveries"]
|
||||
)
|
||||
|
||||
def test_owner_password_recovery_invalidates_codes_issued_for_other_accounts(self):
|
||||
code = self.issue().json()["recovery_code"]
|
||||
owner_recovery = self.client.post(
|
||||
"/api/v1/auth/password/recovery/owner",
|
||||
headers=self.headers(self.owner_session),
|
||||
json={"current_password": "Owner-password", "identity_verified": True},
|
||||
)
|
||||
self.assertEqual(200, owner_recovery.status_code, owner_recovery.text)
|
||||
recovered = self.recover(
|
||||
owner_recovery.json()["recovery_code"],
|
||||
email=self.owner.email,
|
||||
new_password="Owner-recovered-password",
|
||||
)
|
||||
self.assertEqual(200, recovered.status_code, recovered.text)
|
||||
rejected = self.recover(code)
|
||||
self.assertEqual(400, rejected.status_code, rejected.text)
|
||||
self.assertEqual("recovery_invalid", rejected.json()["detail"]["code"])
|
||||
self.db.expire_all()
|
||||
self.assertTrue(verify_password("Initial-password", self.account.password_hash))
|
||||
self.assertTrue(
|
||||
verify_password("Owner-recovered-password", self.owner.password_hash)
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
self.db.query(PasswordRecovery)
|
||||
.filter(PasswordRecovery.consumed_at.is_(None))
|
||||
.count(),
|
||||
)
|
||||
|
||||
def test_flagged_account_can_sign_out_and_requires_csrf_for_cookie_logout(self):
|
||||
self.client.cookies.set(settings.auth_session_cookie_name, self.current.token)
|
||||
self.client.cookies.set(settings.auth_csrf_cookie_name, self.current.csrf_token)
|
||||
self.assertEqual(403, self.client.post("/api/v1/auth/logout").status_code)
|
||||
result = self.client.post(
|
||||
"/api/v1/auth/logout", headers={"x-csrf-token": self.current.csrf_token}
|
||||
)
|
||||
self.assertEqual(200, result.status_code)
|
||||
self.assertEqual(
|
||||
401,
|
||||
self.client.get("/api/v1/auth/session", headers=self.headers()).status_code,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account
|
||||
from govoplan_access.backend.security.passwords import hash_password
|
||||
from govoplan_core.db.migrations import alembic_config
|
||||
|
||||
|
||||
class PasswordRecoveryMigrationTests(unittest.TestCase):
|
||||
def test_release_and_development_upgrade_preserve_existing_credentials(self):
|
||||
for track in ("release", "dev"):
|
||||
with (
|
||||
self.subTest(track=track),
|
||||
tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-password-migration-"
|
||||
) as directory,
|
||||
):
|
||||
url = f"sqlite:///{Path(directory) / 'isolated-upgrade.db'}"
|
||||
config = alembic_config(
|
||||
database_url=url, enabled_modules=("access",), migration_track=track
|
||||
)
|
||||
command.upgrade(config, "4f2a9c8e7b6d")
|
||||
command.upgrade(config, "d8f1b4e7a0c3")
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Account(
|
||||
id="existing",
|
||||
email="existing@example.test",
|
||||
normalized_email="existing@example.test",
|
||||
password_hash=hash_password("Existing-password"),
|
||||
password_reset_required=True,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
with engine.connect() as connection:
|
||||
before = list(
|
||||
connection.execute(
|
||||
text("SELECT * FROM access_accounts")
|
||||
).mappings()
|
||||
)
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
command.upgrade(config, "e9a2c5f8b1d4")
|
||||
command.upgrade(config, "e9a2c5f8b1d4")
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
self.assertEqual(
|
||||
tables | {"access_password_recoveries"},
|
||||
set(inspector.get_table_names()),
|
||||
)
|
||||
self.assertEqual(
|
||||
before,
|
||||
list(
|
||||
connection.execute(
|
||||
text("SELECT * FROM access_accounts")
|
||||
).mappings()
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"id",
|
||||
"account_id",
|
||||
"issuer_account_id",
|
||||
"issuer_membership_id",
|
||||
"code_hash",
|
||||
"expires_at",
|
||||
"consumed_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
},
|
||||
{
|
||||
column["name"]
|
||||
for column in inspector.get_columns(
|
||||
"access_password_recoveries"
|
||||
)
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
3,
|
||||
len(
|
||||
inspector.get_foreign_keys("access_password_recoveries")
|
||||
),
|
||||
)
|
||||
self.assertIn(
|
||||
["code_hash"],
|
||||
[
|
||||
constraint["column_names"]
|
||||
for constraint in inspector.get_unique_constraints(
|
||||
"access_password_recoveries"
|
||||
)
|
||||
],
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
import test_password_recovery as recovery_fixture
|
||||
from govoplan_access.backend.api.v1 import auth
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.db.models import PasswordRecovery
|
||||
from govoplan_access.backend.security.passwords import verify_password
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.environ.get("GOVOPLAN_ACCESS_TEST_POSTGRES_URL"),
|
||||
"set GOVOPLAN_ACCESS_TEST_POSTGRES_URL to a disposable PostgreSQL test database",
|
||||
)
|
||||
class PasswordRecoveryPostgresTests(unittest.TestCase):
|
||||
"""Opt-in real transaction races; no application/default database fallback."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
url = make_url(os.environ["GOVOPLAN_ACCESS_TEST_POSTGRES_URL"])
|
||||
if url.get_backend_name() != "postgresql" or not url.database:
|
||||
raise ValueError("An explicit disposable PostgreSQL database is required")
|
||||
self.schema = "access_password_race_" + uuid.uuid4().hex
|
||||
self.admin_engine = create_engine(url)
|
||||
self.addCleanup(self.admin_engine.dispose)
|
||||
with self.admin_engine.begin() as connection:
|
||||
connection.execute(text(f'CREATE SCHEMA "{self.schema}"'))
|
||||
self.addCleanup(self._drop_schema)
|
||||
self.engine = create_engine(
|
||||
url,
|
||||
connect_args={
|
||||
"options": (
|
||||
f"-c search_path={self.schema} -c statement_timeout=30000 "
|
||||
"-c lock_timeout=15000 -c idle_in_transaction_session_timeout=60000"
|
||||
)
|
||||
},
|
||||
)
|
||||
self.addCleanup(self.engine.dispose)
|
||||
self.access = recovery_fixture.PasswordRecoveryTests(methodName="runTest")
|
||||
self.addCleanup(self._close_fixture)
|
||||
# Reuse the synthetic HTTP/security fixture, replacing only its SQLite
|
||||
# engine. The production account locks/CAS and request SQL sessions run.
|
||||
with patch.object(recovery_fixture, "create_engine", return_value=self.engine):
|
||||
self.access.setUp()
|
||||
|
||||
def _drop_schema(self) -> None:
|
||||
# The identifier is generated here, never read from the supplied URL.
|
||||
with self.admin_engine.begin() as connection:
|
||||
connection.execute(text(f'DROP SCHEMA "{self.schema}" CASCADE'))
|
||||
|
||||
def _close_fixture(self) -> None:
|
||||
try:
|
||||
if hasattr(self.access, "client"):
|
||||
self.access.client.close()
|
||||
if hasattr(self.access, "db"):
|
||||
self.access.db.close()
|
||||
finally:
|
||||
self.access.doCleanups()
|
||||
principal_summary_cache.clear()
|
||||
|
||||
def _post(self, endpoint, payload, headers=None):
|
||||
# Separate cookie jars and real per-request SQL sessions in each thread.
|
||||
with TestClient(self.access.client.app) as client:
|
||||
return client.post(
|
||||
"/api/v1/auth/password/" + endpoint,
|
||||
json=payload,
|
||||
headers=headers or {},
|
||||
)
|
||||
|
||||
def _race(self, endpoint, payloads, *, headers=None, locked_account="person"):
|
||||
barrier = threading.Barrier(2)
|
||||
real_lock = auth.locked_local_account
|
||||
|
||||
def synchronized_lock(session, account_id):
|
||||
if account_id == locked_account:
|
||||
barrier.wait(timeout=10)
|
||||
return real_lock(session, account_id)
|
||||
|
||||
with patch.object(auth, "locked_local_account", side_effect=synchronized_lock):
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [
|
||||
executor.submit(self._post, endpoint, payload, headers)
|
||||
for payload in payloads
|
||||
]
|
||||
return [future.result(timeout=35) for future in futures]
|
||||
|
||||
def test_same_recovery_code_has_exactly_one_winner(self):
|
||||
issued = self.access.issue()
|
||||
self.assertEqual(200, issued.status_code)
|
||||
code = issued.json()["recovery_code"]
|
||||
passwords = ["Concurrent-recovery-one", "Concurrent-recovery-two"]
|
||||
responses = self._race(
|
||||
"recover",
|
||||
[
|
||||
{
|
||||
"email": "person@example.test",
|
||||
"recovery_code": code,
|
||||
"new_password": value,
|
||||
}
|
||||
for value in passwords
|
||||
],
|
||||
)
|
||||
self.assertEqual([200, 400], sorted(item.status_code for item in responses))
|
||||
winner = next(
|
||||
index for index, item in enumerate(responses) if item.status_code == 200
|
||||
)
|
||||
self.access.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password(passwords[winner], self.access.account.password_hash)
|
||||
)
|
||||
self.assertIsNotNone(self.access.db.query(PasswordRecovery).one().consumed_at)
|
||||
|
||||
def test_same_old_password_has_exactly_one_winner(self):
|
||||
passwords = ["Concurrent-change-one", "Concurrent-change-two"]
|
||||
responses = self._race(
|
||||
"change",
|
||||
[
|
||||
{"current_password": "Initial-password", "new_password": value}
|
||||
for value in passwords
|
||||
],
|
||||
headers=self.access.headers(),
|
||||
)
|
||||
self.assertEqual([200, 401], sorted(item.status_code for item in responses))
|
||||
winner = next(
|
||||
index for index, item in enumerate(responses) if item.status_code == 200
|
||||
)
|
||||
self.access.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password(passwords[winner], self.access.account.password_hash)
|
||||
)
|
||||
|
||||
def test_competing_issuance_leaves_exactly_one_usable_code(self):
|
||||
responses = self._race(
|
||||
"recovery/person",
|
||||
[{"current_password": "Owner-password", "identity_verified": True}] * 2,
|
||||
headers=self.access.headers(self.access.owner_session),
|
||||
locked_account="owner",
|
||||
)
|
||||
self.assertEqual([200, 200], [item.status_code for item in responses])
|
||||
codes = [item.json()["recovery_code"] for item in responses]
|
||||
self.assertEqual(2, len(set(codes)))
|
||||
self.access.db.expire_all()
|
||||
self.assertEqual(
|
||||
1,
|
||||
self.access.db.query(PasswordRecovery)
|
||||
.filter(PasswordRecovery.consumed_at.is_(None))
|
||||
.count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
[200, 400], sorted(self.access.recover(code).status_code for code in codes)
|
||||
)
|
||||
|
||||
def test_issuer_password_revocation_wins_paused_redemption(self):
|
||||
issued = self.access.issue()
|
||||
self.assertEqual(200, issued.status_code)
|
||||
code = issued.json()["recovery_code"]
|
||||
reached = threading.Event()
|
||||
proceed = threading.Event()
|
||||
real_authorized = auth.recovery_issuer_authorized
|
||||
|
||||
def paused_authorization(*args, **kwargs):
|
||||
reached.set()
|
||||
self.assertTrue(proceed.wait(timeout=35))
|
||||
return real_authorized(*args, **kwargs)
|
||||
|
||||
with patch.object(
|
||||
auth, "recovery_issuer_authorized", side_effect=paused_authorization
|
||||
):
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
redemption = executor.submit(
|
||||
self._post,
|
||||
"recover",
|
||||
{
|
||||
"email": "person@example.test",
|
||||
"recovery_code": code,
|
||||
"new_password": "Must-not-replace-password",
|
||||
},
|
||||
)
|
||||
try:
|
||||
self.assertTrue(reached.wait(timeout=10))
|
||||
changed = self._post(
|
||||
"change",
|
||||
{
|
||||
"current_password": "Owner-password",
|
||||
"new_password": "Owner-race-password",
|
||||
},
|
||||
self.access.headers(self.access.owner_session),
|
||||
)
|
||||
self.assertEqual(200, changed.status_code)
|
||||
finally:
|
||||
proceed.set()
|
||||
rejected = redemption.result(timeout=35)
|
||||
self.assertEqual(400, rejected.status_code)
|
||||
self.assertEqual("recovery_invalid", rejected.json()["detail"]["code"])
|
||||
self.access.db.expire_all()
|
||||
self.assertTrue(
|
||||
verify_password("Initial-password", self.access.account.password_hash)
|
||||
)
|
||||
self.assertTrue(
|
||||
verify_password("Owner-race-password", self.access.owner.password_hash)
|
||||
)
|
||||
self.assertIsNotNone(self.access.db.query(PasswordRecovery).one().consumed_at)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs",
|
||||
"test:passwords": "node --test scripts/test-passwords.mjs"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
// Use the workspace's shared frontend compiler, without loading the application.
|
||||
const require = createRequire(new URL("../../../govoplan-core/webui/package.json", import.meta.url));
|
||||
const { transformSync } = require("esbuild");
|
||||
const calls = [];
|
||||
function load(relativePath, imports = {}) {
|
||||
const source = readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
const code = transformSync(source, { loader: "ts", format: "cjs", target: "es2022" }).code;
|
||||
const context = vm.createContext({ module: { exports: {} }, require: () => imports });
|
||||
context.exports = context.module.exports;
|
||||
vm.runInContext(code, context);
|
||||
return context.module.exports;
|
||||
}
|
||||
const api = load("../src/api/passwords.ts", {
|
||||
apiFetch: (...args) => { calls.push(args); return Promise.resolve({}); },
|
||||
isApiError: (error) => Boolean(error?.fixtureApiError)
|
||||
});
|
||||
const { passwordTranslations } = load("../src/i18n/passwordTranslations.ts");
|
||||
const settings = { apiBaseUrl: "https://fixture.invalid", apiKey: "fixture-key", accessToken: "legacy-fixture-token" };
|
||||
const current = "fixture-current-password";
|
||||
const next = "fixture-next-password";
|
||||
const code = "fixture-recovery-code";
|
||||
|
||||
test("password requests carry credentials only in POST bodies and public calls discard bearer settings", async () => {
|
||||
calls.length = 0;
|
||||
await api.fetchPasswordPolicy(settings);
|
||||
await api.changePassword(settings, current, next);
|
||||
await api.issuePasswordRecovery(settings, "account/1", current, true);
|
||||
await api.recoverPassword(settings, "person@example.test", code, next);
|
||||
assert.equal(calls[0][0].apiKey, "");
|
||||
assert.equal(calls[0][0].accessToken, "");
|
||||
assert.equal(calls[0][2].cache, "no-store");
|
||||
assert.equal(calls[1][0], settings);
|
||||
assert.deepEqual(JSON.parse(calls[1][2].body), { current_password: current, new_password: next });
|
||||
assert.equal(calls[2][1], "/api/v1/auth/password/recovery/account%2F1");
|
||||
assert.deepEqual(JSON.parse(calls[2][2].body), { current_password: current, identity_verified: true });
|
||||
assert.equal(calls[3][0].apiKey, "");
|
||||
assert.equal(calls[3][0].accessToken, "");
|
||||
assert.deepEqual(JSON.parse(calls[3][2].body), { email: "person@example.test", recovery_code: code, new_password: next });
|
||||
for (const [, path, options] of calls.slice(1)) {
|
||||
assert.equal(options.method, "POST");
|
||||
for (const secret of [current, next, code]) assert.equal(path.includes(secret), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("all stable password errors have EN/DE messages, while arbitrary input never becomes display text", () => {
|
||||
const codes = ["current_password_invalid", "invalid_new_password", "password_unchanged", "password_recovery_disabled", "password_rate_limited", "local_password_unavailable", "recovery_invalid", "recovery_issuer_required", "recovery_membership_required", "password_changed_concurrently", "password_change_required"];
|
||||
for (const value of codes) {
|
||||
const key = api.passwordErrorMessage({ fixtureApiError: true, status: 400, body: JSON.stringify({ detail: { code: value, input: current } }) });
|
||||
assert.ok(passwordTranslations.en[key], value);
|
||||
assert.ok(passwordTranslations.de[key], value);
|
||||
assert.notEqual(key, "i18n:govoplan-access.password.request_failed");
|
||||
}
|
||||
for (const body of [current, JSON.stringify({ detail: [{ input: current }] }), JSON.stringify({ detail: { code: "toString", input: code } })]) {
|
||||
assert.equal(api.passwordErrorMessage({ fixtureApiError: true, status: 500, body }), "i18n:govoplan-access.password.request_failed");
|
||||
}
|
||||
assert.equal(api.passwordErrorMessage(new Error(current)), "i18n:govoplan-access.password.request_failed");
|
||||
for (const status of [401, 403, 422, 429]) {
|
||||
const key = api.passwordErrorMessage({ fixtureApiError: true, status, body: JSON.stringify({ detail: current }) });
|
||||
assert.ok(passwordTranslations.en[key]);
|
||||
assert.ok(passwordTranslations.de[key]);
|
||||
assert.equal(key.includes(current), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("password translations keep the EN/DE workflow and placeholder contracts aligned", () => {
|
||||
assert.deepEqual(Object.keys(passwordTranslations.en).sort(), Object.keys(passwordTranslations.de).sort());
|
||||
for (const key of Object.keys(passwordTranslations.en)) {
|
||||
assert.deepEqual(passwordTranslations.en[key].match(/\{value\d+\}/g) ?? [], passwordTranslations.de[key].match(/\{value\d+\}/g) ?? [], key);
|
||||
}
|
||||
});
|
||||
@@ -4,8 +4,7 @@ import type {
|
||||
DeltaDeletedItem,
|
||||
PrivacyRetentionPolicy,
|
||||
ResourceAccessExplanationOptions,
|
||||
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse,
|
||||
TenantAdminItem
|
||||
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse
|
||||
} from "@govoplan/core-webui";
|
||||
import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
|
||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||
@@ -137,6 +136,7 @@ export type ResourceAccessExplanationResponse = CoreResourceAccessExplanationRes
|
||||
|
||||
export type SystemAccountItem = {
|
||||
account_id: string;
|
||||
local_password?: boolean;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
is_active: boolean;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { apiFetch, isApiError, type ApiSettings, type LoginResponse } from "@govoplan/core-webui";
|
||||
|
||||
export type PasswordPolicy = {
|
||||
recovery_enabled: boolean;
|
||||
min_length: number;
|
||||
max_length: number;
|
||||
recovery_minutes: number;
|
||||
};
|
||||
|
||||
export type PasswordRecoveryCode = { recovery_code: string; expires_at: string };
|
||||
|
||||
export function fetchPasswordPolicy(settings: ApiSettings): Promise<PasswordPolicy> {
|
||||
return apiFetch({ ...settings, apiKey: "", accessToken: "" }, "/api/v1/auth/password/policy", { cache: "no-store" });
|
||||
}
|
||||
|
||||
export function changePassword(settings: ApiSettings, currentPassword: string, newPassword: string): Promise<LoginResponse> {
|
||||
return apiFetch(settings, "/api/v1/auth/password/change", {
|
||||
method: "POST", body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
|
||||
});
|
||||
}
|
||||
|
||||
export function issuePasswordRecovery(settings: ApiSettings, accountId: string, currentPassword: string, identityVerified: true): Promise<PasswordRecoveryCode> {
|
||||
return apiFetch(settings, `/api/v1/auth/password/recovery/${encodeURIComponent(accountId)}`, {
|
||||
method: "POST", body: JSON.stringify({ current_password: currentPassword, identity_verified: identityVerified })
|
||||
});
|
||||
}
|
||||
|
||||
export function recoverPassword(settings: ApiSettings, email: string, recoveryCode: string, newPassword: string): Promise<{ ok: boolean }> {
|
||||
return apiFetch({ ...settings, apiKey: "", accessToken: "" }, "/api/v1/auth/password/recover", {
|
||||
method: "POST", body: JSON.stringify({ email, recovery_code: recoveryCode, new_password: newPassword })
|
||||
});
|
||||
}
|
||||
|
||||
const passwordErrors: Record<string, string> = {
|
||||
current_password_invalid: "i18n:govoplan-access.password.current_invalid",
|
||||
invalid_new_password: "i18n:govoplan-access.password.invalid_new",
|
||||
password_unchanged: "i18n:govoplan-access.password.unchanged",
|
||||
password_recovery_disabled: "i18n:govoplan-access.password.recovery_disabled",
|
||||
password_rate_limited: "i18n:govoplan-access.password.rate_limited",
|
||||
local_password_unavailable: "i18n:govoplan-access.password.local_only",
|
||||
recovery_invalid: "i18n:govoplan-access.password.recovery_invalid",
|
||||
recovery_issuer_required: "i18n:govoplan-access.password.issuer_required",
|
||||
recovery_membership_required: "i18n:govoplan-access.password.membership_required",
|
||||
password_changed_concurrently: "i18n:govoplan-access.password.changed_concurrently",
|
||||
password_change_required: "i18n:govoplan-access.password.required"
|
||||
};
|
||||
|
||||
export function passwordErrorMessage(error: unknown): string {
|
||||
if (isApiError(error)) {
|
||||
try {
|
||||
const code: unknown = JSON.parse(error.body)?.detail?.code;
|
||||
if (typeof code === "string" && Object.prototype.hasOwnProperty.call(passwordErrors, code)) return passwordErrors[code];
|
||||
} catch { /* Never display arbitrary response content from a secret-bearing request. */ }
|
||||
if (error.status === 401) return "i18n:govoplan-access.password.session_expired";
|
||||
if (error.status === 403) return "i18n:govoplan-access.password.not_allowed";
|
||||
if (error.status === 422) return "i18n:govoplan-access.password.invalid_fields";
|
||||
if (error.status === 429) return "i18n:govoplan-access.password.rate_limited";
|
||||
}
|
||||
return "i18n:govoplan-access.password.request_failed";
|
||||
}
|
||||
@@ -358,6 +358,7 @@ export default function AdminPage({
|
||||
{!contributedSection && active === "system-users" && (
|
||||
<SystemUsersPanel
|
||||
settings={settings}
|
||||
auth={auth}
|
||||
canCreate={hasScope(auth, "system:accounts:create")}
|
||||
canUpdate={hasScope(auth, "system:accounts:update")}
|
||||
canSuspend={hasScope(auth, "system:accounts:suspend")}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { Search, Pencil, Plus, Trash2, KeyRound } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { hasScope } from "@govoplan/core-webui";
|
||||
import PasswordRecoveryIssueDialog from "../passwords/PasswordRecoveryIssueDialog";
|
||||
import { usePasswordPolicy } from "../passwords/usePasswordPolicy";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -36,6 +39,7 @@ const emptyDraft = {
|
||||
|
||||
export default function SystemUsersPanel({
|
||||
settings,
|
||||
auth,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canSuspend,
|
||||
@@ -50,7 +54,7 @@ export default function SystemUsersPanel({
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [accounts, setAccounts] = useState<SystemAccountItem[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
@@ -61,6 +65,11 @@ export default function SystemUsersPanel({
|
||||
const [viewing, setViewing] = useState<SystemAccountItem | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null);
|
||||
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;value: string;} | null>(null);
|
||||
const [recovering, setRecovering] = useState<SystemAccountItem | null>(null);
|
||||
const { policy: passwordPolicy } = usePasswordPolicy(settings);
|
||||
const canIssueRecovery = Boolean(passwordPolicy?.recovery_enabled
|
||||
&& auth.principal?.auth_method === "session" && auth.user.local_password
|
||||
&& hasScope(auth, "system:*") && !auth.user.required_auth_action);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -216,13 +225,17 @@ export default function SystemUsersPanel({
|
||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
...(canIssueRecovery && row.local_password === true && row.is_active
|
||||
? [{ id: "recover-password", label: "i18n:govoplan-access.password.issue_title", icon: <KeyRound />, helpContextId: "access.password.issue-recovery", helpModuleId: "access", onClick: () => setRecovering(row) }]
|
||||
: []),
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), disabledReason: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.memberships.some((membership) => membership.is_last_active_owner) ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
||||
]} /> }],
|
||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate, canIssueRecovery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{recovering && canIssueRecovery && <PasswordRecoveryIssueDialog key={recovering.account_id} settings={settings} account={recovering} onClose={() => setRecovering(null)} />}
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.central_users.91ac1b51"
|
||||
description="i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button, DismissibleAlert, FormField, FormLayout, PasswordField, i18nMessage, usePlatformLanguage,
|
||||
type ApiSettings, type AuthInfo, type AuthUpdate
|
||||
} from "@govoplan/core-webui";
|
||||
import { changePassword, passwordErrorMessage } from "../../api/passwords";
|
||||
import { usePasswordPolicy } from "./usePasswordPolicy";
|
||||
|
||||
export default function PasswordChangePanel({ settings, auth, onAuthChange }: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const { policy, error: policyError, reload } = usePasswordPolicy(settings);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const required = auth.user.required_auth_action === "change_password";
|
||||
const localSession = auth.principal?.auth_method === "session" && auth.user.local_password === true;
|
||||
const complete = Boolean(policy && currentPassword && Array.from(currentPassword).length <= 1024 && newPassword === confirmation
|
||||
&& Array.from(newPassword).length >= policy.min_length
|
||||
&& Array.from(newPassword).length <= policy.max_length);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || !complete || !localSession) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
try {
|
||||
const response = await changePassword(settings, currentPassword, newPassword);
|
||||
onAuthChange(response, "");
|
||||
setSuccess(true);
|
||||
} catch (reason) {
|
||||
setError(passwordErrorMessage(reason));
|
||||
} finally {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmation("");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <section>
|
||||
<h1>{required ? "i18n:govoplan-access.password.required_title" : "i18n:govoplan-access.password.change_title"}</h1>
|
||||
{required && <p>i18n:govoplan-access.password.required</p>}
|
||||
{!localSession ? <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-access.password.local_only</DismissibleAlert> : <>
|
||||
<p>i18n:govoplan-access.password.change_consequences</p>
|
||||
{policyError && <DismissibleAlert tone="warning" dismissible={false}>{policyError}<Button onClick={reload} helpContextId="access.password.change" helpModuleId="access">i18n:govoplan-access.reload.cce71553</Button></DismissibleAlert>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success">i18n:govoplan-access.password.changed</DismissibleAlert>}
|
||||
<FormLayout columns={1} collapseAt="standard" onSubmit={submit}>
|
||||
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.password.change" helpModuleId="access">
|
||||
<PasswordField aria-label={translateText("i18n:govoplan-access.current_password.5e551021")} value={currentPassword} onValueChange={setCurrentPassword} autoComplete="current-password" maxLength={2048} required disabled={busy} />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-access.password.new" help={policy ? i18nMessage("i18n:govoplan-access.password.length", { value0: policy.min_length, value1: policy.max_length }) : undefined} helpContextId="access.password.change" helpModuleId="access">
|
||||
<PasswordField aria-label={translateText("i18n:govoplan-access.password.new")} value={newPassword} onValueChange={setNewPassword} autoComplete="new-password" minLength={policy?.min_length} maxLength={2 * (policy?.max_length ?? 1024)} required disabled={busy} generator />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-access.password.confirm" helpContextId="access.password.change" helpModuleId="access">
|
||||
<PasswordField aria-label={translateText("i18n:govoplan-access.password.confirm")} value={confirmation} onValueChange={setConfirmation} autoComplete="new-password" maxLength={2 * (policy?.max_length ?? 1024)} required disabled={busy} />
|
||||
</FormField>
|
||||
{confirmation && newPassword !== confirmation && <p role="status">i18n:govoplan-access.password.mismatch</p>}
|
||||
<Button type="submit" variant="primary" disabled={busy || !complete} helpContextId="access.password.change" helpModuleId="access"
|
||||
disabledReason={busy ? "i18n:govoplan-access.password.saving" : !complete ? "i18n:govoplan-access.password.complete_fields" : undefined}>
|
||||
{busy ? "i18n:govoplan-access.password.saving" : "i18n:govoplan-access.password.change_title"}
|
||||
</Button>
|
||||
</FormLayout>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Link } from "react-router";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { usePasswordPolicy } from "./usePasswordPolicy";
|
||||
|
||||
export default function PasswordLoginHelp({ settings, onNavigate }: { settings: ApiSettings; onNavigate: () => void }) {
|
||||
const { policy } = usePasswordPolicy(settings);
|
||||
return policy?.recovery_enabled
|
||||
? <p><Link to="/password-recovery" onClick={onNavigate} data-help-context-id="access.password.recover" data-help-module-id="access">i18n:govoplan-access.password.forgot</Link></p>
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button, Dialog, DismissibleAlert, FormField, FormLayout, PasswordField,
|
||||
i18nMessage, usePlatformLanguage, type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import { issuePasswordRecovery, passwordErrorMessage, type PasswordRecoveryCode } from "../../api/passwords";
|
||||
|
||||
export default function PasswordRecoveryIssueDialog({ settings, account, onClose }: {
|
||||
settings: ApiSettings;
|
||||
account: { account_id: string; email: string };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const formId = useId();
|
||||
const [password, setPassword] = useState("");
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [result, setResult] = useState<PasswordRecoveryCode | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ready = verified && Boolean(password) && Array.from(password).length <= 1024;
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || !ready || result) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setResult(await issuePasswordRecovery(settings, account.account_id, password, true));
|
||||
} catch (reason) { setError(passwordErrorMessage(reason)); }
|
||||
finally {
|
||||
setPassword("");
|
||||
setVerified(false);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <Dialog open variant="administration" size="large"
|
||||
title="i18n:govoplan-access.password.issue_title" onClose={() => { if (!busy) onClose(); }}
|
||||
footer={<>
|
||||
<Button onClick={onClose} disabled={busy} helpContextId="access.password.issue-recovery" helpModuleId="access">{result ? "i18n:govoplan-access.close.bbfa773e" : "i18n:govoplan-access.cancel.77dfd213"}</Button>
|
||||
{!result && <Button type="submit" form={formId} variant="primary" disabled={busy || !ready} helpContextId="access.password.issue-recovery" helpModuleId="access"
|
||||
disabledReason={busy ? "i18n:govoplan-access.password.saving" : !ready ? "i18n:govoplan-access.password.issue_requirements" : undefined}>
|
||||
i18n:govoplan-access.password.issue_title
|
||||
</Button>}
|
||||
</>}>
|
||||
<p>{i18nMessage("i18n:govoplan-access.password.issue_for", { value0: account.email })}</p>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{result ? <>
|
||||
<p>i18n:govoplan-access.password.code_once</p>
|
||||
<FormField label="i18n:govoplan-access.password.recovery_code" helpContextId="access.password.issue-recovery" helpModuleId="access"><input value={result.recovery_code} readOnly autoComplete="off" /></FormField>
|
||||
<p>{i18nMessage("i18n:govoplan-access.password.code_expires", { value0: new Date(result.expires_at).toLocaleString() })}</p>
|
||||
<p>i18n:govoplan-access.password.code_delivery</p>
|
||||
</> : <FormLayout columns={1} collapseAt="standard" id={formId} onSubmit={submit}>
|
||||
<p>i18n:govoplan-access.password.issue_consequences</p>
|
||||
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.password.issue-recovery" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.current_password.5e551021")} value={password} onValueChange={setPassword} autoComplete="current-password" maxLength={2048} required disabled={busy} /></FormField>
|
||||
<label className="checkbox-field" data-help-context-id="access.password.issue-recovery" data-help-module-id="access"><input type="checkbox" checked={verified} onChange={(event) => setVerified(event.target.checked)} disabled={busy} required /> <span>i18n:govoplan-access.password.identity_verified</span></label>
|
||||
</FormLayout>}
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Button, DismissibleAlert, FormField, FormLayout, PasswordField, i18nMessage, usePlatformLanguage, type ApiSettings } from "@govoplan/core-webui";
|
||||
import { passwordErrorMessage, recoverPassword } from "../../api/passwords";
|
||||
import { usePasswordPolicy } from "./usePasswordPolicy";
|
||||
|
||||
export default function PasswordRecoveryPage({ settings }: { settings: ApiSettings }) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const { policy, error: policyError, reload } = usePasswordPolicy(settings);
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [complete, setComplete] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ready = Boolean(policy?.recovery_enabled && email.trim() && code.trim()
|
||||
&& password === confirmation && Array.from(password).length >= policy.min_length
|
||||
&& Array.from(password).length <= policy.max_length);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || !ready) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await recoverPassword(settings, email.trim(), code.trim(), password);
|
||||
setComplete(true);
|
||||
setEmail("");
|
||||
} catch (reason) { setError(passwordErrorMessage(reason)); }
|
||||
finally {
|
||||
setCode("");
|
||||
setPassword("");
|
||||
setConfirmation("");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="public-landing auth-action-page"><section className="public-card">
|
||||
<h1>i18n:govoplan-access.password.recover_title</h1>
|
||||
{complete ? <DismissibleAlert tone="success" dismissible={false}>i18n:govoplan-access.password.recovered</DismissibleAlert> : <>
|
||||
<p>i18n:govoplan-access.password.recovery_instructions</p>
|
||||
<p>i18n:govoplan-access.password.recovery_consequences</p>
|
||||
{policyError && <DismissibleAlert tone="warning" dismissible={false}>{policyError}<Button onClick={reload} helpContextId="access.password.recover" helpModuleId="access">i18n:govoplan-access.reload.cce71553</Button></DismissibleAlert>}
|
||||
{policy && !policy.recovery_enabled && <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-access.password.recovery_disabled</DismissibleAlert>}
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{policy?.recovery_enabled && <FormLayout columns={1} collapseAt="standard" onSubmit={submit}>
|
||||
<FormField label="i18n:govoplan-access.email.84add5b2" helpContextId="access.password.recover" helpModuleId="access"><input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} required maxLength={320} disabled={busy} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.password.recovery_code" helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.recovery_code")} value={code} onValueChange={setCode} autoComplete="off" maxLength={256} required disabled={busy} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.password.new" help={i18nMessage("i18n:govoplan-access.password.length", { value0: policy.min_length, value1: policy.max_length })} helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.new")} value={password} onValueChange={setPassword} autoComplete="new-password" minLength={policy.min_length} maxLength={2 * policy.max_length} required disabled={busy} generator /></FormField>
|
||||
<FormField label="i18n:govoplan-access.password.confirm" helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.confirm")} value={confirmation} onValueChange={setConfirmation} autoComplete="new-password" maxLength={2 * policy.max_length} required disabled={busy} /></FormField>
|
||||
{confirmation && confirmation !== password && <p role="status">i18n:govoplan-access.password.mismatch</p>}
|
||||
<Button type="submit" variant="primary" helpContextId="access.password.recover" helpModuleId="access" disabled={busy || !ready} disabledReason={busy ? "i18n:govoplan-access.password.saving" : !ready ? "i18n:govoplan-access.password.complete_fields" : undefined}>{busy ? "i18n:govoplan-access.password.saving" : "i18n:govoplan-access.password.recover_title"}</Button>
|
||||
</FormLayout>}
|
||||
</>}
|
||||
<p><Link to="/" data-help-context-id="access.password.recover" data-help-module-id="access">i18n:govoplan-access.password.return_sign_in</Link></p>
|
||||
</section></div>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { fetchPasswordPolicy, passwordErrorMessage, type PasswordPolicy } from "../../api/passwords";
|
||||
|
||||
export function usePasswordPolicy(settings: ApiSettings) {
|
||||
const [policy, setPolicy] = useState<PasswordPolicy | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [revision, reload] = useState(0);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setPolicy(null);
|
||||
setError("");
|
||||
fetchPasswordPolicy(settings).then((value) => {
|
||||
if (current) setPolicy(value);
|
||||
}).catch((reason) => {
|
||||
if (current) setError(passwordErrorMessage(reason));
|
||||
});
|
||||
return () => { current = false; };
|
||||
}, [settings.apiBaseUrl, revision]);
|
||||
return { policy, error, reload: () => reload((value) => value + 1) };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const passwordTranslations: PlatformTranslations = {
|
||||
en: {
|
||||
"i18n:govoplan-access.password.change_title": "Change password",
|
||||
"i18n:govoplan-access.password.required_title": "Change your initial password",
|
||||
"i18n:govoplan-access.password.required": "Set a new password before opening your workspace. Enter the current or initial password you used to sign in.",
|
||||
"i18n:govoplan-access.password.local_only": "Password changes require an interactive session for a local account. External accounts must use their identity provider.",
|
||||
"i18n:govoplan-access.password.change_consequences": "Changing your password ends all other browser sessions and revokes all account API keys. This browser receives a new session. Update integrations with newly issued API keys afterwards. Unused recovery codes for this account and codes it issued for other people also become invalid.",
|
||||
"i18n:govoplan-access.password.changed": "Your password was changed. Other sessions ended and account API keys were revoked.",
|
||||
"i18n:govoplan-access.password.new": "New password",
|
||||
"i18n:govoplan-access.password.confirm": "Confirm new password",
|
||||
"i18n:govoplan-access.password.length": "Use between {value0} and {value1} characters.",
|
||||
"i18n:govoplan-access.password.mismatch": "The new passwords do not match.",
|
||||
"i18n:govoplan-access.password.complete_fields": "Complete the required fields and enter matching new passwords of the required length.",
|
||||
"i18n:govoplan-access.password.saving": "Updating password…",
|
||||
"i18n:govoplan-access.password.current_invalid": "Your current password was not accepted. Enter it again to authorize this action.",
|
||||
"i18n:govoplan-access.password.invalid_new": "Use a new password between 10 and 1024 characters.",
|
||||
"i18n:govoplan-access.password.unchanged": "Choose a password different from your current password.",
|
||||
"i18n:govoplan-access.password.recovery_disabled": "Administrator-assisted password recovery is not enabled. Contact your administrator for help.",
|
||||
"i18n:govoplan-access.password.rate_limited": "Too many attempts. Wait before trying again.",
|
||||
"i18n:govoplan-access.password.recovery_invalid": "This recovery code is invalid, expired, already used, or no longer authorized. Ask your System owner for a new code.",
|
||||
"i18n:govoplan-access.password.issuer_required": "Only a current System owner can issue a recovery code.",
|
||||
"i18n:govoplan-access.password.membership_required": "This account needs an active tenant membership before password recovery is available.",
|
||||
"i18n:govoplan-access.password.changed_concurrently": "The account password changed during this operation. Sign in again before continuing.",
|
||||
"i18n:govoplan-access.password.session_expired": "Your session is no longer valid. Sign in again to continue.",
|
||||
"i18n:govoplan-access.password.not_allowed": "This password operation is not permitted for the current account or session.",
|
||||
"i18n:govoplan-access.password.invalid_fields": "Check the required fields and password length, then enter your credentials again.",
|
||||
"i18n:govoplan-access.password.request_failed": "The password service could not complete the request. Check your connection and try again.",
|
||||
"i18n:govoplan-access.password.forgot": "Forgot your password?",
|
||||
"i18n:govoplan-access.password.recover_title": "Recover local password",
|
||||
"i18n:govoplan-access.password.recovery_instructions": "Contact a System owner to verify your identity independently and receive a recovery code. Enter your account email and that code below. GovOPlaN does not send a recovery email.",
|
||||
"i18n:govoplan-access.password.recovery_consequences": "Successful recovery ends all browser sessions and revokes all account API keys. Unused recovery codes for this account and codes it issued for other people also become invalid. You must then sign in with the new password.",
|
||||
"i18n:govoplan-access.password.recovered": "Your password was replaced and existing sessions and API keys were revoked. Sign in with your new password.",
|
||||
"i18n:govoplan-access.password.recovery_code": "Recovery code",
|
||||
"i18n:govoplan-access.password.return_sign_in": "Return to sign in",
|
||||
"i18n:govoplan-access.password.issue_title": "Issue recovery code",
|
||||
"i18n:govoplan-access.password.issue_for": "Recover access for {value0}.",
|
||||
"i18n:govoplan-access.password.issue_requirements": "Enter your current password and confirm that you independently verified this person's identity.",
|
||||
"i18n:govoplan-access.password.identity_verified": "I independently verified this person's identity outside GovOPlaN.",
|
||||
"i18n:govoplan-access.password.issue_consequences": "Issuing a code replaces earlier unused recovery codes. When used, it replaces the account password, ends every browser session, and revokes all account API keys. Codes the target account issued for other people also become invalid. Verify the account holder before proceeding.",
|
||||
"i18n:govoplan-access.password.code_once": "This code is shown once. It can be used once before its expiry.",
|
||||
"i18n:govoplan-access.password.code_expires": "Expires: {value0}",
|
||||
"i18n:govoplan-access.password.code_delivery": "Give the code only to the verified account holder through your agreed confidential channel. Direct them to Password recovery from the sign-in screen. Closing this dialog clears the code from this screen."
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-access.password.change_title": "Passwort ändern",
|
||||
"i18n:govoplan-access.password.required_title": "Initiales Passwort ändern",
|
||||
"i18n:govoplan-access.password.required": "Legen Sie ein neues Passwort fest, bevor Sie den Arbeitsbereich öffnen. Geben Sie das aktuelle oder initiale Passwort ein, mit dem Sie sich angemeldet haben.",
|
||||
"i18n:govoplan-access.password.local_only": "Passwortänderungen benötigen eine interaktive Sitzung für ein lokales Konto. Externe Konten verwenden ihren Identitätsanbieter.",
|
||||
"i18n:govoplan-access.password.change_consequences": "Die Passwortänderung beendet alle anderen Browsersitzungen und widerruft sämtliche API-Schlüssel des Kontos. Dieser Browser erhält eine neue Sitzung. Aktualisieren Sie anschließend Integrationen mit neu ausgestellten API-Schlüsseln. Ungenutzte Wiederherstellungscodes für dieses Konto sowie von ihm für andere Personen ausgestellte Codes werden ebenfalls ungültig.",
|
||||
"i18n:govoplan-access.password.changed": "Ihr Passwort wurde geändert. Andere Sitzungen wurden beendet und die API-Schlüssel des Kontos widerrufen.",
|
||||
"i18n:govoplan-access.password.new": "Neues Passwort",
|
||||
"i18n:govoplan-access.password.confirm": "Neues Passwort bestätigen",
|
||||
"i18n:govoplan-access.password.length": "Verwenden Sie zwischen {value0} und {value1} Zeichen.",
|
||||
"i18n:govoplan-access.password.mismatch": "Die neuen Passwörter stimmen nicht überein.",
|
||||
"i18n:govoplan-access.password.complete_fields": "Füllen Sie die Pflichtfelder aus und geben Sie übereinstimmende neue Passwörter der erforderlichen Länge ein.",
|
||||
"i18n:govoplan-access.password.saving": "Passwort wird aktualisiert…",
|
||||
"i18n:govoplan-access.password.current_invalid": "Ihr aktuelles Passwort wurde nicht akzeptiert. Geben Sie es erneut ein, um diese Aktion zu autorisieren.",
|
||||
"i18n:govoplan-access.password.invalid_new": "Verwenden Sie ein neues Passwort mit 10 bis 1024 Zeichen.",
|
||||
"i18n:govoplan-access.password.unchanged": "Wählen Sie ein anderes Passwort als Ihr aktuelles Passwort.",
|
||||
"i18n:govoplan-access.password.recovery_disabled": "Die administrativ unterstützte Passwortwiederherstellung ist nicht aktiviert. Wenden Sie sich an Ihre Administration.",
|
||||
"i18n:govoplan-access.password.rate_limited": "Zu viele Versuche. Warten Sie, bevor Sie es erneut versuchen.",
|
||||
"i18n:govoplan-access.password.recovery_invalid": "Dieser Wiederherstellungscode ist ungültig, abgelaufen, bereits verwendet oder nicht mehr autorisiert. Bitten Sie den Systemverantwortlichen um einen neuen Code.",
|
||||
"i18n:govoplan-access.password.issuer_required": "Nur ein aktueller Systemverantwortlicher darf einen Wiederherstellungscode ausstellen.",
|
||||
"i18n:govoplan-access.password.membership_required": "Das Konto benötigt vor einer Passwortwiederherstellung eine aktive Mandantenmitgliedschaft.",
|
||||
"i18n:govoplan-access.password.changed_concurrently": "Das Kontopasswort wurde während dieses Vorgangs geändert. Melden Sie sich erneut an, bevor Sie fortfahren.",
|
||||
"i18n:govoplan-access.password.session_expired": "Ihre Sitzung ist nicht mehr gültig. Melden Sie sich erneut an, um fortzufahren.",
|
||||
"i18n:govoplan-access.password.not_allowed": "Dieser Passwortvorgang ist für das aktuelle Konto oder die aktuelle Sitzung nicht erlaubt.",
|
||||
"i18n:govoplan-access.password.invalid_fields": "Prüfen Sie Pflichtfelder und Passwortlänge und geben Sie Ihre Zugangsdaten erneut ein.",
|
||||
"i18n:govoplan-access.password.request_failed": "Der Passwortdienst konnte die Anfrage nicht abschließen. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
|
||||
"i18n:govoplan-access.password.forgot": "Passwort vergessen?",
|
||||
"i18n:govoplan-access.password.recover_title": "Lokales Passwort wiederherstellen",
|
||||
"i18n:govoplan-access.password.recovery_instructions": "Wenden Sie sich an einen Systemverantwortlichen, um Ihre Identität unabhängig prüfen zu lassen und einen Wiederherstellungscode zu erhalten. Geben Sie unten Ihre Konto-E-Mail-Adresse und diesen Code ein. GovOPlaN versendet keine Wiederherstellungs-E-Mail.",
|
||||
"i18n:govoplan-access.password.recovery_consequences": "Eine erfolgreiche Wiederherstellung beendet alle Browsersitzungen und widerruft sämtliche API-Schlüssel des Kontos. Ungenutzte Wiederherstellungscodes für dieses Konto sowie von ihm für andere Personen ausgestellte Codes werden ebenfalls ungültig. Melden Sie sich anschließend mit dem neuen Passwort an.",
|
||||
"i18n:govoplan-access.password.recovered": "Ihr Passwort wurde ersetzt. Bestehende Sitzungen und API-Schlüssel wurden widerrufen. Melden Sie sich mit Ihrem neuen Passwort an.",
|
||||
"i18n:govoplan-access.password.recovery_code": "Wiederherstellungscode",
|
||||
"i18n:govoplan-access.password.return_sign_in": "Zurück zur Anmeldung",
|
||||
"i18n:govoplan-access.password.issue_title": "Wiederherstellungscode ausstellen",
|
||||
"i18n:govoplan-access.password.issue_for": "Zugriff für {value0} wiederherstellen.",
|
||||
"i18n:govoplan-access.password.issue_requirements": "Geben Sie Ihr aktuelles Passwort ein und bestätigen Sie die unabhängige Prüfung der Identität dieser Person.",
|
||||
"i18n:govoplan-access.password.identity_verified": "Ich habe die Identität dieser Person unabhängig außerhalb von GovOPlaN geprüft.",
|
||||
"i18n:govoplan-access.password.issue_consequences": "Ein neuer Code ersetzt frühere unbenutzte Wiederherstellungscodes. Seine Verwendung ersetzt das Kontopasswort, beendet jede Browsersitzung und widerruft sämtliche API-Schlüssel des Kontos. Vom Zielkonto für andere Personen ausgestellte Codes werden ebenfalls ungültig. Prüfen Sie vorab die Identität des Kontoinhabers.",
|
||||
"i18n:govoplan-access.password.code_once": "Dieser Code wird einmal angezeigt. Er kann vor seinem Ablauf einmal verwendet werden.",
|
||||
"i18n:govoplan-access.password.code_expires": "Gültig bis: {value0}",
|
||||
"i18n:govoplan-access.password.code_delivery": "Übermitteln Sie den Code ausschließlich dem verifizierten Kontoinhaber über den vereinbarten vertraulichen Kanal. Verweisen Sie auf die Passwortwiederherstellung im Anmeldebildschirm. Beim Schließen dieses Dialogs wird der Code aus dieser Ansicht entfernt."
|
||||
}
|
||||
};
|
||||
+22
-4
@@ -1,15 +1,19 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { ActingContextRuntimeUiCapability, PlatformRouteContext, PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui";
|
||||
import type { ActingContextRuntimeUiCapability, AuthActionUiCapability, PlatformRouteContext, PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui";
|
||||
import { adminReadScopes } from "@govoplan/core-webui";
|
||||
import ActingContextSelector from "./features/acting-context/ActingContextSelector";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import { passwordTranslations } from "./i18n/passwordTranslations";
|
||||
|
||||
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
||||
const SessionSettingsPanel = lazy(() => import("./features/sessions/SessionSettingsPanel"));
|
||||
const PasswordChangePanel = lazy(() => import("./features/passwords/PasswordChangePanel"));
|
||||
const PasswordRecoveryPage = lazy(() => import("./features/passwords/PasswordRecoveryPage"));
|
||||
const PasswordLoginHelp = lazy(() => import("./features/passwords/PasswordLoginHelp"));
|
||||
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
en: { ...generatedTranslations.en, ...passwordTranslations.en },
|
||||
de: { ...generatedTranslations.de, ...passwordTranslations.de }
|
||||
};
|
||||
|
||||
const accessAdminSurfaces = [
|
||||
@@ -26,11 +30,21 @@ const accessAdminSurfaces = [
|
||||
{ id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.group_credentials.4af2c025", order: 30 },
|
||||
{ id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.user_credentials.4af2c026", order: 30 },
|
||||
{ id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.reusable_credentials.4af2c022", order: 30 },
|
||||
{ id: "access.settings.sessions", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.sessions_and_devices.5e551001", order: 20 }
|
||||
{ id: "access.settings.sessions", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.sessions_and_devices.5e551001", order: 20 },
|
||||
{ id: "access.settings.password", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.password.change_title", order: 21 }
|
||||
];
|
||||
|
||||
const accessSettingsSections: SettingsSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "password",
|
||||
surfaceId: "access.settings.password",
|
||||
label: "i18n:govoplan-access.password.change_title",
|
||||
group: "account",
|
||||
order: 21,
|
||||
render: ({ settings, auth, onAuthChange }) => onAuthChange
|
||||
? createElement(PasswordChangePanel, { settings, auth, onAuthChange }) : null
|
||||
},
|
||||
{
|
||||
id: "sessions",
|
||||
surfaceId: "access.settings.sessions",
|
||||
@@ -61,7 +75,11 @@ export const accessModule: PlatformWebModule = {
|
||||
|
||||
routes: [
|
||||
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }],
|
||||
publicRoutes: [
|
||||
{ path: "/password-recovery", render: ({ settings }) => createElement(PasswordRecoveryPage, { settings }) }
|
||||
],
|
||||
uiCapabilities: {
|
||||
"auth.actions": { actions: ["change_password"], RequiredAction: PasswordChangePanel, LoginHelp: PasswordLoginHelp } satisfies AuthActionUiCapability,
|
||||
"access.actingContext": { Selector: ActingContextSelector } satisfies ActingContextRuntimeUiCapability,
|
||||
"settings.sections": accessSettingsSections
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user