Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 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 @@ -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"] }
Expand Down
65 changes: 33 additions & 32 deletions src/auth/handlers.rs
Original file line number Diff line number Diff line change
@@ -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},
};

Expand All @@ -25,11 +23,11 @@ use time::OffsetDateTime;
async fn register(
ctx: Data<Context>,
Json(user_data): Json<SignUpCredentials>,
) -> Result<HttpResponse> {
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();
Comment thread
t3m8ch marked this conversation as resolved.
Comment thread
evgenymng marked this conversation as resolved.

ctx.db.add_user(user).await?;

Expand All @@ -46,24 +44,27 @@ async fn register(
async fn login(
ctx: Data<Context>,
Json(creds): Json<SignInCredentials>,
) -> Result<HttpResponse> {
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)
Expand All @@ -83,14 +84,14 @@ async fn login(
)
)]
#[post("/refresh")]
async fn refresh(ctx: Data<Context>, req: HttpRequest) -> Result<HttpResponse> {
let Some(cookie) = req.cookie("refresh_token") else {
return Ok(HttpResponse::Unauthorized().finish());
};
async fn refresh(ctx: Data<Context>, 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)
Expand All @@ -111,7 +112,7 @@ async fn refresh(ctx: Data<Context>, req: HttpRequest) -> Result<HttpResponse> {
)
)]
#[post("/logout")]
async fn logout() -> Result<HttpResponse> {
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| {
Expand Down
15 changes: 0 additions & 15 deletions src/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -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<T> = std::result::Result<T, Error>;
49 changes: 49 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -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<HttpResponse, ApiError>;

impl From<sqlx::Error> 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(),
})
}
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod auth;
mod config;
mod context;
mod db;
mod error;
mod models;
mod routes;

Expand Down