feat(pass reset): request a pass change on 1st login
This commit is contained in:
@@ -72,6 +72,28 @@ pub struct UserDto {
|
||||
/// `frontend/src/lib/stores/preferences.svelte.ts`). Always present
|
||||
/// on the wire; empty bag is `{}`, never `null`.
|
||||
pub ui_preferences: serde_json::Value,
|
||||
/// Mirrors `auth.users.force_password_change_at_next_login`. Set
|
||||
/// TRUE by the admin password-reset flow (see
|
||||
/// `AuthApplicationService::admin_reset_password`) and cleared by
|
||||
/// a successful self-service `POST /api/auth/change-password`.
|
||||
///
|
||||
/// Populated only by the `/api/auth/me` handler and the login
|
||||
/// response minter (via a distinct code path). `From<User>` — used
|
||||
/// by admin listings, share-recipient responses, group-member DTOs,
|
||||
/// etc. — leaves it at `false`. The flag is a per-session-account
|
||||
/// concern (does *this* user need to change their password before
|
||||
/// they can proceed?), not a general user attribute worth
|
||||
/// surfacing on every list row.
|
||||
///
|
||||
/// The load-bearing consumer is the SPA's session store: on
|
||||
/// startup and after every refresh, `/me` returns the current
|
||||
/// flag value and the SPA's nav-guard blocks navigation to
|
||||
/// anything but the change-password surface until it flips
|
||||
/// back to false. Backend enforcement is separate (see the
|
||||
/// `require_no_password_change_pending` middleware) — this DTO
|
||||
/// field is what the SPA reads to render the mandatory-mode UI.
|
||||
#[serde(default)]
|
||||
pub force_password_change: bool,
|
||||
}
|
||||
|
||||
/// Compact row returned by the paginated admin user table.
|
||||
@@ -145,6 +167,13 @@ impl From<User> for UserDto {
|
||||
preferred_locale: p.preferred_locale,
|
||||
notify_on_share: p.notify_on_share,
|
||||
ui_preferences: p.ui_preferences,
|
||||
// Defaults to false. The `/me` handler + the login-response
|
||||
// minter populate this via a distinct code path (a
|
||||
// repo read that goes through the auth service's cache);
|
||||
// admin listings and other UserDto consumers deliberately
|
||||
// leave it false — the flag is per-session-account state,
|
||||
// not a general user attribute.
|
||||
force_password_change: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1863,6 +1863,27 @@ impl AuthApplicationService {
|
||||
));
|
||||
}
|
||||
|
||||
// Reject same-as-current. Load-bearing when the caller is on
|
||||
// an admin-picked temp password (force_password_change_at_next_login
|
||||
// = TRUE): silently accepting the same string would clear the
|
||||
// force flag without the user actually rotating the credential,
|
||||
// defeating the whole "temporary password" pattern. Verify
|
||||
// against the stored hash (constant-time via `verify_password`)
|
||||
// rather than string-comparing plaintexts, so length / case
|
||||
// typos on the caller's part still fail cleanly. Handler
|
||||
// layer remaps the message to `error_type: "PasswordUnchanged"`.
|
||||
let same_as_current = self
|
||||
.password_hasher
|
||||
.verify_password(&dto.new_password, hash)
|
||||
.await?;
|
||||
if same_as_current {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"User",
|
||||
"New password must differ from the current password",
|
||||
));
|
||||
}
|
||||
|
||||
// Hash new password and update user
|
||||
let new_hash = self
|
||||
.password_hasher
|
||||
@@ -1889,6 +1910,15 @@ impl AuthApplicationService {
|
||||
);
|
||||
}
|
||||
|
||||
// Evict the cached UserFlags entry so
|
||||
// `require_no_password_change_pending` sees the just-cleared
|
||||
// flag on the next request — otherwise the caller would keep
|
||||
// hitting 403 PasswordChangeRequired until the 30 s TTL rolls
|
||||
// over. (The revoke_all_user_sessions below will force a
|
||||
// re-login anyway, but the cache eviction covers the window
|
||||
// between change_password success and the new session mint.)
|
||||
self.user_flags_cache.invalidate(&user_id).await;
|
||||
|
||||
// Optional: revoke all sessions to force re-login with new password
|
||||
self.session_storage
|
||||
.revoke_all_user_sessions(user_id)
|
||||
@@ -2755,13 +2785,70 @@ impl AuthApplicationService {
|
||||
let hash = self.password_hasher.hash_password(new_password).await?;
|
||||
self.user_storage.change_password(user_id, &hash).await?;
|
||||
|
||||
// Mark the admin-picked password as temporary so the user gets
|
||||
// prompted to pick their own on next login. Two branches:
|
||||
//
|
||||
// * OPAQUE wired: `clear_registration` is the atomic write
|
||||
// that (a) NULLs the OPAQUE envelope + migration mark so
|
||||
// the migrated user drops back to legacy login (the old
|
||||
// envelope is bound to the OLD passphrase and would fail
|
||||
// OPAQUE KE3), and (b) sets `force_password_change`.
|
||||
// Silent-migration on the next legacy login re-mints a
|
||||
// fresh envelope bound to the admin's new password; the
|
||||
// force flag then routes the SPA to change-password.
|
||||
//
|
||||
// * OPAQUE off: no envelope to invalidate; just flip the
|
||||
// force flag directly via user_storage. Same downstream
|
||||
// behaviour — SPA sees force_password_change=true on
|
||||
// the next login response and routes accordingly.
|
||||
//
|
||||
// Both writes are non-fatal (logged at warn on failure): the
|
||||
// password reset itself succeeded, and a stale force flag is
|
||||
// recoverable on the next admin reset.
|
||||
if let Some(opaque) = self.opaque_repo.as_ref() {
|
||||
if let Err(e) = opaque.clear_registration(user_id).await {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.admin_reset_opaque_clear_failed",
|
||||
user_id = %user_id,
|
||||
error = %e,
|
||||
"OPAQUE clear_registration failed during admin password reset — \
|
||||
force flag + envelope invalidation deferred to next opportunity"
|
||||
);
|
||||
}
|
||||
} else if let Err(e) = self.user_storage.set_force_password_change(user_id).await {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.admin_reset_force_flag_failed",
|
||||
user_id = %user_id,
|
||||
error = %e,
|
||||
"set_force_password_change failed during admin password reset — \
|
||||
user will not be prompted to change from admin's temp password"
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate all existing sessions so the user must re-login
|
||||
// with the new password. Mirrors the behaviour of change_password().
|
||||
self.session_storage
|
||||
.revoke_all_user_sessions(user_id)
|
||||
.await?;
|
||||
|
||||
tracing::info!(user_id = %user_id, "Admin reset password — all sessions revoked");
|
||||
// Evict the cached UserFlags row so the next authenticated
|
||||
// request from this user (on their next session) sees the
|
||||
// updated force_password_change value without waiting for
|
||||
// the 30s TTL. The middleware
|
||||
// `require_no_password_change_pending` reads from this cache
|
||||
// — a stale FALSE would keep the API open to the admin's
|
||||
// temp-password holder until the TTL rolled over.
|
||||
self.user_flags_cache.invalidate(&user_id).await;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.admin_reset_password",
|
||||
user_id = %user_id,
|
||||
opaque_wired = self.opaque_repo.is_some(),
|
||||
"👮🏻♂️ Admin reset password — sessions revoked, force-change flag set"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,15 @@ pub struct UserFlags {
|
||||
pub role: UserRole,
|
||||
pub is_external: bool,
|
||||
pub active: bool,
|
||||
/// Mirrors `auth.users.force_password_change_at_next_login`. TRUE
|
||||
/// after an admin password-reset; the `require_no_password_change_pending`
|
||||
/// middleware refuses every authenticated endpoint except the
|
||||
/// change-password / me / logout / refresh allowlist while it's set.
|
||||
/// Cached alongside the other flags so per-request enforcement
|
||||
/// doesn't add a DB round-trip. Eagerly invalidated by
|
||||
/// `admin_reset_password` (flip to TRUE) and `change_password`
|
||||
/// (flip to FALSE) so the gate lifts within one round-trip.
|
||||
pub force_password_change: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -62,9 +62,12 @@ impl UserPgRepository {
|
||||
pub async fn get_user_flags(&self, id: Uuid) -> UserRepositoryResult<UserFlags> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT role::text as role_text, is_external, active
|
||||
FROM auth.users
|
||||
WHERE id = $1
|
||||
SELECT role::text as role_text,
|
||||
is_external,
|
||||
active,
|
||||
force_password_change_at_next_login
|
||||
FROM auth.users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -82,6 +85,7 @@ impl UserPgRepository {
|
||||
role,
|
||||
is_external: row.get("is_external"),
|
||||
active: row.get("active"),
|
||||
force_password_change: row.get("force_password_change_at_next_login"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,6 +165,30 @@ impl UserPgRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set `force_password_change_at_next_login = TRUE`. Used by
|
||||
/// admin-initiated password reset when the OPAQUE substrate is NOT
|
||||
/// wired. When it IS wired, callers should prefer
|
||||
/// `OpaquePgRepository::clear_registration` which does the same
|
||||
/// flag flip AND invalidates the OPAQUE envelope in one UPDATE
|
||||
/// (see the port doc on `clear_registration` for the atomicity
|
||||
/// contract). This method exists so OPAQUE-off deployments still
|
||||
/// get the "admin's temp password prompts change on next login"
|
||||
/// behaviour without having to depend on the OPAQUE code path.
|
||||
pub async fn set_force_password_change(&self, id: Uuid) -> UserRepositoryResult<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users
|
||||
SET force_password_change_at_next_login = TRUE
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&*self.pool)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates a user's profile image (URL or data URI). Not part of the
|
||||
/// `UserRepository` trait — called directly from `AuthApplicationService`.
|
||||
pub async fn update_image(
|
||||
|
||||
@@ -614,11 +614,24 @@ pub async fn get_current_user(
|
||||
// never count against this envelope — collaborating in a team drive
|
||||
// costs no personal bytes. The matching cap is
|
||||
// `storage_quota_bytes` (admin-only mutation).
|
||||
let user = auth_service
|
||||
let mut user = auth_service
|
||||
.auth_application_service
|
||||
.get_user_by_id(user_id)
|
||||
.await?;
|
||||
|
||||
// Overlay the cached `force_password_change` flag (see UserFlags).
|
||||
// `From<User>` defaults to false; the SPA reads this field on
|
||||
// startup to decide whether to enter mandatory change-password
|
||||
// mode. Using the cached path (`get_user_flags` → `user_flags_cache`)
|
||||
// avoids a second DB round-trip on this hot endpoint.
|
||||
if let Ok(flags) = auth_service
|
||||
.auth_application_service
|
||||
.get_user_flags(user_id)
|
||||
.await
|
||||
{
|
||||
user.force_password_change = flags.force_password_change;
|
||||
}
|
||||
|
||||
Ok((StatusCode::OK, Json(user)))
|
||||
}
|
||||
|
||||
@@ -652,12 +665,28 @@ pub async fn change_password(
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
||||
|
||||
auth_service
|
||||
match auth_service
|
||||
.auth_application_service
|
||||
.change_password(user_id, dto)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(StatusCode::OK),
|
||||
Err(err) => {
|
||||
// Remap the same-as-current guard into a stable error_type
|
||||
// the SPA can surface as "pick a different one" without
|
||||
// needing to fall back to the generic 400 message. The
|
||||
// service returns InvalidInput; keep the 400 status but
|
||||
// swap the shape.
|
||||
if err.message == "New password must differ from the current password" {
|
||||
return Err(AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New password must differ from the current password",
|
||||
"PasswordUnchanged",
|
||||
));
|
||||
}
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the authenticated external user into a full internal
|
||||
|
||||
@@ -242,6 +242,129 @@ pub async fn require_internal_user_layer(
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// Endpoints the gate lets through even when
|
||||
/// `force_password_change_at_next_login` is TRUE — the caller needs
|
||||
/// them to complete the mandatory reset:
|
||||
///
|
||||
/// * `GET /api/auth/me` — the SPA must be able to read
|
||||
/// the flag (that's what tells it to enter mandatory-mode).
|
||||
/// * `PUT /api/auth/change-password` — the way OUT of the state.
|
||||
/// * `POST /api/auth/logout` — bailing out is always allowed.
|
||||
///
|
||||
/// `/api/auth/refresh` is not on this list because refresh is mounted
|
||||
/// on a rate-limited public path that doesn't carry a `CurrentUser` at
|
||||
/// middleware time; the gate never fires on it. If refresh ever moves
|
||||
/// under the gate, add `(&Method::POST, "/api/auth/refresh")` here.
|
||||
fn is_password_change_pending_allowlisted(
|
||||
method: &axum::http::Method,
|
||||
path: &str,
|
||||
) -> bool {
|
||||
use axum::http::Method;
|
||||
matches!(
|
||||
(method, path),
|
||||
(&Method::GET, "/api/auth/me")
|
||||
| (&Method::PUT, "/api/auth/change-password")
|
||||
| (&Method::POST, "/api/auth/logout")
|
||||
)
|
||||
}
|
||||
|
||||
/// Axum middleware layer that blocks EVERY authenticated request when
|
||||
/// the caller's `force_password_change_at_next_login` flag is TRUE —
|
||||
/// EXCEPT the small allowlist above ([`is_password_change_pending_allowlisted`]).
|
||||
/// Mounted on all authenticated `/api/*` subtrees so an admin-set
|
||||
/// temp password cannot be used to hit files / WebDAV / CalDAV / etc.
|
||||
/// via any non-SPA client.
|
||||
///
|
||||
/// The flag is read from the cached `UserFlags` (same cache the role /
|
||||
/// external guards use — see [`require_internal_user`]), so this adds
|
||||
/// no DB round-trip on the hot path. `admin_reset_password` and
|
||||
/// `change_password` both invalidate the entry eagerly so the gate
|
||||
/// lifts within one request round-trip.
|
||||
///
|
||||
/// Response shape on refusal: `403 { error_type: "PasswordChangeRequired" }`.
|
||||
/// The SPA reads that error_type on any subsequent request that leaks
|
||||
/// past its own nav guard (mid-navigation refresh, stale tab, …) and
|
||||
/// bounces the user back to the change-password screen. Non-SPA
|
||||
/// clients (WebDAV sync, mobile app, curl) get the same 403 — that's
|
||||
/// intentional; they need to log in via the SPA once to complete the
|
||||
/// reset before other clients work again.
|
||||
///
|
||||
/// Must run AFTER the auth middleware. On unauthenticated paths (no
|
||||
/// `CurrentUser` populated) this is a pass-through — the inner
|
||||
/// handler / auth layer will produce the 401.
|
||||
pub async fn require_no_password_change_pending_layer(
|
||||
State(state): State<Arc<AppState>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Cheap path check FIRST — allowlisted endpoints never even hit
|
||||
// the flag lookup. Keeps the /me polling path (which the SPA hits
|
||||
// as part of every session-probe) from doing the cache lookup on
|
||||
// every call, and makes the allowlist trivially auditable in one
|
||||
// place (see `is_password_change_pending_allowlisted`).
|
||||
//
|
||||
// MUST use `OriginalUri` — axum's `.nest("/api/auth", …)` strips
|
||||
// the prefix so `request.uri().path()` returns `/me` inside the
|
||||
// nested router, not `/api/auth/me`. The allowlist is defined
|
||||
// against the operator-visible full URL, so we need the pre-strip
|
||||
// path. `OriginalUri` is set on the request extensions by axum
|
||||
// whenever a nest strips a prefix; falls back to the current path
|
||||
// when this middleware is layered on a top-level (non-nested)
|
||||
// router (defense in depth).
|
||||
let full_path = request
|
||||
.extensions()
|
||||
.get::<axum::extract::OriginalUri>()
|
||||
.map(|uri| uri.0.path().to_owned())
|
||||
.unwrap_or_else(|| request.uri().path().to_owned());
|
||||
if is_password_change_pending_allowlisted(request.method(), &full_path) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
let caller_id = request
|
||||
.extensions()
|
||||
.get::<Arc<CurrentUser>>()
|
||||
.map(|cu| cu.id);
|
||||
|
||||
let (Some(caller_id), Some(svc)) = (
|
||||
caller_id,
|
||||
state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.map(|s| &*s.auth_application_service),
|
||||
) else {
|
||||
return next.run(request).await;
|
||||
};
|
||||
|
||||
// Cached lookup — no DB hit on the hot path. Fail-open on repo
|
||||
// error (the same posture as require_internal_user_layer above):
|
||||
// a transient DB blip must not lock every user out of every API,
|
||||
// and the SPA-side nav guard is a defense-in-depth backstop.
|
||||
let flag = match svc.get_user_flags(caller_id).await {
|
||||
Ok(f) => f.force_password_change,
|
||||
Err(_) => false,
|
||||
};
|
||||
if flag {
|
||||
// Log the operator-visible full path (not the nest-stripped
|
||||
// one). `full_path` was computed above via `OriginalUri`.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.password_change_required_blocked",
|
||||
reason = "force_password_change_pending",
|
||||
caller_id = %caller_id,
|
||||
path = %full_path,
|
||||
"👮🏻♂️ Blocked API access — user must change admin-set temp password first"
|
||||
);
|
||||
return AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Password change required before accessing this endpoint",
|
||||
"PasswordChangeRequired",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -251,6 +374,7 @@ mod tests {
|
||||
role,
|
||||
is_external: false,
|
||||
active,
|
||||
force_password_change: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
-1
@@ -776,6 +776,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let auth_public = auth_public_routes().with_state(app_state.clone());
|
||||
// Protected auth routes (/me, /change-password, /logout) — require auth + CSRF
|
||||
let auth_protected = auth_protected_routes()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
@@ -784,6 +788,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.with_state(app_state.clone());
|
||||
// App password management routes — require auth + CSRF
|
||||
let app_pw_protected = app_password_handler::app_password_routes()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
@@ -802,6 +810,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// is safe.
|
||||
let opaque_register_protected =
|
||||
oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_register_routes()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
@@ -835,6 +847,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
device_auth_handler::device_auth_public_routes().with_state(app_state.clone());
|
||||
// Protected endpoints: /api/auth/device/verify, /api/auth/device/devices
|
||||
let device_protected = device_auth_handler::device_auth_protected_routes()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
@@ -844,6 +860,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Protected API routes — require valid JWT token
|
||||
let protected_api = api_routes
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
@@ -857,8 +877,22 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// surface to a principal kind that can do nothing with it. The
|
||||
// `require_internal_user_layer` runs AFTER auth (tower order:
|
||||
// later .layer() = outermost = runs first).
|
||||
use oxicloud::interfaces::middleware::user::require_internal_user_layer;
|
||||
//
|
||||
// `require_no_password_change_pending_layer` is layered on
|
||||
// every authenticated /api/* subtree so an admin-set temp
|
||||
// password cannot be used against files / WebDAV / CalDAV /
|
||||
// admin from any non-SPA client. The layer allowlists /me,
|
||||
// change-password, and logout internally so the SPA can
|
||||
// complete the reset flow — see the middleware doc for the
|
||||
// allowlist and its rationale.
|
||||
use oxicloud::interfaces::middleware::user::{
|
||||
require_internal_user_layer, require_no_password_change_pending_layer,
|
||||
};
|
||||
let caldav_protected = caldav_router
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_internal_user_layer,
|
||||
@@ -868,6 +902,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
auth_middleware,
|
||||
));
|
||||
let carddav_protected = carddav_router
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_internal_user_layer,
|
||||
@@ -877,6 +915,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
auth_middleware,
|
||||
));
|
||||
let webdav_protected = webdav_router
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_no_password_change_pending_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_internal_user_layer,
|
||||
|
||||
Reference in New Issue
Block a user