API REST en Rust con Actix-web, SQLite (sqlx) y JWT — proyecto práctico

rust API REST en Rust con Actix-web, SQLite (sqlx) y JWT — proyecto práctico

API REST en Rust con Actix-web, SQLite (sqlx) y JWT — proyecto práctico

Vamos a crear una pequeña API de tareas (todos) con autenticación por JWT. El stack es deliberately minimal para que puedas tener algo funcional y seguro rápido: actix-web (servidor), sqlx + SQLite (persistencia), jsonwebtoken (JWT), argon2 (hash de contraseñas).

Requisitos previos

  • Tener Rust 1.70+ (o estable actual).
  • sqlite3 instalado en el sistema (para inspeccionar la DB si hace falta).
  • Familiaridad básica con cargo y async/await.

Estructura del proyecto

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

Cargo.toml

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

[dependencies]
actix-web = "4"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
sqlx = { version = "0.6", features = ["sqlite", "runtime-tokio-native-tls", "macros"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenv = "0.15"
jsonwebtoken = { version = "8", features = ["serde"] }
argon2 = "0.4"
password-hash = "0.4"
rand_core = "0.6"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
env_logger = "0.10"
log = "0.4"

Explicación rápida: sqlx con sqlite evita gestores externos; jsonwebtoken genera/valida tokens; argon2 para el hash seguro de contraseñas.

.env

DATABASE_URL=sqlite:./db.sqlite
JWT_SECRET=change_this_secret_to_a_long_random_value

En producción cambia JWT_SECRET por una clave fuerte y usa variables de entorno en el entorno del servidor.

src/db.rs — inicialización de la BD

use sqlx::SqlitePool;

pub async fn init_db(pool: &SqlitePool) -> anyhow::Result<()> {
    // Tablas simples: users y todos
    sqlx::query(
        r#"
        CREATE TABLE IF NOT EXISTS users (
            id TEXT PRIMARY KEY,
            username TEXT NOT NULL UNIQUE,
            password_hash TEXT NOT NULL
        );
        CREATE TABLE IF NOT EXISTS todos (
            id TEXT PRIMARY KEY,
            user_id TEXT NOT NULL,
            title TEXT NOT NULL,
            done INTEGER NOT NULL DEFAULT 0,
            FOREIGN KEY (user_id) REFERENCES users(id)
        );
        "#,
    )
    .execute(pool)
    .await?;

    Ok(())
}

src/models.rs — DTOs y mapping

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

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

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

#[derive(Serialize, FromRow)]
pub struct Todo {
    pub id: String,
    pub user_id: String,
    pub title: String,
    pub done: bool,
}

#[derive(Deserialize)]
pub struct NewTodoRequest {
    pub title: String,
}

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

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

src/auth.rs — JWT y extractor

use actix_web::{dev::Payload, web::Data, Error, FromRequest, HttpRequest};
use futures::future::{err, ok, Ready};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use chrono::Utc;

pub struct AppState {
    pub jwt_secret: String,
}

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

pub fn create_jwt(user_id: &Uuid, secret: &str) -> Result {
    let expiration = (Utc::now().timestamp() + 60 * 60 * 24) as usize; // 24h
    let claims = Claims {
        sub: user_id.to_string(),
        exp: expiration,
    };
    encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()))
}

pub struct AuthUser(pub Uuid);

impl FromRequest for AuthUser {
    type Error = Error;
    type Future = Ready>;
    type Config = ();

    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
        let state = req
            .app_data::>()
            .map(|d| d.jwt_secret.clone());

        let header = req
            .headers()
            .get("Authorization")
            .and_then(|h| h.to_str().ok())
            .unwrap_or("");

        if state.is_none() {
            return err(actix_web::error::ErrorUnauthorized("JWT secret not configured"));
        }

        let secret = state.unwrap();

        if !header.starts_with("Bearer ") {
            return err(actix_web::error::ErrorUnauthorized("Missing token"));
        }

        let token = &header[7..];

        match decode::(
            token,
            &DecodingKey::from_secret(secret.as_ref()),
            &Validation::default(),
        ) {
            Ok(data) => match Uuid::parse_str(&data.claims.sub) {
                Ok(uid) => ok(AuthUser(uid)),
                Err(_) => err(actix_web::error::ErrorUnauthorized("Invalid user id in token")),
            },
            Err(_) => err(actix_web::error::ErrorUnauthorized("Invalid token")),
        }
    }
}

src/routes.rs — endpoints

use actix_web::{web, HttpResponse};
use sqlx::SqlitePool;
use uuid::Uuid;
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
use password_hash::SaltString;
use rand_core::OsRng;

use crate::models::*;
use crate::auth::{create_jwt, AuthUser, AppState};

pub fn config(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/")
            .route("/signup", web::post().to(signup))
            .route("/login", web::post().to(login))
            .route("/todos", web::get().to(list_todos))
            .route("/todos", web::post().to(create_todo))
            .route("/todos/{id}/toggle", web::put().to(toggle_todo)),
    );
}

async fn signup(
    pool: web::Data,
    body: web::Json,
) -> Result {
    let username = body.username.trim();
    let password = body.password.as_str();

    // check existing
    if let Some(_) = sqlx::query_as::<_, DbUser>("SELECT id, username, password_hash FROM users WHERE username = ?")
        .bind(username)
        .fetch_optional(pool.get_ref())
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?
    {
        return Ok(HttpResponse::Conflict().body("username taken"));
    }

    // hash password
    let salt = SaltString::generate(&mut OsRng);
    let argon2 = Argon2::default();
    let password_hash = argon2
        .hash_password(password.as_bytes(), &salt)
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?
        .to_string();

    let user_id = Uuid::new_v4();

    sqlx::query("INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)")
        .bind(user_id.to_string())
        .bind(username)
        .bind(password_hash)
        .execute(pool.get_ref())
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(HttpResponse::Created().finish())
}

async fn login(
    pool: web::Data,
    app_state: web::Data,
    body: web::Json,
) -> Result {
    let username = body.username.trim();
    let password = body.password.as_str();

    let db_user = sqlx::query_as::<_, DbUser>("SELECT id, username, password_hash FROM users WHERE username = ?")
        .bind(username)
        .fetch_optional(pool.get_ref())
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    let db_user = match db_user {
        Some(u) => u,
        None => return Ok(HttpResponse::Unauthorized().finish()),
    };

    // verify
    let parsed_hash = password_hash::PasswordHash::new(&db_user.password_hash)
        .map_err(|_| actix_web::error::ErrorInternalServerError("hash parse error"))?;
    let argon2 = Argon2::default();
    if argon2.verify_password(password.as_bytes(), &parsed_hash).is_err() {
        return Ok(HttpResponse::Unauthorized().finish());
    }

    let uid = Uuid::parse_str(&db_user.id).map_err(|_| actix_web::error::ErrorInternalServerError("uuid"))?;
    let token = create_jwt(&uid, &app_state.jwt_secret).map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(HttpResponse::Ok().json(TokenResponse { token }))
}

async fn list_todos(
    pool: web::Data,
    auth: AuthUser,
) -> Result {
    let rows = sqlx::query_as::<_, Todo>("SELECT id, user_id, title, done FROM todos WHERE user_id = ?")
        .bind(auth.0.to_string())
        .fetch_all(pool.get_ref())
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(HttpResponse::Ok().json(rows))
}

async fn create_todo(
    pool: web::Data,
    auth: AuthUser,
    body: web::Json,
) -> Result {
    let id = Uuid::new_v4();
    sqlx::query("INSERT INTO todos (id, user_id, title, done) VALUES (?, ?, ?, ?)")
        .bind(id.to_string())
        .bind(auth.0.to_string())
        .bind(body.title.trim())
        .bind(0)
        .execute(pool.get_ref())
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(HttpResponse::Created().finish())
}

async fn toggle_todo(
    pool: web::Data,
    auth: AuthUser,
    path: web::Path<(String,)>,
) -> Result {
    let id = &path.0;

    // ensure owner
    let todo = sqlx::query_as::<_, Todo>("SELECT id, user_id, title, done FROM todos WHERE id = ?")
        .bind(id)
        .fetch_optional(pool.get_ref())
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    let todo = match todo {
        Some(t) => t,
        None => return Ok(HttpResponse::NotFound().finish()),
    };

    if todo.user_id != auth.0.to_string() {
        return Ok(HttpResponse::Forbidden().finish());
    }

    let new_done = if todo.done { 0 } else { 1 };

    sqlx::query("UPDATE todos SET done = ? WHERE id = ?")
        .bind(new_done)
        .bind(id)
        .execute(pool.get_ref())
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(HttpResponse::Ok().finish())
}

src/main.rs — arranque

mod db;
mod models;
mod routes;
mod auth;

use actix_web::{web, App, HttpServer};
use dotenv::dotenv;
use std::env;
use sqlx::SqlitePool;
use env_logger;

use auth::AppState;

#[actix_web::main]
async fn main() -> anyhow::Result<()> {
    env_logger::init();
    dotenv().ok();

    let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let jwt_secret = env::var("JWT_SECRET").unwrap_or_else(|_| "secret".into());

    let pool = SqlitePool::connect(&db_url).await?;
    db::init_db(&pool).await?;

    let app_state = web::Data::new(AppState { jwt_secret });

    println!("Listening on http://127.0.0.1:8080");

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(pool.clone()))
            .app_data(app_state.clone())
            .configure(routes::config)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await?;

    Ok(())
}

Por qué diseñé así (no sólo cómo)

  • Actix-web: rendimiento y ecosistema, ideal para APIs asincrónicas.
  • sqlx + SQLite: sqlx permite consultas parametrizadas y async; SQLite mantiene el proyecto autocontenido (útil para pruebas y demos). Puedes cambiar a Postgres con mínimo esfuerzo.
  • Argon2 para contraseñas: resistencia a ataques de fuerza bruta y GPUs; nunca guardes contraseñas en claro ni uses hashing rápido (MD5/SHA).
  • JWT con claims mínimos (sub, exp): suficiente para sesiones stateless. Ten en cuenta que revocar tokens requiere añadir lista de revocación o rotación de claves.
  • Extractor AuthUser: un patrón idiomático en actix para inyectar la identidad del usuario directamente en los handlers.

Cómo probar

  1. cargo run
  2. POST /signup { "username":"alice", "password":"pass" } -> 201
  3. POST /login { "username":"alice", "password":"pass" } -> { token }
  4. GET /todos con header Authorization: Bearer <token> -> []
  5. POST /todos { "title": "Comprar leche" } con Authorization -> 201
  6. PUT /todos/{id}/toggle para marcar/desmarcar

Ejemplo curl (login):

curl -X POST http://127.0.0.1:8080/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"pass"}'

Próximos pasos y mejoras recomendadas

  • Rotación y caducidad de claves JWT; si necesitas revocación, añade una tabla de tokens invalidated o usa JWTs cortos + refresh tokens.
  • Migraciones gestionadas (por ejemplo sqlx-cli o refinery) para entornos más complejos.
  • Tests: unitarios para la lógica y tests de integración con una DB en memoria.
  • Rate limiting y logging estructurado en producción.

Consejo avanzado: usa claims con jti (token id) y una tabla de jti permitidos para poder revocar tokens individualmente sin sacrificar la naturaleza stateless; además firma tu JWT_SECRET con un HSM o servicio de secretos en producción y habilita TLS siempre.

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!

Iniciar Sesión