API de autenticación en Rust con Axum, SQLx y JWT (registro, login y endpoint protegido)

rust API de autenticación en Rust con Axum, SQLx y JWT (registro, login y endpoint protegido)

API de autenticación en Rust con Axum, SQLx y JWT

Proyecto práctico: crea una API pequeña que permita registro, login (con hash de contraseña) y un endpoint protegido por JWT. Ideal para entender cómo integrar Axum (HTTP), SQLx (DB) y jsonwebtoken.

Requisitos previos

  • Rust (stable) y Cargo instalados
  • sqlite3 (o cualquier visualizador de SQLite) — usaremos SQLite para simplificar
  • Conocimientos básicos de HTTP y JSON

Estructura de carpetas

rust-auth-api/
├─ Cargo.toml
└─ src/
   ├─ main.rs
   ├─ db.rs
   ├─ models.rs
   ├─ handlers.rs
   └─ auth.rs

Cargo.toml

[package]
name = "rust-auth-api"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.6"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.6", features = ["sqlite", "runtime-tokio-rustls"] }
dotenvy = "0.15"
argon2 = "0.4"
jsonwebtoken = "8.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

Variables de entorno

Archivo .env (en la raíz del proyecto):

DATABASE_URL=sqlite://./app.db
JWT_SECRET=replace_with_a_long_random_secret

Crear la base de datos y la tabla inicial (usa sqlite3 o cualquier cliente):

sqlite3 app.db

-- dentro de sqlite> prompt:
CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  username TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL
);

src/db.rs

use sqlx::sqlite::SqlitePoolOptions;
use sqlx::SqlitePool;
use std::time::Duration;

pub async fn init_pool(database_url: &str) -> anyhow::Result {
    let pool = SqlitePoolOptions::new()
        .max_connections(5)
        .connect_timeout(Duration::from_secs(5))
        .connect(database_url)
        .await?;

    Ok(pool)
}

src/models.rs

use serde::{Deserialize, Serialize};
use sqlx::FromRow;

#[derive(Debug, FromRow)]
pub struct UserRow {
    pub id: i64,
    pub username: String,
    pub password_hash: String,
}

#[derive(Debug, Serialize)]
pub struct PublicUser {
    pub id: i64,
    pub username: String,
}

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

src/auth.rs

use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::password_hash::SaltString;
use argon2::password_hash::rand_core::OsRng;
use jsonwebtoken::{EncodingKey, DecodingKey, Header, Validation, encode, decode, TokenData};
use serde::{Serialize, Deserialize};
use axum::http::{Request, StatusCode};
use axum::middleware::Next;
use axum::response::Response;
use axum::extract::Extension;
use std::sync::Arc;
use crate::models::PublicUser;

#[derive(Serialize, Deserialize)]
struct Claims {
    sub: i64,
    exp: usize,
}

pub fn hash_password(password: &str) -> anyhow::Result {
    let salt = SaltString::generate(&mut OsRng);
    let argon2 = Argon2::default();
    let password_hash = argon2.hash_password(password.as_bytes(), &salt)?.to_string();
    Ok(password_hash)
}

pub fn verify_password(hash: &str, password: &str) -> anyhow::Result {
    let parsed = PasswordHash::new(hash)?;
    Ok(Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
}

pub fn create_jwt(sub: i64, secret: &str, exp_seconds: usize) -> anyhow::Result {
    let expiration = (chrono::Utc::now().timestamp() as usize) + exp_seconds;
    let claims = Claims { sub, exp: expiration };
    let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))?;
    Ok(token)
}

pub fn decode_jwt(token: &str, secret: &str) -> anyhow::Result> {
    let token_data = decode::(token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::default())?;
    Ok(token_data)
}

// Middleware que protege rutas: comprueba Authorization Bearer y añade la extensión "user_id"
pub async fn require_auth(mut req: Request, next: Next) -> Result {
    use axum::http::header::AUTHORIZATION;
    let secret = std::env::var("JWT_SECRET").unwrap_or_default();
    let header = req.headers().get(AUTHORIZATION).and_then(|v| v.to_str().ok()).unwrap_or("");

    if !header.starts_with("Bearer ") {
        return Err((StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header").into_response());
    }

    let token = &header[7..];
    match decode_jwt(token, &secret) {
        Ok(token_data) => {
            let user_id = token_data.claims.sub;
            // Insert user_id en extensions para que handlers lo lean
            req.extensions_mut().insert(user_id);
            Ok(next.run(req).await)
        }
        Err(_) => Err((StatusCode::UNAUTHORIZED, "Invalid token").into_response()),
    }
}

src/handlers.rs

use axum::{Extension, Json, http::StatusCode};
use serde_json::json;
use sqlx::SqlitePool;
use crate::models::{AuthPayload, PublicUser, UserRow};
use crate::auth::{hash_password, verify_password, create_jwt};

pub async fn register(
    Extension(pool): Extension,
    Json(payload): Json,
) -> Result<(StatusCode, Json), (StatusCode, String)> {
    // comprobar si ya existe
    let maybe = sqlx::query("SELECT id FROM users WHERE username = ?")
        .bind(&payload.username)
        .fetch_optional(&pool)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    if maybe.is_some() {
        return Err((StatusCode::CONFLICT, "username already exists".to_string()));
    }

    let password_hash = hash_password(&payload.password).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let res = sqlx::query("INSERT INTO users (username, password_hash) VALUES (?, ?)")
        .bind(&payload.username)
        .bind(&password_hash)
        .execute(&pool)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let user_id = res.last_insert_rowid();

    let public = PublicUser { id: user_id, username: payload.username };

    Ok((StatusCode::CREATED, Json(json!({ "user": public }))))
}

pub async fn login(
    Extension(pool): Extension,
    Json(payload): Json,
) -> Result, (StatusCode, String)> {
    let row: UserRow = sqlx::query_as("SELECT id, username, password_hash FROM users WHERE username = ?")
        .bind(&payload.username)
        .fetch_one(&pool)
        .await
        .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid credentials".to_string()))?;

    let valid = verify_password(&row.password_hash, &payload.password).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    if !valid {
        return Err((StatusCode::UNAUTHORIZED, "invalid credentials".to_string()));
    }

    let secret = std::env::var("JWT_SECRET").unwrap_or_default();
    let token = create_jwt(row.id, &secret, 60 * 60 * 24).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(json!({ "token": token })))
}

pub async fn me(
    Extension(pool): Extension,
    Extension(user_id): Extension,
) -> Result, (StatusCode, String)> {
    let row: UserRow = sqlx::query_as("SELECT id, username, password_hash FROM users WHERE id = ?")
        .bind(user_id)
        .fetch_one(&pool)
        .await
        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;

    let public = PublicUser { id: row.id, username: row.username };
    Ok(Json(serde_json::json!({ "user": public })))
}

src/main.rs

mod db;
mod models;
mod handlers;
mod auth;

use axum::{Router, routing::{post, get}, Extension};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use std::net::SocketAddr;
use sqlx::SqlitePool;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // logger
    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::from_default_env())
        .with(tracing_subscriber::fmt::layer())
        .init();

    dotenvy::dotenv().ok();
    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL env var");

    let pool: SqlitePool = db::init_pool(&database_url).await?;

    // router
    let app = Router::new()
        .route("/register", post(handlers::register))
        .route("/login", post(handlers::login))
        // /me protegido con middleware
        .route("/me", get(handlers::me))
        .layer(Extension(pool.clone()))
        .route_layer(axum::middleware::from_fn(auth::require_auth));

    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    tracing::info!("listening on {}", addr);
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await?;

    Ok(())
}

Por qué estas decisiones

  • Axum: ergonomía para construir APIs async y middleware sencillo.
  • SQLx + SQLite: SQLx ofrece un pool async y es fácil empezar con SQLite para demos; para producción cambia a Postgres y habilita las macros de verificación.
  • Argon2: hashing seguro de contraseñas (mejor que SHA).
  • JWT: permite autenticar de forma stateless. Es simple y suficiente para sesiones cortas o APIs.
  • Middleware: centraliza la verificación del token y evita repetir lógica de extracción del usuario en cada handler.

Cómo probar

  1. Crear .env con DATABASE_URL y JWT_SECRET.
  2. Crear la tabla users (ver sección SQL).
  3. cargo run
  4. Usando curl o Postman:
    • POST /register {"username":"alice","password":"secret"}
    • POST /login {"username":"alice","password":"secret"} -> devuelve token
    • GET /me con header Authorization: Bearer <token>

Notas importantes y siguientes pasos

  • Este ejemplo omite validaciones de entrada detalladas (longitud, caracteres) y protección contra bruteforce: añade rate limiting y validación en producción.
  • En producción usa TLS, asegura JWT_SECRET con un gestor de secretos y considera tokens de refresco para sesiones largas.
  • Si migras a Postgres, activa las macros de SQLx (feature "macros") para comprobación de consultas en compile-time.

Consejo avanzado: implementa rotación de claves JWT (kid en encabezado) y un endpoint de revocación que almacene tokens revocados o una versión de sesión en la DB para invalidar tokens antiguos. Atención a no almacenar secretos en el código ni devolver hashes de contraseña en respuestas JSON.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

© 2026 Space Howen
Hecho con ❤️ para el 🌍