fix(auth): use middleware-based auth for app-password API endpoints
The Nextcloud integration added duplicate /api/auth/app-passwords handlers that only accepted Bearer tokens, breaking cookie-authenticated browser sessions (profile page). Remove the duplicates and mount the original app_password_handler routes which use CurrentUser from the auth middleware, supporting all auth methods (cookie, Bearer, Basic).
This commit is contained in:
@@ -1,18 +1,16 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
extract::{Json, Path, Query, State},
|
extract::{Json, Query, State},
|
||||||
http::{HeaderMap, StatusCode, header},
|
http::{HeaderMap, StatusCode},
|
||||||
response::{IntoResponse, Redirect, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
routing::{delete, get, post, put},
|
routing::{get, post, put},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::application::dtos::user_dto::{
|
use crate::application::dtos::user_dto::{
|
||||||
AppPasswordCreatedDto, AppPasswordDto, ChangePasswordDto, CreateAppPasswordDto, LoginDto,
|
ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto,
|
||||||
OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto, RefreshTokenDto, RegisterDto,
|
RefreshTokenDto, RegisterDto, SetupAdminDto,
|
||||||
SetupAdminDto,
|
|
||||||
};
|
};
|
||||||
use crate::application::ports::auth_ports::TokenServicePort;
|
|
||||||
use crate::application::services::auth_application_service::OidcCallbackResult;
|
use crate::application::services::auth_application_service::OidcCallbackResult;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::interfaces::api::cookie_auth;
|
use crate::interfaces::api::cookie_auth;
|
||||||
@@ -37,11 +35,6 @@ pub fn auth_protected_routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/me", get(get_current_user))
|
.route("/me", get(get_current_user))
|
||||||
.route("/change-password", put(change_password))
|
.route("/change-password", put(change_password))
|
||||||
.route("/logout", post(logout))
|
.route("/logout", post(logout))
|
||||||
.route(
|
|
||||||
"/app-passwords",
|
|
||||||
get(list_app_passwords).post(create_app_password),
|
|
||||||
)
|
|
||||||
.route("/app-passwords/{id}", delete(delete_app_password))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rate-limited auth routes — split out so main.rs can apply per-endpoint
|
/// Rate-limited auth routes — split out so main.rs can apply per-endpoint
|
||||||
@@ -500,139 +493,6 @@ async fn get_system_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// App Password Handlers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
async fn create_app_password(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Json(dto): Json<CreateAppPasswordDto>,
|
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
|
||||||
let auth_service = state
|
|
||||||
.auth_service
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
|
||||||
|
|
||||||
let token = headers
|
|
||||||
.get(header::AUTHORIZATION)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.and_then(|value| value.strip_prefix("Bearer "))
|
|
||||||
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
|
|
||||||
|
|
||||||
let claims = auth_service
|
|
||||||
.token_service
|
|
||||||
.validate_token(token)
|
|
||||||
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
|
|
||||||
|
|
||||||
let nextcloud = state
|
|
||||||
.nextcloud
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?;
|
|
||||||
|
|
||||||
let label = dto.label.trim();
|
|
||||||
if label.is_empty() || label.len() > 128 {
|
|
||||||
return Err(AppError::new(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"Label must be between 1 and 128 characters",
|
|
||||||
"InvalidInput",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (id, password) = nextcloud
|
|
||||||
.app_passwords
|
|
||||||
.create_nc(&claims.sub, label)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
StatusCode::CREATED,
|
|
||||||
Json(AppPasswordCreatedDto {
|
|
||||||
id,
|
|
||||||
label: label.to_string(),
|
|
||||||
password,
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_app_passwords(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
|
||||||
let auth_service = state
|
|
||||||
.auth_service
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
|
||||||
|
|
||||||
let token = headers
|
|
||||||
.get(header::AUTHORIZATION)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.and_then(|value| value.strip_prefix("Bearer "))
|
|
||||||
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
|
|
||||||
|
|
||||||
let claims = auth_service
|
|
||||||
.token_service
|
|
||||||
.validate_token(token)
|
|
||||||
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
|
|
||||||
|
|
||||||
let nextcloud = state
|
|
||||||
.nextcloud
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?;
|
|
||||||
|
|
||||||
let records = nextcloud
|
|
||||||
.app_passwords
|
|
||||||
.list_nc(&claims.sub)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
let passwords: Vec<AppPasswordDto> = records
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| AppPasswordDto {
|
|
||||||
id: r.id,
|
|
||||||
label: r.label,
|
|
||||||
created_at: r.created_at,
|
|
||||||
last_used_at: r.last_used_at,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok((StatusCode::OK, Json(passwords)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_app_password(
|
|
||||||
State(state): State<Arc<AppState>>,
|
|
||||||
headers: HeaderMap,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<impl IntoResponse, AppError> {
|
|
||||||
let auth_service = state
|
|
||||||
.auth_service
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
|
||||||
|
|
||||||
let token = headers
|
|
||||||
.get(header::AUTHORIZATION)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.and_then(|value| value.strip_prefix("Bearer "))
|
|
||||||
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
|
|
||||||
|
|
||||||
let claims = auth_service
|
|
||||||
.token_service
|
|
||||||
.validate_token(token)
|
|
||||||
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
|
|
||||||
|
|
||||||
let nextcloud = state
|
|
||||||
.nextcloud
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?;
|
|
||||||
|
|
||||||
nextcloud
|
|
||||||
.app_passwords
|
|
||||||
.delete_by_user(&id, &claims.sub)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// OIDC Handlers
|
// OIDC Handlers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
+11
@@ -187,6 +187,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
auth_protected_routes, auth_public_routes, login_route, refresh_route, register_route,
|
auth_protected_routes, auth_public_routes, login_route, refresh_route, register_route,
|
||||||
setup_route,
|
setup_route,
|
||||||
};
|
};
|
||||||
|
use oxicloud::interfaces::api::handlers::app_password_handler;
|
||||||
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
||||||
use oxicloud::interfaces::middleware::auth::auth_middleware;
|
use oxicloud::interfaces::middleware::auth::auth_middleware;
|
||||||
use oxicloud::interfaces::middleware::csrf::csrf_middleware;
|
use oxicloud::interfaces::middleware::csrf::csrf_middleware;
|
||||||
@@ -250,6 +251,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
auth_middleware,
|
auth_middleware,
|
||||||
))
|
))
|
||||||
.with_state(app_state.clone());
|
.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(csrf_middleware))
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
app_state.clone(),
|
||||||
|
auth_middleware,
|
||||||
|
))
|
||||||
|
.with_state(app_state.clone());
|
||||||
// One-time setup route — public, rate-limited like register
|
// One-time setup route — public, rate-limited like register
|
||||||
let setup_router = setup_route()
|
let setup_router = setup_route()
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
@@ -302,6 +311,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.nest("/api/auth", auth_public)
|
.nest("/api/auth", auth_public)
|
||||||
// Protected auth endpoints (/me, /change-password, /logout)
|
// Protected auth endpoints (/me, /change-password, /logout)
|
||||||
.nest("/api/auth", auth_protected)
|
.nest("/api/auth", auth_protected)
|
||||||
|
// App password management (create, list, revoke)
|
||||||
|
.nest("/api/auth", app_pw_protected)
|
||||||
// One-time setup endpoint — public, rate-limited
|
// One-time setup endpoint — public, rate-limited
|
||||||
.nest("/api", setup_router)
|
.nest("/api", setup_router)
|
||||||
// Device Auth Grant public endpoints (authorize + token polling)
|
// Device Auth Grant public endpoints (authorize + token polling)
|
||||||
|
|||||||
@@ -165,12 +165,13 @@ function renderPwRow(pw) {
|
|||||||
|
|
||||||
async function loadAppPasswords() {
|
async function loadAppPasswords() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(API + '/auth/app-passwords', { headers: headers() });
|
const resp = await fetch(API + '/auth/app-passwords', { headers: headers(), credentials: 'same-origin' });
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
document.getElementById('app-passwords-section').classList.add('hidden');
|
document.getElementById('app-passwords-section').classList.add('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const passwords = await resp.json();
|
const data = await resp.json();
|
||||||
|
const passwords = data.app_passwords || data;
|
||||||
const userPws = passwords.filter(function (pw) { return !isAutoPassword(pw); });
|
const userPws = passwords.filter(function (pw) { return !isAutoPassword(pw); });
|
||||||
const autoPws = passwords.filter(isAutoPassword);
|
const autoPws = passwords.filter(isAutoPassword);
|
||||||
|
|
||||||
@@ -231,6 +232,7 @@ async function createAppPassword() {
|
|||||||
const resp = await fetch(API + '/auth/app-passwords', {
|
const resp = await fetch(API + '/auth/app-passwords', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers(),
|
headers: headers(),
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({ label: label })
|
body: JSON.stringify({ label: label })
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
@@ -266,7 +268,8 @@ async function revokeAppPassword(id, label) {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch(API + '/auth/app-passwords/' + encodeURIComponent(id), {
|
const resp = await fetch(API + '/auth/app-passwords/' + encodeURIComponent(id), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: headers()
|
headers: headers(),
|
||||||
|
credentials: 'same-origin'
|
||||||
});
|
});
|
||||||
if (resp.ok || resp.status === 204) {
|
if (resp.ok || resp.status === 204) {
|
||||||
document.getElementById('app-pw-created').classList.add('hidden');
|
document.getElementById('app-pw-created').classList.add('hidden');
|
||||||
|
|||||||
Reference in New Issue
Block a user