Récupérer l'utilisateur authentifié dans les requêtes actix-web

Dans ma quête de l'apprentissage du Rust, je me suis mis en tête de faire une petite API d’agrégation de flux RSS, parce que le flux RSS c'est bien, le flux RSS c'est la vie. Dans une prochaine vie, je tenterai peut-être de faire une interface web, en Rust/Wasm parce que pourquoi pas, et parce que ma mère ne veut pas que je fasse de Javascript.
Pour ça, j'ai décidé d'utiliser le framework Actix pour le web (après avoir longtemps hésité avec Rocket) et Diesel pour l'ORM. Je suis tombé assez rapidement sur une problématique un peu relou qui pourtant, peut facilement tomber sous le sens: je voulais pouvoir protéger mes routes (pas toutes, mais une grosse majorité) via une authentification HTTP Basic Auth ou via un JWT. Et surtout, je voulais pouvoir facilement récupérer cet éventuel utilisateur dans mes routes
Après avoir lutter pendant quelques soirées, en cherchant a utiliser diverse librairies (actix-web-httpauth, actix-identify entre autre), j'ai fini par tomber sur ce post sur stackoverflow et ce fut la révélation.
L'idée est d'utiliser le trait
FromRequest sur une struct
de son choix. Ce trait
permet d'extraire la struct
en question de la requête entrante pour pouvoir l'utiliser dans sa méthode.
Un exemple, en supposant que la struct
User implémente le trait FromRequest:
struct AuthenticatedUser {
id: usize,
name: String
}
#[get("/pouet")]
async fn pouet(user: AuthenticatedUser) -> HttpResponse {
log::info!("User {} (id. {}) requested a pouet", user.name, user.id);
HttpResponse::Ok().json(json!({ "name": user.name, "id": user.id, "pouet": "pouet!"}))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Init dotenv
dotenv::dotenv().ok();
// set up database connection pool
let connection_spec = std::env::var("DATABASE_URL").unwrap_or_else(|_| String::from("database.db"));
let manager = ConnectionManager::<SqliteConnection>::new(connection_spec);
let pool: DbPool = r2d2::Pool::builder()
.build(manager)
.expect("Failed to create pool.");
HttpServer::new(move || {
App::new()
.data(pool.clone())
.service(pouet)
})
.bind(std::env::var("LISTEN_ON").unwrap_or_else(|_| String::from("0.0.0.0:8080")))?
.run()
.await
}
Ok mec, t'es mignon, on peut utiliser magiquement User dans ses méthodes, mais comment on implémente le trait
en question?
C'est là que ça devient fun, et la réponse se trouve dans la doc d'Actix, où il y a un joli exemple.
Dans mon cas, je veux pouvoir faire face a deux cas de figure:
- L'utilisateur authentifie chaque requête avec HTTP Basic auth (un header
Authorization: Basic Y291Y291OmxvbAo=
par exemple). Dans ce cas, on va utiliser les credentials fournis dans le header pour aller pécho le user en base de données, et si tout est ok, on va construire une struct AuthenticatedUser avec ça et on la retourne. - L'utilisateur utilise un JWT qu'il a récupérer depuis un autre endpoint d'authentification, que je n'ai pas mis dans le code parce que la grosse flemme (
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiOikiLCJpZCI6OH0.2bG9a8Y0Eo7XbfidO2t35VqmNfK34owXIanQx0gB5eU
par exemple). Dans ce cas là, et puisque le endpoint d'authentication renvoie déjà un AuthenticatedUser sous la forme d'un JWT (on a bien fait les chose), il ne reste qu'à désérialiser le JWT, récupérer le AuthenticatedUser, vérifier qu'il est toujours valide (on a mis une expiration, on a bien fait les choses²) et on a plus rien a faire d'autre que retourner la struct.
impl FromRequest for AuthenticatedUser {
type Error = ApiError; // ApiError est une erreur à moi
type Future = Ready<Result<Self, Self::Error>>;
type Config = ();
fn from_request(req: &HttpRequest, _: &mut dev::Payload) -> Self::Future {
// On se fout du payload dans notre cas, mais au cas où,
// y'a moyen de le récupérer
// Je pécho mon pool de connexion SQL, au besoin
let pool = req.app_data::<web::Data<DbPool>>().unwrap();
// On récupère le header
let header_value = match get_auth_header(req.headers()) {
Ok(header) => header,
Err(e) => return err(e),
// En cas de soucis, je renvoie l'erreur en tant
// que future
};
let mut split_header = header_value.split_whitespace();
// On choppe le scheme d'authentication
// ("Basic", "Bearer", etc)
let scheme = split_header.next().unwrap();
// On récupère ce qu'il y a après le scheme
// (on pleure si on trouve rien de valable)
let value = if let Some(token) = split_header.next() {
token
} else {
return err(ApiError::unauthorized());
};
// Enfin on match sur le scheme et la valeur
return match (scheme, value) {
// Si le scheme est Bearer, on va vérifier le JWT
("Bearer", token) => match verify_jwt(token) {
// Si il est bon, on le retourne
Ok(user) => ok(user),
// Sinon, on appelle notre maman
Err(e) => err(e),
},
// Si on est dans un Http Basic Auth
("Basic", _) => {
// On va tenter d'extraire les credentials
// depuis la valeur du header
let (user, password) = match get_creds(header_value) {
Ok(credentials) => credentials,
Err(e) => return err(e),
};
// Avec ces credentials, on va aller vérifier en base
// de données que tout est ok
match fetch_and_check_user(&user, &password, pool) {
// Si c'est bon, on crée notre structure a partir
// de celle de la base de données
Ok(u) => ok(AuthenticatedUser::from_user(&u)),
// Sinon, on chouine
Err(e) => err(e),
}
}
// Ici, on on récupère les schemes qu'on ne gère
// pas et on pleure
(error, _) => err(ApiError::unauthorized(format!(
"Unknown Authorization scheme: {}",
error
))),
};
}
}
Alors, c'est assez verbeux (d'autant plus que j'ai extrait pas mal de code dans des méthodes), probablement en partie parce que ma maîtrise du Rust assez rudimentaire mais ça fait super bien le taf.
Pour les gens qui viennent de Spring Boot (j'en fais parti), cette méthode permet d'obtenir un équivalent de @AuthenticationPrincipal
dans une méthode de controller.
Mais alors que faire quand on ne veut pas forcément protéger une route? Avec Option
pardi!
#[get("/coucou")]
async fn coucou(user: Option<AuthedUser>) -> HttpResponse {
match user {
Some(user) => {
log::info!("User {} (id. {}) requested a pouet", user.login, user.id);
HttpResponse::Ok().json(json!({ "name": user.login, "id": user.id, "pouet": "pouet!"}))
}
None => {
log::info!("Anonymous user requested a pouet");
HttpResponse::Ok().json(json!({ "name": "anonymous", "id": 666, "pouet": "pouet!"}))
}
}
}
Et c'est tout! Avoir trouvé ce truc m'a pas mal remotivé dans ce projet qui traînassait depuis des lustres, donc j'espère que ça pourra être utile à d'autres!