feat(pass reset): request a pass change on 1st login

This commit is contained in:
Edouard Vanbelle
2026-08-04 21:05:08 +02:00
parent 6965855388
commit 2de476d281
13 changed files with 731 additions and 19 deletions
+60 -1
View File
@@ -48,6 +48,13 @@ export interface ApiClientDeps {
rawFetch: FetchFn; rawFetch: FetchFn;
/** Invoked once when a refresh definitively fails (clear session + redirect). */ /** Invoked once when a refresh definitively fails (clear session + redirect). */
onSessionExpired: () => void; 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`. */ /** Test seam for `window.location.origin`. */
origin?: string; origin?: string;
} }
@@ -77,6 +84,10 @@ function bypassesRetry(urlStr: string): boolean {
*/ */
export function createApiFetch(deps: ApiClientDeps): FetchFn { export function createApiFetch(deps: ApiClientDeps): FetchFn {
const { rawFetch, onSessionExpired } = deps; 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; let refreshInFlight: Promise<boolean> | null = null;
async function refresh(): Promise<boolean> { async function refresh(): Promise<boolean> {
@@ -114,6 +125,32 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
// refresh doesn't accidentally clear a live banner. // refresh doesn't accidentally clear a live banner.
updateFromHeader(response.headers.get(SERVER_STATUS_HEADER)); 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; if (response.status !== 401) return response;
const urlStr = urlString(input as RequestInfo | URL); const urlStr = urlString(input as RequestInfo | URL);
@@ -146,13 +183,35 @@ export function setSessionExpiredHandler(fn: () => void): void {
sessionExpiredHandler = fn; 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 = const rawFetch: FetchFn =
typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : (undefined as never); typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : (undefined as never);
/** App-wide fetch — route every API call through this. */ /** App-wide fetch — route every API call through this. */
export const apiFetch: FetchFn = createApiFetch({ export const apiFetch: FetchFn = createApiFetch({
rawFetch, rawFetch,
onSessionExpired: () => sessionExpiredHandler() onSessionExpired: () => sessionExpiredHandler(),
onPasswordChangeRequired: () => passwordChangeRequiredHandler()
}); });
/** Convenience: fetch JSON, throwing on non-2xx. */ /** Convenience: fetch JSON, throwing on non-2xx. */
+11
View File
@@ -216,6 +216,17 @@ export interface User {
* delete it from the bag. * delete it from the bag.
*/ */
ui_preferences: Record<string, unknown>; 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 /** Fields rendered by the paginated admin table. Full account details remain
+12
View File
@@ -19,6 +19,18 @@ class SessionStore {
isExternalUser = $derived(this.user?.is_external ?? false); isExternalUser = $derived(this.user?.is_external ?? false);
isAuthenticated = $derived(this.user !== null); 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 * Resolve the session once. Probes /api/auth/me; on 401 it makes a single
+44
View File
@@ -8,6 +8,7 @@
import AppShell from '$lib/components/AppShell.svelte'; import AppShell from '$lib/components/AppShell.svelte';
import DialogHost from '$lib/components/DialogHost.svelte'; import DialogHost from '$lib/components/DialogHost.svelte';
import Toaster from '$lib/components/Toaster.svelte'; import Toaster from '$lib/components/Toaster.svelte';
import { setPasswordChangeRequiredHandler } from '$lib/api/client';
import { session } from '$lib/stores/session.svelte'; import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte'; import { ui } from '$lib/stores/ui.svelte';
import { hashUrlToPath } from '$lib/utils/hashRedirect'; import { hashUrlToPath } from '$lib/utils/hashRedirect';
@@ -44,6 +45,21 @@
// the guard waits for it before deciding. // the guard waits for it before deciding.
let providers = $state<OidcProviders | null>(null); 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 () => { onMount(async () => {
await killLegacyServiceWorker(); await killLegacyServiceWorker();
@@ -84,6 +100,34 @@
} }
void goto(resolve(`/login?redirect=${encodeURIComponent(path)}`), { replaceState: true }); 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> </script>
{#if isPublic(page.url.pathname)} {#if isPublic(page.url.pathname)}
+161 -5
View File
@@ -1,7 +1,12 @@
<script lang="ts"> <script lang="ts">
import { errorToast } from '$lib/utils/errors'; import { errorToast } from '$lib/utils/errors';
import { relativeTimeAgo } from '$lib/utils/time'; 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 { import {
changePassword, changePassword,
createAppPassword, createAppPassword,
@@ -13,7 +18,7 @@
type AppPassword, type AppPassword,
type ProfilePatch type ProfilePatch
} from '$lib/api/endpoints/profile'; } 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 { SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import Icon from '$lib/icons/Icon.svelte'; import Icon from '$lib/icons/Icon.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte'; import { confirmDialog } from '$lib/stores/dialogs.svelte';
@@ -68,6 +73,37 @@
const canEditImage = $derived(session.user?.can_edit_image === true && isLocal); const canEditImage = $derived(session.user?.can_edit_image === true && isLocal);
const showPasswordCard = $derived(isLocal && passwordLoginEnabled); 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( const storagePct = $derived(
session.user && session.user.storage_quota_bytes > 0 session.user && session.user.storage_quota_bytes > 0
? Math.min( ? Math.min(
@@ -161,18 +197,72 @@
); );
return; 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; savingPassword = true;
try { try {
await changePassword(currentPw, newPw); await changePassword(currentPw, newPw);
currentPw = newPw = confirmPw = ''; currentPw = newPw = confirmPw = '';
ui.notify(t('profile.password_updated', 'Password updated'), 'success'); 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) { } catch (err) {
errorToast(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 { } finally {
savingPassword = false; 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 ────────────────────────────────────────────────── // ── Avatar edit panel ──────────────────────────────────────────────────
function openAvatarEdit() { function openAvatarEdit() {
avatarEditOpen = true; avatarEditOpen = true;
@@ -304,9 +394,41 @@
<svelte:head><title>{t('nav.profile', 'Profile')} · OxiCloud</title></svelte:head> <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> <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} {#if session.user}
<!-- Avatar / identity --> <!-- Avatar / identity -->
<div class="card avatar-card"> <div class="card avatar-card">
@@ -738,7 +860,7 @@
<!-- Change password --> <!-- Change password -->
{#if showPasswordCard} {#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> <h2><Icon name="key" /> {t('profile.change_password', 'Change Password')}</h2>
<label> <label>
<span>{t('profile.current_password', 'Current Password')}</span> <span>{t('profile.current_password', 'Current Password')}</span>
@@ -800,6 +922,40 @@
border-radius: var(--radius-lg); 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 { .card h2 {
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
font-size: 1.125rem; font-size: 1.125rem;
+29
View File
@@ -72,6 +72,28 @@ pub struct UserDto {
/// `frontend/src/lib/stores/preferences.svelte.ts`). Always present /// `frontend/src/lib/stores/preferences.svelte.ts`). Always present
/// on the wire; empty bag is `{}`, never `null`. /// on the wire; empty bag is `{}`, never `null`.
pub ui_preferences: serde_json::Value, 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. /// Compact row returned by the paginated admin user table.
@@ -145,6 +167,13 @@ impl From<User> for UserDto {
preferred_locale: p.preferred_locale, preferred_locale: p.preferred_locale,
notify_on_share: p.notify_on_share, notify_on_share: p.notify_on_share,
ui_preferences: p.ui_preferences, 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 // Hash new password and update user
let new_hash = self let new_hash = self
.password_hasher .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 // Optional: revoke all sessions to force re-login with new password
self.session_storage self.session_storage
.revoke_all_user_sessions(user_id) .revoke_all_user_sessions(user_id)
@@ -2755,13 +2785,70 @@ impl AuthApplicationService {
let hash = self.password_hasher.hash_password(new_password).await?; let hash = self.password_hasher.hash_password(new_password).await?;
self.user_storage.change_password(user_id, &hash).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 // Invalidate all existing sessions so the user must re-login
// with the new password. Mirrors the behaviour of change_password(). // with the new password. Mirrors the behaviour of change_password().
self.session_storage self.session_storage
.revoke_all_user_sessions(user_id) .revoke_all_user_sessions(user_id)
.await?; .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(()) Ok(())
} }
+9
View File
@@ -38,6 +38,15 @@ pub struct UserFlags {
pub role: UserRole, pub role: UserRole,
pub is_external: bool, pub is_external: bool,
pub active: 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)] #[derive(Debug, Clone)]
@@ -62,9 +62,12 @@ impl UserPgRepository {
pub async fn get_user_flags(&self, id: Uuid) -> UserRepositoryResult<UserFlags> { pub async fn get_user_flags(&self, id: Uuid) -> UserRepositoryResult<UserFlags> {
let row = sqlx::query( let row = sqlx::query(
r#" r#"
SELECT role::text as role_text, is_external, active SELECT role::text as role_text,
FROM auth.users is_external,
WHERE id = $1 active,
force_password_change_at_next_login
FROM auth.users
WHERE id = $1
"#, "#,
) )
.bind(id) .bind(id)
@@ -82,6 +85,7 @@ impl UserPgRepository {
role, role,
is_external: row.get("is_external"), is_external: row.get("is_external"),
active: row.get("active"), active: row.get("active"),
force_password_change: row.get("force_password_change_at_next_login"),
}) })
} }
@@ -161,6 +165,30 @@ impl UserPgRepository {
Ok(()) 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 /// Updates a user's profile image (URL or data URI). Not part of the
/// `UserRepository` trait — called directly from `AuthApplicationService`. /// `UserRepository` trait — called directly from `AuthApplicationService`.
pub async fn update_image( pub async fn update_image(
+34 -5
View File
@@ -614,11 +614,24 @@ pub async fn get_current_user(
// never count against this envelope — collaborating in a team drive // never count against this envelope — collaborating in a team drive
// costs no personal bytes. The matching cap is // costs no personal bytes. The matching cap is
// `storage_quota_bytes` (admin-only mutation). // `storage_quota_bytes` (admin-only mutation).
let user = auth_service let mut user = auth_service
.auth_application_service .auth_application_service
.get_user_by_id(user_id) .get_user_by_id(user_id)
.await?; .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))) Ok((StatusCode::OK, Json(user)))
} }
@@ -652,12 +665,28 @@ pub async fn change_password(
.as_ref() .as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
auth_service match auth_service
.auth_application_service .auth_application_service
.change_password(user_id, dto) .change_password(user_id, dto)
.await?; .await
{
Ok(StatusCode::OK) 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 /// Convert the authenticated external user into a full internal
+124
View File
@@ -242,6 +242,129 @@ pub async fn require_internal_user_layer(
next.run(request).await 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -251,6 +374,7 @@ mod tests {
role, role,
is_external: false, is_external: false,
active, active,
force_password_change: false,
} }
} }
+43 -1
View File
@@ -776,6 +776,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let auth_public = auth_public_routes().with_state(app_state.clone()); let auth_public = auth_public_routes().with_state(app_state.clone());
// Protected auth routes (/me, /change-password, /logout) — require auth + CSRF // Protected auth routes (/me, /change-password, /logout) — require auth + CSRF
let auth_protected = auth_protected_routes() 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(csrf_middleware))
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
app_state.clone(), app_state.clone(),
@@ -784,6 +788,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.with_state(app_state.clone()); .with_state(app_state.clone());
// App password management routes — require auth + CSRF // App password management routes — require auth + CSRF
let app_pw_protected = app_password_handler::app_password_routes() 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(csrf_middleware))
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
app_state.clone(), app_state.clone(),
@@ -802,6 +810,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
// is safe. // is safe.
let opaque_register_protected = let opaque_register_protected =
oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_register_routes() 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(csrf_middleware))
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
app_state.clone(), 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()); device_auth_handler::device_auth_public_routes().with_state(app_state.clone());
// Protected endpoints: /api/auth/device/verify, /api/auth/device/devices // Protected endpoints: /api/auth/device/verify, /api/auth/device/devices
let device_protected = device_auth_handler::device_auth_protected_routes() 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(csrf_middleware))
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
app_state.clone(), app_state.clone(),
@@ -844,6 +860,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
// Protected API routes — require valid JWT token // Protected API routes — require valid JWT token
let protected_api = api_routes 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(csrf_middleware))
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
app_state.clone(), 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 // surface to a principal kind that can do nothing with it. The
// `require_internal_user_layer` runs AFTER auth (tower order: // `require_internal_user_layer` runs AFTER auth (tower order:
// later .layer() = outermost = runs first). // 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 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( .layer(axum::middleware::from_fn_with_state(
app_state.clone(), app_state.clone(),
require_internal_user_layer, require_internal_user_layer,
@@ -868,6 +902,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
auth_middleware, auth_middleware,
)); ));
let carddav_protected = carddav_router 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( .layer(axum::middleware::from_fn_with_state(
app_state.clone(), app_state.clone(),
require_internal_user_layer, require_internal_user_layer,
@@ -877,6 +915,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
auth_middleware, auth_middleware,
)); ));
let webdav_protected = webdav_router 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( .layer(axum::middleware::from_fn_with_state(
app_state.clone(), app_state.clone(),
require_internal_user_layer, require_internal_user_layer,
+85 -3
View File
@@ -130,7 +130,13 @@ Content-Type: application/json
HTTP 403 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 POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
{ "username": "charlie-ops", "password": "AdminResetPassword2!" } { "username": "charlie-ops", "password": "AdminResetPassword2!" }
@@ -138,6 +144,82 @@ Content-Type: application/json
HTTP 200 HTTP 200
[Captures] [Captures]
charlie_token_v2: jsonpath "$.access_token" 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 POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
{ "username": "charlie-ops", "password": "AdminResetPassword2!" } { "username": "charlie-ops", "password": "CharliePicked3!" }
HTTP 403 HTTP 403
@@ -172,7 +254,7 @@ HTTP 200
POST {{base_url}}/api/auth/login POST {{base_url}}/api/auth/login
Content-Type: application/json Content-Type: application/json
{ "username": "charlie-ops", "password": "AdminResetPassword2!" } { "username": "charlie-ops", "password": "CharliePicked3!" }
HTTP 200 HTTP 200