feat(pass reset): request a pass change on 1st login
This commit is contained in:
@@ -48,6 +48,13 @@ export interface ApiClientDeps {
|
||||
rawFetch: FetchFn;
|
||||
/** Invoked once when a refresh definitively fails (clear session + redirect). */
|
||||
onSessionExpired: () => void;
|
||||
/**
|
||||
* Invoked when the server returns `403 { error_type: "PasswordChangeRequired" }`.
|
||||
* Typically routes the SPA to `/profile?forcePasswordChange=1` — the same
|
||||
* destination the root layout's nav-guard uses for a fresh navigation. Default
|
||||
* is a no-op; the app wires the real handler at startup.
|
||||
*/
|
||||
onPasswordChangeRequired?: () => void;
|
||||
/** Test seam for `window.location.origin`. */
|
||||
origin?: string;
|
||||
}
|
||||
@@ -77,6 +84,10 @@ function bypassesRetry(urlStr: string): boolean {
|
||||
*/
|
||||
export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
||||
const { rawFetch, onSessionExpired } = deps;
|
||||
// Default no-op keeps existing test callers that don't wire this
|
||||
// dep from crashing on a 403 PasswordChangeRequired — they'd just
|
||||
// see the raw 403 flow through, which is what they already assert.
|
||||
const onPasswordChangeRequired = deps.onPasswordChangeRequired ?? (() => {});
|
||||
let refreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
async function refresh(): Promise<boolean> {
|
||||
@@ -114,6 +125,32 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
|
||||
// refresh doesn't accidentally clear a live banner.
|
||||
updateFromHeader(response.headers.get(SERVER_STATUS_HEADER));
|
||||
|
||||
// Backend `require_no_password_change_pending_layer` returns 403
|
||||
// `PasswordChangeRequired` on every non-allowlisted endpoint
|
||||
// while the caller's `force_password_change_at_next_login` flag
|
||||
// is set (admin picked a temporary password). Intercepting here
|
||||
// short-circuits any stale-tab request that outran the SPA's
|
||||
// nav-guard — the user is bounced to `/profile` in mandatory
|
||||
// mode, matching what the guard would do on a fresh navigation.
|
||||
//
|
||||
// Clones the body so downstream callers can still consume the
|
||||
// response after we've peeked at the error_type. Skipped for
|
||||
// non-JSON responses (WebDAV, etc.) — the check silently
|
||||
// falls through and returns the original 403 to the caller,
|
||||
// which will surface its own error the usual way.
|
||||
if (response.status === 403) {
|
||||
const clone = response.clone();
|
||||
try {
|
||||
const body = (await clone.json()) as { error_type?: unknown };
|
||||
if (body?.error_type === 'PasswordChangeRequired') {
|
||||
onPasswordChangeRequired();
|
||||
}
|
||||
} catch {
|
||||
/* not JSON or parse failed — pass through as normal 403 */
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.status !== 401) return response;
|
||||
|
||||
const urlStr = urlString(input as RequestInfo | URL);
|
||||
@@ -146,13 +183,35 @@ export function setSessionExpiredHandler(fn: () => void): void {
|
||||
sessionExpiredHandler = fn;
|
||||
}
|
||||
|
||||
// Same shape as `sessionExpiredHandler` — mutable so the app can install
|
||||
// the real behaviour post-mount, and a fallback for the (rare) case
|
||||
// where no handler is wired yet (bootstrap, tests). The fallback does
|
||||
// a hard `window.location` navigation so a stale tab that outran the
|
||||
// SPA's nav-guard still lands the user on the mandatory form.
|
||||
let passwordChangeRequiredHandler: () => void = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const here = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
window.location.href = `/profile?forcePasswordChange=1&next=${here}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Wire the SPA's mandatory-mode handler. Called once from the root
|
||||
* layout: uses `goto()` for a soft nav so `next=` preserves the
|
||||
* intended destination without triggering a full page reload.
|
||||
*/
|
||||
export function setPasswordChangeRequiredHandler(fn: () => void): void {
|
||||
passwordChangeRequiredHandler = fn;
|
||||
}
|
||||
|
||||
const rawFetch: FetchFn =
|
||||
typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : (undefined as never);
|
||||
|
||||
/** App-wide fetch — route every API call through this. */
|
||||
export const apiFetch: FetchFn = createApiFetch({
|
||||
rawFetch,
|
||||
onSessionExpired: () => sessionExpiredHandler()
|
||||
onSessionExpired: () => sessionExpiredHandler(),
|
||||
onPasswordChangeRequired: () => passwordChangeRequiredHandler()
|
||||
});
|
||||
|
||||
/** Convenience: fetch JSON, throwing on non-2xx. */
|
||||
|
||||
@@ -216,6 +216,17 @@ export interface User {
|
||||
* delete it from the bag.
|
||||
*/
|
||||
ui_preferences: Record<string, unknown>;
|
||||
/**
|
||||
* Mirrors `auth.users.force_password_change_at_next_login`. Only
|
||||
* populated by `GET /api/auth/me` (see the backend UserDto doc for
|
||||
* why other UserDto call-sites default to false). When true, the
|
||||
* SPA MUST lock navigation to the password-change surface — the
|
||||
* root layout's guard + the backend's `require_no_password_change_pending`
|
||||
* middleware together enforce this. Optional on the wire because
|
||||
* older backend builds omit it and `#[serde(default)]` maps
|
||||
* missing → `false`.
|
||||
*/
|
||||
force_password_change?: boolean;
|
||||
}
|
||||
|
||||
/** Fields rendered by the paginated admin table. Full account details remain
|
||||
|
||||
@@ -19,6 +19,18 @@ class SessionStore {
|
||||
|
||||
isExternalUser = $derived(this.user?.is_external ?? false);
|
||||
isAuthenticated = $derived(this.user !== null);
|
||||
/**
|
||||
* TRUE when the backend has set `force_password_change_at_next_login`
|
||||
* on this account — an admin picked a temporary password and the
|
||||
* user MUST change it before doing anything else. Drives the root
|
||||
* layout's mandatory-mode redirect: any protected route other than
|
||||
* `/profile` bounces back until the flag flips to false.
|
||||
*
|
||||
* Set to false by default so an older backend that predates the
|
||||
* flag (or a malformed `/me` response) doesn't accidentally
|
||||
* quarantine every user.
|
||||
*/
|
||||
mustChangePassword = $derived(this.user?.force_password_change === true);
|
||||
|
||||
/**
|
||||
* Resolve the session once. Probes /api/auth/me; on 401 it makes a single
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import AppShell from '$lib/components/AppShell.svelte';
|
||||
import DialogHost from '$lib/components/DialogHost.svelte';
|
||||
import Toaster from '$lib/components/Toaster.svelte';
|
||||
import { setPasswordChangeRequiredHandler } from '$lib/api/client';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { hashUrlToPath } from '$lib/utils/hashRedirect';
|
||||
@@ -44,6 +45,21 @@
|
||||
// the guard waits for it before deciding.
|
||||
let providers = $state<OidcProviders | null>(null);
|
||||
|
||||
// Wire the fetch-interceptor's mandatory-mode handler to a soft
|
||||
// `goto()` so a stale-tab request that surfaces a 403
|
||||
// `PasswordChangeRequired` routes to `/profile` without a full
|
||||
// page reload — preserving the SPA session, drives cache, etc.
|
||||
// The `next=` carries the intended destination so the profile
|
||||
// page can bounce back after the change lands. Falls back to
|
||||
// `window.location` if no `next` context (see the default handler
|
||||
// in client.ts).
|
||||
setPasswordChangeRequiredHandler(() => {
|
||||
const path = page.url.pathname + page.url.search;
|
||||
void goto(resolve(`/profile?forcePasswordChange=1&next=${encodeURIComponent(path)}`), {
|
||||
replaceState: true
|
||||
});
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
await killLegacyServiceWorker();
|
||||
|
||||
@@ -84,6 +100,34 @@
|
||||
}
|
||||
void goto(resolve(`/login?redirect=${encodeURIComponent(path)}`), { replaceState: true });
|
||||
});
|
||||
|
||||
// Mandatory change-password guard. When the backend has set
|
||||
// `force_password_change_at_next_login` (admin reset), the SPA MUST
|
||||
// keep the user on the profile page until they pick a new password.
|
||||
// The backend also refuses every non-allowlisted endpoint with 403
|
||||
// PasswordChangeRequired — this guard is the UX side of that lock,
|
||||
// so the user sees the password form instead of a wall of 403s
|
||||
// wherever they clicked.
|
||||
//
|
||||
// Runs AFTER the unauthenticated guard so we don't misroute a
|
||||
// still-loading session. Skips the guard on `/login` too — a
|
||||
// signed-out user on the login form doesn't yet have a session
|
||||
// state to consult, and if `session.mustChangePassword` is true
|
||||
// on `/login` (rare — happens if the user reloaded post-login
|
||||
// but before the profile navigation completed), the login-form
|
||||
// success handler will route to `/profile?forcePasswordChange=1`
|
||||
// on its own.
|
||||
$effect(() => {
|
||||
if (!ready) return;
|
||||
if (!session.isAuthenticated) return;
|
||||
if (!session.mustChangePassword) return;
|
||||
const path = page.url.pathname;
|
||||
if (path === '/profile' || isPublic(path)) return;
|
||||
// Preserve the intended destination so the profile page can
|
||||
// bounce back once the password is successfully changed.
|
||||
const next = encodeURIComponent(path);
|
||||
void goto(resolve(`/profile?forcePasswordChange=1&next=${next}`), { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isPublic(page.url.pathname)}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { errorToast } from '$lib/utils/errors';
|
||||
import { relativeTimeAgo } from '$lib/utils/time';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { Pathname } from '$app/types';
|
||||
import { page } from '$app/state';
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import {
|
||||
changePassword,
|
||||
createAppPassword,
|
||||
@@ -13,7 +18,7 @@
|
||||
type AppPassword,
|
||||
type ProfilePatch
|
||||
} from '$lib/api/endpoints/profile';
|
||||
import { getOidcProviders } from '$lib/api/endpoints/auth';
|
||||
import { fetchMe, getOidcProviders } from '$lib/api/endpoints/auth';
|
||||
import { SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { confirmDialog } from '$lib/stores/dialogs.svelte';
|
||||
@@ -68,6 +73,37 @@
|
||||
const canEditImage = $derived(session.user?.can_edit_image === true && isLocal);
|
||||
const showPasswordCard = $derived(isLocal && passwordLoginEnabled);
|
||||
|
||||
/**
|
||||
* Mandatory change-password mode. TRUE when the backend has
|
||||
* flagged the account (`session.mustChangePassword`) OR the URL
|
||||
* carries `?forcePasswordChange=1` (arrived here from the login
|
||||
* form / layout guard). Either signal locks the page into a
|
||||
* single-purpose form: banner + password card only, other cards
|
||||
* hidden. The URL param is a belt-and-braces alongside the store
|
||||
* — a stale-tab session that lost the flag momentarily still
|
||||
* shows the mandatory UI if the URL says so, and the layout guard
|
||||
* will bounce a non-flagged user back off `/profile` naturally.
|
||||
*/
|
||||
const forceModeQueryParam = $derived(page.url.searchParams.get('forcePasswordChange') === '1');
|
||||
const mandatoryMode = $derived(session.mustChangePassword || forceModeQueryParam);
|
||||
/**
|
||||
* Destination to bounce back to after a successful change. Only
|
||||
* consulted in mandatory mode; caller-supplied via `?next=<encoded>`
|
||||
* (added by the layout guard). Falls back to `/files` — the
|
||||
* standard SPA landing point — when absent or when the value
|
||||
* isn't a same-origin path (`startsWith('/')`).
|
||||
*/
|
||||
const nextAfterChange = $derived.by(() => {
|
||||
const raw = page.url.searchParams.get('next');
|
||||
if (!raw) return '/files';
|
||||
try {
|
||||
const decoded = decodeURIComponent(raw);
|
||||
return decoded.startsWith('/') ? decoded : '/files';
|
||||
} catch {
|
||||
return '/files';
|
||||
}
|
||||
});
|
||||
|
||||
const storagePct = $derived(
|
||||
session.user && session.user.storage_quota_bytes > 0
|
||||
? Math.min(
|
||||
@@ -161,18 +197,72 @@
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (newPw === currentPw) {
|
||||
// Fast client-side reject — the backend also enforces this
|
||||
// (400 `PasswordUnchanged`) but the SPA can save the round-
|
||||
// trip. Load-bearing in mandatory mode: silently accepting
|
||||
// same-as-current would clear the force flag without a real
|
||||
// rotation, defeating the "temporary password" pattern.
|
||||
ui.notify(
|
||||
t('profile.password_unchanged', 'New password must differ from the current one.'),
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
savingPassword = true;
|
||||
try {
|
||||
await changePassword(currentPw, newPw);
|
||||
currentPw = newPw = confirmPw = '';
|
||||
ui.notify(t('profile.password_updated', 'Password updated'), 'success');
|
||||
|
||||
// If we're in mandatory mode, the backend just cleared the
|
||||
// force flag AND revoked all sessions. Refresh the session
|
||||
// so the layout guard lifts, then bounce to the intended
|
||||
// destination the layout captured on entry. Refresh order
|
||||
// matters: goto() before the session refresh would race
|
||||
// the layout's `mustChangePassword` derived and re-redirect
|
||||
// us right back to /profile.
|
||||
if (mandatoryMode) {
|
||||
try {
|
||||
const me = await fetchMe();
|
||||
if (me) session.setUser(me);
|
||||
} catch {
|
||||
/* stale session state is recoverable — the next request refreshes it */
|
||||
}
|
||||
await goto(resolve(nextAfterChange as Pathname), { replaceState: true });
|
||||
}
|
||||
} catch (err) {
|
||||
// Remap the backend's `PasswordUnchanged` error_type to a
|
||||
// specific, translatable message — the generic errorToast
|
||||
// would show the raw server string. Every other error
|
||||
// path still flows through errorToast.
|
||||
if (err instanceof ApiError && err.errorType === 'PasswordUnchanged') {
|
||||
ui.notify(
|
||||
t('profile.password_unchanged', 'New password must differ from the current one.'),
|
||||
'error'
|
||||
);
|
||||
} else {
|
||||
errorToast(err);
|
||||
}
|
||||
} finally {
|
||||
savingPassword = false;
|
||||
}
|
||||
}
|
||||
|
||||
// In mandatory mode, focus the current-password input as soon as
|
||||
// the DOM is ready so the user can type without scrolling / clicking
|
||||
// around to find the form. `tick()` waits for the reactive render;
|
||||
// the null-check tolerates the (rare) case where the form isn't
|
||||
// mounted yet on first paint.
|
||||
onMount(async () => {
|
||||
if (!mandatoryMode) return;
|
||||
await tick();
|
||||
const el = document.querySelector<HTMLInputElement>(
|
||||
'[data-testid="profile-current-password-input"]'
|
||||
);
|
||||
el?.focus();
|
||||
});
|
||||
|
||||
// ── Avatar edit panel ──────────────────────────────────────────────────
|
||||
function openAvatarEdit() {
|
||||
avatarEditOpen = true;
|
||||
@@ -304,9 +394,41 @@
|
||||
|
||||
<svelte:head><title>{t('nav.profile', 'Profile')} · OxiCloud</title></svelte:head>
|
||||
|
||||
<main class="profile">
|
||||
<main class="profile" class:profile--mandatory={mandatoryMode}>
|
||||
<h1>{t('nav.profile', 'Profile')}</h1>
|
||||
|
||||
{#if mandatoryMode}
|
||||
<!--
|
||||
Mandatory-mode banner. Rendered above every other section
|
||||
whenever `session.mustChangePassword` is TRUE or the URL
|
||||
carries `?forcePasswordChange=1`. Explains WHY the user
|
||||
landed here (an admin picked a temporary password) and
|
||||
what they need to do (rotate before continuing). Backend
|
||||
also refuses every non-allowlisted endpoint with 403
|
||||
PasswordChangeRequired — so a user who dismisses the
|
||||
banner via URL manipulation still can't reach any file /
|
||||
DAV / admin endpoint until the change lands.
|
||||
-->
|
||||
<div
|
||||
class="mandatory-banner"
|
||||
role="alert"
|
||||
data-testid="profile-mandatory-change-password-banner"
|
||||
>
|
||||
<Icon name="shield-alt" />
|
||||
<div class="mandatory-banner__body">
|
||||
<strong>
|
||||
{t('profile.mandatory_change_title', 'Please change your password to continue.')}
|
||||
</strong>
|
||||
<p>
|
||||
{t(
|
||||
'profile.mandatory_change_body',
|
||||
'An administrator has set a temporary password for your account. Choose your own password below before you can access the rest of the application.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if session.user}
|
||||
<!-- Avatar / identity -->
|
||||
<div class="card avatar-card">
|
||||
@@ -738,7 +860,7 @@
|
||||
|
||||
<!-- Change password -->
|
||||
{#if showPasswordCard}
|
||||
<form class="card" data-testid="profile-password-form" onsubmit={savePassword}>
|
||||
<form class="card password-card" data-testid="profile-password-form" onsubmit={savePassword}>
|
||||
<h2><Icon name="key" /> {t('profile.change_password', 'Change Password')}</h2>
|
||||
<label>
|
||||
<span>{t('profile.current_password', 'Current Password')}</span>
|
||||
@@ -800,6 +922,40 @@
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/*
|
||||
* Mandatory-mode: hide every card except the identity header
|
||||
* (`.avatar-card` keeps context — who am I?) and the password
|
||||
* form. Backend blocks non-allowlisted endpoints with 403 anyway;
|
||||
* this is the UX side of that lock so the user sees exactly one
|
||||
* form to fill in. Ergonomically loud banner + a single card.
|
||||
*/
|
||||
.profile--mandatory :global(.card):not(.avatar-card, .password-card) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mandatory-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--color-bg-warning-subtle, var(--color-bg-surface));
|
||||
border: 1px solid var(--color-border-warning, var(--color-border));
|
||||
border-left: 4px solid var(--color-accent-warning, var(--color-accent));
|
||||
border-radius: var(--radius-md, var(--radius-lg));
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.mandatory-banner__body strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mandatory-banner__body p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.125rem;
|
||||
|
||||
@@ -72,6 +72,28 @@ pub struct UserDto {
|
||||
/// `frontend/src/lib/stores/preferences.svelte.ts`). Always present
|
||||
/// on the wire; empty bag is `{}`, never `null`.
|
||||
pub ui_preferences: serde_json::Value,
|
||||
/// Mirrors `auth.users.force_password_change_at_next_login`. Set
|
||||
/// TRUE by the admin password-reset flow (see
|
||||
/// `AuthApplicationService::admin_reset_password`) and cleared by
|
||||
/// a successful self-service `POST /api/auth/change-password`.
|
||||
///
|
||||
/// Populated only by the `/api/auth/me` handler and the login
|
||||
/// response minter (via a distinct code path). `From<User>` — used
|
||||
/// by admin listings, share-recipient responses, group-member DTOs,
|
||||
/// etc. — leaves it at `false`. The flag is a per-session-account
|
||||
/// concern (does *this* user need to change their password before
|
||||
/// they can proceed?), not a general user attribute worth
|
||||
/// surfacing on every list row.
|
||||
///
|
||||
/// The load-bearing consumer is the SPA's session store: on
|
||||
/// startup and after every refresh, `/me` returns the current
|
||||
/// flag value and the SPA's nav-guard blocks navigation to
|
||||
/// anything but the change-password surface until it flips
|
||||
/// back to false. Backend enforcement is separate (see the
|
||||
/// `require_no_password_change_pending` middleware) — this DTO
|
||||
/// field is what the SPA reads to render the mandatory-mode UI.
|
||||
#[serde(default)]
|
||||
pub force_password_change: bool,
|
||||
}
|
||||
|
||||
/// Compact row returned by the paginated admin user table.
|
||||
@@ -145,6 +167,13 @@ impl From<User> for UserDto {
|
||||
preferred_locale: p.preferred_locale,
|
||||
notify_on_share: p.notify_on_share,
|
||||
ui_preferences: p.ui_preferences,
|
||||
// Defaults to false. The `/me` handler + the login-response
|
||||
// minter populate this via a distinct code path (a
|
||||
// repo read that goes through the auth service's cache);
|
||||
// admin listings and other UserDto consumers deliberately
|
||||
// leave it false — the flag is per-session-account state,
|
||||
// not a general user attribute.
|
||||
force_password_change: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1863,6 +1863,27 @@ impl AuthApplicationService {
|
||||
));
|
||||
}
|
||||
|
||||
// Reject same-as-current. Load-bearing when the caller is on
|
||||
// an admin-picked temp password (force_password_change_at_next_login
|
||||
// = TRUE): silently accepting the same string would clear the
|
||||
// force flag without the user actually rotating the credential,
|
||||
// defeating the whole "temporary password" pattern. Verify
|
||||
// against the stored hash (constant-time via `verify_password`)
|
||||
// rather than string-comparing plaintexts, so length / case
|
||||
// typos on the caller's part still fail cleanly. Handler
|
||||
// layer remaps the message to `error_type: "PasswordUnchanged"`.
|
||||
let same_as_current = self
|
||||
.password_hasher
|
||||
.verify_password(&dto.new_password, hash)
|
||||
.await?;
|
||||
if same_as_current {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"New password must differ from the current password",
|
||||
));
|
||||
}
|
||||
|
||||
// Hash new password and update user
|
||||
let new_hash = self
|
||||
.password_hasher
|
||||
@@ -1889,6 +1910,15 @@ impl AuthApplicationService {
|
||||
);
|
||||
}
|
||||
|
||||
// Evict the cached UserFlags entry so
|
||||
// `require_no_password_change_pending` sees the just-cleared
|
||||
// flag on the next request — otherwise the caller would keep
|
||||
// hitting 403 PasswordChangeRequired until the 30 s TTL rolls
|
||||
// over. (The revoke_all_user_sessions below will force a
|
||||
// re-login anyway, but the cache eviction covers the window
|
||||
// between change_password success and the new session mint.)
|
||||
self.user_flags_cache.invalidate(&user_id).await;
|
||||
|
||||
// Optional: revoke all sessions to force re-login with new password
|
||||
self.session_storage
|
||||
.revoke_all_user_sessions(user_id)
|
||||
@@ -2755,13 +2785,70 @@ impl AuthApplicationService {
|
||||
let hash = self.password_hasher.hash_password(new_password).await?;
|
||||
self.user_storage.change_password(user_id, &hash).await?;
|
||||
|
||||
// Mark the admin-picked password as temporary so the user gets
|
||||
// prompted to pick their own on next login. Two branches:
|
||||
//
|
||||
// * OPAQUE wired: `clear_registration` is the atomic write
|
||||
// that (a) NULLs the OPAQUE envelope + migration mark so
|
||||
// the migrated user drops back to legacy login (the old
|
||||
// envelope is bound to the OLD passphrase and would fail
|
||||
// OPAQUE KE3), and (b) sets `force_password_change`.
|
||||
// Silent-migration on the next legacy login re-mints a
|
||||
// fresh envelope bound to the admin's new password; the
|
||||
// force flag then routes the SPA to change-password.
|
||||
//
|
||||
// * OPAQUE off: no envelope to invalidate; just flip the
|
||||
// force flag directly via user_storage. Same downstream
|
||||
// behaviour — SPA sees force_password_change=true on
|
||||
// the next login response and routes accordingly.
|
||||
//
|
||||
// Both writes are non-fatal (logged at warn on failure): the
|
||||
// password reset itself succeeded, and a stale force flag is
|
||||
// recoverable on the next admin reset.
|
||||
if let Some(opaque) = self.opaque_repo.as_ref() {
|
||||
if let Err(e) = opaque.clear_registration(user_id).await {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.admin_reset_opaque_clear_failed",
|
||||
user_id = %user_id,
|
||||
error = %e,
|
||||
"OPAQUE clear_registration failed during admin password reset — \
|
||||
force flag + envelope invalidation deferred to next opportunity"
|
||||
);
|
||||
}
|
||||
} else if let Err(e) = self.user_storage.set_force_password_change(user_id).await {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.admin_reset_force_flag_failed",
|
||||
user_id = %user_id,
|
||||
error = %e,
|
||||
"set_force_password_change failed during admin password reset — \
|
||||
user will not be prompted to change from admin's temp password"
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate all existing sessions so the user must re-login
|
||||
// with the new password. Mirrors the behaviour of change_password().
|
||||
self.session_storage
|
||||
.revoke_all_user_sessions(user_id)
|
||||
.await?;
|
||||
|
||||
tracing::info!(user_id = %user_id, "Admin reset password — all sessions revoked");
|
||||
// Evict the cached UserFlags row so the next authenticated
|
||||
// request from this user (on their next session) sees the
|
||||
// updated force_password_change value without waiting for
|
||||
// the 30s TTL. The middleware
|
||||
// `require_no_password_change_pending` reads from this cache
|
||||
// — a stale FALSE would keep the API open to the admin's
|
||||
// temp-password holder until the TTL rolled over.
|
||||
self.user_flags_cache.invalidate(&user_id).await;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.admin_reset_password",
|
||||
user_id = %user_id,
|
||||
opaque_wired = self.opaque_repo.is_some(),
|
||||
"👮🏻♂️ Admin reset password — sessions revoked, force-change flag set"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,15 @@ pub struct UserFlags {
|
||||
pub role: UserRole,
|
||||
pub is_external: bool,
|
||||
pub active: bool,
|
||||
/// Mirrors `auth.users.force_password_change_at_next_login`. TRUE
|
||||
/// after an admin password-reset; the `require_no_password_change_pending`
|
||||
/// middleware refuses every authenticated endpoint except the
|
||||
/// change-password / me / logout / refresh allowlist while it's set.
|
||||
/// Cached alongside the other flags so per-request enforcement
|
||||
/// doesn't add a DB round-trip. Eagerly invalidated by
|
||||
/// `admin_reset_password` (flip to TRUE) and `change_password`
|
||||
/// (flip to FALSE) so the gate lifts within one round-trip.
|
||||
pub force_password_change: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -62,7 +62,10 @@ impl UserPgRepository {
|
||||
pub async fn get_user_flags(&self, id: Uuid) -> UserRepositoryResult<UserFlags> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT role::text as role_text, is_external, active
|
||||
SELECT role::text as role_text,
|
||||
is_external,
|
||||
active,
|
||||
force_password_change_at_next_login
|
||||
FROM auth.users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -82,6 +85,7 @@ impl UserPgRepository {
|
||||
role,
|
||||
is_external: row.get("is_external"),
|
||||
active: row.get("active"),
|
||||
force_password_change: row.get("force_password_change_at_next_login"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,6 +165,30 @@ impl UserPgRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set `force_password_change_at_next_login = TRUE`. Used by
|
||||
/// admin-initiated password reset when the OPAQUE substrate is NOT
|
||||
/// wired. When it IS wired, callers should prefer
|
||||
/// `OpaquePgRepository::clear_registration` which does the same
|
||||
/// flag flip AND invalidates the OPAQUE envelope in one UPDATE
|
||||
/// (see the port doc on `clear_registration` for the atomicity
|
||||
/// contract). This method exists so OPAQUE-off deployments still
|
||||
/// get the "admin's temp password prompts change on next login"
|
||||
/// behaviour without having to depend on the OPAQUE code path.
|
||||
pub async fn set_force_password_change(&self, id: Uuid) -> UserRepositoryResult<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users
|
||||
SET force_password_change_at_next_login = TRUE
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates a user's profile image (URL or data URI). Not part of the
|
||||
/// `UserRepository` trait — called directly from `AuthApplicationService`.
|
||||
pub async fn update_image(
|
||||
|
||||
@@ -614,11 +614,24 @@ pub async fn get_current_user(
|
||||
// never count against this envelope — collaborating in a team drive
|
||||
// costs no personal bytes. The matching cap is
|
||||
// `storage_quota_bytes` (admin-only mutation).
|
||||
let user = auth_service
|
||||
let mut user = auth_service
|
||||
.auth_application_service
|
||||
.get_user_by_id(user_id)
|
||||
.await?;
|
||||
|
||||
// Overlay the cached `force_password_change` flag (see UserFlags).
|
||||
// `From<User>` defaults to false; the SPA reads this field on
|
||||
// startup to decide whether to enter mandatory change-password
|
||||
// mode. Using the cached path (`get_user_flags` → `user_flags_cache`)
|
||||
// avoids a second DB round-trip on this hot endpoint.
|
||||
if let Ok(flags) = auth_service
|
||||
.auth_application_service
|
||||
.get_user_flags(user_id)
|
||||
.await
|
||||
{
|
||||
user.force_password_change = flags.force_password_change;
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, Json(user)))
|
||||
}
|
||||
|
||||
@@ -652,12 +665,28 @@ pub async fn change_password(
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
||||
|
||||
auth_service
|
||||
match auth_service
|
||||
.auth_application_service
|
||||
.change_password(user_id, dto)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(StatusCode::OK),
|
||||
Err(err) => {
|
||||
// Remap the same-as-current guard into a stable error_type
|
||||
// the SPA can surface as "pick a different one" without
|
||||
// needing to fall back to the generic 400 message. The
|
||||
// service returns InvalidInput; keep the 400 status but
|
||||
// swap the shape.
|
||||
if err.message == "New password must differ from the current password" {
|
||||
return Err(AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New password must differ from the current password",
|
||||
"PasswordUnchanged",
|
||||
));
|
||||
}
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the authenticated external user into a full internal
|
||||
|
||||
@@ -242,6 +242,129 @@ pub async fn require_internal_user_layer(
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Endpoints the gate lets through even when
|
||||
/// `force_password_change_at_next_login` is TRUE — the caller needs
|
||||
/// them to complete the mandatory reset:
|
||||
///
|
||||
/// * `GET /api/auth/me` — the SPA must be able to read
|
||||
/// the flag (that's what tells it to enter mandatory-mode).
|
||||
/// * `PUT /api/auth/change-password` — the way OUT of the state.
|
||||
/// * `POST /api/auth/logout` — bailing out is always allowed.
|
||||
///
|
||||
/// `/api/auth/refresh` is not on this list because refresh is mounted
|
||||
/// on a rate-limited public path that doesn't carry a `CurrentUser` at
|
||||
/// middleware time; the gate never fires on it. If refresh ever moves
|
||||
/// under the gate, add `(&Method::POST, "/api/auth/refresh")` here.
|
||||
fn is_password_change_pending_allowlisted(
|
||||
method: &axum::http::Method,
|
||||
path: &str,
|
||||
) -> bool {
|
||||
use axum::http::Method;
|
||||
matches!(
|
||||
(method, path),
|
||||
(&Method::GET, "/api/auth/me")
|
||||
| (&Method::PUT, "/api/auth/change-password")
|
||||
| (&Method::POST, "/api/auth/logout")
|
||||
)
|
||||
}
|
||||
|
||||
/// Axum middleware layer that blocks EVERY authenticated request when
|
||||
/// the caller's `force_password_change_at_next_login` flag is TRUE —
|
||||
/// EXCEPT the small allowlist above ([`is_password_change_pending_allowlisted`]).
|
||||
/// Mounted on all authenticated `/api/*` subtrees so an admin-set
|
||||
/// temp password cannot be used to hit files / WebDAV / CalDAV / etc.
|
||||
/// via any non-SPA client.
|
||||
///
|
||||
/// The flag is read from the cached `UserFlags` (same cache the role /
|
||||
/// external guards use — see [`require_internal_user`]), so this adds
|
||||
/// no DB round-trip on the hot path. `admin_reset_password` and
|
||||
/// `change_password` both invalidate the entry eagerly so the gate
|
||||
/// lifts within one request round-trip.
|
||||
///
|
||||
/// Response shape on refusal: `403 { error_type: "PasswordChangeRequired" }`.
|
||||
/// The SPA reads that error_type on any subsequent request that leaks
|
||||
/// past its own nav guard (mid-navigation refresh, stale tab, …) and
|
||||
/// bounces the user back to the change-password screen. Non-SPA
|
||||
/// clients (WebDAV sync, mobile app, curl) get the same 403 — that's
|
||||
/// intentional; they need to log in via the SPA once to complete the
|
||||
/// reset before other clients work again.
|
||||
///
|
||||
/// Must run AFTER the auth middleware. On unauthenticated paths (no
|
||||
/// `CurrentUser` populated) this is a pass-through — the inner
|
||||
/// handler / auth layer will produce the 401.
|
||||
pub async fn require_no_password_change_pending_layer(
|
||||
State(state): State<Arc<AppState>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Cheap path check FIRST — allowlisted endpoints never even hit
|
||||
// the flag lookup. Keeps the /me polling path (which the SPA hits
|
||||
// as part of every session-probe) from doing the cache lookup on
|
||||
// every call, and makes the allowlist trivially auditable in one
|
||||
// place (see `is_password_change_pending_allowlisted`).
|
||||
//
|
||||
// MUST use `OriginalUri` — axum's `.nest("/api/auth", …)` strips
|
||||
// the prefix so `request.uri().path()` returns `/me` inside the
|
||||
// nested router, not `/api/auth/me`. The allowlist is defined
|
||||
// against the operator-visible full URL, so we need the pre-strip
|
||||
// path. `OriginalUri` is set on the request extensions by axum
|
||||
// whenever a nest strips a prefix; falls back to the current path
|
||||
// when this middleware is layered on a top-level (non-nested)
|
||||
// router (defense in depth).
|
||||
let full_path = request
|
||||
.extensions()
|
||||
.get::<axum::extract::OriginalUri>()
|
||||
.map(|uri| uri.0.path().to_owned())
|
||||
.unwrap_or_else(|| request.uri().path().to_owned());
|
||||
if is_password_change_pending_allowlisted(request.method(), &full_path) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
let caller_id = request
|
||||
.extensions()
|
||||
.get::<Arc<CurrentUser>>()
|
||||
.map(|cu| cu.id);
|
||||
|
||||
let (Some(caller_id), Some(svc)) = (
|
||||
caller_id,
|
||||
state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.map(|s| &*s.auth_application_service),
|
||||
) else {
|
||||
return next.run(request).await;
|
||||
};
|
||||
|
||||
// Cached lookup — no DB hit on the hot path. Fail-open on repo
|
||||
// error (the same posture as require_internal_user_layer above):
|
||||
// a transient DB blip must not lock every user out of every API,
|
||||
// and the SPA-side nav guard is a defense-in-depth backstop.
|
||||
let flag = match svc.get_user_flags(caller_id).await {
|
||||
Ok(f) => f.force_password_change,
|
||||
Err(_) => false,
|
||||
};
|
||||
if flag {
|
||||
// Log the operator-visible full path (not the nest-stripped
|
||||
// one). `full_path` was computed above via `OriginalUri`.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.password_change_required_blocked",
|
||||
reason = "force_password_change_pending",
|
||||
caller_id = %caller_id,
|
||||
path = %full_path,
|
||||
"👮🏻♂️ Blocked API access — user must change admin-set temp password first"
|
||||
);
|
||||
return AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Password change required before accessing this endpoint",
|
||||
"PasswordChangeRequired",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -251,6 +374,7 @@ mod tests {
|
||||
role,
|
||||
is_external: false,
|
||||
active,
|
||||
force_password_change: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
-1
@@ -776,6 +776,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let auth_public = auth_public_routes().with_state(app_state.clone());
|
||||
// Protected auth routes (/me, /change-password, /logout) — require auth + CSRF
|
||||
let auth_protected = auth_protected_routes()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
@@ -784,6 +788,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.with_state(app_state.clone());
|
||||
// App password management routes — require auth + CSRF
|
||||
let app_pw_protected = app_password_handler::app_password_routes()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
@@ -802,6 +810,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// is safe.
|
||||
let opaque_register_protected =
|
||||
oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_register_routes()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
@@ -835,6 +847,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
device_auth_handler::device_auth_public_routes().with_state(app_state.clone());
|
||||
// Protected endpoints: /api/auth/device/verify, /api/auth/device/devices
|
||||
let device_protected = device_auth_handler::device_auth_protected_routes()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
@@ -844,6 +860,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Protected API routes — require valid JWT token
|
||||
let protected_api = api_routes
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
@@ -857,8 +877,22 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// surface to a principal kind that can do nothing with it. The
|
||||
// `require_internal_user_layer` runs AFTER auth (tower order:
|
||||
// later .layer() = outermost = runs first).
|
||||
use oxicloud::interfaces::middleware::user::require_internal_user_layer;
|
||||
//
|
||||
// `require_no_password_change_pending_layer` is layered on
|
||||
// every authenticated /api/* subtree so an admin-set temp
|
||||
// password cannot be used against files / WebDAV / CalDAV /
|
||||
// admin from any non-SPA client. The layer allowlists /me,
|
||||
// change-password, and logout internally so the SPA can
|
||||
// complete the reset flow — see the middleware doc for the
|
||||
// allowlist and its rationale.
|
||||
use oxicloud::interfaces::middleware::user::{
|
||||
require_internal_user_layer, require_no_password_change_pending_layer,
|
||||
};
|
||||
let caldav_protected = caldav_router
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_internal_user_layer,
|
||||
@@ -868,6 +902,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
auth_middleware,
|
||||
));
|
||||
let carddav_protected = carddav_router
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_internal_user_layer,
|
||||
@@ -877,6 +915,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
auth_middleware,
|
||||
));
|
||||
let webdav_protected = webdav_router
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_internal_user_layer,
|
||||
|
||||
@@ -130,7 +130,13 @@ Content-Type: application/json
|
||||
|
||||
HTTP 403
|
||||
|
||||
# New password works.
|
||||
# New password works AND the login response carries
|
||||
# `force_password_change: true` — the admin-picked password is
|
||||
# temporary; the SPA reads this to enter mandatory-mode and route
|
||||
# the user to `/profile?forcePasswordChange=1`. See the backend
|
||||
# `admin_reset_password` → `OpaquePgRepository::clear_registration`
|
||||
# (or `UserPgRepository::set_force_password_change` when OPAQUE is
|
||||
# off) for the atomic flag write.
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "charlie-ops", "password": "AdminResetPassword2!" }
|
||||
@@ -138,6 +144,82 @@ Content-Type: application/json
|
||||
HTTP 200
|
||||
[Captures]
|
||||
charlie_token_v2: jsonpath "$.access_token"
|
||||
[Asserts]
|
||||
jsonpath "$.force_password_change" == true
|
||||
|
||||
|
||||
# `require_no_password_change_pending_layer` middleware assertion:
|
||||
# a random authenticated endpoint that ISN'T on the allowlist
|
||||
# (`/me`, `/change-password`, `/logout`) must refuse with
|
||||
# `403 PasswordChangeRequired` while the flag is set. Without
|
||||
# this gate the admin-picked password would let holders reach
|
||||
# files / DAV / admin via any non-SPA client.
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{charlie_token_v2}}
|
||||
|
||||
HTTP 403
|
||||
[Asserts]
|
||||
jsonpath "$.error_type" == "PasswordChangeRequired"
|
||||
|
||||
|
||||
# Same session, but the allowlisted `/api/auth/me` DOES pass —
|
||||
# the SPA needs this to detect the flag and render the mandatory
|
||||
# banner. Response also mirrors the flag so a page reload sees
|
||||
# the same state a fresh login would.
|
||||
GET {{base_url}}/api/auth/me
|
||||
Authorization: Bearer {{charlie_token_v2}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.force_password_change" == true
|
||||
|
||||
|
||||
# Trying to change back to the SAME password must fail with a
|
||||
# distinct `PasswordUnchanged` error_type — silently accepting
|
||||
# the no-op would clear the force flag without actually rotating
|
||||
# the credential, defeating the temporary-password pattern.
|
||||
PUT {{base_url}}/api/auth/change-password
|
||||
Authorization: Bearer {{charlie_token_v2}}
|
||||
Content-Type: application/json
|
||||
{ "current_password": "AdminResetPassword2!", "new_password": "AdminResetPassword2!" }
|
||||
|
||||
HTTP 400
|
||||
[Asserts]
|
||||
jsonpath "$.error_type" == "PasswordUnchanged"
|
||||
|
||||
|
||||
# Change to a genuinely different password: succeeds AND
|
||||
# `change_password` revokes all sessions (per its own contract);
|
||||
# the CURRENT token stops working right after. That side effect
|
||||
# is what forces the user through a fresh login where the flag
|
||||
# is now cleared.
|
||||
PUT {{base_url}}/api/auth/change-password
|
||||
Authorization: Bearer {{charlie_token_v2}}
|
||||
Content-Type: application/json
|
||||
{ "current_password": "AdminResetPassword2!", "new_password": "CharliePicked3!" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# Fresh login with the user-picked password: succeeds AND the
|
||||
# flag has flipped back to false, so mandatory-mode is off.
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "charlie-ops", "password": "CharliePicked3!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
charlie_token_v3: jsonpath "$.access_token"
|
||||
[Asserts]
|
||||
jsonpath "$.force_password_change" == false
|
||||
|
||||
|
||||
# Same random endpoint that 403'd above now succeeds — the gate
|
||||
# has lifted.
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{charlie_token_v3}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -155,7 +237,7 @@ HTTP 200
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "charlie-ops", "password": "AdminResetPassword2!" }
|
||||
{ "username": "charlie-ops", "password": "CharliePicked3!" }
|
||||
|
||||
HTTP 403
|
||||
|
||||
@@ -172,7 +254,7 @@ HTTP 200
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "charlie-ops", "password": "AdminResetPassword2!" }
|
||||
{ "username": "charlie-ops", "password": "CharliePicked3!" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
Reference in New Issue
Block a user