Autenticación
Corvis no impone una estrategia de autenticación, pero trae las piezas para la más habitual en APIs: tokens JWT firmados por el servidor y verificados por un guard. Esta página arma ese flujo completo con JwtModule, JwtService y un CanActivate propio, siguiendo el ejemplo examples/cats-app.
El flujo es el siguiente:
- El cliente envía sus credenciales a
POST /auth/login. - Un servicio comprueba las credenciales y firma un JWT con
JwtService::sign. - En cada petición posterior el cliente envía
Authorization: Bearer <token>. - Un guard extrae el token, lo verifica y deja los claims en la request para el handler.
Requisito
JwtModule y JwtService están detrás de la feature jwt del crate corvis, que viene activada por defecto.
Registrar JwtModule
JwtModule::register recibe un JwtModuleOptions y devuelve un módulo dinámico que exporta JwtService. Lo normal es leer el secreto de la configuración con register_async, que recibe primero si el módulo es global y después una fábrica asíncrona con acceso al contenedor:
use corvis::prelude::*;
use corvis::{JwtModule, JwtModuleOptions};
#[module(
imports = [
UsersModule,
JwtModule::register_async(true, |ctx| async move {
let config = ctx.get::<ConfigService>().await?;
let secret = config
.get::<String>("JWT_SECRET")
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| corvis::anyhow::anyhow!("JWT_SECRET is required"))?;
Ok(JwtModuleOptions::new(secret).expires_in(config.get_or("JWT_EXPIRES_IN", "1h".to_string())))
}),
],
controllers = [AuthController],
providers = [AuthService],
exports = [AuthService],
)]
pub struct AuthModule;Con el primer argumento en true, JwtService queda visible en toda la aplicación sin importar el módulo en cada sitio (ver Módulos). Si la fábrica devuelve un error, la aplicación no arranca: es preferible a firmar tokens con un secreto conocido.
Opciones de JwtModuleOptions:
| Método | Efecto |
|---|---|
JwtModuleOptions::new(secret) | Secreto HMAC (HS256 por defecto) |
.expires_in("15m") | Añade exp. Acepta la gramática del paquete ms ("60s", "1.5h", "2 days"); un número sin unidad son segundos. Un valor que no se entiende hace fallar sign |
.algorithm(JwtAlgorithm::RS256) | Algoritmo (corvis::jwt::JwtAlgorithm, reexportado de jsonwebtoken) |
.private_key(pem) / .public_key(pem) | Claves PEM para RS*, PS*, ES* y EdDSA. Usa JwtModuleOptions::new(""): un secreto no vacío tiene prioridad sobre las claves |
.issuer("api") / .audience("web") | Añade iss/aud al firmar y los exige al verificar |
.global(true) | Solo para register: registra el módulo como global |
Firmar el token
JwtService se inyecta como cualquier provider. sign serializa el payload, añade iat (y exp si configuraste expires_in) y devuelve el token:
use corvis::JwtService;
use corvis::prelude::*;
#[injectable]
pub struct AuthService {
users_service: Arc<UsersService>,
jwt_service: Arc<JwtService>,
}
impl AuthService {
pub async fn sign_in(&self, username: &str, pass: &str) -> HttpResult<Value> {
let user = self.users_service.find_one(username).await;
let Some(user) = user.filter(|u| u.password == pass) else {
return Err(UnauthorizedException.into());
};
let payload = JwtPayload { sub: user.user_id, username: user.username, roles: user.roles };
let token = self.jwt_service.sign(&payload).map_err(HttpException::from_anyhow)?;
Ok(json!({ "access_token": token }))
}
}Contraseñas
El ejemplo compara contraseñas en texto plano para no distraer del flujo. En una aplicación real guarda un hash y compáralo con una función de derivación lenta; ver Cifrado y hashing.
Para opciones por llamada existe sign_with(&payload, JwtSignOptions { .. }), con expires_in, secret, subject y private_key. También están sign_async y verify_async, que hacen lo mismo que sus versiones síncronas.
El endpoint de login
#[controller("auth")]
pub struct AuthController {
auth_service: Arc<AuthService>,
}
#[routes]
impl AuthController {
#[post("login")]
#[http_code(HttpStatus::OK)]
#[set_metadata(IS_PUBLIC_KEY, true)]
async fn sign_in(&self, #[body] dto: SignInDto) -> HttpResult<Value> {
self.auth_service.sign_in(&dto.username, &dto.password).await
}
#[get("profile")]
fn get_profile(&self, user: CurrentUser) -> JwtPayload {
user.0
}
}#[http_code(HttpStatus::OK)] cambia el 201 que un POST devuelve por defecto. El parámetro user: CurrentUser no lleva atributo: se resuelve con FromContext (ver más abajo).
El guard de autenticación
El guard lee la cabecera con extract_bearer_token, verifica el token y adjunta el payload a la request:
use corvis::prelude::*;
use corvis::{JwtService, extract_bearer_token};
#[injectable]
pub struct AuthGuard {
jwt_service: Arc<JwtService>,
reflector: Arc<Reflector>,
}
#[async_trait]
impl CanActivate for AuthGuard {
async fn can_activate(&self, ctx: &ExecutionContext) -> Result<bool, HttpException> {
let is_public: bool = self
.reflector
.get_all_and_override(IS_PUBLIC_KEY, &[ctx.get_handler(), ctx.get_class()])
.unwrap_or(false);
if is_public {
return Ok(true);
}
let request = ctx.switch_to_http().get_request();
let token = extract_bearer_token(request).ok_or(UnauthorizedException)?;
let payload: JwtPayload = self.jwt_service.verify_or_unauthorized(&token)?;
request.set_user(&payload);
Ok(true)
}
}verify::<T>(token)comprueba la firma (solo con el algoritmo configurado),nbf,exp(caducado cuandoahora >= exp),iss/audsi los configuraste, y deserializa los claims enT. Devuelve uncorvis::Result<T>.verify_or_unauthorized::<T>(token)hace lo mismo y convierte cualquier fallo en un401.decode(token)devuelve el payload comoValuesin verificar la firma. No lo uses para decidir permisos.
Hay dos formas de guardar el usuario en la request:
request.set_user(&payload)lo serializa a JSON; se lee conrequest.user_as::<JwtPayload>()orequest.user().request.insert(valor)guarda un valor tipado (debe serClone); se lee conrequest.get::<T>(). Es lo que haceexamples/tertulia.
Activar la autenticación en todas las rutas
Registrado como provider con Provider::app_guard, el guard se aplica a todas las rutas de los controladores y puede recibir dependencias por inyección:
#[module(
imports = [AuthModule, UsersModule, CatsModule],
providers = [Provider::app_guard::<AuthGuard>()],
)]
pub struct AppModule;Las rutas abiertas se marcan con metadata que el guard consulta mediante Reflector:
pub const IS_PUBLIC_KEY: &str = "isPublic";
#[get]
#[set_metadata(IS_PUBLIC_KEY, true)]
fn find_all(&self) -> Vec<Cat> { /* ... */ }#[set_metadata] también puede ir debajo de #[controller] para abrir un controlador completo.
Alcance de los guards globales
Provider::app_guard y app.use_global_guards solo protegen rutas de controladores. No se aplican a los gateways de WebSocket, a los listeners upgrade de corvis::ws, a los archivos estáticos ni a las rutas de Swagger/Scalar. Protege esas superficies por separado.
Leer el usuario en el handler
Un tipo propio que implementa FromContext funciona como un decorador de parámetro: basta con declararlo como argumento del handler, sin atributo.
pub struct CurrentUser(pub JwtPayload);
#[async_trait]
impl FromContext for CurrentUser {
async fn from_context(ctx: &ExecutionContext) -> Result<Self, HttpException> {
ctx.request()
.user_as::<JwtPayload>()
.map(CurrentUser)
.ok_or_else(|| UnauthorizedException.into())
}
}Ver Decoradores personalizados para más detalles.
Autenticación en WebSockets
Un navegador no puede enviar cabeceras al abrir un WebSocket, así que el token suele viajar en la query. examples/tertulia lo verifica en handle_connection y cierra la conexión si no es válido:
async fn handle_connection(&self, client: WsClient) {
let handshake = client.handshake();
let token = handshake.query_param("token").map(str::to_string).or_else(|| extract_bearer_token(handshake));
let Some(user) = token.and_then(|t| self.jwt.verify::<ChatUser>(&t).ok()) else {
client.disconnect_with(4401, "Unauthorized");
return;
};
client.insert(user);
}El mismo AuthGuard de tertulia sirve para HTTP y para WebSocket: consulta ctx.get_type() y, en ContextType::Ws, lee el usuario guardado en el cliente. Ver Guards de WebSockets.
Lo que no incluye Corvis
- No hay estrategias tipo Passport (local, OAuth, OpenID Connect) ni sesiones de servidor. Ver Passport y Sesión.
- No hay refresh tokens integrados: se implementan firmando un segundo token con otra vigencia (
sign_withconJwtSignOptions { expires_in: Some("7d".into()), .. }) y un endpoint que lo canjea.