Seguridad práctica en Rust: JWT, hashing y middlewares – Capítulo 40

Seguridad Práctica en Aplicaciones Rust
Implementación de mecanismos de autenticación y autorización


La seguridad es un pilar fundamental en el desarrollo de aplicaciones Rust, especialmente en entornos web donde las vulnerabilidades pueden comprometer datos sensibles. Este capítulo explora técnicas prácticas para implementar autenticación segura, centrándose en herramientas y patrones que protegen contra amenazas comunes. Al dominar estos conceptos, se fortalece la integridad de las aplicaciones, preparando el terreno para sistemas robustos y escalables.

JSON Web Tokens (JWT) con la crate jsonwebtokens

Los JSON Web Tokens (JWT) representan un estándar para la transmisión segura de información entre partes, codificada como un objeto JSON compacto y auto-contenido. En Rust, la crate jsonwebtokens facilita su manejo, permitiendo la creación, validación y decodificación de tokens de manera eficiente. Un JWT consta de tres partes separadas por puntos: el encabezado (header), la carga útil (payload) y la firma (signature). El encabezado especifica el algoritmo de firma, como HMAC con SHA-256 (HS256), mientras que la payload contiene claims estándar o personalizados, tales como el identificador del usuario (sub) o la fecha de expiración (exp).

Para generar un JWT, se inicializa un codificador con una clave secreta. Consideremos el siguiente fragmento de código aislado que ilustra la creación de un token básico:

use jsonwebtokens::{encode, Algorithm, Encoder};
use jsonwebtokens::raw::JsonValue;

let secret = b"mi_clave_secreta";
let alg = Algorithm::HS256;
let encoder = Encoder::new(secret, alg);

let mut claims = JsonValue::new_object();
claims.insert("sub".to_string(), JsonValue::String("usuario123".to_string()));
claims.insert("exp".to_string(), JsonValue::Number(chrono::Utc::now().timestamp() + 3600).into());

let token = encoder.encode(&claims).unwrap();

Aquí, se crea un token con un claim de sujeto y expiración en una hora. Es crucial destacar que la clave secreta debe almacenarse de forma segura, nunca en el código fuente, y rotarse periódicamente para mitigar riesgos de exposición. La validación implica decodificar el token y verificar la firma, utilizando un decodificador correspondiente. Un caso borde surge cuando el token expira: la crate arroja un error específico, que debe manejarse para rechazar accesos no autorizados.

En comparación con otros lenguajes como JavaScript (donde bibliotecas como jsonwebtoken operan de manera similar), Rust enfatiza la seguridad de tipos, previniendo errores comunes en la manipulación de strings. Sin embargo, nunca se debe confiar en claims no verificados, ya que un atacante podría forjar tokens si la clave se compromete. La crate jsonwebtokens no soporta algoritmos asimétricos de forma nativa, lo que la hace ideal para escenarios simétricos simples, pero requiere extensiones para entornos más complejos.

Hashing de contraseñas con Argon2

Argon2 es un algoritmo de hashing de contraseñas diseñado para resistir ataques de fuerza bruta y side-channel, ganador del Password Hashing Competition en 2015. En Rust, la crate argon2 proporciona una implementación eficiente, permitiendo configurar parámetros como el costo de memoria, iteraciones y paralelismo para equilibrar seguridad y rendimiento. A diferencia de algoritmos obsoletos como MD5 o SHA-1, Argon2 incorpora sales aleatorias y peppering opcional, haciendo que cada hash sea único incluso para contraseñas idénticas.

El proceso de hashing implica generar una sal aleatoria y aplicar el algoritmo. Un ejemplo mínimo ilustra su uso:

use argon2::{self, Config, Variant, Version};
use rand::rngs::OsRng;
use rand::RngCore;

let password = b"contrasena_segura";
let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt);

let config = Config {
    variant: Variant::Argon2id,
    version: Version::V0x13,
    mem_cost: 65536,
    time_cost: 10,
    lanes: 4,
    secret: &[],
    ad: &[],
    hash_length: 32,
};

let hash = argon2::hash_encoded(password, &salt, &config).unwrap();

Este código produce un hash codificado en formato PHC (Password Hashing Competition), que incluye la sal y parámetros. Un detalle sutil es que incrementar el costo de memoria (mem_cost) eleva la resistencia contra GPUs paralelas, pero puede impactar el rendimiento en servidores con recursos limitados. Para verificación, se utiliza verify_encoded, comparando el hash almacenado con uno recién generado a partir de la contraseña proporcionada.

En contraste con lenguajes como Python (donde passlib ofrece abstracciones similares), Rust’s argon2 expone configuraciones de bajo nivel, fomentando un uso consciente. Casos borde incluyen sales insuficientemente aleatorias, que comprometen la seguridad; siempre se debe emplear un generador criptográficamente seguro como OsRng. Argon2id, la variante híbrida, se recomienda para la mayoría de aplicaciones, combinando protección contra ataques de tiempo y memoria.

Cookies HttpOnly y Secure

Las cookies son mecanismos para almacenar datos en el navegador del usuario, pero su mal uso puede exponer vulnerabilidades como cross-site scripting (XSS) o man-in-the-middle. En Rust, al trabajar con frameworks web como Actix o Rocket, se configuran atributos como HttpOnly y Secure para mitigar riesgos. HttpOnly impide el acceso a la cookie vía JavaScript, previniendo ataques XSS, mientras que Secure asegura que la cookie solo se envíe sobre conexiones HTTPS, protegiendo contra intercepciones.

Un ejemplo aislado en un handler de Actix Web demuestra su configuración:

use actix_web::{cookie::Cookie, HttpResponse};

let mut cookie = Cookie::new("session_id", "valor_secreto");
cookie.set_http_only(true);
cookie.set_secure(true);
cookie.set_path("/");
cookie.set_max_age(time::Duration::hours(1));

HttpResponse::Ok()
    .cookie(cookie)
    .body("Cookie configurada")

Aquí, la cookie se marca como HttpOnly y Secure, con una duración de una hora. Es esencial notar que omitir Secure en producción permite ataques de downgrade a HTTP, exponiendo datos sensibles. Otro atributo clave es SameSite, que puede configurarse como Strict o Lax para controlar el envío en solicitudes cross-site, mitigando CSRF (Cross-Site Request Forgery).

Comparado con entornos como Node.js (donde Express maneja cookies de forma similar), Rust enfatiza la inmutabilidad y tipos seguros, reduciendo errores en la configuración. Un caso borde surge en entornos de desarrollo locales sin HTTPS; en tales escenarios, Secure debe desactivarse temporalmente, pero nunca en producción. Estas banderas no sustituyen una validación server-side, sino que complementan capas de seguridad más amplias.

Middlewares de autenticación

Los middlewares en Rust actúan como filtros en la cadena de procesamiento de solicitudes, ideales para implementar autenticación y autorización de manera modular. En frameworks como Actix Web, un middleware puede extraer y validar tokens JWT de cabeceras o cookies, rechazando solicitudes no autorizadas antes de alcanzar los handlers principales. Esto promueve la separación de preocupaciones, permitiendo reutilizar lógica de seguridad across endpoints.

Un middleware básico para validar JWT podría implementarse así:

use actix_web::{dev::Service, middleware::Middleware, Error};
use jsonwebtokens::{decode, Algorithm, Decoder};

struct AuthMiddleware {
    secret: Vec<u8>,
}

impl<S> Middleware<S> for AuthMiddleware {
    fn start(&self, req: &actix_web::dev::ServiceRequest) -> actix_web::dev::MiddlewareStartResult {
        if let Some(auth_header) = req.headers().get("Authorization") {
            if let Ok(auth_str) = auth_header.to_str() {
                if auth_str.starts_with("Bearer ") {
                    let token = &auth_str[7..];
                    let alg = Algorithm::HS256;
                    let decoder = Decoder::new(&self.secret, alg);
                    if decoder.decode(token).is_ok() {
                        return Ok(None); 
// Proceder

                    }
                }
            }
        }
        Err(Error::from(actix_web::error::ErrorUnauthorized("No autorizado")))
    }
}

Este middleware verifica un token Bearer en la cabecera Authorization. Un detalle sutil es manejar errores de decodificación gracefully, registrando intentos fallidos para detectar patrones de ataque. En comparación con middlewares en Go (usando Gin), Rust’s borrow checker asegura que las referencias a secretos no se expongan inadvertidamente.

Middlewares pueden extenderse para roles, verificando claims específicos en el JWT. Casos borde incluyen tokens malformados, que deben rechazarse inmediatamente para evitar procesamiento innecesario.

Proyecto completo: API con login, roles y refresh tokens

Para integrar los conceptos previos, se presenta un proyecto completo: una API REST simple con endpoints para login, protegidos por roles y soporte para refresh tokens. La estructura de carpetas es la siguiente:

proyecto_seguridad/
├── Cargo.toml
├── src/
   ├── main.rs
   ├── auth.rs
   ├── models.rs
   └── routes.rs

El archivo Cargo.toml incluye dependencias necesarias:

[package]
name = "proyecto_seguridad"
version = "0.1.0"
edition = "2021"

[dependencies]
actix-web = "4.0"
jsonwebtokens = "0.6"
argon2 = "0.4"
rand = "0.8"
chrono = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

En src/models.rs, se definen estructuras para usuarios y tokens:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct User {
    pub id: u32,
    pub username: String,
    pub password_hash: String,
    pub role: String, 
// e.g., "admin" or "user"

}

#[derive(Serialize, Deserialize)]
pub struct LoginRequest {
    pub username: String,
    pub password: String,
}

#[derive(Serialize)]
pub struct TokenResponse {
    pub access_token: String,
    pub refresh_token: String,
}

En src/auth.rs, se implementan funciones para hashing, JWT y refresh:

use crate::models::{TokenResponse, User};
use argon2::{self, Config};
use jsonwebtokens::{encode, decode, Algorithm, Encoder, Decoder};
use rand::rngs::OsRng;
use rand::RngCore;
use std::collections::HashMap;

const SECRET: &[u8] = b"clave_secreta_produccion";

pub fn hash_password(password: &str) -> String {
    let mut salt = [0u8; 16];
    OsRng.fill_bytes(&mut salt);
    let config = Config::default();
    argon2::hash_encoded(password.as_bytes(), &salt, &config).unwrap()
}

pub fn verify_password(hash: &str, password: &str) -> bool {
    argon2::verify_encoded(hash, password.as_bytes()).unwrap_or(false)
}

pub fn generate_tokens(user: &User) -> TokenResponse {
    let alg = Algorithm::HS256;
    let encoder = Encoder::new(SECRET, alg);

    let mut access_claims = serde_json::json!({
        "sub": user.username,
        "role": user.role,
        "exp": chrono::Utc::now().timestamp() + 900, 
// 15 min

    });
    let access_token = encoder.encode(&access_claims).unwrap();

    let mut refresh_claims = serde_json::json!({
        "sub": user.username,
        "exp": chrono::Utc::now().timestamp() + 604800, 
// 7 días

    });
    let refresh_token = encoder.encode(&refresh_claims).unwrap();

    TokenResponse { access_token, refresh_token }
}

pub fn validate_token(token: &str, required_role: Option<&str>) -> Result<String, &'static str> {
    let alg = Algorithm::HS256;
    let decoder = Decoder::new(SECRET, alg);
    match decoder.decode(token) {
        Ok(claims) => {
            if let Some(exp) = claims.get("exp").and_then(|v| v.as_i64()) {
                if exp < chrono::Utc::now().timestamp() {
                    return Err("Token expirado");
                }
            }
            if let Some(role) = required_role {
                if claims.get("role").and_then(|v| v.as_str()) != Some(role) {
                    return Err("Rol insuficiente");
                }
            }
            claims.get("sub").and_then(|v| v.as_str()).map(|s| s.to_string()).ok_or("Claim sub ausente")
        }
        Err(_) => Err("Token inválido"),
    }
}

pub fn refresh_access_token(refresh_token: &str) -> Result<String, &'static str> {
    let alg = Algorithm::HS256;
    let decoder = Decoder::new(SECRET, alg);
    match decoder.decode(refresh_token) {
        Ok(claims) => {
            if let Some(exp) = claims.get("exp").and_then(|v| v.as_i64()) {
                if exp < chrono::Utc::now().timestamp() {
                    return Err("Refresh token expirado");
                }
            }
            let sub = claims.get("sub").and_then(|v| v.as_str()).ok_or("Claim sub ausente")?;
            let role = "user"; 
// Simular obtención de rol desde DB

            let mut new_claims = serde_json::json!({
                "sub": sub,
                "role": role,
                "exp": chrono::Utc::now().timestamp() + 900,
            });
            let encoder = Encoder::new(SECRET, alg);
            Ok(encoder.encode(&new_claims).unwrap())
        }
        Err(_) => Err("Refresh token inválido"),
    }
}

En src/routes.rs, se definen los endpoints:

use actix_web::{post, get, web, HttpResponse, Responder};
use crate::auth::{generate_tokens, validate_token, verify_password, refresh_access_token};
use crate::models::{LoginRequest, TokenResponse, User};
use std::sync::Mutex;


// Simular base de datos

lazy_static::lazy_static! {
    static ref USERS: Mutex<Vec<User>> = Mutex::new(vec![
        User { id: 1, username: "admin".to_string(), password_hash: crate::auth::hash_password("adminpass"), role: "admin".to_string() },
        User { id: 2, username: "user".to_string(), password_hash: crate::auth::hash_password("userpass"), role: "user".to_string() },
    ]);
}

#[post("/login")]
async fn login(req: web::Json<LoginRequest>) -> impl Responder {
    let users = USERS.lock().unwrap();
    if let Some(user) = users.iter().find(|u| u.username == req.username) {
        if verify_password(&user.password_hash, &req.password) {
            let tokens = generate_tokens(user);
            let mut cookie = actix_web::cookie::Cookie::new("refresh_token", tokens.refresh_token.clone());
            cookie.set_http_only(true);
            cookie.set_secure(true); 
// En producción

            cookie.set_path("/");
            return HttpResponse::Ok().cookie(cookie).json(tokens);
        }
    }
    HttpResponse::Unauthorized().body("Credenciales inválidas")
}

#[get("/protected")]
async fn protected(req: web::HttpRequest) -> impl Responder {
    if let Some(auth_header) = req.headers().get("Authorization") {
        if let Ok(auth_str) = auth_header.to_str() {
            if auth_str.starts_with("Bearer ") {
                let token = &auth_str[7..];
                match validate_token(token, None) {
                    Ok(_) => { return HttpResponse::Ok().body("Acceso concedido"); }
                    Err(msg) => { return HttpResponse::Unauthorized().body(msg); }
                }
            }
        }
    }
    HttpResponse::Unauthorized().body("No autorizado")
}

#[get("/admin")]
async fn admin(req: web::HttpRequest) -> impl Responder {
    if let Some(auth_header) = req.headers().get("Authorization") {
        if let Ok(auth_str) = auth_header.to_str() {
            if auth_str.starts_with("Bearer ") {
                let token = &auth_str[7..];
                match validate_token(token, Some("admin")) {
                    Ok(_) => { return HttpResponse::Ok().body("Acceso admin concedido"); }
                    Err(msg) => { return HttpResponse::Unauthorized().body(msg); }
                }
            }
        }
    }
    HttpResponse::Unauthorized().body("No autorizado")
}

#[post("/refresh")]
async fn refresh(req: web::HttpRequest) -> impl Responder {
    if let Some(cookie) = req.cookie("refresh_token") {
        match refresh_access_token(cookie.value()) {
            Ok(new_token) => HttpResponse::Ok().json(serde_json::json!({"access_token": new_token})),
            Err(msg) => HttpResponse::Unauthorized().body(msg),
        }
    } else {
        HttpResponse::Unauthorized().body("Refresh token ausente")
    }
}

Finalmente, en src/main.rs:

use actix_web::{web, App, HttpServer, middleware};
use routes::{login, protected, admin, refresh};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .wrap(middleware::Logger::default())
            .service(login)
            .service(protected)
            .service(admin)
            .service(refresh)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

Este proyecto integra JWT para access tokens, Argon2 para contraseñas, cookies HttpOnly/Secure para refresh tokens y middlewares implícitos en los handlers (pudiendo extenderse). Para compilar, ejecutar cargo build y cargo run. En producción, reemplazar la base de datos simulada por una real y manejar secretos adecuadamente.

Habiendo establecido bases sólidas en seguridad práctica, el siguiente capítulo aborda la optimización de rendimiento en aplicaciones Rust, explorando técnicas para escalabilidad y eficiencia en entornos de alta carga.

Dejar un comentario

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

Scroll al inicio