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:
@@ -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
|
||||
|
||||
94
umbratrans_web/migrations/0002_password_reset_purpose.sql
Normal file
94
umbratrans_web/migrations/0002_password_reset_purpose.sql
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
575
umbratrans_web/tests/test_password_reset.py
Normal file
575
umbratrans_web/tests/test_password_reset.py
Normal 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"
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
186
web/assets/reset.js
Normal 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
199
web/assets/settings.js
Normal 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));
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
67
web/reset-password.html
Normal 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
75
web/settings.html
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user