Add password reset and self-service account settings

An administrator could not reset a password for bdmurph73@gmail.com, and
there was no way for a signed-in person to change their own. Both are now
in place.

Migration 0002 gives single-use tokens a purpose. 0001 reused the
control plane's unused bootstrap_tokens for email verification, and the
table has no notion of what a token is for, which was invisible while
there was only one kind. With a second kind it stops being invisible: a
reset link clicked by anyone holding it would confirm an address, and a
verification link would set a password. The column defaults to the
existing purpose, so no row is rewritten and verification is unchanged.
Every token lookup now filters on purpose, and the adapter reads the
purpose from the row rather than hardcoding it.

An administrator may start a reset but never finish one. The link goes to
the address on the account, only its holder can complete it, and the
endpoint returns a bool rather than a password. An administrator who can
set someone's password can also sign in as them indefinitely. A reset
revokes every session, because a reset is what someone reaches for when
they think their password is compromised, and revoking only the session
that completed it would leave a stolen cookie working.

Reset links live one hour against the two days a verification link gets.
A verification link confirms an address the person already controls; a
reset link is a live credential for as long as it is valid.

Three bugs found while building it, all mine:

- request_password_reset did not call _authorize_target, so an organization
  administrator could start a reset on an account in another tenant. Caught
  by a test written for the scoping rule that revoke_sessions already
  followed.

- The in-memory repository never checked token expiry, only PostgreSQL did.
  So an expired link was honoured in every test and refused in production -
  the wrong direction for a divergence between two stores of the same rule.

- The PostgreSQL adapter's get_live_verification_token took a purpose but
  not the now its in-memory counterpart needed, so the first call against a
  real database raised TypeError and returned a 500. The conformance test
  only checked that a method existed. It now compares signatures, which
  immediately caught that the protocol itself was missing the parameter.

The reported failure was a deadlock rather than a missing feature. The
account was invited: it could not sign in because the address was
unverified, and I refused to reset anything not yet active. It is now
allowed, and completing the reset also confirms the address, since holding
the link proves control of it. Without that the flow would not converge -
reset the password, sign in, refused again, using a verification link
that was never sent. Suspended and deactivated accounts are still refused,
because a reset restores sign-in and would otherwise undo a suspension
without going through the status controls.

Verified live: the account that could not be reset is now active, verified
and able to sign in, with password.reset_requested, password.reset_completed
and email.verified all in the audit trail. A reset token is refused by the
verification endpoint, an invented token gives the same answer as a used
one, and a token in a reset URL is redacted from the access log.

327 tests pass, 337 with the integration suite.
This commit is contained in:
umbra audio
2026-09-27 23:46:59 -07:00
parent 7696c9d2e0
commit e034061ec1
23 changed files with 1883 additions and 23 deletions

View File

@@ -43,7 +43,30 @@ this precisely because it is a role address rather than a person's account, and
because it can be given its own app password. It is still a credential in a file
on this host.
### A note on row-level security
### Account recovery and self-service
An administrator can start a password reset for any account in their scope, from
the row actions on the accounts page. It does not set a password: the link goes to
the address on the account and only its holder can complete it, because an
administrator who can choose someone's password can also sign in as them
indefinitely. Completing a reset revokes every session on the account, so a
cookie captured beforehand stops working.
Anyone signed in can change their own password at `/settings.html`, which needs
the current password. Changing it also revokes every other session.
Reset links live one hour, against the two days a verification link gets, and the
two are not interchangeable: `bootstrap_tokens` carries a purpose and every
lookup filters on it, so a reset link cannot confirm an address and a verification
link cannot set a password. Migration `0002` added that column.
An account that has never confirmed its address can be reset, and doing so also
confirms it. Holding the link proves control of the address, which is what
verification asserts, and without it the flow does not converge: the person
resets the password, signs in, and is refused for the same reason, using a
verification link they never received.
## A note on row-level security
The application owns the tables in this database, and RLS is enabled but not
forced, so **the policies are not currently enforced and tenant isolation rests

View File

@@ -0,0 +1,94 @@
-- 0002: give single-use tokens a purpose.
--
-- 0001 reused the control plane's existing, unused `bootstrap_tokens` table for
-- email verification. That table has no notion of what a token is for, so with
-- only one kind of token in play the omission was invisible.
--
-- It stops being invisible the moment a second kind exists. A password-reset
-- token and an email-verification token would be interchangeable: a reset link
-- clicked by anyone holding it would confirm an address, and a verification link
-- would set a password. Neither is acceptable, and the table as it stands cannot
-- tell them apart.
--
-- The column defaults to the existing purpose, so every row already written stays
-- valid and the verification flow is unchanged. No existing row is rewritten, and
-- the verification code continues to work without modification.
--
-- `0002_password_reset_purpose.sql`, additive, and safe to apply more than once.
BEGIN;
-- ---------------------------------------------------------------------
-- 1. Purpose
-- ---------------------------------------------------------------------
-- A default, not a null, so the column can be NOT NULL and an insert that
-- forgets to name a purpose is still valid and still safe: it lands as
-- email_verification, which is the only purpose that existed before this
-- migration and therefore the conservative choice.
ALTER TABLE public.bootstrap_tokens
ADD COLUMN IF NOT EXISTS purpose text NOT NULL DEFAULT 'email_verification';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'bootstrap_tokens'
AND c.conname = 'bootstrap_tokens_purpose_check'
) THEN
ALTER TABLE public.bootstrap_tokens
ADD CONSTRAINT bootstrap_tokens_purpose_check
CHECK (purpose IN ('email_verification', 'password_reset'));
RAISE NOTICE 'attached bootstrap_tokens_purpose_check';
ELSE
RAISE NOTICE 'bootstrap_tokens_purpose_check already present; leaving it alone';
END IF;
END
$$;
COMMENT ON COLUMN public.bootstrap_tokens.purpose IS
'What this token authorises: email_verification confirms an address, password_reset sets a new password. They are deliberately not interchangeable, because each would otherwise grant the other''s capability.';
-- ---------------------------------------------------------------------
-- 2. Lookup
-- ---------------------------------------------------------------------
-- Every token read filters on user and purpose together, and an unconsumed token
-- for one purpose is looked up on every reset and every verification. The index
-- covers that; without it each of those is a sequential scan of the table, which
-- on a busy deployment is also a cheap way for an unauthenticated endpoint to
-- generate load.
CREATE INDEX IF NOT EXISTS bootstrap_tokens_user_purpose_idx
ON public.bootstrap_tokens (user_id, purpose, expires_at)
WHERE consumed_at IS NULL;
-- ---------------------------------------------------------------------
-- 3. Rollback
-- ---------------------------------------------------------------------
-- Additive, so a rollback is dropping what was added. The column is dropped
-- before the index, since the index depends on it. Any token of a purpose other
-- than email_verification must be deleted first, or the drop will leave rows the
-- column no longer describes; the guarded delete below handles that, and the
-- NOTICE says how many rows it removed rather than passing over them.
--
-- DROP INDEX IF EXISTS public.bootstrap_tokens_user_purpose_idx;
--
-- DO $$
-- BEGIN
-- IF EXISTS (
-- SELECT 1 FROM information_schema.columns
-- WHERE table_schema = 'public'
-- AND table_name = 'bootstrap_tokens'
-- AND column_name = 'purpose'
-- ) THEN
-- DELETE FROM public.bootstrap_tokens WHERE purpose <> 'email_verification';
-- ALTER TABLE public.bootstrap_tokens DROP COLUMN purpose;
-- END IF;
-- END
-- $$;
COMMIT;

View File

@@ -38,10 +38,26 @@ MAX_ORGANIZATION_NAME_LENGTH = 120
#: long enough that a link found in a mailbox months later still works.
VERIFICATION_TOKEN_TTL_SECONDS = 48 * 60 * 60
#: The only purpose a verification token is minted for. Named so the
#: ``bootstrap_tokens`` row cannot later be reused for something else without
#: the audit trail showing a different purpose.
#: What a single-use token authorises. These are deliberately distinct values and
#: the store filters on them: a password-reset token and an email-verification
#: token are not interchangeable, because each would otherwise grant the other's
#: capability. ``bootstrap_tokens`` carries the purpose for exactly this reason.
VERIFICATION_PURPOSE_EMAIL = "email_verification"
PASSWORD_RESET_PURPOSE = "password_reset"
#: Every purpose a token may be minted for, in the order the migration declares
#: them. A purpose outside this set is refused rather than stored.
TOKEN_PURPOSES = (VERIFICATION_PURPOSE_EMAIL, PASSWORD_RESET_PURPOSE)
#: Lifetime of a password-reset link.
#:
#: Much shorter than a verification link, and deliberately so. A verification link
#: only confirms an address the person already controls. A reset link grants a new
#: password, so one left in a mailbox, in a browser's history, or in a forwarded
#: thread is a live credential for as long as it is valid. One hour is long enough
#: to read a message and click a link, and short enough that a link found later is
#: almost certainly stale.
PASSWORD_RESET_TOKEN_TTL_SECONDS = 60 * 60
MIN_ORGANIZATION_NAME_LENGTH = 2
MAX_ORGANIZATION_SLUG_LENGTH = 63
@@ -617,6 +633,9 @@ __all__ = [
"MAX_USERNAME_LENGTH",
"MIN_ORGANIZATION_NAME_LENGTH",
"MIN_USERNAME_LENGTH",
"PASSWORD_RESET_PURPOSE",
"PASSWORD_RESET_TOKEN_TTL_SECONDS",
"TOKEN_PURPOSES",
"VERIFICATION_PURPOSE_EMAIL",
"VERIFICATION_TOKEN_TTL_SECONDS",
"AccountStatus",

View File

@@ -837,8 +837,10 @@ class PostgresAccountRepository(AccountRepository):
with self._connection() as connection:
connection.execute(
"""
INSERT INTO public.bootstrap_tokens (id, user_id, token_hash, expires_at, consumed_at, created_at)
VALUES (%s, %s, %s, %s, %s, %s)
INSERT INTO public.bootstrap_tokens (
id, user_id, token_hash, expires_at, consumed_at, created_at, purpose
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (id) DO NOTHING
""",
(
@@ -848,28 +850,51 @@ class PostgresAccountRepository(AccountRepository):
token.expires_at,
token.consumed_at,
token.created_at,
token.purpose,
),
)
return token
def get_live_verification_token(self, token_hash: str) -> VerificationToken | None:
"""Return an unconsumed, unexpired token matching this hash.
def get_live_verification_token(
self,
token_hash: str,
*,
purpose: str = VERIFICATION_PURPOSE_EMAIL,
now: datetime | None = None,
) -> VerificationToken | None:
"""Return an unconsumed, unexpired token for this hash *and purpose*.
Expiry is filtered in SQL rather than in Python so that a token that
expired while the process was down is still refused, and so the decision
is made by the same clock the row was written with.
Both are matched in the query rather than checked afterwards. A
password-reset token and an email-verification token are both a hash
against a user, so a lookup that ignored purpose would accept one where
the other is required: a verification link would set a password, and a
reset link would confirm an address. The ``purpose`` column added by
migration 0002 exists for this.
Expiry is filtered in SQL rather than in Python so a token that expired
while the process was down is still refused, and so the decision is made
against the same clock the row was written with.
``now`` is accepted and deliberately unused. The in-memory repository has
to be told what time it is, and the two have to agree about the contract,
but here the comparison is ``expires_at > now()`` inside the query: the
database's clock, against the database's own timestamp. Substituting the
application's clock would make a token appear live or dead depending on
which repository answered, which is the divergence this whole argument
exists to prevent.
"""
with self._connection() as connection:
row = connection.execute(
"""
SELECT id, user_id, token_hash, expires_at, consumed_at, created_at
SELECT id, user_id, token_hash, expires_at, consumed_at, created_at, purpose
FROM public.bootstrap_tokens
WHERE token_hash = %s
AND purpose = %s
AND consumed_at IS NULL
AND expires_at > now()
""",
(token_hash,),
(token_hash, purpose),
).fetchone()
return _verification_from_row(row) if row else None
@@ -1184,11 +1209,11 @@ def _audit_from_row(row: Mapping[str, Any], original: AuditEvent | None) -> Audi
def _verification_from_row(row: Mapping[str, Any]) -> VerificationToken:
"""Map a ``bootstrap_tokens`` row.
"""Map a ``bootstrap_tokens`` row, reading the purpose from the row.
``purpose`` is fixed rather than read: the table has no such column, and
phase 1 issues only email-verification tokens. See
:meth:`PostgresAccountRepository.create_verification_token`.
The purpose used to be hardcoded here because the table had no such column.
Migration 0002 added it, and it is read now, because a token that reports the
wrong purpose is exactly the substitution the column exists to prevent.
"""
return VerificationToken(
@@ -1198,7 +1223,7 @@ def _verification_from_row(row: Mapping[str, Any]) -> VerificationToken:
expires_at=row["expires_at"],
created_at=row["created_at"],
consumed_at=row["consumed_at"],
purpose=VERIFICATION_PURPOSE_EMAIL,
purpose=row["purpose"],
)

View File

@@ -19,6 +19,7 @@ from uuid import UUID
from .errors import ConflictError, NotFoundError, RepositoryError
from .models import (
VERIFICATION_PURPOSE_EMAIL,
AccountStatus,
AuditEvent,
CredentialRecord,
@@ -31,6 +32,7 @@ from .models import (
email_key,
username_key,
)
from .security import utc_now
@runtime_checkable
@@ -163,11 +165,25 @@ class AccountRepository(Protocol):
raise NotImplementedError(f"{type(self).__name__} does not implement create_verification_token().")
def get_live_verification_token(self, token_hash: str) -> VerificationToken | None:
def get_live_verification_token(
self,
token_hash: str,
*,
purpose: str = VERIFICATION_PURPOSE_EMAIL,
now: datetime | None = None,
) -> VerificationToken | None:
"""Return an unconsumed, unexpired token for this purpose, if any.
Used to reject a second use of the same link without revealing whether
the first use succeeded.
``purpose`` and ``now`` are part of the lookup rather than checks applied
to its result. A password-reset token and an email-verification token are
both a bare hash against a user, so a lookup that ignored the purpose
would let one stand in for the other. And the in-memory implementation has
no database clock to consult, so it is told what time it is; the SQL
adapter takes the same argument and ignores it, because there the
comparison happens inside the query.
"""
raise NotImplementedError(f"{type(self).__name__} does not implement get_live_verification_token().")
@@ -423,10 +439,40 @@ class InMemoryAccountRepository:
self._verification_tokens[token.id] = token
return token
def get_live_verification_token(self, token_hash: str) -> VerificationToken | None:
def get_live_verification_token(
self,
token_hash: str,
*,
purpose: str = VERIFICATION_PURPOSE_EMAIL,
now: datetime | None = None,
) -> VerificationToken | None:
"""A live, unexpired, unconsumed token for this hash *and purpose*.
The purpose is part of the lookup, not a filter applied afterwards. A
reset token and a verification token are both a bare hash against the
same user, so a lookup that ignored purpose would let one stand in for the
other: clicking a verification link would set a password, and clicking a
reset link would confirm an address.
Expiry is part of the lookup too, and takes a clock so the caller can
supply the one it is already using. This was missing, and the two
repositories disagreed about it: the SQL adapter filters
``expires_at > now()`` in the query, so PostgreSQL refused an expired
link while this one honoured it. Every test that runs against the
in-memory store was therefore asserting a rule the authoritative store
does not follow, which is the worst direction for that kind of
divergence to go in.
"""
moment = now or utc_now()
with self._lock:
for token in self._verification_tokens.values():
if token.token_hash == token_hash and not token.is_consumed():
if (
token.token_hash == token_hash
and token.purpose == purpose
and not token.is_consumed()
and token.expires_at > moment
):
return token
return None

View File

@@ -24,11 +24,14 @@ from .app import (
from .models import Principal
from .schemas import (
ChangeFirstPasswordRequest,
ChangeOwnPasswordRequest,
LoginRequest,
LoginResponse,
LogoutResponse,
PasswordResetAvailableResponse,
ResendVerificationRequest,
ResendVerificationResponse,
ResetPasswordRequest,
SessionUserResponse,
UserResponse,
VerifyEmailRequest,
@@ -170,6 +173,78 @@ def verify_email_from_link(
)
@router.get("/reset-password", response_model=PasswordResetAvailableResponse)
def reset_password_available(token: str, request: Request) -> PasswordResetAvailableResponse:
"""Report whether a reset link is usable, without consuming it.
The page needs this to avoid showing a password form for a link that will be
refused on submit. It is deliberately one answer for every reason - unknown,
already used, expired - so it cannot be used to test whether a link exists.
"""
service: AccountService = get_service(request)
available = service.password_reset_is_usable(token)
return PasswordResetAvailableResponse(
available=available,
message=(
"This link works once and expires in one hour. Choose a new password below."
if available
else "This link is no longer valid. It may already have been used, or it may have expired."
),
)
@router.post("/reset-password", response_model=ResendVerificationResponse)
def reset_password(payload: ResetPasswordRequest, request: Request) -> ResendVerificationResponse:
"""Consume a reset link and set a new password.
Unauthenticated by design: the token is the credential. Every session on the
account is revoked when it succeeds, so anyone holding a cookie captured
before the reset loses access.
"""
service: AccountService = get_service(request)
service.reset_password_with_token(
payload.token,
payload.new_password,
request_id=request_id(request),
)
return ResendVerificationResponse(
message="Your password has been changed. You can sign in with it now."
)
@router.post("/change-password", response_model=LoginResponse)
def change_own_password(
payload: ChangeOwnPasswordRequest,
request: Request,
principal: Principal = Depends(get_principal_allowing_password_change),
) -> LoginResponse:
"""Change the signed-in person's own password.
Requires the current password and returns a fresh session, because every
other session on the account is revoked. The response shape matches sign-in
so the caller can continue without a second round trip.
"""
service: AccountService = get_service(request)
result = service.change_own_password(
principal,
current_password=payload.current_password,
new_password=payload.new_password,
request_id=request_id(request),
)
response = Response()
set_session_cookie(response, token=result.session.token, settings=request.app.state.settings)
return LoginResponse(
user=UserResponse.from_view(result.view),
must_change_password=False,
csrf_token=result.session.csrf_token,
idle_expires_at=result.session.idle_expires_at,
absolute_expires_at=result.session.absolute_expires_at,
)
@router.post("/resend-verification", response_model=ResendVerificationResponse)
def resend_verification(
payload: ResendVerificationRequest,

View File

@@ -34,6 +34,7 @@ from .schemas import (
OrganizationResponse,
OrganizationStatusChangeResponse,
OrganizationSummaryResponse,
PasswordResetRequestedResponse,
RevokeSessionsResponse,
SetAccountStatusRequest,
SetOrganizationStatusRequest,
@@ -164,6 +165,31 @@ def revoke_sessions(
)
@router.post("/accounts/{user_id}/password-reset", response_model=PasswordResetRequestedResponse)
def request_password_reset(
user_id: UUID,
request: Request,
principal: Principal = Depends(require_admin),
) -> PasswordResetRequestedResponse:
"""Mail a password-reset link to an account.
The administrator does not receive the new password and never sets one; the
link goes to the address on the account and only its holder can complete it.
An administrator who could set someone's password could also sign in as them
indefinitely, and a password is not something to hand to a colleague.
Scoped like every other administrative action, so an organization
administrator can reset only within their own tenant.
"""
service: AccountService = get_service(request)
service.request_password_reset(principal, user_id, request_id=request_id(request))
return PasswordResetRequestedResponse(
message="A password reset link has been sent to the address on that account. "
"It works once and expires in one hour."
)
@router.get("/summary", response_model=OrganizationSummaryResponse)
def summary(
request: Request,

View File

@@ -364,6 +364,48 @@ class UpdateOrganizationRequest(RequestModel):
slug: str | None = Field(default=None, max_length=63)
class PasswordResetAvailableResponse(BaseModel):
"""Whether a reset link can still be used.
One answer for every failure reason, so it cannot be used to learn whether a
given link ever existed.
"""
available: bool
message: str
class ResetPasswordRequest(RequestModel):
"""A token from a reset link, and the password to set with it."""
token: str = Field(min_length=16, max_length=256)
new_password: str = Field(min_length=1, max_length=1024)
class ChangeOwnPasswordRequest(RequestModel):
"""A signed-in person changing their own password.
The current password is required, so a stolen session cookie alone cannot
take the account over. It is not required by the first-login flow, which
exists precisely because the holder has only the one-time password.
"""
current_password: str = Field(min_length=1, max_length=1024)
new_password: str = Field(min_length=1, max_length=1024)
class PasswordResetRequestedResponse(BaseModel):
"""The outcome of an administrator asking for a reset.
Says a link was sent and nothing more. The address is not echoed and no
detail about the account is returned, so the endpoint is safe to leave open
to an administrator who is entitled to see the account list anyway but not to
probe addresses outside their scope.
"""
message: str
class SetOrganizationStatusRequest(RequestModel):
status: OrganizationStatus
reason: str | None = Field(default=None, max_length=200)

View File

@@ -59,6 +59,8 @@ from .mailer import Mailer, MailerError, build_mailer
from .models import (
ALLOWED_ORGANIZATION_TRANSITIONS,
ALLOWED_STATUS_TRANSITIONS,
PASSWORD_RESET_PURPOSE,
PASSWORD_RESET_TOKEN_TTL_SECONDS,
VERIFICATION_PURPOSE_EMAIL,
VERIFICATION_TOKEN_TTL_SECONDS,
AccountStatus,
@@ -129,6 +131,8 @@ ACTION_AUTH_LOGOUT = "auth.logout"
ACTION_AUTH_LOGOUT_EVERYWHERE = "auth.logout_everywhere"
ACTION_EMAIL_VERIFICATION_ISSUED = "email.verification_issued"
ACTION_EMAIL_VERIFIED = "email.verified"
ACTION_PASSWORD_RESET_REQUESTED = "password.reset_requested"
ACTION_PASSWORD_RESET_COMPLETED = "password.reset_completed"
ACTION_SESSION_REVOKED = "session.revoked"
ACTION_SESSION_EXPIRED = "session.expired"
ACTION_SESSION_INVALIDATED = "session.invalidated"
@@ -803,7 +807,13 @@ class AccountService:
now = self._now()
if not isinstance(token, str) or not token:
raise VerificationFailedError()
record = self._repository.get_live_verification_token(hash_session_token(token))
# Purpose and clock go to the lookup rather than being checked after it.
# The post-check below is kept as a second opinion, because the two
# repositories compare expiry against different clocks - the database's
# and the caller's - and agreeing is worth the cost of one comparison.
record = self._repository.get_live_verification_token(
hash_session_token(token), purpose=VERIFICATION_PURPOSE_EMAIL, now=now
)
if record is None or not record.is_usable(now) or record.purpose != VERIFICATION_PURPOSE_EMAIL:
raise VerificationFailedError()
# Compare-and-set: two concurrent uses cannot both observe success.
@@ -855,6 +865,274 @@ class AccountService:
self._deliver(user.email, link)
return True
# -- password reset ------------------------------------------------------
def request_password_reset(
self,
actor: Principal,
user_id: UUID,
*,
request_id: str | None = None,
) -> bool:
"""Mail a password-reset link to an account. Returns whether one was sent.
The administrator does not learn the new password and never sets one.
A reset that an administrator chooses is a reset the account owner did
not ask for, and an administrator who can set someone's password can
impersonate them indefinitely. The link goes to the address on the
account, so only the owner can complete it.
The return value distinguishes "mailed" from "not applicable" for the
caller's audit, and deliberately does not distinguish why. See
:meth:`reset_password_with_token` for the enumeration rule that applies
here too.
"""
self._require_admin(actor)
user = self._require_user(user_id)
# Scoped like every other administrative action. Without this an
# organization administrator could start a password reset on an account in
# another tenant, which is a way to cause a targeted denial of service
# even though the link itself stays with the account owner.
self._authorize_target(actor, user)
credential = self._repository.get_credential(user.id)
if credential is None or credential.credential_type is not CredentialType.LOCAL_PASSWORD:
# OIDC accounts have no local password to reset. Refused plainly here
# because the caller is an authenticated administrator who can already
# see the credential type; there is nothing to protect from them.
raise StateTransitionError(
"not_a_local_account",
"That account signs in through an identity provider and has no password to reset.",
)
if user.status not in (AccountStatus.ACTIVE, AccountStatus.INVITED):
# Suspended and deactivated are excluded on purpose. A reset revokes
# sessions and restores sign-in, so allowing one would be a way to
# undo a suspension without going through the status controls, and an
# administrator who wants an account usable again should say so
# explicitly with those.
#
# ``invited`` is allowed, and this was a real deadlock before it was:
# an account that has never confirmed its address cannot sign in, and
# an earlier version of this method refused to reset it, so it could be
# reached by neither route. It is also safe, because the link goes to
# the very address the account has not proved it controls, so only the
# holder can complete it.
raise StateTransitionError(
"account_not_active",
"Only an active or newly-created account can have its password reset.",
)
now = self._now()
self._repository.delete_expired_verification_tokens(now=now)
token = generate_token(32)
self._repository.create_verification_token(
VerificationToken(
id=uuid4(),
user_id=user.id,
token_hash=hash_session_token(token),
expires_at=now + timedelta(seconds=PASSWORD_RESET_TOKEN_TTL_SECONDS),
created_at=now,
purpose=PASSWORD_RESET_PURPOSE,
)
)
# A previously issued reset link is now stale in the only sense that
# matters: consuming this one leaves the earlier one still valid, so the
# newest link is not the only live one. That is deliberate. Revoking the
# old one on issue would mean an administrator repeatedly asking for a
# reset could silently break a link the owner is in the middle of using.
# Both are short-lived, both are single-use, and only the newest is
# described as current in the message.
self._deliver_reset(user.email, self._password_reset_link(token))
self._audit(
action=ACTION_PASSWORD_RESET_REQUESTED,
outcome="success",
actor=actor,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"username": user.audit_handle},
)
LOGGER.info("password reset requested user_id=%s by=%s", user.id, actor.user_id)
return True
def password_reset_is_usable(self, token: str) -> bool:
"""Whether a reset link is still live, without consuming it.
Lets the page avoid presenting a password form for a link that will be
refused on submit. It reads the same record
:meth:`reset_password_with_token` requires, and does not consume it, so
asking twice does not use the link up.
One answer for every reason - unknown, used, expired, wrong purpose - so
it cannot be used to learn whether a link exists.
"""
record = self._repository.get_live_verification_token(
hash_session_token(token or ""), purpose=PASSWORD_RESET_PURPOSE, now=self._now()
)
return record is not None
def reset_password_with_token(
self,
token: str,
new_password: str,
*,
request_id: str | None = None,
) -> None:
"""Consume a reset link and set a new password.
Every failure - unknown, already used, expired, wrong purpose - raises the
same error, so this cannot be used to discover which links exist or what
they are for. In particular a verification link presented here is refused
exactly as an invented link is.
On success every existing session is revoked, not just the one that
completed the reset. Anyone holding a session cookie copied before the
reset keeps access otherwise, and a reset is what a person reaches for
precisely when they believe someone else has their password.
"""
now = self._now()
record = self._repository.get_live_verification_token(
hash_session_token(token), purpose=PASSWORD_RESET_PURPOSE, now=now
)
if record is None:
self._audit(
action=ACTION_PASSWORD_RESET_COMPLETED,
outcome="failure",
request_id=request_id,
detail={"reason": "invalid_or_expired_token"},
)
raise VerificationFailedError()
if not self._repository.mark_verification_token_consumed(record.id, now=now):
# Lost the race with a simultaneous request using the same link.
raise VerificationFailedError()
user = self._require_user(record.user_id)
credential = self._repository.get_credential(user.id)
if credential is None or credential.credential_type is not CredentialType.LOCAL_PASSWORD:
raise VerificationFailedError()
try:
self._passwords.policy.validate(new_password, username=user.username)
except PasswordPolicyError as error:
# The token is already spent, so a rejected password costs the user
# another link. That is the safe direction: leaving the token live
# would let a weak first attempt be followed by a stronger one by
# whoever found it.
self._audit(
action=ACTION_PASSWORD_RESET_COMPLETED,
outcome="failure",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "password_policy"},
)
raise error
if self._passwords.verify_password(credential.password_hash, new_password):
raise PasswordPolicyError(("must_differ_from_current",))
self._repository.save_credential(
replace(
credential,
password_hash=self._passwords.hash_password(new_password),
must_change_password=False,
failed_login_count=0,
locked_until=None,
password_changed_at=now,
updated_at=now,
)
)
# Holding the link proves control of the address, which is exactly what
# email verification asserts. For an account that has never been verified
# the two are the same fact, so a completed reset confirms it as well.
#
# Without this the flow does not converge: the person resets the password,
# signs in, and is refused again for an unconfirmed address, using a
# verification link they never received in the first place.
# ``is_email_verified`` is a property derived from status, so confirming
# the address is setting the status. There is no second field to keep in
# step, which is the right way round.
was_invited = user.status is AccountStatus.INVITED
user = replace(
user,
status=AccountStatus.ACTIVE if was_invited else user.status,
updated_at=now,
version=user.version + 1,
)
self._repository.save_user(user)
revoked = self._repository.revoke_sessions(user.id, reason="password_reset", now=now)
self._audit(
action=ACTION_PASSWORD_RESET_COMPLETED,
outcome="success",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"username": user.audit_handle, "revoked_sessions": revoked},
)
if was_invited:
# A separate event, because it is a separate fact. An administrator
# reading the trail should be able to see that a reset also confirmed
# an address, not infer it from a password change.
self._audit(
action=ACTION_EMAIL_VERIFIED,
outcome="success",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"username": user.audit_handle, "source": "password_reset"},
)
LOGGER.info("password reset completed user_id=%s sessions_revoked=%d confirmed=%s", user.id, revoked, was_invited)
def _password_reset_link(self, token: str) -> str:
base = self._settings.verification_base_url
return f"{base}/reset-password.html?token={token}" if base else f"/reset-password.html?token={token}"
def _deliver_reset(self, recipient: str, link: str) -> None:
"""Send the reset message, failing loudly without exposing the link."""
subject = "Reset your UmbraTrans password"
body = (
"A password reset was requested for your UmbraTrans account.\n\n"
"If it was you, choose a new password here:\n\n"
f" {link}\n\n"
"The link works once and expires in one hour. If you did not ask for a reset, "
"ignore this message: nothing has changed and no one can use this link without it.\n"
)
try:
self._mailer.send(to=recipient, subject=subject, body=body)
except MailerError:
LOGGER.error(
"password reset mail could not be delivered transport=%s", self._mailer.transport_name
)
raise VerificationDeliveryError() from None
def change_own_password(
self,
principal: Principal,
*,
current_password: str,
new_password: str,
request_id: str | None = None,
) -> AuthenticationResult:
"""Change the signed-in person's own password.
The same rules as a reset, reached with the current password instead of a
mailed link: the password is re-verified, it must satisfy the policy and
differ from the current one, and every session is revoked with a new one
issued. That is why this is not simply the first-login change renamed:
the first-login flow exists for a session that cannot do anything else,
and it does not require the current password, because the holder only has
the one-time one.
"""
return self.change_first_password(
principal,
current_password=current_password,
new_password=new_password,
request_id=request_id,
)
def _verification_link(self, token: str) -> str:
"""Build the link the confirmation message carries.

View File

@@ -113,6 +113,8 @@ def test_every_page_that_posts_has_some_way_to_obtain_a_token() -> None:
"assets/verify.js": ("verifyEmail",),
"assets/app.js": ("changeFirstPassword",),
"assets/admin.js": ("createAccount", "createOrganization", "setAccountStatus"),
"assets/reset.js": ("resetPassword",),
"assets/settings.js": ("changeOwnPassword", "logout"),
}
for relative, calls in pages.items():
source = _read(relative)

View File

@@ -0,0 +1,575 @@
"""Tests for password reset and for changing one's own password.
Two features with one shared hazard, so the properties are grouped by what they
protect rather than by which endpoint they belong to:
* a reset link is a live credential for as long as it is valid, so it is
single-use, short-lived, and stored only as a hash;
* a reset token and an email-verification token must not stand in for each
other, because either would then grant the other's capability;
* completing a reset must end every session, or a stolen cookie survives the
reset that was supposed to evict it;
* an administrator must be able to start a reset but never to finish one, since
one who can set a password can sign in as that person indefinitely;
* no endpoint may reveal whether an account or a link exists.
"""
from __future__ import annotations
import re
from dataclasses import fields
from datetime import timedelta
import pytest
from fastapi.testclient import TestClient
from umbratrans_web.app import create_app
from umbratrans_web.config import Environment, Settings
from umbratrans_web.errors import (
InvalidCredentialsError,
PasswordPolicyError,
StateTransitionError,
VerificationFailedError,
)
from umbratrans_web.mailer import CapturingMailer
from umbratrans_web.models import (
PASSWORD_RESET_PURPOSE,
PASSWORD_RESET_TOKEN_TTL_SECONDS,
VERIFICATION_PURPOSE_EMAIL,
VERIFICATION_TOKEN_TTL_SECONDS,
AccountStatus,
Principal,
RoleName,
)
from umbratrans_web.repository import InMemoryAccountRepository
from umbratrans_web.security import hash_session_token, utc_now
from umbratrans_web.service import AccountService
from .conftest import (
ensure_organization,
provision_invite,
provision_local,
provision_oidc,
random_email,
random_password,
)
@pytest.fixture
def mailer() -> CapturingMailer:
return CapturingMailer()
@pytest.fixture
def service(mailer: CapturingMailer) -> AccountService:
repository = InMemoryAccountRepository()
return AccountService(repository, Settings(environment=Environment.TEST), mailer=mailer)
@pytest.fixture
def platform(service: AccountService) -> Principal:
account = provision_local(service, username="Reset.Admin", role=str(RoleName.PLATFORM_ADMIN))
return Principal(
user_id=account.user_id, username=account.username or "", role=RoleName.PLATFORM_ADMIN, session_id=account.user_id
)
def _link_from(mailer: CapturingMailer) -> str:
return mailer.sent[-1].body
def _token_from(mailer: CapturingMailer) -> str:
match = re.search(r"reset-password\.html\?token=([^\s\"'>]+)", mailer.sent[-1].body)
assert match is not None, f"no reset link in the message: {mailer.sent[-1].body!r}"
return match.group(1)
# -- the link is a credential -----------------------------------------------
def test_a_reset_link_goes_to_the_address_on_the_account(
service: AccountService, platform: Principal, mailer: CapturingMailer
) -> None:
account = provision_local(service, username="Reset.Target")
assert service.request_password_reset(platform, account.user_id) is True
assert mailer.sent[-1].to == account.email
def test_the_administrator_never_receives_the_new_password(
service: AccountService, platform: Principal
) -> None:
"""An admin who can set a password can impersonate the account holder.
The link is the whole mechanism: it goes to the account's address, only its
holder can complete it, and the service hands back a bool rather than a
password. There is no code path here that returns a usable secret to the
caller.
"""
account = provision_local(service, username="Reset.NoLeak")
result = service.request_password_reset(platform, account.user_id)
assert result is True
assert not isinstance(result, str)
def test_a_reset_link_is_single_use(service: AccountService, platform: Principal, mailer: CapturingMailer) -> None:
account = provision_local(service, username="Reset.Single")
service.request_password_reset(platform, account.user_id)
token = _token_from(mailer)
service.reset_password_with_token(token, random_password(30))
# The same link again is refused, exactly as an invented one is.
with pytest.raises(VerificationFailedError):
service.reset_password_with_token(token, random_password(30))
def test_a_reset_link_expires_quickly(service: AccountService, platform: Principal, mailer: CapturingMailer) -> None:
"""Far shorter than a verification link, because it grants a password.
A verification link only confirms an address the person already controls. A
reset link is a live credential, and a reset is what someone reaches for
precisely when they think a link in an old message might still work.
"""
assert PASSWORD_RESET_TOKEN_TTL_SECONDS == 3600
# A verification link, for comparison, lives for two days. The reset is an
# order of magnitude shorter because it grants a password rather than
# confirming an address.
assert PASSWORD_RESET_TOKEN_TTL_SECONDS * 48 <= VERIFICATION_TOKEN_TTL_SECONDS
account = provision_local(service, username="Reset.Ttl")
service.request_password_reset(platform, account.user_id)
token = _token_from(mailer) # noqa: F841 - used below
stored = service.repository.get_live_verification_token(
hash_session_token(token), purpose=PASSWORD_RESET_PURPOSE
)
assert stored is not None
lifetime = (stored.expires_at - stored.created_at).total_seconds() # type: ignore[union-attr]
assert lifetime == PASSWORD_RESET_TOKEN_TTL_SECONDS
def test_an_expired_reset_link_is_refused(
service: AccountService, platform: Principal, mailer: CapturingMailer, monkeypatch: pytest.MonkeyPatch
) -> None:
account = provision_local(service, username="Reset.Expired")
service.request_password_reset(platform, account.user_id)
token = _token_from(mailer)
later = utc_now() + timedelta(seconds=PASSWORD_RESET_TOKEN_TTL_SECONDS + 60)
monkeypatch.setattr(service, "_now", lambda: later)
with pytest.raises(VerificationFailedError):
service.reset_password_with_token(token, random_password(30))
# -- purposes must not be interchangeable -----------------------------------
def test_a_reset_token_is_not_accepted_for_email_verification(
service: AccountService, platform: Principal, mailer: CapturingMailer
) -> None:
"""Otherwise a reset link would confirm an address it never proved.
``bootstrap_tokens`` is one table for both kinds of token. The purpose
column added by migration 0002 is the only thing keeping them apart, so this
asserts the lookup really filters on it rather than matching a bare hash.
"""
account = provision_local(service, username="Reset.CrossPurpose")
service.request_password_reset(platform, account.user_id)
token = _token_from(mailer)
# The same hash, asked for under the other purpose, finds nothing. That is
# the whole point of the column: one hash, two purposes, and the lookup
# refuses to cross them.
assert (
service.repository.get_live_verification_token(
hash_session_token(token), purpose=VERIFICATION_PURPOSE_EMAIL
)
is None
)
assert (
service.repository.get_live_verification_token(
hash_session_token(token), purpose=PASSWORD_RESET_PURPOSE
)
is not None
)
with pytest.raises(VerificationFailedError):
service.verify_email(token)
def test_a_verification_token_is_not_accepted_as_a_reset(
service: AccountService, mailer: CapturingMailer
) -> None:
account = provision_oidc(service, _actor(service), username="Verify.CrossPurpose")
link = service.issue_email_verification(account.user_id)
token = re.search(r"token=([^\s\"'>]+)", link).group(1) # type: ignore[union-attr]
assert service.password_reset_is_usable(token) is False
with pytest.raises(VerificationFailedError):
service.reset_password_with_token(token, random_password(30))
# -- sessions ----------------------------------------------------------------
def test_completing_a_reset_revokes_every_session(
service: AccountService, platform: Principal, mailer: CapturingMailer
) -> None:
"""Otherwise a stolen cookie survives the reset meant to evict it.
A reset is what someone reaches for when they believe their password is
compromised. If the old sessions keep working, the person who has the
password keeps the account, and the reset has only changed the password.
"""
account = provision_local(service, username="Reset.Sessions")
first = service.authenticate(email=account.email, password=account.password)
second = service.authenticate(email=account.email, password=account.password)
assert service.repository.count_sessions(account.user_id) >= 2
service.request_password_reset(platform, account.user_id)
service.reset_password_with_token(_token_from(mailer), random_password(30))
assert service.repository.count_sessions(account.user_id) == 0
for token in (first.session.token, second.session.token):
with pytest.raises(InvalidCredentialsError):
service.resolve_session(token)
def test_the_old_password_stops_working_after_a_reset(
service: AccountService, platform: Principal, mailer: CapturingMailer
) -> None:
account = provision_local(service, username="Reset.OldPassword")
service.request_password_reset(platform, account.user_id)
replacement = random_password(30)
service.reset_password_with_token(_token_from(mailer), replacement)
with pytest.raises(InvalidCredentialsError):
service.authenticate(email=account.email, password=account.password)
assert service.authenticate(email=account.email, password=replacement)
def test_a_reset_clears_a_lockout(
service: AccountService, platform: Principal, mailer: CapturingMailer
) -> None:
"""A locked-out owner cannot ask for help, and an admin cannot set a password.
So a reset is the way out of a lockout, and the counter has to be cleared or
the new password would be refused for the same reason the old one was.
"""
account = provision_local(service, username="Reset.Locked")
for _ in range(3):
with pytest.raises(InvalidCredentialsError):
service.authenticate(email=account.email, password="wrong-one")
service.request_password_reset(platform, account.user_id)
replacement = random_password(30)
service.reset_password_with_token(_token_from(mailer), replacement)
assert service.authenticate(email=account.email, password=replacement)
# -- the policy still applies -------------------------------------------------
def test_a_reset_refuses_a_password_the_policy_rejects(
service: AccountService, platform: Principal, mailer: CapturingMailer
) -> None:
account = provision_local(service, username="Reset.Weak")
service.request_password_reset(platform, account.user_id)
token = _token_from(mailer)
with pytest.raises(PasswordPolicyError):
service.reset_password_with_token(token, "short")
# And the token is spent, so a weak first attempt does not leave the link
# usable by whoever found it.
with pytest.raises(VerificationFailedError):
service.reset_password_with_token(token, random_password(30))
def test_a_reset_refuses_the_current_password(
service: AccountService, platform: Principal, mailer: CapturingMailer
) -> None:
account = provision_local(service, username="Reset.Same")
service.request_password_reset(platform, account.user_id)
with pytest.raises(PasswordPolicyError):
service.reset_password_with_token(_token_from(mailer), account.password)
# -- scope and refusals ------------------------------------------------------
def test_an_organization_administrator_cannot_reset_outside_its_tenant(
service: AccountService,
) -> None:
mine = ensure_organization(service, name="Mine").id
theirs = ensure_organization(service, name="Theirs").id
outsider = provision_local(
service, username="Outside.User", role=str(RoleName.MEMBER), organization_id=theirs
)
org_admin = provision_local(
service, username="Scoped.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=mine
)
actor = Principal(
user_id=org_admin.user_id,
username=org_admin.username or "",
role=RoleName.ORGANIZATION_ADMIN,
organization_id=mine,
session_id=org_admin.user_id,
)
with pytest.raises(Exception):
service.request_password_reset(actor, outsider.user_id)
def test_an_oidc_account_has_no_password_to_reset(
service: AccountService, platform: Principal
) -> None:
account = provision_oidc(service, platform, username="Oidc.NoReset")
with pytest.raises(StateTransitionError) as error:
service.request_password_reset(platform, account.user_id)
assert error.value.code == "not_a_local_account"
def test_a_reset_needs_an_administrator(service: AccountService) -> None:
account = provision_local(service, username="Plain.User")
actor = Principal(
user_id=account.user_id, username=account.username or "", role=RoleName.MEMBER, session_id=account.user_id
)
with pytest.raises(Exception):
service.request_password_reset(actor, account.user_id) # type: ignore[arg-type]
# -- changing your own password ----------------------------------------------
def test_a_person_can_change_their_own_password(
service: AccountService, platform: Principal
) -> None:
account = provision_local(service, username="Own.Password")
actor = Principal(
user_id=account.user_id, username=account.username or "", role=RoleName.MEMBER, session_id=account.user_id
)
result = service.authenticate(email=account.email, password=account.password)
principal = service.resolve_session(result.session.token)
replacement = random_password(30)
changed = service.change_own_password(
principal, current_password=account.password, new_password=replacement
)
assert changed.session.must_change_password is False
assert service.authenticate(email=account.email, password=replacement)
def test_changing_your_own_password_requires_the_current_one(
service: AccountService,
) -> None:
"""Otherwise a stolen session cookie alone takes the account over."""
account = provision_local(service, username="Own.NeedsCurrent")
result = service.authenticate(email=account.email, password=account.password)
principal = service.resolve_session(result.session.token)
with pytest.raises(InvalidCredentialsError):
service.change_own_password(
principal, current_password="not-the-password", new_password=random_password(30)
)
def test_changing_your_own_password_rotates_sessions(service: AccountService) -> None:
account = provision_local(service, username="Own.Rotates")
first = service.authenticate(email=account.email, password=account.password)
second = service.authenticate(email=account.email, password=account.password)
principal = service.resolve_session(first.session.token)
service.change_own_password(
principal, current_password=account.password, new_password=random_password(30)
)
with pytest.raises(InvalidCredentialsError):
service.resolve_session(second.session.token)
# -- over HTTP ---------------------------------------------------------------
def _public_settings() -> Settings:
"""A publicly reachable environment, assembled without running the gate.
The configuration gate is right to refuse a public environment naming a
development repository, and these tests need that combination to reach the
guard on undeliverable mail. The gate is not weakened; only this fixture
skips it, and it says so.
"""
settings = object.__new__(Settings)
base = Settings(environment=Environment.TEST, repository_kind="memory")
for spec in fields(Settings):
object.__setattr__(settings, spec.name, getattr(base, spec.name))
object.__setattr__(settings, "environment", Environment.STAGING)
object.__setattr__(settings, "cookie_secure", True)
object.__setattr__(settings, "require_csrf", True)
object.__setattr__(settings, "mailer_transport", "smtp")
object.__setattr__(settings, "smtp_host", "mail.example.test")
object.__setattr__(settings, "verification_base_url", "https://umbratrans.example.test")
return settings
def test_an_invented_reset_link_is_refused_over_http(
service: AccountService, mailer: CapturingMailer
) -> None:
provision_local(service, username="Http.Reset")
app = create_app(settings=service.settings, repository=service.repository, service=service)
with TestClient(app, base_url="https://testserver") as client:
client.get("/api/health")
csrf = {service.settings.csrf_header_name: client.cookies.get(service.settings.csrf_cookie_name) or ""}
response = client.post(
"/api/auth/reset-password",
json={"token": "z" * 43, "new_password": random_password(30)},
headers=csrf,
)
assert response.status_code == 400
assert response.json()["error"]["code"] == "verification_failed"
def test_a_duplicate_reset_link_answers_the_same_way(
service: AccountService, mailer: CapturingMailer
) -> None:
"""Used and unused must be indistinguishable, or the endpoint is an oracle."""
account = provision_local(service, username="Http.Same")
service.request_password_reset(
Principal(
user_id=provision_local(service, username="Http.Admin", role=str(RoleName.PLATFORM_ADMIN)).user_id,
username="Http.Admin",
role=RoleName.PLATFORM_ADMIN,
session_id=account.user_id,
),
account.user_id,
)
token = _token_from(mailer)
service.reset_password_with_token(token, random_password(30))
app = create_app(settings=service.settings, repository=service.repository, service=service)
with TestClient(app, base_url="https://testserver") as client:
client.get("/api/health")
csrf = {service.settings.csrf_header_name: client.cookies.get(service.settings.csrf_cookie_name) or ""}
used = client.post(
"/api/auth/reset-password",
json={"token": token, "new_password": random_password(30)},
headers=csrf,
)
invented = client.post(
"/api/auth/reset-password",
json={"token": "q" * 43, "new_password": random_password(30)},
headers=csrf,
)
assert used.status_code == invented.status_code == 400
assert used.json() == invented.json()
def test_availability_does_not_reveal_whether_a_link_exists(
service: AccountService, mailer: CapturingMailer
) -> None:
account = provision_local(service, username="Http.Available")
admin = provision_local(service, username="Http.AvailAdmin", role=str(RoleName.PLATFORM_ADMIN))
service.request_password_reset(
Principal(
user_id=admin.user_id, username=admin.username or "", role=RoleName.PLATFORM_ADMIN, session_id=admin.user_id
),
account.user_id,
)
token = _token_from(mailer)
app = create_app(settings=service.settings, repository=service.repository, service=service)
with TestClient(app, base_url="https://testserver") as client:
live = client.get(f"/api/auth/reset-password?token={token}").json()
invented = client.get("/api/auth/reset-password?token=" + "q" * 43).json()
assert live["available"] is True
assert invented["available"] is False
# Asking twice must not use the link up: availability is a read.
with TestClient(app, base_url="https://testserver") as client:
assert client.get(f"/api/auth/reset-password?token={token}").json()["available"] is True
def _actor(service: AccountService) -> Principal:
admin = provision_local(service, username="Actor.Admin", role=str(RoleName.PLATFORM_ADMIN))
return Principal(
user_id=admin.user_id, username=admin.username or "", role=RoleName.PLATFORM_ADMIN, session_id=admin.user_id
)
# -- the case that deadlocked ------------------------------------------------
def test_an_invited_account_can_be_reset(
service: AccountService, platform: Principal, mailer: CapturingMailer
) -> None:
"""An account that has never confirmed its address could be reached by neither route.
It cannot sign in, because sign-in is refused for an unverified address. An
earlier version of this method also refused to reset it, because it was not
yet active, so an administrator had no way to help and the owner had no way
in. That is what was reported, and it is a deadlock rather than a policy.
"""
organization = ensure_organization(service, name="Invited Org")
# A temporary-password account awaiting confirmation, not an OIDC one: this is
# the shape that deadlocked, and an OIDC account has no password to reset at
# all, which is a different refusal.
account = provision_invite(
service,
actor=platform,
username="Invited.Reset",
email=random_email("invited-reset"),
organization_id=organization.id,
)
account_id = account.account.id
account_email = account.account.email
assert account.account.status.value == "invited"
assert account.account.is_email_verified is False
assert service.request_password_reset(platform, account_id) is True
replacement = random_password(30)
service.reset_password_with_token(_token_from(mailer), replacement)
# The reset confirms the address as well, because holding the link proves
# control of the address and that is what verification asserts. Without this
# the person would reset the password, sign in, and be refused for the same
# reason, using a verification link they never received.
verified = service.repository.get_user(account_id)
assert verified is not None
assert verified.status is AccountStatus.ACTIVE
assert verified.is_email_verified is True
# And they can now actually sign in, which is the point of the whole exercise.
assert service.authenticate(email=account_email, password=replacement)
def test_a_reset_confirming_an_address_is_audited_separately(
service: AccountService, platform: Principal, mailer: CapturingMailer
) -> None:
"""An administrator reading the trail should see the confirmation, not infer it."""
organization = ensure_organization(service, name="Invited Audit Org")
account = provision_invite(
service,
actor=platform,
username="Invited.Audit",
email=random_email("invited-audit"),
organization_id=organization.id,
)
service.request_password_reset(platform, account.account.id)
service.reset_password_with_token(_token_from(mailer), random_password(30))
events = service.repository.list_audit_events(target_user_id=account.account.id, limit=50)
actions = [event.action for event in events[0]]
assert "password.reset_completed" in actions
assert "email.verified" in actions
def test_a_suspended_account_cannot_be_reset(
service: AccountService, platform: Principal
) -> None:
"""A reset restores sign-in, so allowing one would undo a suspension."""
account = provision_local(service, username="Suspended.Reset")
service.set_account_status(platform, account.user_id, "suspended", reason="testing")
with pytest.raises(StateTransitionError) as error:
service.request_password_reset(platform, account.user_id)
assert error.value.code == "account_not_active"

View File

@@ -23,6 +23,7 @@ Neither is visible without a real database.
from __future__ import annotations
import inspect
import os
import uuid
from datetime import datetime, timedelta, timezone
@@ -130,6 +131,35 @@ def test_every_repository_implements_every_protocol_method() -> None:
assert missing == [], f"{implementation.__name__} does not implement: {missing}"
def test_every_repository_declares_the_same_signature_as_the_protocol() -> None:
"""Presence is not enough; the call has to be the same call.
The previous check asked only whether a method existed, which is how the
PostgreSQL adapter shipped a ``get_live_verification_token`` that took a
``purpose`` but not the ``now`` its in-memory counterpart required. Every test
that did not open a real database passed, and the first call against a real
one raised ``TypeError`` and returned a 500.
A protocol that names a parameter every implementation must accept is the only
thing that catches that before a deployment does.
"""
from umbratrans_web.dev_repository import InMemoryAccountRepository, JsonDevelopmentRepository
for implementation in (InMemoryAccountRepository, JsonDevelopmentRepository, PostgresAccountRepository):
for name, declared in vars(AccountRepository).items():
if not callable(declared) or name.startswith("_"):
continue
actual = getattr(implementation, name, None)
assert actual is not None, f"{implementation.__name__}.{name} is missing"
expected = inspect.signature(declared)
found = inspect.signature(actual)
assert list(found.parameters) == list(expected.parameters), (
f"{implementation.__name__}.{name}{found} does not match the protocol's "
f"{expected}"
)
def test_an_inherited_stub_raises_rather_than_returning_none() -> None:
"""A repository missing a method must fail loudly.

View File

@@ -614,6 +614,29 @@
}
actions.appendChild(revoke);
// An administrator may start a password reset but never finish one. The link
// goes to the address on the account and only its holder can complete it, so
// this is a way to help someone in, not a way around them.
var reset = el('button', {
class: 'button button--ghost',
type: 'button',
text: 'Send password reset',
'data-account-reset': account.id
});
if (credential.credential_type !== 'local_password') {
// Nothing to reset: this account signs in through an identity provider.
api.disable(reset, true);
reset.title = 'This account signs in through an identity provider and has no password.';
} else if (account.status !== 'active') {
api.disable(reset, true);
reset.title = 'Only an active account can have its password reset.';
} else {
reset.addEventListener('click', function () {
requestPasswordResetFor(account);
});
}
actions.appendChild(reset);
return el('tr', null, [
el('td', null, [
el('div', { class: 'table-user' }, [
@@ -707,6 +730,35 @@
);
}
function requestPasswordResetFor(account) {
// A confirmation, because this mails a credential to a colleague and is not
// undoable. The browser's own confirm() is used rather than a modal so the
// warning cannot be dismissed by habit.
var who = account.display_name || account.email;
var accepted = global.confirm(
'Send a password reset link to ' + account.email + '?\n\n' +
'The link goes to that address and works once. You will not be told the new password, ' +
'and every session on the account is signed out when it is used.'
);
if (!accepted) {
return;
}
var button = document.querySelector('[data-account-reset="' + account.id + '"]');
api.disable(button, true);
api.requestPasswordReset(account.id).then(
function (data) {
api.disable(button, false);
api.showToast(
(data && data.message) || 'A password reset link has been sent to ' + account.email + '.'
);
},
function (error) {
api.disable(button, false);
api.showToast(api.describeError(error), 'error');
}
);
}
function openAccountStatusDialog(account, preferredStatus) {
accountStatusTarget = account;
var targetNode = document.getElementById('account-status-target');

View File

@@ -301,6 +301,33 @@
});
}
function passwordResetAvailable(token, signal) {
return request('/api/auth/reset-password?' + buildQuery({ token: token }), { method: 'GET', signal: signal });
}
function resetPassword(token, newPassword, signal) {
return request('/api/auth/reset-password', {
method: 'POST',
body: { token: token, new_password: newPassword },
signal: signal
});
}
function changeOwnPassword(currentPassword, newPassword, signal) {
return request('/api/auth/change-password', {
method: 'POST',
body: { current_password: currentPassword, new_password: newPassword },
signal: signal
});
}
function requestPasswordReset(userId, signal) {
return request('/api/uadmin/accounts/' + encodeURIComponent(userId) + '/password-reset', {
method: 'POST',
signal: signal
});
}
function resendVerification(email, signal) {
return request('/api/auth/resend-verification', {
method: 'POST',
@@ -753,6 +780,10 @@
health: health,
login: login,
verifyEmail: verifyEmail,
passwordResetAvailable: passwordResetAvailable,
resetPassword: resetPassword,
changeOwnPassword: changeOwnPassword,
requestPasswordReset: requestPasswordReset,
resendVerification: resendVerification,
changeFirstPassword: changeFirstPassword,
me: me,

View File

@@ -156,6 +156,9 @@
);
function openPasswordView() {
// The settings page needs a usable session, and this view is the one
// state where there is not one yet, so the link stays hidden here.
api.show(passwordSettings, false);
api.show(loginView, false);
api.show(passwordView, true);
api.show(adminEntry, false);

186
web/assets/reset.js Normal file
View File

@@ -0,0 +1,186 @@
/*
* The password-reset landing page.
*
* Reached from a link in an email, so like the confirmation page it is the only
* page that arrives without having made a request, and the only one that may
* have no CSRF token. It asks the server whether the link is usable before
* offering a form, so a stale link says so instead of failing on submit.
*
* The token is a live credential for as long as it is valid, so it is kept out
* of the form: arriving from a link puts it straight into a variable and the
* field is left empty, and it is stripped from the address bar. Someone watching
* over a shoulder sees the form, not the token.
*
* No inline script or style, to stay inside the content security policy.
*/
(function (global) {
'use strict';
var api = global.UmbraApi;
if (!api) {
return;
}
var query = api.query;
var formView = query('#reset-form-view');
var unavailable = query('#reset-unavailable');
var form = query('#reset-form');
var message = query('#reset-message');
var submit = query('#reset-submit');
var passwordInput = query('#new-password');
var confirmInput = query('#confirm-password');
var toggle = query('#toggle-new-password');
var policyLength = query('#policy-length');
function setMessage(text, tone) {
if (!message) {
return;
}
message.textContent = text;
message.className = 'form-message' + (tone ? ' form-message--' + tone : '');
}
function show(view) {
if (formView) {
formView.hidden = view !== 'form';
}
if (unavailable) {
unavailable.hidden = view !== 'unavailable';
}
}
function tokenFromLocation() {
var source = (global.location.hash || '') + '&' + (global.location.search || '');
var match = source.match(/[?&#]token=([^&#]+)/);
if (!match) {
return '';
}
try {
return decodeURIComponent(match[1]);
} catch (error) {
return '';
}
}
function stripTokenFromAddress() {
// A single-use credential does not belong in browser history or in a
// screenshot of the address bar.
if (global.history && global.history.replaceState) {
global.history.replaceState(null, '', global.location.pathname);
}
}
function describeLength() {
if (!policyLength) {
return;
}
var value = passwordInput ? passwordInput.value : '';
var long = value.length >= 14;
policyLength.textContent = long
? 'At least 14 characters. Met.'
: 'At least 14 characters. ' + (value ? value.length + ' so far.' : '');
}
if (toggle && passwordInput) {
toggle.addEventListener('click', function () {
var reveal = passwordInput.type === 'password';
passwordInput.type = reveal ? 'text' : 'password';
toggle.setAttribute('aria-pressed', reveal ? 'true' : 'false');
toggle.textContent = reveal ? 'Hide' : 'Show';
});
}
if (passwordInput) {
passwordInput.addEventListener('input', describeLength);
describeLength();
}
if (form) {
form.addEventListener('submit', function (event) {
event.preventDefault();
var token = tokenFromLocation();
var next = passwordInput ? passwordInput.value : '';
var confirm = confirmInput ? confirmInput.value : '';
if (!token) {
setMessage('This page needs the link from your email. Open the link again.', 'error');
return;
}
if (!next) {
setMessage('Choose a new password.', 'error');
return;
}
if (next !== confirm) {
setMessage('The two passwords do not match.', 'error');
return;
}
api.disable(submit, true);
setMessage('Setting your new password…', null);
api
.ensureCsrfToken()
.then(function () {
return api.resetPassword(token, next);
})
.then(
function (data) {
api.disable(submit, false);
if (passwordInput) {
passwordInput.value = '';
}
if (confirmInput) {
confirmInput.value = '';
}
stripTokenFromAddress();
setMessage((data && data.message) || 'Your password has been changed.', 'success');
global.setTimeout(function () {
global.location.replace('index.html');
}, 1800);
},
function (error) {
api.disable(submit, false);
setMessage(api.describeError(error), 'error');
// A refused token will not become usable, so stop offering a form
// rather than letting the person submit into a refusal.
if (error && error.code === 'verification_failed') {
show('unavailable');
return;
}
if (passwordInput) {
passwordInput.focus();
}
}
);
});
}
// Ask whether the link is usable before showing a form for it. The answer is
// deliberately the same for every reason a link can fail, so this page cannot
// be used to learn whether a given link ever existed.
var arriving = tokenFromLocation();
if (!arriving) {
show('unavailable');
return;
}
api
.ensureCsrfToken()
.then(function () {
return api.passwordResetAvailable(arriving);
})
.then(
function (data) {
if (data && data.available) {
show('form');
if (passwordInput) {
passwordInput.focus();
}
} else {
stripTokenFromAddress();
show('unavailable');
}
},
function () {
stripTokenFromAddress();
show('unavailable');
}
);
}(window));

199
web/assets/settings.js Normal file
View File

@@ -0,0 +1,199 @@
/*
* The signed-in person's own account settings.
*
* Two things live here: what the account is, which the service resolves for the
* session, and the password that opens it. Only the password is editable here.
* Changing an address or a role is an administrative act, and a self-service
* page for either would be a way to grant oneself authority.
*
* No inline script or style, to stay inside the content security policy.
*/
(function (global) {
'use strict';
var api = global.UmbraApi;
if (!api) {
return;
}
var query = api.query;
var form = query('#password-form');
var message = query('#password-message');
var submit = query('#password-submit');
var currentInput = query('#current-password');
var passwordInput = query('#new-password');
var confirmInput = query('#confirm-password');
var signout = query('#signout');
var policyLength = query('#policy-length');
var policyMatch = query('#policy-match');
var policyUsername = query('#policy-username');
var session = null;
function setMessage(text, tone) {
if (!message) {
return;
}
message.textContent = text;
message.className = 'form-message' + (tone ? ' form-message--' + tone : '');
}
function setText(id, value) {
var node = query(id);
if (node) {
node.textContent = value;
}
}
function wireToggle(buttonId, inputId) {
var button = query('#' + buttonId);
var input = query('#' + inputId);
if (!button || !input) {
return;
}
button.addEventListener('click', function () {
var reveal = input.type === 'password';
input.type = reveal ? 'text' : 'password';
button.setAttribute('aria-pressed', reveal ? 'true' : 'false');
button.textContent = reveal ? 'Hide' : 'Show';
});
}
wireToggle('toggle-current-password', 'current-password');
wireToggle('toggle-new-password', 'new-password');
function describePolicy() {
if (policyLength) {
var value = passwordInput ? passwordInput.value : '';
var long = value.length >= 14;
policyLength.textContent = long
? 'At least 14 characters. Met.'
: 'At least 14 characters. ' + (value ? value.length + ' so far.' : '');
}
if (policyMatch) {
var next = passwordInput ? passwordInput.value : '';
var confirm = confirmInput ? confirmInput.value : '';
if (!next || !confirm) {
policyMatch.textContent = 'The two entries must match.';
} else {
policyMatch.textContent = next === confirm ? 'The two entries match.' : 'The two entries do not match.';
}
}
if (policyUsername) {
var handle = session && session.username ? session.username : '';
policyUsername.textContent = handle
? 'Must not contain “' + handle + '”.'
: 'Must not contain your account name.';
}
}
if (passwordInput) {
passwordInput.addEventListener('input', describePolicy);
}
if (confirmInput) {
confirmInput.addEventListener('input', describePolicy);
}
if (form) {
form.addEventListener('submit', function (event) {
event.preventDefault();
var current = currentInput ? currentInput.value : '';
var next = passwordInput ? passwordInput.value : '';
var confirm = confirmInput ? confirmInput.value : '';
if (!current) {
setMessage('Enter your current password.', 'error');
return;
}
if (!next) {
setMessage('Choose a new password.', 'error');
return;
}
if (next !== confirm) {
setMessage('The two new passwords do not match.', 'error');
return;
}
api.disable(submit, true);
setMessage('Changing your password…', null);
// The page makes a session request on load, so a CSRF token already
// exists; ensured anyway so this is not quietly dependent on that.
api
.ensureCsrfToken()
.then(function () {
return api.changeOwnPassword(current, next);
})
.then(
function () {
api.disable(submit, false);
if (currentInput) {
currentInput.value = '';
}
if (passwordInput) {
passwordInput.value = '';
}
if (confirmInput) {
confirmInput.value = '';
}
describePolicy();
setMessage('Your password has been changed. Other devices have been signed out.', 'success');
},
function (error) {
api.disable(submit, false);
setMessage(api.describeError(error), 'error');
if (error && error.status === 401) {
global.setTimeout(function () {
global.location.replace('index.html');
}, 1600);
return;
}
if (currentInput) {
currentInput.focus();
}
}
);
});
}
if (signout) {
signout.addEventListener('click', function () {
api.logout().then(
function () {
global.location.replace('index.html');
},
function () {
global.location.replace('index.html');
}
);
});
}
function render(user) {
session = user;
setText('#account-email', user.email || '—');
setText('#account-display-name', user.display_name || '—');
// Absent rather than blank: an account with no handle is normal, and an
// empty string here would look like a rendering failure.
setText('#account-username', user.username || 'Not set');
setText('#account-role', api.label(user.role || ''));
setText('#account-verified', user.is_email_verified === false ? 'Not yet confirmed' : 'Confirmed');
var pill = query('#settings-role');
if (pill) {
pill.textContent = api.label(user.role || '');
}
describePolicy();
}
api.me().then(
function (data) {
render((data && (data.user || data)) || {});
},
function (error) {
// No usable session, so there is nothing to manage here.
if (error && error.status === 401) {
global.location.replace('index.html');
return;
}
setMessage(api.describeError(error), 'error');
}
);
}(window));

View File

@@ -80,6 +80,14 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
.brand-points > div { display: flex; align-items: center; gap: .65rem; }
.point-dot { width: .45rem; height: .45rem; border-radius: 50%; background: var(--workspace-teal); box-shadow: 0 0 0 .3rem rgba(45, 212, 191, .12); }
.prototype-note { margin: 0; color: #64748b; font-size: .72rem; letter-spacing: .04em; }
/* The read-only account facts on the settings page. A definition list rather
than a table, because these are terms and their values, not tabular data. */
.settings-list { margin: 0 0 .4rem; padding: .1rem 0; }
.settings-list > div { display: flex; justify-content: space-between; gap: 1rem; padding: .6rem 0; border-bottom: 1px solid var(--admin-border); }
.settings-list > div:last-child { border-bottom: 0; }
.settings-list dt { color: var(--admin-muted); font-size: .78rem; }
.settings-list dd { margin: 0; color: #e2e8f0; font-size: .82rem; font-weight: 700; text-align: right; overflow-wrap: anywhere; }
/* Standing deployment notices. Never dismissible: each one describes a state
in which an action about to be taken will not work. */
.admin-banners { display: grid; gap: .6rem; margin: 0 0 1.4rem; }

View File

@@ -89,6 +89,7 @@
<input id="confirm-password" name="confirm_password" type="password" autocomplete="new-password" required>
<div id="password-message" class="form-message" role="status" aria-live="polite"></div>
<button id="password-submit" class="button button--primary button--wide" type="submit">Set new password <span aria-hidden="true">→</span></button>
<a id="password-settings" class="admin-link" href="settings.html" hidden><span>Your account settings</span><span aria-hidden="true">↗</span></a>
<button id="password-signout" class="button button--ghost button--wide" type="button">Sign out instead</button>
</form>
<p class="auth-footnote">This page cannot be left until the password has been changed. Your session stays open while you do.</p>

67
web/reset-password.html Normal file
View File

@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Choose a new UmbraTrans password">
<meta name="referrer" content="same-origin">
<title>Choose a new password · UmbraTrans</title>
<link rel="icon" type="image/png" href="assets/umbratrans-emblem.png">
<link rel="stylesheet" href="assets/styles.css">
</head>
<body class="auth-page">
<main class="auth-shell">
<section class="auth-brand" aria-labelledby="brand-title">
<a class="brand-lockup" href="index.html" aria-label="UmbraTrans home">
<img class="brand-image" src="assets/umbratrans-emblem.png" alt="" aria-hidden="true">
<span>UmbraTrans</span>
</a>
<div class="brand-copy">
<p class="eyebrow">Account recovery</p>
<h1 id="brand-title">Choose a new password.</h1>
<p class="brand-lede">This link works once and expires an hour after it was sent. Setting a new password signs out every other device on the account.</p>
</div>
</section>
<section class="auth-card" aria-labelledby="reset-title">
<div id="reset-unavailable" hidden>
<div class="auth-card-heading">
<span class="status-pill status-pill--warning">Link not usable</span>
<h2 id="reset-title">This link is no longer valid</h2>
<p id="reset-unavailable-text">This link may already have been used, or it may have expired. Ask for a new one and try again.</p>
</div>
<a class="admin-link" href="index.html"><span>Back to sign in</span><span aria-hidden="true">↗</span></a>
</div>
<div id="reset-form-view" hidden>
<div class="auth-card-heading">
<span class="status-pill status-pill--info">Almost done</span>
<h2 id="reset-title">Choose a new password</h2>
<p id="reset-intro">Pick something you have not used here before. Every other signed-in device will be signed out when you do.</p>
</div>
<form id="reset-form" class="stack-form" novalidate>
<label for="new-password">New password</label>
<div class="password-field">
<input id="new-password" name="new_password" type="password" autocomplete="new-password" required>
<button id="toggle-new-password" class="password-toggle" type="button" aria-pressed="false" aria-controls="new-password">Show</button>
</div>
<label for="confirm-password">Confirm new password</label>
<input id="confirm-password" name="confirm_password" type="password" autocomplete="new-password" required>
<ul class="policy-list" id="reset-policy">
<li id="policy-length">At least 14 characters.</li>
<li id="policy-match">The two entries must match.</li>
</ul>
<div id="reset-message" class="form-message" role="status" aria-live="polite"></div>
<button id="reset-submit" class="button button--primary button--wide" type="submit">Set new password <span aria-hidden="true">→</span></button>
</form>
</div>
</section>
</main>
<script src="assets/api.js" defer></script>
<script src="assets/reset.js" defer></script>
</body>
</html>

75
web/settings.html Normal file
View File

@@ -0,0 +1,75 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Your UmbraTrans account settings">
<meta name="referrer" content="same-origin">
<title>Your account · UmbraTrans</title>
<link rel="icon" type="image/png" href="assets/umbratrans-emblem.png">
<link rel="stylesheet" href="assets/styles.css">
</head>
<body class="auth-page">
<main class="auth-shell">
<section class="auth-brand" aria-labelledby="brand-title">
<a class="brand-lockup" href="index.html" aria-label="UmbraTrans home">
<img class="brand-image" src="assets/umbratrans-emblem.png" alt="" aria-hidden="true">
<span>UmbraTrans</span>
</a>
<div class="brand-copy">
<p class="eyebrow">Your account</p>
<h1 id="brand-title">Settings</h1>
<p class="brand-lede">What this account is, and the password that opens it.</p>
</div>
</section>
<section class="auth-card" aria-labelledby="settings-title">
<div class="auth-card-heading">
<span class="status-pill status-pill--info" id="settings-role">Signed in</span>
<h2 id="settings-title">Your account</h2>
<p>Details are resolved by the service for your session and cannot be edited here.</p>
</div>
<dl class="settings-list" id="account-details">
<div><dt>Email address</dt><dd id="account-email">—</dd></div>
<div><dt>Display name</dt><dd id="account-display-name">—</dd></div>
<div><dt>Account name</dt><dd id="account-username">—</dd></div>
<div><dt>Role</dt><dd id="account-role">—</dd></div>
<div><dt>Address confirmed</dt><dd id="account-verified">—</dd></div>
</dl>
<div class="auth-divider"><span>Password</span></div>
<form id="password-form" class="stack-form" novalidate>
<p class="field-hint">You need your current password to change it, so a signed-in session on its own is not enough. Every other device is signed out when you do.</p>
<label for="current-password">Current password</label>
<div class="password-field">
<input id="current-password" name="current_password" type="password" autocomplete="current-password" required>
<button id="toggle-current-password" class="password-toggle" type="button" aria-pressed="false" aria-controls="current-password">Show</button>
</div>
<label for="new-password">New password</label>
<div class="password-field">
<input id="new-password" name="new_password" type="password" autocomplete="new-password" required>
<button id="toggle-new-password" class="password-toggle" type="button" aria-pressed="false" aria-controls="new-password">Show</button>
</div>
<label for="confirm-password">Confirm new password</label>
<input id="confirm-password" name="confirm_password" type="password" autocomplete="new-password" required>
<ul class="policy-list" id="settings-policy">
<li id="policy-length">At least 14 characters.</li>
<li id="policy-match">The two entries must match.</li>
<li id="policy-username">Must not contain your account name.</li>
</ul>
<div id="password-message" class="form-message" role="status" aria-live="polite"></div>
<button id="password-submit" class="button button--primary button--wide" type="submit">Change password <span aria-hidden="true">→</span></button>
<button id="signout" class="button button--ghost button--wide" type="button">Sign out</button>
</form>
</section>
</main>
<script src="assets/api.js" defer></script>
<script src="assets/settings.js" defer></script>
</body>
</html>

View File

@@ -33,6 +33,7 @@
<a class="admin-nav-link" href="#accounts" data-view-link="accounts"><span aria-hidden="true">♙</span>Accounts</a>
<a class="admin-nav-link" href="#organizations" data-view-link="organizations"><span aria-hidden="true">◈</span>Organizations</a>
<a class="admin-nav-link" href="#audit" data-view-link="audit"><span aria-hidden="true">≡</span>Audit log</a>
<a class="admin-nav-link" href="../settings.html"><span aria-hidden="true">⚿</span>Your account</a>
</nav>
<div class="admin-sidebar-footer">
<p class="admin-service-line">

View File

@@ -36,6 +36,7 @@
<small id="rail-role">Checking session</small>
</span>
</div>
<a class="text-button" href="settings.html">Your account</a>
<button class="text-button" type="button" data-signout>Sign out</button>
</div>
</aside>
@@ -46,7 +47,8 @@
<div class="topbar-actions">
<span class="status-pill status-pill--info" id="workspace-session-pill">Session active</span>
<a id="workspace-admin-topbar" class="topbar-admin-link" href="uadmin/" hidden>Control panel <span aria-hidden="true">↗</span></a>
<button class="text-button topbar-signout" type="button" data-signout>Sign out</button>
<a class="text-button topbar-signout" href="settings.html">Your account</a>
<button class="text-button topbar-signout" type="button" data-signout>Sign out</button>
</div>
</header>