diff --git a/src/ctx/api/mod.rs b/src/ctx/api/mod.rs new file mode 100644 index 0000000..5aa8f74 --- /dev/null +++ b/src/ctx/api/mod.rs @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2022 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +pub mod v1; diff --git a/src/ctx/api/v1/account.rs b/src/ctx/api/v1/account.rs new file mode 100644 index 0000000..ec16bc6 --- /dev/null +++ b/src/ctx/api/v1/account.rs @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2022 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +//! Account management utility datastructures and methods +use serde::{Deserialize, Serialize}; + +pub use super::auth; +use crate::ctx::Ctx; +use crate::db; +use crate::errors::*; + +#[derive(Clone, Debug, Deserialize, Serialize)] +/// Data structure used in `*_exists` methods +pub struct AccountCheckResp { + /// set to true if the attribute in question exists + pub exists: bool, +} + +/// Data structure used to change password of a registered user +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ChangePasswordReqest { + /// current password + pub password: String, + /// new password + pub new_password: String, + /// new password confirmation + pub confirm_new_password: String, +} + +impl Ctx { + /// check if email exists on database + pub async fn email_exists(&self, email: &str) -> ServiceResult { + let resp = AccountCheckResp { + exists: self.db.email_exists(email).await?, + }; + + Ok(resp) + } + + /// update email + pub async fn set_email(&self, username: &str, new_email: &str) -> ServiceResult<()> { + self.creds.email(new_email)?; + + let username = self.creds.username(username)?; + + let payload = db::UpdateEmail { + username: &username, + new_email, + }; + self.db.update_email(&payload).await?; + Ok(()) + } + + /// check if email exists in database + pub async fn username_exists(&self, username: &str) -> ServiceResult { + let processed_uname = self.creds.username(username)?; + let resp = AccountCheckResp { + exists: self.db.username_exists(&processed_uname).await?, + }; + Ok(resp) + } + + /// update username of a registered user + pub async fn update_username( + &self, + current_username: &str, + new_username: &str, + ) -> ServiceResult { + let processed_uname = self.creds.username(new_username)?; + + self.db + .update_username(current_username, &processed_uname) + .await?; + + Ok(processed_uname) + } + + // returns Ok(()) upon successful authentication + async fn authenticate(&self, username: &str, password: &str) -> ServiceResult<()> { + use argon2_creds::Config; + let username = self.creds.username(username)?; + let resp = self + .db + .get_password(&db::Login::Username(&username)) + .await?; + if Config::verify(&resp.hash, password)? { + Ok(()) + } else { + Err(ServiceError::WrongPassword) + } + } + + /// delete user + pub async fn delete_user(&self, username: &str, password: &str) -> ServiceResult<()> { + let username = self.creds.username(username)?; + self.authenticate(&username, password).await?; + self.db.delete_user(&username).await?; + Ok(()) + } + + /// change password + pub async fn change_password( + &self, + + username: &str, + payload: &ChangePasswordReqest, + ) -> ServiceResult<()> { + if payload.new_password != payload.confirm_new_password { + return Err(ServiceError::PasswordsDontMatch); + } + + self.authenticate(username, &payload.password).await?; + + let hash = self.creds.password(&payload.new_password)?; + + let username = self.creds.username(username)?; + let db_payload = db::NameHash { username, hash }; + + self.db.update_password(&db_payload).await?; + + Ok(()) + } +} diff --git a/src/ctx/api/v1/auth.rs b/src/ctx/api/v1/auth.rs new file mode 100644 index 0000000..1d3ace3 --- /dev/null +++ b/src/ctx/api/v1/auth.rs @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2022 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +//! Authentication helper methods and data structures +use serde::{Deserialize, Serialize}; + +use crate::ctx::Ctx; +use crate::db; +use crate::errors::*; + +/// Register payload +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Register { + /// username + pub username: String, + /// password + pub password: String, + /// password confirmation: `password` and `confirm_password` must match + pub confirm_password: String, + pub email: String, +} + +/// Login payload +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Login { + // login accepts both username and email under "username field" + // TODO update all instances where login is used + /// user identifier: either username or email + /// an email is detected by checkinf for the existence of `@` character + pub login: String, + /// password + pub password: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +/// struct used to represent password +pub struct Password { + /// password + pub password: String, +} + +impl Ctx { + /// Log in method. Returns `Ok(())` when user is authenticated and errors when authentication + /// fails + pub async fn login(&self, payload: &Login) -> ServiceResult { + use argon2_creds::Config; + + let verify = |stored: &str, received: &str| { + if Config::verify(stored, received)? { + Ok(()) + } else { + Err(ServiceError::WrongPassword) + } + }; + + let creds = if payload.login.contains('@') { + self.db + .get_password(&db::Login::Email(&payload.login)) + .await? + } else { + self.db + .get_password(&db::Login::Username(&payload.login)) + .await? + }; + verify(&creds.hash, &payload.password)?; + Ok(creds.username) + } + + /// register new user + pub async fn register(&self, payload: &Register) -> ServiceResult<()> { + if !self.settings.allow_registration { + return Err(ServiceError::ClosedForRegistration); + } + + if payload.password != payload.confirm_password { + return Err(ServiceError::PasswordsDontMatch); + } + let username = self.creds.username(&payload.username)?; + let hash = self.creds.password(&payload.password)?; + + self.creds.email(&payload.email)?; + + let db_payload = db::Register { + username: &username, + hash: &hash, + email: &payload.email, + }; + + self.db.register(&db_payload).await + } +} diff --git a/src/ctx.rs b/src/ctx/api/v1/mod.rs similarity index 68% rename from src/ctx.rs rename to src/ctx/api/v1/mod.rs index 117247c..20b63b5 100644 --- a/src/ctx.rs +++ b/src/ctx/api/v1/mod.rs @@ -14,22 +14,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -use std::sync::Arc; +pub mod account; +pub mod auth; -use crate::db::*; -use crate::settings::Settings; - -pub type ArcCtx = Arc; - -#[derive(Clone)] -pub struct Ctx { - pub settings: Settings, - pub db: Database, -} - -impl Ctx { - pub async fn new(settings: Settings) -> Arc { - let db = get_db(&settings).await; - Arc::new(Self { settings, db }) - } -} +#[cfg(test)] +mod tests; diff --git a/src/ctx/api/v1/tests/accounts.rs b/src/ctx/api/v1/tests/accounts.rs new file mode 100644 index 0000000..2146dc6 --- /dev/null +++ b/src/ctx/api/v1/tests/accounts.rs @@ -0,0 +1,227 @@ +/* +* Copyright (C) 2022 Aravinth Manivannan +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see . +*/ +use crate::api::v1::account::{Email, Username}; +use crate::ctx::api::v1::account::ChangePasswordReqest; +use crate::ctx::api::v1::auth::Password; +use crate::ctx::api::v1::auth::Register; +use crate::ctx::ArcCtx; +use crate::errors::*; +use crate::*; + +#[actix_rt::test] +async fn postgrest_account_works() { + let (_dir, ctx) = crate::tests::get_ctx().await; + uname_email_exists_works(ctx.clone()).await; + email_udpate_password_validation_del_userworks(ctx.clone()).await; + username_update_works(ctx).await; +} + +async fn uname_email_exists_works(ctx: ArcCtx) { + const NAME: &str = "testuserexistsfoo"; + const NAME2: &str = "testuserexists22"; + const NAME3: &str = "testuserexists32"; + const PASSWORD: &str = "longpassword2"; + const EMAIL: &str = "accotestsuser22@a.com"; + const EMAIL2: &str = "accotestsuser222@a.com"; + const EMAIL3: &str = "accotestsuser322@a.com"; + + let _ = ctx.db.delete_user(NAME).await; + let _ = ctx.db.delete_user(PASSWORD).await; + let _ = ctx.db.delete_user(NAME2).await; + let _ = ctx.db.delete_user(NAME3).await; + + // check username exists for non existent account + println!("{:?}", ctx.username_exists(NAME).await); + assert!(!ctx.username_exists(NAME).await.unwrap().exists); + // check username email for non existent account + assert!(!ctx.email_exists(EMAIL).await.unwrap().exists); + + let mut register_payload = Register { + username: NAME.into(), + password: PASSWORD.into(), + confirm_password: PASSWORD.into(), + email: EMAIL.into(), + }; + ctx.register(®ister_payload).await.unwrap(); + register_payload.username = NAME2.into(); + register_payload.email = EMAIL2.into(); + ctx.register(®ister_payload).await.unwrap(); + + // check username exists + assert!(ctx.username_exists(NAME).await.unwrap().exists); + assert!(ctx.username_exists(NAME2).await.unwrap().exists); + // check email exists + assert!(ctx.email_exists(EMAIL).await.unwrap().exists); + + // update username + ctx.update_username(NAME2, NAME3).await.unwrap(); + assert!(!ctx.username_exists(NAME2).await.unwrap().exists); + assert!(ctx.username_exists(NAME3).await.unwrap().exists); + + assert!(matches!( + ctx.update_username(NAME3, NAME).await.err(), + Some(ServiceError::UsernameTaken) + )); + + // update email + assert_eq!( + ctx.set_email(NAME, EMAIL2).await.err(), + Some(ServiceError::EmailTaken) + ); + ctx.set_email(NAME, EMAIL3).await.unwrap(); + + // change password + let mut change_password_req = ChangePasswordReqest { + password: PASSWORD.into(), + new_password: NAME.into(), + confirm_new_password: PASSWORD.into(), + }; + assert_eq!( + ctx.change_password(NAME, &change_password_req).await.err(), + Some(ServiceError::PasswordsDontMatch) + ); + + change_password_req.confirm_new_password = NAME.into(); + ctx.change_password(NAME, &change_password_req) + .await + .unwrap(); +} + +async fn email_udpate_password_validation_del_userworks(ctx: ArcCtx) { + const NAME: &str = "testuser32sd2"; + const PASSWORD: &str = "longpassword2"; + const EMAIL: &str = "testuser12232@a.com2"; + const NAME2: &str = "eupdauser22"; + const EMAIL2: &str = "eupdauser22@a.com"; + + let _ = ctx.delete_user(NAME, PASSWORD).await; + let _ = ctx.delete_user(NAME2, PASSWORD).await; + + let _ = ctx.register_and_signin(NAME2, EMAIL2, PASSWORD).await; + let (_creds, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(ctx).await; + + // update email + let mut email_payload = Email { + email: EMAIL.into(), + }; + let email_update_resp = actix_web::test::call_service( + &app, + post_request!(&email_payload, crate::V1_API_ROUTES.account.update_email) + //post_request!(&email_payload, EMAIL_UPDATE) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + assert_eq!(email_update_resp.status(), StatusCode::OK); + + // check duplicate email while duplicate email + email_payload.email = EMAIL2.into(); + ctx.bad_post_req_test( + NAME, + PASSWORD, + crate::V1_API_ROUTES.account.update_email, + &email_payload, + ServiceError::EmailTaken, + ) + .await; + + // wrong password while deleting account + let mut payload = Password { + password: NAME.into(), + }; + ctx.bad_post_req_test( + NAME, + PASSWORD, + V1_API_ROUTES.account.delete, + &payload, + ServiceError::WrongPassword, + ) + .await; + + // delete account + payload.password = PASSWORD.into(); + let delete_user_resp = actix_web::test::call_service( + &app, + post_request!(&payload, crate::V1_API_ROUTES.account.delete) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + + assert_eq!(delete_user_resp.status(), StatusCode::OK); + + // try to delete an account that doesn't exist + let account_not_found_resp = actix_web::test::call_service( + &app, + post_request!(&payload, crate::V1_API_ROUTES.account.delete) + .cookie(cookies) + .to_request(), + ) + .await; + assert_eq!(account_not_found_resp.status(), StatusCode::NOT_FOUND); + let txt: ErrorToResponse = actix_web::test::read_body_json(account_not_found_resp).await; + assert_eq!(txt.error, format!("{}", ServiceError::AccountNotFound)); +} + +async fn username_update_works(ctx: ArcCtx) { + const NAME: &str = "testuse23423rupda"; + const EMAIL: &str = "testu23423serupda@sss.com"; + const EMAIL2: &str = "testu234serupda2@sss.com"; + const PASSWORD: &str = "longpassword2"; + const NAME2: &str = "terstusrt23423ds"; + const NAME_CHANGE: &str = "terstu234234srtdsxx"; + + let _ = futures::join!( + ctx.delete_user(NAME, PASSWORD), + ctx.delete_user(NAME2, PASSWORD), + ctx.delete_user(NAME_CHANGE, PASSWORD) + ); + + let _ = ctx.register_and_signin(NAME2, EMAIL2, PASSWORD).await; + let (_creds, signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(ctx).await; + + // update username + let mut username_udpate = Username { + username: NAME_CHANGE.into(), + }; + let username_update_resp = actix_web::test::call_service( + &app, + post_request!( + &username_udpate, + crate::V1_API_ROUTES.account.update_username + ) + .cookie(cookies) + .to_request(), + ) + .await; + assert_eq!(username_update_resp.status(), StatusCode::OK); + + // check duplicate username with duplicate username + username_udpate.username = NAME2.into(); + ctx.bad_post_req_test( + NAME_CHANGE, + PASSWORD, + V1_API_ROUTES.account.update_username, + &username_udpate, + ServiceError::UsernameTaken, + ) + .await; +} diff --git a/src/ctx/api/v1/tests/auth.rs b/src/ctx/api/v1/tests/auth.rs new file mode 100644 index 0000000..cfe4d86 --- /dev/null +++ b/src/ctx/api/v1/tests/auth.rs @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2022 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use std::sync::Arc; + +//use crate::api::v1::auth::{Login, Register}; +use crate::ctx::api::v1::auth::{Login, Register}; +use crate::ctx::Ctx; +use crate::errors::*; + +#[actix_rt::test] +async fn postgrest_auth_works() { + let (_dir, ctx) = crate::tests::get_ctx().await; + auth_works(ctx).await; +} + +async fn auth_works(ctx: Arc) { + const NAME: &str = "testuser"; + const PASSWORD: &str = "longpassword"; + const EMAIL: &str = "testuser1@a.com"; + + let _ = ctx.delete_user(NAME, PASSWORD).await; + + // 1. Register with email == None + let mut register_payload = Register { + username: NAME.into(), + password: PASSWORD.into(), + confirm_password: PASSWORD.into(), + email: EMAIL.into(), + }; + + // registration: passwords don't match + register_payload.confirm_password = NAME.into(); + assert!(matches!( + ctx.register(®ister_payload).await.err(), + Some(ServiceError::PasswordsDontMatch) + )); + + register_payload.confirm_password = PASSWORD.into(); + + ctx.register(®ister_payload).await.unwrap(); + // check if duplicate username is allowed + assert!(matches!( + ctx.register(®ister_payload).await.err(), + Some(ServiceError::UsernameTaken) + )); + + // check if duplicate email is allowed + let name = format!("{}dupemail", NAME); + register_payload.username = name; + assert!(matches!( + ctx.register(®ister_payload).await.err(), + Some(ServiceError::EmailTaken) + )); + + // Sign in with email + let mut creds = Login { + login: EMAIL.into(), + password: PASSWORD.into(), + }; + ctx.login(&creds).await.unwrap(); + + // signin with username + creds.login = NAME.into(); + ctx.login(&creds).await.unwrap(); + + // sigining in with non-existent username + creds.login = "nonexistantuser".into(); + assert!(matches!( + ctx.login(&creds).await.err(), + Some(ServiceError::AccountNotFound) + )); + + // sigining in with non-existent email + creds.login = "nonexistantuser@example.com".into(); + assert!(matches!( + ctx.login(&creds).await.err(), + Some(ServiceError::AccountNotFound) + )); + + // sign in with incorrect password + creds.login = NAME.into(); + creds.password = NAME.into(); + assert!(matches!( + ctx.login(&creds).await.err(), + Some(ServiceError::WrongPassword) + )); + + // delete user + ctx.delete_user(NAME, PASSWORD).await.unwrap(); +} diff --git a/src/ctx/api/v1/tests/mod.rs b/src/ctx/api/v1/tests/mod.rs new file mode 100644 index 0000000..469eff6 --- /dev/null +++ b/src/ctx/api/v1/tests/mod.rs @@ -0,0 +1,2 @@ +mod accounts; +mod auth; diff --git a/src/ctx/mod.rs b/src/ctx/mod.rs new file mode 100644 index 0000000..6861361 --- /dev/null +++ b/src/ctx/mod.rs @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2022 Aravinth Manivannan + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +use std::sync::Arc; +use std::thread; + +use crate::db::*; +use crate::settings::Settings; +use argon2_creds::{Config as ArgonConfig, ConfigBuilder as ArgonConfigBuilder, PasswordPolicy}; + +pub mod api; + +pub type ArcCtx = Arc; + +#[derive(Clone)] +pub struct Ctx { + pub settings: Settings, + pub db: Database, + /// credential-procession policy + pub creds: ArgonConfig, +} + +impl Ctx { + /// Get credential-processing policy + pub fn get_creds() -> ArgonConfig { + ArgonConfigBuilder::default() + .username_case_mapped(true) + .profanity(true) + .blacklist(true) + .password_policy(PasswordPolicy::default()) + .build() + .unwrap() + } + + pub async fn new(settings: Settings) -> Arc { + let creds = Self::get_creds(); + let c = creds.clone(); + + #[allow(unused_variables)] + let init = thread::spawn(move || { + log::info!("Initializing credential manager"); + c.init(); + log::info!("Initialized credential manager"); + }); + let db = get_db(&settings).await; + + #[cfg(not(debug_assertions))] + init.join(); + + Arc::new(Self { + settings, + db, + creds, + }) + } +}