diff --git a/src/auth.rs b/src/auth.rs index 722cf2f1..e782fd59 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -5,6 +5,12 @@ use actix_web::HttpRequest; use crate::errors::ServiceError; use crate::models::user::{UserClaims, UserCompact, UserId}; use crate::services::authentication::JsonWebToken; +use crate::web::api::v1::extractors::bearer_token::BearerToken; + +// todo: refactor this after finishing migration to Axum. +// - Extract service to handle Json Web Tokens: `new`, `sign_jwt`, `verify_jwt`. +// - Move the rest to `src/web/api/v1/auth.rs`. It's a helper for Axum handlers +// to get user id from request. pub struct Authentication { json_web_token: Arc, @@ -30,13 +36,25 @@ impl Authentication { self.json_web_token.verify(token).await } - /// Get Claims from Request + // Begin ActixWeb + + /// Get User id from `ActixWeb` Request + /// + /// # Errors + /// + /// This function will return an error if it can get claims from the request + pub async fn get_user_id_from_actix_web_request(&self, req: &HttpRequest) -> Result { + let claims = self.get_claims_from_actix_web_request(req).await?; + Ok(claims.user.user_id) + } + + /// Get Claims from `ActixWeb` Request /// /// # Errors /// - /// This function will return an `ServiceError::TokenNotFound` if `HeaderValue` is `None` - /// This function will pass through the `ServiceError::TokenInvalid` if unable to verify the JWT. - pub async fn get_claims_from_request(&self, req: &HttpRequest) -> Result { + /// - Return an `ServiceError::TokenNotFound` if `HeaderValue` is `None`. + /// - Pass through the `ServiceError::TokenInvalid` if unable to verify the JWT. + async fn get_claims_from_actix_web_request(&self, req: &HttpRequest) -> Result { match req.headers().get("Authorization") { Some(auth) => { let split: Vec<&str> = auth @@ -55,13 +73,37 @@ impl Authentication { } } - /// Get User id from Request + // End ActixWeb + + // Begin Axum + + /// Get User id from bearer token /// /// # Errors /// /// This function will return an error if it can get claims from the request - pub async fn get_user_id_from_request(&self, req: &HttpRequest) -> Result { - let claims = self.get_claims_from_request(req).await?; + pub async fn get_user_id_from_bearer_token(&self, maybe_token: &Option) -> Result { + let claims = self.get_claims_from_bearer_token(maybe_token).await?; Ok(claims.user.user_id) } + + /// Get Claims from bearer token + /// + /// # Errors + /// + /// This function will: + /// + /// - Return an `ServiceError::TokenNotFound` if `HeaderValue` is `None`. + /// - Pass through the `ServiceError::TokenInvalid` if unable to verify the JWT. + async fn get_claims_from_bearer_token(&self, maybe_token: &Option) -> Result { + match maybe_token { + Some(token) => match self.verify_jwt(&token.value()).await { + Ok(claims) => Ok(claims), + Err(e) => Err(e), + }, + None => Err(ServiceError::TokenNotFound), + } + } + + // End Axum } diff --git a/src/routes/category.rs b/src/routes/category.rs index f087d2b8..30d3643a 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -41,7 +41,7 @@ pub struct Category { /// This function will return an error if unable to get user. /// This function will return an error if unable to insert into the database the new category. pub async fn add(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let _category_id = app_data.category_service.add_category(&payload.name, &user_id).await?; @@ -61,7 +61,7 @@ pub async fn delete(req: HttpRequest, payload: web::Json, app_data: We // And we should use the ID instead of the name, because the name could change // or we could add support for multiple languages. - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; app_data.category_service.delete_category(&payload.name, &user_id).await?; diff --git a/src/routes/proxy.rs b/src/routes/proxy.rs index c985b53e..0ff99d08 100644 --- a/src/routes/proxy.rs +++ b/src/routes/proxy.rs @@ -21,7 +21,7 @@ pub fn init(cfg: &mut web::ServiceConfig) { /// /// This function will return `Ok` only for now. pub async fn get_proxy_image(req: HttpRequest, app_data: WebAppData, path: web::Path) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await.ok(); + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await.ok(); match user_id { Some(user_id) => { diff --git a/src/routes/settings.rs b/src/routes/settings.rs index 806426b8..378a05dd 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -25,7 +25,7 @@ pub fn init(cfg: &mut web::ServiceConfig) { /// /// This function will return an error if unable to get user from database. pub async fn get_all_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let all_settings = app_data.settings_service.get_all(&user_id).await?; @@ -46,7 +46,7 @@ pub async fn update_handler( payload: web::Json, app_data: WebAppData, ) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let new_settings = app_data.settings_service.update_all(payload.into_inner(), &user_id).await?; diff --git a/src/routes/tag.rs b/src/routes/tag.rs index b7de7b16..fb8f51bf 100644 --- a/src/routes/tag.rs +++ b/src/routes/tag.rs @@ -44,7 +44,7 @@ pub struct Create { /// * Get the compact user from the user id. /// * Add the new tag to the database. pub async fn create(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; app_data.tag_service.add_tag(&payload.name, &user_id).await?; @@ -68,7 +68,7 @@ pub struct Delete { /// * Get the compact user from the user id. /// * Delete the tag from the database. pub async fn delete(req: HttpRequest, payload: web::Json, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; app_data.tag_service.delete_tag(&payload.tag_id, &user_id).await?; diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs index 86ddf2ba..40edc129 100644 --- a/src/routes/torrent.rs +++ b/src/routes/torrent.rs @@ -77,7 +77,7 @@ pub struct Update { /// This function will return an error if there was a problem uploading the /// torrent. pub async fn upload_torrent_handler(req: HttpRequest, payload: Multipart, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let torrent_request = get_torrent_request_from_payload(payload).await?; @@ -99,7 +99,7 @@ pub async fn upload_torrent_handler(req: HttpRequest, payload: Multipart, app_da /// Returns `ServiceError::BadRequest` if the torrent info-hash is invalid. pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { let info_hash = get_torrent_info_hash_from_request(&req)?; - let user_id = app_data.auth.get_user_id_from_request(&req).await.ok(); + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await.ok(); let torrent = app_data.torrent_service.get_torrent(&info_hash, user_id).await?; @@ -115,7 +115,7 @@ pub async fn download_torrent_handler(req: HttpRequest, app_data: WebAppData) -> /// This function will return an error if unable to get torrent info. pub async fn get_torrent_info_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { let info_hash = get_torrent_info_hash_from_request(&req)?; - let user_id = app_data.auth.get_user_id_from_request(&req).await.ok(); + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await.ok(); let torrent_response = app_data.torrent_service.get_torrent_info(&info_hash, user_id).await?; @@ -137,7 +137,7 @@ pub async fn update_torrent_info_handler( app_data: WebAppData, ) -> ServiceResult { let info_hash = get_torrent_info_hash_from_request(&req)?; - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let torrent_response = app_data .torrent_service @@ -158,7 +158,7 @@ pub async fn update_torrent_info_handler( /// * Delete the torrent. pub async fn delete_torrent_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { let info_hash = get_torrent_info_hash_from_request(&req)?; - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let deleted_torrent_response = app_data.torrent_service.delete_torrent(&info_hash, &user_id).await?; diff --git a/src/routes/user.rs b/src/routes/user.rs index 5daa8a67..020726b3 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -134,7 +134,7 @@ pub async fn email_verification_handler(req: HttpRequest, app_data: WebAppData) /// /// This function will return if the user could not be banned. pub async fn ban_handler(req: HttpRequest, app_data: WebAppData) -> ServiceResult { - let user_id = app_data.auth.get_user_id_from_request(&req).await?; + let user_id = app_data.auth.get_user_id_from_actix_web_request(&req).await?; let to_be_banned_username = req.match_info().get("user").ok_or(ServiceError::InternalServerError)?; app_data.ban_service.ban_user(to_be_banned_username, &user_id).await?; diff --git a/src/web/api/v1/auth.rs b/src/web/api/v1/auth.rs index b84adee9..545e3054 100644 --- a/src/web/api/v1/auth.rs +++ b/src/web/api/v1/auth.rs @@ -78,3 +78,16 @@ //! "data": "new category" //! } //! ``` + +use hyper::http::HeaderValue; + +/// Parses the token from the `Authorization` header. +pub fn parse_token(authorization: &HeaderValue) -> String { + let split: Vec<&str> = authorization + .to_str() + .expect("variable `auth` contains data that is not visible ASCII chars.") + .split("Bearer") + .collect(); + let token = split[1].trim(); + token.to_string() +} diff --git a/src/web/api/v1/contexts/user/handlers.rs b/src/web/api/v1/contexts/user/handlers.rs index 4d10023b..543b87f9 100644 --- a/src/web/api/v1/contexts/user/handlers.rs +++ b/src/web/api/v1/contexts/user/handlers.rs @@ -10,6 +10,7 @@ use super::forms::{JsonWebToken, LoginForm, RegistrationForm}; use super::responses::{self, NewUser, TokenResponse}; use crate::common::AppData; use crate::errors::ServiceError; +use crate::web::api::v1::extractors::bearer_token::Extract; use crate::web::api::v1::responses::OkResponse; // Registration @@ -99,6 +100,9 @@ pub async fn verify_token_handler( } } +#[derive(Deserialize)] +pub struct UsernameParam(pub String); + /// It renews the JWT. /// /// # Errors @@ -118,6 +122,32 @@ pub async fn renew_token_handler( } } +/// It bans a user from the index. +/// +/// # Errors +/// +/// This function will return if: +/// +/// - The JWT provided by the banning authority was not valid. +/// - The user could not be banned: it does not exist, etcetera. +#[allow(clippy::unused_async)] +pub async fn ban_handler( + State(app_data): State>, + Path(to_be_banned_username): Path, + Extract(maybe_bearer_token): Extract, +) -> Result>, ServiceError> { + // todo: add reason and `date_expiry` parameters to request + + let user_id = app_data.auth.get_user_id_from_bearer_token(&maybe_bearer_token).await?; + + match app_data.ban_service.ban_user(&to_be_banned_username.0, &user_id).await { + Ok(_) => Ok(axum::Json(OkResponse { + data: format!("Banned user: {}", to_be_banned_username.0), + })), + Err(error) => Err(error), + } +} + /// It returns the base API URL without the port. For example: `http://localhost`. fn api_base_url(host: &str) -> String { // HTTPS is not supported yet. diff --git a/src/web/api/v1/contexts/user/routes.rs b/src/web/api/v1/contexts/user/routes.rs index 22eefa0e..b2a21624 100644 --- a/src/web/api/v1/contexts/user/routes.rs +++ b/src/web/api/v1/contexts/user/routes.rs @@ -3,11 +3,11 @@ //! Refer to the [API endpoint documentation](crate::web::api::v1::contexts::user). use std::sync::Arc; -use axum::routing::{get, post}; +use axum::routing::{delete, get, post}; use axum::Router; use super::handlers::{ - email_verification_handler, login_handler, registration_handler, renew_token_handler, verify_token_handler, + ban_handler, email_verification_handler, login_handler, registration_handler, renew_token_handler, verify_token_handler, }; use crate::common::AppData; @@ -27,5 +27,8 @@ pub fn router(app_data: Arc) -> Router { // Authentication .route("/login", post(login_handler).with_state(app_data.clone())) .route("/token/verify", post(verify_token_handler).with_state(app_data.clone())) - .route("/token/renew", post(renew_token_handler).with_state(app_data)) + .route("/token/renew", post(renew_token_handler).with_state(app_data.clone())) + // User ban + // code-review: should not this be a POST method? We add the user to the blacklist. We do not delete the user. + .route("/ban/:user", delete(ban_handler).with_state(app_data)) } diff --git a/src/web/api/v1/extractors/bearer_token.rs b/src/web/api/v1/extractors/bearer_token.rs new file mode 100644 index 00000000..1c9b5be9 --- /dev/null +++ b/src/web/api/v1/extractors/bearer_token.rs @@ -0,0 +1,36 @@ +use axum::async_trait; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::response::Response; +use serde::Deserialize; + +use crate::web::api::v1::auth::parse_token; + +pub struct Extract(pub Option); + +#[derive(Deserialize, Debug)] +pub struct BearerToken(String); + +impl BearerToken { + #[must_use] + pub fn value(&self) -> String { + self.0.clone() + } +} + +#[async_trait] +impl FromRequestParts for Extract +where + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let header = parts.headers.get("Authorization"); + + match header { + Some(header_value) => Ok(Extract(Some(BearerToken(parse_token(header_value))))), + None => Ok(Extract(None)), + } + } +} diff --git a/src/web/api/v1/extractors/mod.rs b/src/web/api/v1/extractors/mod.rs new file mode 100644 index 00000000..36d737ca --- /dev/null +++ b/src/web/api/v1/extractors/mod.rs @@ -0,0 +1 @@ +pub mod bearer_token; diff --git a/src/web/api/v1/mod.rs b/src/web/api/v1/mod.rs index 67490e6e..e9b3e9d6 100644 --- a/src/web/api/v1/mod.rs +++ b/src/web/api/v1/mod.rs @@ -6,5 +6,6 @@ //! information. pub mod auth; pub mod contexts; +pub mod extractors; pub mod responses; pub mod routes; diff --git a/tests/common/contexts/user/asserts.rs b/tests/common/contexts/user/asserts.rs index 620096c3..bcf92f5f 100644 --- a/tests/common/contexts/user/asserts.rs +++ b/tests/common/contexts/user/asserts.rs @@ -2,7 +2,7 @@ use super::forms::RegistrationForm; use super::responses::LoggedInUserData; use crate::common::asserts::assert_json_ok; use crate::common::contexts::user::responses::{ - AddedUserResponse, SuccessfulLoginResponse, TokenRenewalData, TokenRenewalResponse, TokenVerifiedResponse, + AddedUserResponse, BannedUserResponse, SuccessfulLoginResponse, TokenRenewalData, TokenRenewalResponse, TokenVerifiedResponse, }; use crate::common::responses::TextResponse; @@ -47,3 +47,15 @@ pub fn assert_token_renewal_response(response: &TextResponse, logged_in_user: &L assert_json_ok(response); } + +pub fn assert_banned_user_response(response: &TextResponse, registered_user: &RegistrationForm) { + let banned_user_response: BannedUserResponse = serde_json::from_str(&response.body) + .unwrap_or_else(|_| panic!("response {:#?} should be a BannedUserResponse", response.body)); + + assert_eq!( + banned_user_response.data, + format!("Banned user: {}", registered_user.username) + ); + + assert_json_ok(response); +} diff --git a/tests/e2e/contexts/user/contract.rs b/tests/e2e/contexts/user/contract.rs index cb53d879..73eff810 100644 --- a/tests/e2e/contexts/user/contract.rs +++ b/tests/e2e/contexts/user/contract.rs @@ -310,4 +310,78 @@ mod with_axum_implementation { assert_token_renewal_response(&response, &logged_in_user); } } + + mod banned_user_list { + use std::env; + + use torrust_index_backend::web::api; + + use crate::common::client::Client; + use crate::common::contexts::user::asserts::assert_banned_user_response; + use crate::common::contexts::user::forms::Username; + use crate::e2e::config::ENV_VAR_E2E_EXCLUDE_AXUM_IMPL; + use crate::e2e::contexts::user::steps::{new_logged_in_admin, new_logged_in_user, new_registered_user}; + use crate::e2e::environment::TestEnv; + + #[tokio::test] + async fn it_should_allow_an_admin_to_ban_a_user() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_in_admin = new_logged_in_admin(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_in_admin.token); + + let registered_user = new_registered_user(&env).await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_banned_user_response(&response, ®istered_user); + } + + #[tokio::test] + async fn it_should_not_allow_a_non_admin_to_ban_a_user() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let logged_non_admin = new_logged_in_user(&env).await; + + let client = Client::authenticated(&env.server_socket_addr().unwrap(), &logged_non_admin.token); + + let registered_user = new_registered_user(&env).await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_eq!(response.status, 403); + } + + #[tokio::test] + async fn it_should_not_allow_a_guest_to_ban_a_user() { + let mut env = TestEnv::new(); + env.start(api::Implementation::Axum).await; + + if env::var(ENV_VAR_E2E_EXCLUDE_AXUM_IMPL).is_ok() { + println!("Skipped"); + return; + } + + let client = Client::unauthenticated(&env.server_socket_addr().unwrap()); + + let registered_user = new_registered_user(&env).await; + + let response = client.ban_user(Username::new(registered_user.username.clone())).await; + + assert_eq!(response.status, 401); + } + } }