API REST en Rust con Actix-web, SQLite y JWT (registro, login y ruta protegida)

rust API REST en Rust con Actix-web, SQLite y JWT (registro, login y ruta protegida)

API REST en Rust con Actix-web, SQLite y JWT

Proyecto práctico: montamos una API mínima en Rust que permite registro, login (contraseñas hasheadas con bcrypt) y una ruta protegida por JWT. Usamos Actix-web para el servidor, sqlx + SQLite para la persistencia y jsonwebtoken para los tokens.

Requisitos previos

  • Rust (stable) y cargo
  • sqlite3 (opcional, para inspeccionar la DB)
  • Conocimientos básicos de HTTP y JSON

Estructura de carpetas

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

Cargo.toml

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

[dependencies]
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.6", features = ["sqlite", "runtime-tokio-rustls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
dotenvy = "0.15"
bcrypt = "0.13"
jsonwebtoken = "8"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }

Explicación breve del enfoque

  • Usamos sqlx con SQLite para mantener dependencias y setup simples — basta un fichero DB.
  • No usamos migraciones avanzadas aquí; la app crea la tabla si no existe en el arranque.
  • Las contraseñas se hashean con bcrypt y nunca se devuelven en respuestas.
  • JWT contiene el id del usuario (sub) y una exp; la firma usa HMAC-SHA256 con una clave secreta.

Archivos principales (código completo)

src/main.rs

use actix_web::{web, App, HttpServer};
use dotenvy::dotenv;
use std::env;

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

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();

    let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://./data.db".to_string());
    let jwt_secret = env::var("JWT_SECRET").unwrap_or_else(|_| "change_me_to_a_strong_secret".to_string());

    let pool = db::init_pool(&db_url).await.expect("DB pool");
    db::init_db(&pool).await.expect("init DB");

    // share pool and secret with handlers
    let data = handlers::AppState { pool, jwt_secret };

    println!("Server running at http://127.0.0.1:8080");

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(data.clone()))
            .service(
                web::scope("/api")
                    .route("/register", web::post().to(handlers::register))
                    .route("/login", web::post().to(handlers::login))
                    .route("/me", web::get().to(handlers::me)),
            )
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

src/db.rs

use sqlx::SqlitePool;

pub async fn init_pool(database_url: &str) -> Result {
    let pool = SqlitePool::connect(database_url).await?;
    Ok(pool)
}

pub async fn init_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
    // Tabla users: id (text, PK), username (unique), password (hashed)
    sqlx::query(
        "CREATE TABLE IF NOT EXISTS users (
            id TEXT PRIMARY KEY,
            username TEXT NOT NULL UNIQUE,
            password TEXT NOT NULL
        );",
    )
    .execute(pool)
    .await?;

    Ok(())
}

src/models.rs

use serde::{Deserialize, Serialize};

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

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

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

src/auth.rs

use chrono::Utc;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};

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

pub fn create_jwt(user_id: &str, secret: &str, hours_valid: i64) -> Result {
    let exp = (Utc::now().timestamp() + hours_valid * 3600) as usize;
    let claims = Claims { sub: user_id.to_string(), exp };
    let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()))?;
    Ok(token)
}

pub fn verify_jwt(token: &str, secret: &str) -> Result {
    let validation = Validation::default();
    let token_data = decode::(token, &DecodingKey::from_secret(secret.as_ref()), &validation)?;
    Ok(token_data.claims.sub)
}

src/handlers.rs

use crate::db;
use crate::models::{LoginPayload, MeResponse, RegisterPayload};
use crate::auth;
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use sqlx::SqlitePool;
use serde_json::json;
use uuid::Uuid;
use bcrypt::{hash, verify, DEFAULT_COST};

#[derive(Clone)]
pub struct AppState {
    pub pool: SqlitePool,
    pub jwt_secret: String,
}

pub async fn register(data: web::Data, payload: web::Json) -> impl Responder {
    let pool = &data.pool;
    let username = payload.username.trim();
    let password = payload.password.trim();

    if username.is_empty() || password.is_empty() {
        return HttpResponse::BadRequest().json(json!({"error": "username and password required"}));
    }

    // Check if user exists
    let existing = sqlx::query!("SELECT id FROM users WHERE username = ?", username)
        .fetch_optional(pool)
        .await;

    match existing {
        Ok(Some(_)) => return HttpResponse::BadRequest().json(json!({"error": "username taken"})),
        Ok(None) => (),
        Err(e) => return HttpResponse::InternalServerError().json(json!({"error": e.to_string()})),
    }

    let user_id = Uuid::new_v4().to_string();
    let password_hash = match hash(password, DEFAULT_COST) {
        Ok(h) => h,
        Err(_) => return HttpResponse::InternalServerError().json(json!({"error": "hash error"})),
    };

    let res = sqlx::query("INSERT INTO users (id, username, password) VALUES (?, ?, ?)")
        .bind(&user_id)
        .bind(username)
        .bind(&password_hash)
        .execute(pool)
        .await;

    match res {
        Ok(_) => HttpResponse::Ok().json(json!({"id": user_id, "username": username})),
        Err(e) => HttpResponse::InternalServerError().json(json!({"error": e.to_string()})),
    }
}

pub async fn login(data: web::Data, payload: web::Json) -> impl Responder {
    let pool = &data.pool;
    let username = payload.username.trim();
    let password = payload.password.trim();

    let row = sqlx::query!("SELECT id, password FROM users WHERE username = ?", username)
        .fetch_optional(pool)
        .await;

    let row = match row {
        Ok(Some(r)) => r,
        Ok(None) => return HttpResponse::Unauthorized().json(json!({"error": "invalid credentials"})),
        Err(e) => return HttpResponse::InternalServerError().json(json!({"error": e.to_string()})),
    };

    let valid = match verify(password, &row.password) {
        Ok(v) => v,
        Err(_) => false,
    };

    if !valid {
        return HttpResponse::Unauthorized().json(json!({"error": "invalid credentials"}));
    }

    let token = match auth::create_jwt(&row.id, &data.jwt_secret, 24) {
        Ok(t) => t,
        Err(_) => return HttpResponse::InternalServerError().json(json!({"error": "token error"})),
    };

    HttpResponse::Ok().json(json!({"token": token}))
}

pub async fn me(data: web::Data, req: HttpRequest) -> impl Responder {
    let pool = &data.pool;
    let header = req.headers().get("Authorization");

    let token = match header {
        Some(hv) => {
            let s = match hv.to_str() {
                Ok(v) => v,
                Err(_) => return HttpResponse::BadRequest().json(json!({"error": "invalid auth header"})),
            };
            if !s.starts_with("Bearer ") {
                return HttpResponse::BadRequest().json(json!({"error": "invalid auth header"}));
            }
            s.trim_start_matches("Bearer ").trim()
        }
        None => return HttpResponse::Unauthorized().json(json!({"error": "missing authorization"})),
    };

    let user_id = match auth::verify_jwt(token, &data.jwt_secret) {
        Ok(id) => id,
        Err(_) => return HttpResponse::Unauthorized().json(json!({"error": "invalid token"})),
    };

    let row = sqlx::query!("SELECT id, username FROM users WHERE id = ?", user_id)
        .fetch_optional(pool)
        .await;

    match row {
        Ok(Some(r)) => HttpResponse::Ok().json(MeResponse { id: r.id, username: r.username }),
        Ok(None) => HttpResponse::NotFound().json(json!({"error": "user not found"})),
        Err(e) => HttpResponse::InternalServerError().json(json!({"error": e.to_string()})),
    }
}

Variables de entorno

Puedes crear un archivo .env en el proyecto con:

DATABASE_URL=sqlite://./data.db
JWT_SECRET=una_clave_secreta_larga_y_segura

Cómo ejecutar

cargo run

Pruebas rápidas con curl

Registro:

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

Login:

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

# Respuesta: {"token":"..."}

Ruta protegida (/me):

curl http://127.0.0.1:8080/api/me \
  -H "Authorization: Bearer "

Por qué se hizo así (decisiones técnicas)

  • Actix-web: rendimiento y ergonomía con extractores y rutas, buena integración async.
  • sqlx + SQLite: simplicidad para ejemplos. Para producción cambia a Postgres/MySQL con pool y migraciones.
  • bcrypt: hashing probado para contraseñas; ajustar el cost según hardware.
  • JWT: útil para APIs stateless; aquí la claims contiene solo el id. En producción, añade jti, aud, iss según necesidades.
  • Creación de tabla en arranque: conveniente en ejemplos; para proyectos reales usa migraciones versionadas.

Próximos pasos recomendados

  • Agregar validación más estricta (longitud de contraseña, sanitización del username).
  • Agregar refresh tokens y revocación (guardar jti en DB para invalidación).
  • Implementar pruebas unitarias y de integración (actix-web test server).
  • Migrar a Postgres en producción y añadir Diesel o sqlx migrations.

Consejo avanzado: no uses un JWT largo plazo sin mecanismo de revocación; guarda identificadores de token (jti) y una lista de revocación si necesitas invalidar tokens rápidamente. Además, guarda la clave secreta en un gestor de secretos y rota periódicamente.

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