feat(pass reset): request a pass change on 1st login
This commit is contained in:
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
// 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);
|
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;
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +62,10 @@ 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,
|
||||||
|
is_external,
|
||||||
|
active,
|
||||||
|
force_password_change_at_next_login
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#,
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user