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
- Crear .env con DATABASE_URL y JWT_SECRET.
- Crear la tabla users (ver sección SQL).
- cargo run
- 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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación