Skip to content

Commit

Permalink
Merge #196: Axum API: user context
Browse files Browse the repository at this point in the history
d3b5b15 refactor(api): [#183] Axum API, user context, ban user (Jose Celano)
9564dec refactor(api): [#183] Axum API, user context, renew JWT (Jose Celano)
b15616c refactor(api): [#183] Axum API, user context, verify JWT (Jose Celano)
3f639b3 refactor(api): [#183] Axum API, user context, login (Jose Celano)
a341e38 refactor(api): [#183] Axum API, user context, email verification (Jose Celano)
91522f4 feat: add cargo dependency tower-http (Jose Celano)
79682a5 refactor(api): [#183] Axum API, user context, registration (Jose Celano)

Pull request description:

  API migration to Axum for `user` context.

Top commit has no ACKs.

Tree-SHA512: efd396039246739428c9b80ff942b8672bcf115a5d4dcac1ef18fb960de1d2dfd8d86bf1d490b6d6b8294f0fd9d3f92b9f89814c781d632a8533074cccca08b0
  • Loading branch information
josecelano committed Jun 14, 2023
2 parents 9f8832b + d3b5b15 commit 03dba5c
Show file tree
Hide file tree
Showing 32 changed files with 804 additions and 114 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/develop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ jobs:
run: cargo llvm-cov nextest
- name: E2E Tests
run: ./docker/bin/run-e2e-tests.sh
env:
TORRUST_IDX_BACK_E2E_EXCLUDE_AXUM_IMPL: "true"
25 changes: 25 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ thiserror = "1.0"
binascii = "0.1"
axum = "0.6.18"
hyper = "0.14.26"
tower-http = { version = "0.4.0", features = ["cors"]}

[dev-dependencies]
rand = "0.8"
Expand Down
56 changes: 49 additions & 7 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonWebToken>,
Expand All @@ -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<UserId, ServiceError> {
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<UserClaims, ServiceError> {
/// - 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<UserClaims, ServiceError> {
match req.headers().get("Authorization") {
Some(auth) => {
let split: Vec<&str> = auth
Expand All @@ -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<UserId, ServiceError> {
let claims = self.get_claims_from_request(req).await?;
pub async fn get_user_id_from_bearer_token(&self, maybe_token: &Option<BearerToken>) -> Result<UserId, ServiceError> {
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<BearerToken>) -> Result<UserClaims, ServiceError> {
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
}
6 changes: 6 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,12 @@ impl Configuration {

settings_lock.website.name.clone()
}

pub async fn get_api_base_url(&self) -> Option<String> {
let settings_lock = self.settings.read().await;

settings_lock.net.base_url.clone()
}
}

/// The public backend configuration.
Expand Down
128 changes: 69 additions & 59 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,49 +146,7 @@ pub struct ErrorToResponse {

impl ResponseError for ServiceError {
fn status_code(&self) -> StatusCode {
#[allow(clippy::match_same_arms)]
match self {
ServiceError::ClosedForRegistration => StatusCode::FORBIDDEN,
ServiceError::EmailInvalid => StatusCode::BAD_REQUEST,
ServiceError::NotAUrl => StatusCode::BAD_REQUEST,
ServiceError::WrongPasswordOrUsername => StatusCode::FORBIDDEN,
ServiceError::UsernameNotFound => StatusCode::NOT_FOUND,
ServiceError::UserNotFound => StatusCode::NOT_FOUND,
ServiceError::AccountNotFound => StatusCode::NOT_FOUND,
ServiceError::ProfanityError => StatusCode::BAD_REQUEST,
ServiceError::BlacklistError => StatusCode::BAD_REQUEST,
ServiceError::UsernameCaseMappedError => StatusCode::BAD_REQUEST,
ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST,
ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST,
ServiceError::PasswordsDontMatch => StatusCode::BAD_REQUEST,
ServiceError::UsernameTaken => StatusCode::BAD_REQUEST,
ServiceError::UsernameInvalid => StatusCode::BAD_REQUEST,
ServiceError::EmailTaken => StatusCode::BAD_REQUEST,
ServiceError::EmailNotVerified => StatusCode::FORBIDDEN,
ServiceError::TokenNotFound => StatusCode::UNAUTHORIZED,
ServiceError::TokenExpired => StatusCode::UNAUTHORIZED,
ServiceError::TokenInvalid => StatusCode::UNAUTHORIZED,
ServiceError::TorrentNotFound => StatusCode::BAD_REQUEST,
ServiceError::InvalidTorrentFile => StatusCode::BAD_REQUEST,
ServiceError::InvalidTorrentPiecesLength => StatusCode::BAD_REQUEST,
ServiceError::InvalidFileType => StatusCode::BAD_REQUEST,
ServiceError::BadRequest => StatusCode::BAD_REQUEST,
ServiceError::InvalidCategory => StatusCode::BAD_REQUEST,
ServiceError::InvalidTag => StatusCode::BAD_REQUEST,
ServiceError::Unauthorized => StatusCode::FORBIDDEN,
ServiceError::InfoHashAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::TorrentTitleAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::CategoryAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::TagAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::EmailMissing => StatusCode::NOT_FOUND,
ServiceError::FailedToSendVerificationEmail => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::WhitelistingError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::CategoryNotFound => StatusCode::NOT_FOUND,
ServiceError::TagNotFound => StatusCode::NOT_FOUND,
}
http_status_code_for_service_error(self)
}

fn error_response(&self) -> HttpResponse {
Expand Down Expand Up @@ -220,22 +178,7 @@ impl From<sqlx::Error> for ServiceError {

impl From<database::Error> for ServiceError {
fn from(e: database::Error) -> Self {
#[allow(clippy::match_same_arms)]
match e {
database::Error::Error => ServiceError::InternalServerError,
database::Error::ErrorWithText(_) => ServiceError::InternalServerError,
database::Error::UsernameTaken => ServiceError::UsernameTaken,
database::Error::EmailTaken => ServiceError::EmailTaken,
database::Error::UserNotFound => ServiceError::UserNotFound,
database::Error::CategoryAlreadyExists => ServiceError::CategoryAlreadyExists,
database::Error::CategoryNotFound => ServiceError::InvalidCategory,
database::Error::TagAlreadyExists => ServiceError::TagAlreadyExists,
database::Error::TagNotFound => ServiceError::InvalidTag,
database::Error::TorrentNotFound => ServiceError::TorrentNotFound,
database::Error::TorrentAlreadyExists => ServiceError::InfoHashAlreadyExists,
database::Error::TorrentTitleAlreadyExists => ServiceError::TorrentTitleAlreadyExists,
database::Error::UnrecognizedDatabaseDriver => ServiceError::InternalServerError,
}
map_database_error_to_service_error(&e)
}
}

Expand Down Expand Up @@ -266,3 +209,70 @@ impl From<serde_json::Error> for ServiceError {
ServiceError::InternalServerError
}
}

#[must_use]
pub fn http_status_code_for_service_error(error: &ServiceError) -> StatusCode {
#[allow(clippy::match_same_arms)]
match error {
ServiceError::ClosedForRegistration => StatusCode::FORBIDDEN,
ServiceError::EmailInvalid => StatusCode::BAD_REQUEST,
ServiceError::NotAUrl => StatusCode::BAD_REQUEST,
ServiceError::WrongPasswordOrUsername => StatusCode::FORBIDDEN,
ServiceError::UsernameNotFound => StatusCode::NOT_FOUND,
ServiceError::UserNotFound => StatusCode::NOT_FOUND,
ServiceError::AccountNotFound => StatusCode::NOT_FOUND,
ServiceError::ProfanityError => StatusCode::BAD_REQUEST,
ServiceError::BlacklistError => StatusCode::BAD_REQUEST,
ServiceError::UsernameCaseMappedError => StatusCode::BAD_REQUEST,
ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST,
ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST,
ServiceError::PasswordsDontMatch => StatusCode::BAD_REQUEST,
ServiceError::UsernameTaken => StatusCode::BAD_REQUEST,
ServiceError::UsernameInvalid => StatusCode::BAD_REQUEST,
ServiceError::EmailTaken => StatusCode::BAD_REQUEST,
ServiceError::EmailNotVerified => StatusCode::FORBIDDEN,
ServiceError::TokenNotFound => StatusCode::UNAUTHORIZED,
ServiceError::TokenExpired => StatusCode::UNAUTHORIZED,
ServiceError::TokenInvalid => StatusCode::UNAUTHORIZED,
ServiceError::TorrentNotFound => StatusCode::BAD_REQUEST,
ServiceError::InvalidTorrentFile => StatusCode::BAD_REQUEST,
ServiceError::InvalidTorrentPiecesLength => StatusCode::BAD_REQUEST,
ServiceError::InvalidFileType => StatusCode::BAD_REQUEST,
ServiceError::BadRequest => StatusCode::BAD_REQUEST,
ServiceError::InvalidCategory => StatusCode::BAD_REQUEST,
ServiceError::InvalidTag => StatusCode::BAD_REQUEST,
ServiceError::Unauthorized => StatusCode::FORBIDDEN,
ServiceError::InfoHashAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::TorrentTitleAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::TrackerOffline => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::CategoryAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::TagAlreadyExists => StatusCode::BAD_REQUEST,
ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::EmailMissing => StatusCode::NOT_FOUND,
ServiceError::FailedToSendVerificationEmail => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::WhitelistingError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
ServiceError::CategoryNotFound => StatusCode::NOT_FOUND,
ServiceError::TagNotFound => StatusCode::NOT_FOUND,
}
}

#[must_use]
pub fn map_database_error_to_service_error(error: &database::Error) -> ServiceError {
#[allow(clippy::match_same_arms)]
match error {
database::Error::Error => ServiceError::InternalServerError,
database::Error::ErrorWithText(_) => ServiceError::InternalServerError,
database::Error::UsernameTaken => ServiceError::UsernameTaken,
database::Error::EmailTaken => ServiceError::EmailTaken,
database::Error::UserNotFound => ServiceError::UserNotFound,
database::Error::CategoryAlreadyExists => ServiceError::CategoryAlreadyExists,
database::Error::CategoryNotFound => ServiceError::InvalidCategory,
database::Error::TagAlreadyExists => ServiceError::TagAlreadyExists,
database::Error::TagNotFound => ServiceError::InvalidTag,
database::Error::TorrentNotFound => ServiceError::TorrentNotFound,
database::Error::TorrentAlreadyExists => ServiceError::InfoHashAlreadyExists,
database::Error::TorrentTitleAlreadyExists => ServiceError::TorrentTitleAlreadyExists,
database::Error::UnrecognizedDatabaseDriver => ServiceError::InternalServerError,
}
}
4 changes: 2 additions & 2 deletions src/routes/category.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Category>, app_data: WebAppData) -> ServiceResult<impl Responder> {
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?;

Expand All @@ -61,7 +61,7 @@ pub async fn delete(req: HttpRequest, payload: web::Json<Category>, 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?;

Expand Down
2 changes: 1 addition & 1 deletion src/routes/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> ServiceResult<impl Responder> {
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) => {
Expand Down
4 changes: 2 additions & 2 deletions src/routes/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<impl Responder> {
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?;

Expand All @@ -46,7 +46,7 @@ pub async fn update_handler(
payload: web::Json<config::TorrustBackend>,
app_data: WebAppData,
) -> ServiceResult<impl Responder> {
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?;

Expand Down
4 changes: 2 additions & 2 deletions src/routes/tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Create>, app_data: WebAppData) -> ServiceResult<impl Responder> {
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?;

Expand All @@ -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<Delete>, app_data: WebAppData) -> ServiceResult<impl Responder> {
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?;

Expand Down
Loading

0 comments on commit 03dba5c

Please sign in to comment.