89 changes: 89 additions & 0 deletions backend/src/routes/users/login.rs
@@ -0,0 +1,89 @@
use sqlx::Row;

#[derive(serde::Deserialize, Debug, serde::Serialize)]
pub struct LoginUser {
email: String,
password: String,
}

#[tracing::instrument(name = "Logging a user in", skip( pool, user, session), fields(user_email = %user.email))]
#[actix_web::post("/login/")]
async fn login_user(
pool: actix_web::web::Data<sqlx::postgres::PgPool>,
user: actix_web::web::Json<LoginUser>,
session: actix_session::Session,
) -> actix_web::HttpResponse {
match get_user_who_is_active(&pool, &user.email).await {
Ok(loggedin_user) => match tokio::task::spawn_blocking(move || {
crate::utils::verify_password(loggedin_user.password.as_ref(), user.password.as_bytes())
})
.await
.expect("Unable to unwrap JoinError.")
{
Ok(_) => {
tracing::event!(target: "backend", tracing::Level::INFO, "User logged in successfully.");
session.renew();
session
.insert(crate::types::USER_ID_KEY, loggedin_user.id)
.expect("`user_id` cannot be inserted into session");
session
.insert(crate::types::USER_EMAIL_KEY, &loggedin_user.email)
.expect("`user_email` cannot be inserted into session");

actix_web::HttpResponse::Ok().json(crate::types::UserVisible {
id: loggedin_user.id,
email: loggedin_user.email,
first_name: loggedin_user.first_name,
last_name: loggedin_user.last_name,
is_active: loggedin_user.is_active,
is_staff: loggedin_user.is_staff,
is_superuser: loggedin_user.is_superuser,
date_joined: loggedin_user.date_joined,
thumbnail: loggedin_user.thumbnail,
})
}
Err(e) => {
tracing::event!(target: "argon2",tracing::Level::ERROR, "Failed to authenticate user: {:#?}", e);
actix_web::HttpResponse::BadRequest().json(crate::types::ErrorResponse {
error: "Email and password do not match".to_string(),
})
}
},
Err(e) => {
tracing::event!(target: "sqlx",tracing::Level::ERROR, "User not found:{:#?}", e);
actix_web::HttpResponse::NotFound().json(crate::types::ErrorResponse {
error: "A user with these details does not exist. If you registered with these details, ensure you activate your account by clicking on the link sent to your e-mail address".to_string(),
})
}
}
}

#[tracing::instrument(name = "Getting a user from DB.", skip(pool, email),fields(user_email = %email))]
pub async fn get_user_who_is_active(
pool: &sqlx::postgres::PgPool,
email: &String,
) -> Result<crate::types::User, sqlx::Error> {
match sqlx::query("SELECT id, email, password, first_name, last_name, is_staff, is_superuser, thumbnail, date_joined FROM users WHERE email = $1 AND is_active = TRUE")
.bind(email)
.map(|row: sqlx::postgres::PgRow| crate::types::User {
id: row.get("id"),
email: row.get("email"),
password: row.get("password"),
first_name: row.get("first_name"),
last_name: row.get("last_name"),
is_active: true,
is_staff: row.get("is_staff"),
is_superuser: row.get("is_superuser"),
thumbnail: row.get("thumbnail"),
date_joined: row.get("date_joined"),
})
.fetch_one(pool)
.await
{
Ok(user) => Ok(user),
Err(e) => {
tracing::event!(target: "sqlx",tracing::Level::ERROR, "User not found in DB: {:#?}", e);
Err(e)
}
}
}
32 changes: 32 additions & 0 deletions backend/src/routes/users/logout.rs
@@ -0,0 +1,32 @@
#[tracing::instrument(name = "Log out user", skip(session))]
#[actix_web::post("/logout/")]
pub async fn log_out(session: actix_session::Session) -> actix_web::HttpResponse {
match session_user_id(&session).await {
Ok(_) => {
tracing::event!(target: "backend", tracing::Level::INFO, "Users retrieved from the DB.");
session.purge();
actix_web::HttpResponse::Ok().json(crate::types::SuccessResponse {
message: "You have successfully logged out".to_string(),
})
}
Err(e) => {
tracing::event!(target: "backend",tracing::Level::ERROR, "Failed to get user from session: {:#?}", e);
actix_web::HttpResponse::BadRequest().json(crate::types::ErrorResponse {
error:
"We currently have some issues. Kindly try again and ensure you are logged in"
.to_string(),
})
}
}
}

#[tracing::instrument(name = "Get user_id from session.", skip(session))]
async fn session_user_id(session: &actix_session::Session) -> Result<uuid::Uuid, String> {
match session.get(crate::types::USER_ID_KEY) {
Ok(user_id) => match user_id {
None => Err("You are not authenticated".to_string()),
Some(id) => Ok(id),
},
Err(e) => Err(format!("{e}")),
}
}
11 changes: 10 additions & 1 deletion backend/src/routes/users/mod.rs
@@ -1,5 +1,14 @@
mod confirm_registration;
mod login;
mod logout;
mod register;

pub fn auth_routes_config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(actix_web::web::scope("/users").service(register::register_user));
cfg.service(
actix_web::web::scope("/users")
.service(register::register_user)
.service(confirm_registration::confirm)
.service(login::login_user)
.service(logout::log_out),
);
}
18 changes: 18 additions & 0 deletions backend/src/startup.rs
Expand Up @@ -63,8 +63,26 @@ async fn run(
.expect("Cannot create deadpool redis.");
let redis_pool_data = actix_web::web::Data::new(redis_pool);

// For session
let secret_key = actix_web::cookie::Key::from(settings.secret.hmac_secret.as_bytes());

let server = actix_web::HttpServer::new(move || {
actix_web::App::new()
.wrap(if settings.debug {
actix_session::SessionMiddleware::builder(
actix_session::storage::CookieSessionStore::default(),
secret_key.clone(),
)
.cookie_http_only(true)
.cookie_same_site(actix_web::cookie::SameSite::None)
.cookie_secure(true)
.build()
} else {
actix_session::SessionMiddleware::new(
actix_session::storage::CookieSessionStore::default(),
secret_key.clone(),
)
})
.service(crate::routes::health_check)
// Authentication routes
.configure(crate::routes::auth_routes_config)
Expand Down
5 changes: 5 additions & 0 deletions backend/src/types/general.rs
Expand Up @@ -7,3 +7,8 @@ pub struct ErrorResponse {
pub struct SuccessResponse {
pub message: String,
}

pub const USER_ID_KEY: &'static str = "user_id";
pub const USER_EMAIL_KEY: &'static str = "user_email";
pub const USER_IS_STAFF_KEY: &'static str = "user_is_staff";
pub const USER_IS_SUPERUSER_KEY: &'static str = "user_is_superuser";
7 changes: 6 additions & 1 deletion backend/src/types/mod.rs
@@ -1,5 +1,10 @@
mod general;
mod tokens;
mod users;

pub use general::{ErrorResponse, SuccessResponse};
pub use general::{
ErrorResponse, SuccessResponse, USER_EMAIL_KEY, USER_ID_KEY, USER_IS_STAFF_KEY,
USER_IS_SUPERUSER_KEY,
};
pub use tokens::ConfirmationToken;
pub use users::{LoggedInUser, User, UserVisible};
34 changes: 34 additions & 0 deletions backend/src/types/users.rs
@@ -0,0 +1,34 @@
#[derive(serde::Serialize)]
pub struct User {
pub id: uuid::Uuid,
pub email: String,
pub password: String,
pub first_name: String,
pub last_name: String,
pub is_active: bool,
pub is_staff: bool,
pub is_superuser: bool,
pub thumbnail: Option<String>,
pub date_joined: chrono::DateTime<chrono::Utc>,
}

#[derive(serde::Serialize, serde::Deserialize)]
pub struct UserVisible {
pub id: uuid::Uuid,
pub email: String,
pub first_name: String,
pub last_name: String,
pub is_active: bool,
pub is_staff: bool,
pub is_superuser: bool,
pub thumbnail: Option<String>,
pub date_joined: chrono::DateTime<chrono::Utc>,
}
#[derive(serde::Serialize)]
pub struct LoggedInUser {
pub id: uuid::Uuid,
pub email: String,
pub password: String,
pub is_staff: bool,
pub is_superuser: bool,
}
5 changes: 1 addition & 4 deletions backend/src/utils/auth/password.rs
Expand Up @@ -13,10 +13,7 @@ pub async fn hash(password: &[u8]) -> String {
}

#[tracing::instrument(name = "Verifying user password", skip(password, hash))]
pub async fn verify_password(
hash: &str,
password: &[u8],
) -> Result<(), argon2::password_hash::Error> {
pub fn verify_password(hash: &str, password: &[u8]) -> Result<(), argon2::password_hash::Error> {
let parsed_hash = PasswordHash::new(hash)?;
Argon2::default().verify_password(password, &parsed_hash)
}