Autorización real en Leptos: protege tus server functions, no solo tus rutas

Redirigir al login en la interfaz no protege nada: cada #[server] de Leptos es un endpoint HTTP público, y la autorización real tiene que vivir en el servidor. En este artículo veremos cómo blindar una aplicación full-stack con Leptos y Axum, con código que puedes revisar en la rama feat/autorizacion-server-functions de user-leptos-app.

La ilusión de seguridad con server functions

En el artículo anterior estructuramos una aplicación full-stack con Leptos (actualizada en esta entrega a Leptos 0.9.0-rc0): SSR, hidratación WASM, SQLite y autenticación con cookies de sesión.

Nota: Leptos 0.9 todavía es una versión candidata (0.9.0-rc0), así que algunas APIs podrían cambiar hasta la versión final. Si usas la serie estable 0.8, revisa las diferencias antes de copiar el código.

Teníamos una página /users protegida con un patrón muy común en el frontend:

// Redirección client-side si no hay sesión
Effect::new(move |_| {
    if let AuthState::Unauthenticated = auth.state.get() {
        navigate("/login", Default::default());
    }
});

Parece que todo está bajo control: si un usuario no autenticado entra a /users, la interfaz lo expulsa a /login.

El problema es que cada macro #[server] genera un endpoint HTTP público, por ejemplo POST /api/delete_user. Basta con ejecutar esto en una terminal:

curl -i -X POST http://127.0.0.1:3000/api/delete_user \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "id=1"

Y el usuario quedaba eliminado. La función no comprobaba quién hacía la llamada ni qué rol tenía.

Y el usuario quedaba eliminado. La función no comprobaba quién hacía la llamada ni qué rol tenía.
Regla de oro: las server functions no son llamadas a funciones privadas locales, sino endpoints HTTP expuestos al mundo. Proteger la interfaz es UX; la seguridad real ocurre en el servidor.

El resto del artículo recorre las piezas que usamos para cerrar ese hueco: errores tipados, políticas compartidas, guards, una capa api con flujo estándar por función, inyección del pool y tests que atacan la API como lo haría un cliente externo.

Errores tipados con códigos HTTP reales

Por defecto, los errores en Leptos suelen terminar en un genérico ServerFnError::new("Error..."), que devuelve un HTTP 500 con texto plano. Así el cliente no puede distinguir una sesión expirada de un permiso faltante.

La solución es un enum propio, AppError, que implementa FromServerFnError y asigna a cada variante su código HTTP:

// src/domain/error.rs
use http::StatusCode;
use leptos::server_fn::codec::JsonEncoding;
use leptos::server_fn::error::{FromServerFnError, ServerFnErrorErr};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)]
pub enum AppError {
    #[error("Debes iniciar sesión para continuar")]
    Unauthorized, // HTTP 401

    #[error("{0}")]
    Forbidden(String), // HTTP 403

    #[error("{0}")]
    NotFound(String), // HTTP 404

    #[error("{0}")]
    Validation(String), // HTTP 422

    #[error("{0}")]
    Conflict(String), // HTTP 409

    #[error("Error interno del servidor")]
    Internal, // HTTP 500

    #[error("Error de comunicación con el servidor: {0}")]
    ServerFn(String),
}

impl AppError {
    pub fn forbidden(msg: impl Into<String>) -> Self {
        Self::Forbidden(msg.into())
    }
}

impl FromServerFnError for AppError {
    type Encoder = JsonEncoding;

    fn from_server_fn_error(value: ServerFnErrorErr) -> Self {
        AppError::ServerFn(value.to_string())
    }

    fn status_code(&self) -> StatusCode {
        match self {
            AppError::Unauthorized => StatusCode::UNAUTHORIZED,
            AppError::Forbidden(_) => StatusCode::FORBIDDEN,
            AppError::NotFound(_) => StatusCode::NOT_FOUND,
            AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
            AppError::Conflict(_) => StatusCode::CONFLICT,
            AppError::Internal | AppError::ServerFn(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

Con esto, un 401 significa «inicia sesión» y un 403 significa «estás autenticado, pero no tienes permiso». Además, en el servidor implementamos Fromsqlx::Error for AppError para registrar fallos de SQL internamente y devolver siempre AppError::Internal —salvo violaciones UNIQUE, que se traducen en AppError::Conflict (409)—, evitando filtrar detalles del esquema al cliente.

Políticas puras en el dominio

Para no duplicar la lógica de «quién puede hacer qué», la definimos como funciones puras en domain/policy.rs. Al vivir en el dominio isomórfico, se comparten entre cliente y servidor:

  • El servidor las ejecuta para garantizar la seguridad.
  • La interfaz (WASM) las ejecuta para ocultar botones o deshabilitar acciones (UX).
// src/domain/policy.rs
pub fn can_manage_users(actor: &User) -> Result<(), AppError> {
    match actor.role {
        UserRole::Admin => Ok(()),
        UserRole::User => Err(AppError::forbidden(
            "Solo los administradores pueden gestionar usuarios",
        )),
    }
}

pub fn can_delete_user(actor: &User, target_id: i64) -> Result<(), AppError> {
    can_manage_users(actor)?;
    if actor.id == target_id {
        return Err(AppError::forbidden("No puedes eliminar tu propia cuenta"));
    }
    Ok(())
}

En el frontend reutilizamos exactamente la misma función para adaptar la vista y reaccionamos por variante cuando una llamada falla:

// src/frontend/users/list_page.rs
let can_manage = Memo::new(move |_| {
    auth.user()
        .map(|u| policy::can_manage_users(&u).is_ok())
        .unwrap_or(false)
});

// Y al renderizar el Resource de usuarios:
Err(AppError::Unauthorized) => view! {
    <div class="error-state">
        <p>"Tu sesión ha expirado. "</p>
        <a href="/login" class="btn btn-primary">"Iniciar sesión"</a>
    </div>
}.into_any(),

Una sola fuente de verdad: si mañana cambia la regla, cambia en un lugar y tanto el botón como el endpoint la respetan.

Guards reutilizables en el servidor

En el servidor creamos helpers sencillos que extraen la cookie (HttpOnly, SameSite=Lax y Secure en producción), validan la sesión en SQLite y devuelven el usuario. Cada uno se apoya en el anterior:

// src/server/guards.rs
pub async fn current_user() -> Result<Option<User>, AppError> {
    let Some(session_id) = session::read_cookie().await else {
        return Ok(None);
    };

    let pool = db::pool()?;
    let user = session::find_user(&pool, &session_id).await?;
    if user.is_none() {
        session::clear_cookie();
    }
    Ok(user)
}

pub async fn require_user() -> Result<User, AppError> {
    current_user().await?.ok_or(AppError::Unauthorized)
}

pub async fn require_admin() -> Result<User, AppError> {
    let user = require_user().await?;
    policy::can_manage_users(&user)?;
    Ok(user)
}

current_user no falla si no hay sesión: devuelve None (y de paso limpia la cookie del navegador si había caducado). require_user convierte esa ausencia en un 401, y require_admin reutiliza la política del dominio para producir el 403.

src/api: la frontera de seguridad y su flujo estándar

Para que no se mezcle el control de acceso con las consultas SQL, separamos responsabilidades en dos módulos:

  • src/api/: contiene todas las server functions (#[server]). Es la única puerta de entrada desde el exterior.
  • src/server/: repositorios puros (users, session) que asumen que quien los llama ya validó permisos.

Con esa frontera clara, toda server function sigue el mismo orden: autenticar, autorizar y recién entonces ejecutar.

// src/api/users.rs
#[server(prefix = "/api", endpoint = "delete_user")]
pub async fn delete_user(id: i64) -> Result<(), AppError> {
    // 1. Autenticación y rol base (401 sin sesión, 403 si no es admin)
    let actor = guards::require_admin().await?;

    // 2. Regla específica (403 si intenta borrarse a sí mismo)
    policy::can_delete_user(&actor, id)?;

    // 3. Ejecución en base de datos
    let pool = db::pool()?;
    session::delete_for_user(&pool, id).await?;
    users::delete(&pool, id).await
}

El mismo curl del principio ahora recibe un 401 sin cookie y un 403 con la sesión de un usuario normal. Además, al eliminar un usuario borramos sus sesiones activas de inmediato. Como el patrón es siempre igual, auditar un endpoint nuevo consiste en mirar sus primeras líneas.

Adiós a OnceLock: el pool por contexto

Antes, el pool de base de datos vivía en un static OnceLock<SqlitePool>. Un global así dificulta las pruebas automáticas, porque todos los tests comparten el mismo estado.

Ahora usamos leptos_routes_with_context en Axum para inyectar el pool en cada petición (tanto de SSR como de server functions):

// src/server/app.rs
pub fn build_router(leptos_options: LeptosOptions, pool: SqlitePool) -> Router {
    let routes = generate_route_list(App);

    Router::new()
        .leptos_routes_with_context(
            &leptos_options,
            routes,
            move || provide_context(pool.clone()),
            {
                let leptos_options = leptos_options.clone();
                move || shell(leptos_options.clone())
            },
        )
        .fallback(leptos_axum::file_and_error_handler(shell))
        .with_state(leptos_options)
}

En cualquier server function basta con llamar a db::pool(), que extrae use_context::<SqlitePool>(). Así cada test construye su propio router con una base sqlite::memory: aislada (configurada con max_connections(1) para que todas las consultas del test compartan esa misma base en memoria).

Rutas protegidas en SSR: redirección sin parpadeo

El Effect del principio solo corre en el navegador: primero envía la página y espera a que cargue WASM para redirigir. Para que el servidor decida desde el primer momento, AuthContext ahora obtiene la sesión con un Resource reactivo a las acciones de login y logout, exponiendo is_authenticated() -> Option<bool> (None mientras carga).

Eso encaja directo con ProtectedRoute y SsrMode::Async:

// src/app.rs
<ProtectedRoute
    path=StaticSegment("users")
    view=UsersListPage
    condition=move || auth.is_authenticated()
    redirect_path=|| "/login"
    ssr=SsrMode::Async
/>

Leptos resuelve el Resource de sesión durante el SSR y emite directamente un HTTP 302 Found a /login si no hay sesión, sin llegar a mostrar la página. Sigue siendo UX: la seguridad de los datos la garantizan las server functions de src/api.

Verificación con tests de integración reales

Para confirmar que el hueco está cerrado, los tests atacan directamente el router de Axum con tower::ServiceExt::oneshot, emulando a un cliente HTTP externo igual que el curl del inicio:

// tests/authorization.rs
#[tokio::test]
async fn anonymous_cannot_delete_users() {
    let app = TestApp::new().await;
    let (status, _, _) = app.post("delete_user", "id=2", None).await;
    assert_eq!(status, StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn regular_user_cannot_delete_users() {
    let app = TestApp::new().await;
    let cookie = app.login("alice", "alice123").await;
    let (status, _, _) = app.post("delete_user", "id=1", Some(&cookie)).await;
    assert_eq!(status, StatusCode::FORBIDDEN);
}

Se ejecutan con cargo test --features ssr. En total son 15 tests de integración (más los unitarios de domain::policy) que cubren:

  • Llamadas anónimas (401), cookies falsificadas y sesiones expiradas en SQLite.
  • Usuarios sin permisos intentando borrar o promocionarse a Admin (403).
  • Administradores intentando borrarse o quitarse su propio rol (403).
  • Revocación inmediata de sesiones al hacer logout o al eliminar un usuario.
  • Respuesta idéntica (422) y verificación con hash dummy en login para evitar enumerar usuarios por mensaje o tiempo de respuesta.

Hasta donde hemos llegado

La lección central cabe en una línea: cada #[server] es una puerta pública, así que cada una necesita su propio control de acceso. El patrón que usamos lo resume:

  1. Errores tipados (AppError) para que el cliente distinga 401, 403, 409 y 422 sin filtrar errores internos.
  2. Políticas puras en el dominio, compartidas por servidor (seguridad) e interfaz (UX).
  3. Capa api + guards, aplicando el mismo flujo al inicio de cada función.
  4. Pool inyectado por contexto, eliminando globales para poder aislar cada prueba.
  5. Tests HTTP reales contra el router, pensando como un cliente externo y no desde la UI.

Puedes revisar y ejecutar todo el código en la rama feat/autorizacion-server-functions de user-leptos-app. Si ves un caso que los tests no cubren, cuéntamelo en los comentarios.

Sigamos codificando.

Tema:

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.