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
- cargo run
- POST /signup { "username":"alice", "password":"pass" } -> 201
- POST /login { "username":"alice", "password":"pass" } -> { token }
- GET /todos con header Authorization: Bearer <token> -> []
- POST /todos { "title": "Comprar leche" } con Authorization -> 201
- 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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación