diff --git a/Cargo.lock b/Cargo.lock index ddc48d7..582b180 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,7 +36,7 @@ dependencies = [ "brotli", "bytes", "bytestring", - "derive_more", + "derive_more 0.99.17", "encoding_rs", "flate2", "futures-core", @@ -170,7 +170,7 @@ dependencies = [ "bytestring", "cfg-if", "cookie", - "derive_more", + "derive_more 0.99.17", "encoding_rs", "futures-core", "futures-util", @@ -654,6 +654,27 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.59", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -1264,6 +1285,7 @@ dependencies = [ "anyhow", "bcrypt", "chrono", + "derive_more 1.0.0", "dotenvy", "env_logger", "figment", @@ -2373,6 +2395,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +[[package]] +name = "unicode-xid" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" + [[package]] name = "unicode_categories" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 7023423..be28f34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ actix-web = { version = "4.5.1", features = ["rustls", "cookies"] } anyhow = "1.0.81" bcrypt = "0.15.1" chrono = { version = "0.4.37", features = ["serde"] } +derive_more = { version = "1.0.0", features = ["display", "error"] } dotenvy = "0.15.7" env_logger = "0.11.3" figment = { version = "0.10.17", features = ["env"] } diff --git a/src/auth/handlers.rs b/src/auth/handlers.rs index 65134d3..5572de8 100644 --- a/src/auth/handlers.rs +++ b/src/auth/handlers.rs @@ -1,9 +1,7 @@ use crate::{ - auth::{ - jwt::{create_tokens, validate_token}, - Error, Result, - }, + auth::jwt::{create_tokens, validate_token}, context::Context, + error::{ApiError, ApiResult}, models::{SignInCredentials, SignUpCredentials}, }; @@ -25,11 +23,11 @@ use time::OffsetDateTime; async fn register( ctx: Data, Json(user_data): Json, -) -> Result { - log::trace!("Received register request"); - +) -> ApiResult { let mut user = user_data; - user.password = bcrypt::hash(user.password, bcrypt::DEFAULT_COST)?; + // NOTE(evgenymng): This call will never end with an error, because it can + // only produce one, when the cost is invalid, which we cannot possibly have. + user.password = bcrypt::hash(user.password, bcrypt::DEFAULT_COST).unwrap(); ctx.db.add_user(user).await?; @@ -46,24 +44,27 @@ async fn register( async fn login( ctx: Data, Json(creds): Json, -) -> Result { - log::trace!("Received login request"); - - let Ok(user) = ctx.db.get_user_by_creds(&creds).await else { - let _ = bcrypt::hash(&creds.password, bcrypt::DEFAULT_COST)?; - return Ok(HttpResponse::Unauthorized().finish()); - }; - - let utf8_hash = - std::str::from_utf8(&user.password).map_err(|_| Error::Auth)?; - - if !bcrypt::verify(&creds.password, utf8_hash)? { - return Err(Error::Auth); +) -> ApiResult { + let user = ctx.db.get_user_by_creds(&creds).await.map_err(|_| { + // NOTE(t3m8ch): This call will never end with an error, because it can only + // produce one, when the cost is invalid, which we cannot possibly have. + // NOTE(evgenymng): Masquerade as if the user exists and spend time + // calculating hash. + let _ = bcrypt::hash(&creds.password, bcrypt::DEFAULT_COST).unwrap(); + ApiError::WrongCredentials + })?; + + let utf8_hash = std::str::from_utf8(&user.password) + .map_err(|_| ApiError::WrongCredentials)?; + + if !bcrypt::verify(&creds.password, utf8_hash) + .map_err(|_| ApiError::Internal)? + { + return Err(ApiError::WrongCredentials); } - log::trace!("User has been verified"); - let (access_token, refresh_token) = - create_tokens(&ctx.config, &user.email)?; + let (access_token, refresh_token) = create_tokens(&ctx.config, &user.email) + .map_err(|_| ApiError::Internal)?; let cookie_to_add = |name, token| { Cookie::build(name, token) @@ -83,14 +84,14 @@ async fn login( ) )] #[post("/refresh")] -async fn refresh(ctx: Data, req: HttpRequest) -> Result { - let Some(cookie) = req.cookie("refresh_token") else { - return Ok(HttpResponse::Unauthorized().finish()); - }; +async fn refresh(ctx: Data, req: HttpRequest) -> ApiResult { + let cookie = req.cookie("refresh_token").ok_or(ApiError::InvalidToken)?; + + let claims = validate_token(&ctx.config, cookie.value()) + .map_err(|_| ApiError::InvalidToken)?; - let claims = validate_token(&ctx.config, cookie.value())?; - let (access_token, refresh_token) = - create_tokens(&ctx.config, &claims.sub)?; + let (access_token, refresh_token) = create_tokens(&ctx.config, &claims.sub) + .map_err(|_| ApiError::Internal)?; let cookie_to_add = |name, token| { Cookie::build(name, token) @@ -111,7 +112,7 @@ async fn refresh(ctx: Data, req: HttpRequest) -> Result { ) )] #[post("/logout")] -async fn logout() -> Result { +async fn logout() -> ApiResult { // NOTE(granatam): We cannot delete cookies, so we explicitly set its // expiration time to the elapsed time let cookie_to_delete = |name| { diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 8217d2a..0c10a50 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,17 +1,2 @@ pub mod handlers; mod jwt; - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("database error: {0}")] - Sqlx(#[from] sqlx::Error), - #[error("bcrypt error: {0}")] - Bcrypt(#[from] bcrypt::BcryptError), - #[error("jwt error: {0}")] - Jwt(#[from] jsonwebtoken::errors::Error), - #[error("auth error")] - Auth, -} -impl actix_web::error::ResponseError for Error {} - -pub type Result = std::result::Result; diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..a45a46e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,49 @@ +use actix_web::{http::StatusCode, HttpResponse, ResponseError}; +use derive_more::{Display, Error}; +use serde::Serialize; + +/// Represents the complete set of error codes the API may produce. +#[derive(Serialize, Clone, Debug, Display, Error)] +// NOTE(evgenymng): Intentionally not making it `Copy`, because we +// will probably need to store some additional information in those variants +// in the future. +pub enum ApiError { + #[display("wrong_credentials")] + WrongCredentials, + #[display("invalid_token")] + InvalidToken, + #[display("internal")] + Internal, +} + +/// A convenient alias for the API handler's return type. +pub type ApiResult = Result; + +impl From for ApiError { + fn from(_: sqlx::Error) -> Self { + ApiError::Internal + } +} + +/// Structured error's body. If the API call fails with an error, +/// an object with this structure is sent as a response. +#[derive(Serialize)] +struct ErrorBody { + pub error: ApiError, +} + +impl ResponseError for ApiError { + fn status_code(&self) -> StatusCode { + match self { + ApiError::WrongCredentials => StatusCode::UNAUTHORIZED, + ApiError::InvalidToken => StatusCode::UNAUTHORIZED, + ApiError::Internal => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(ErrorBody { + error: self.clone(), + }) + } +} diff --git a/src/main.rs b/src/main.rs index eae2f74..fb422a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod auth; mod config; mod context; mod db; +mod error; mod models; mod routes;