From dbffef5e30e260d7b42ba6426020117eb90243e3 Mon Sep 17 00:00:00 2001 From: realaravinth Date: Mon, 11 Oct 2021 10:25:45 +0530 Subject: [PATCH] admin account management routes --- sqlx-data.json | 124 ++++++++++++++++ src/api/v1/account/delete.rs | 76 ++++++++++ src/api/v1/account/email.rs | 94 +++++++++++++ src/api/v1/account/mod.rs | 83 +++++++++++ src/api/v1/account/password.rs | 206 +++++++++++++++++++++++++++ src/api/v1/account/secret.rs | 90 ++++++++++++ src/api/v1/account/test.rs | 249 +++++++++++++++++++++++++++++++++ src/api/v1/account/username.rs | 110 +++++++++++++++ src/api/v1/auth.rs | 9 +- src/api/v1/meta.rs | 36 ++--- src/api/v1/mod.rs | 8 ++ src/api/v1/routes.rs | 3 + src/api/v1/tests/auth.rs | 168 ++++++++++++++++++++++ src/api/v1/tests/mod.rs | 18 +++ src/api/v1/tests/protected.rs | 70 +++++++++ src/main.rs | 6 +- src/tests.rs | 148 ++++++++++---------- 17 files changed, 1400 insertions(+), 98 deletions(-) create mode 100644 src/api/v1/account/delete.rs create mode 100644 src/api/v1/account/email.rs create mode 100644 src/api/v1/account/mod.rs create mode 100644 src/api/v1/account/password.rs create mode 100644 src/api/v1/account/secret.rs create mode 100644 src/api/v1/account/test.rs create mode 100644 src/api/v1/account/username.rs create mode 100644 src/api/v1/tests/auth.rs create mode 100644 src/api/v1/tests/mod.rs create mode 100644 src/api/v1/tests/protected.rs diff --git a/sqlx-data.json b/sqlx-data.json index 0c26f94..ff4c083 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -90,6 +90,18 @@ "nullable": [] } }, + "2ccaecfee4d2f29ef5278188b304017719720aa986d680d4727a1facbb869c7a": { + "query": "DELETE FROM survey_admins WHERE name = ($1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + } + }, "43b3e771f38bf8059832169227705be06a28925af1b3799ffef5371d511fd138": { "query": "\n INSERT INTO survey_users (created_at, id) VALUES($1, $2)", "describe": { @@ -103,6 +115,72 @@ "nullable": [] } }, + "536541ecf2e1c0403c74b6e2e09b42b73a7741ae4a348ff539ac410022e03ace": { + "query": "SELECT EXISTS (SELECT 1 from survey_admins WHERE name = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + } + }, + "55dde28998a6d12744806035f0a648494a403c7d09ea3caf91bf54869a81aa73": { + "query": "UPDATE survey_admins set password = $1\n WHERE name = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + } + }, + "683707dbc847b37c58c29aaad0d1a978c9fe0657da13af99796e4461134b5a43": { + "query": "UPDATE survey_admins set email = $1\n WHERE name = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + } + }, + "6a26daa84578aed2b2085697cb8358ed7c0a50ba9597fd387b4b09b0a8a154db": { + "query": "SELECT EXISTS (SELECT 1 from survey_admins WHERE email = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + } + }, "8320dda2b3e107d1451fdfb35eb2a4b8e97364e7b1b74ffe4d6913faf132fb61": { "query": "SELECT ID \n FROM survey_responses \n WHERE \n user_id = $1 \n AND \n device_software_recognised = $2;", "describe": { @@ -138,6 +216,52 @@ "nullable": [] } }, + "ab951c5c318174c6538037947c2f52c61bcfe5e5be1901379b715e77f5214dd2": { + "query": "UPDATE survey_admins set secret = $1\n WHERE name = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + } + }, + "c757589ef26a005e3285e7ab20d8a44c4f2e1cb125f8db061dd198cc380bf807": { + "query": "UPDATE survey_admins set name = $1\n WHERE name = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + } + }, + "e9cf5d6d8c9e8327d5c809d47a14a933f324e267f1e7dbb48e1caf1c021adc3f": { + "query": "SELECT secret FROM survey_admins WHERE name = ($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "secret", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + } + }, "fcdc5fe5d496eb516c805e64ec96d9626b74ab33cd6e75e5a08ae88967403b72": { "query": "INSERT INTO survey_response_tokens \n (resp_id, user_id, id)\n VALUES ($1, $2, $3);", "describe": { diff --git a/src/api/v1/account/delete.rs b/src/api/v1/account/delete.rs new file mode 100644 index 0000000..37af1c6 --- /dev/null +++ b/src/api/v1/account/delete.rs @@ -0,0 +1,76 @@ +/* +* Copyright (C) 2021 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 actix_identity::Identity; +use actix_web::{web, HttpResponse, Responder}; + +use super::auth::runners::Password; +use crate::errors::*; +use crate::AppData; + +#[my_codegen::post( + path = "crate::V1_API_ROUTES.account.delete", + wrap = "crate::api::v1::get_admin_check_login()" +)] +async fn delete_account( + id: Identity, + payload: web::Json, + data: AppData, +) -> ServiceResult { + use argon2_creds::Config; + use sqlx::Error::RowNotFound; + + let username = id.identity().unwrap(); + + let rec = sqlx::query_as!( + Password, + r#"SELECT password FROM survey_admins WHERE name = ($1)"#, + &username, + ) + .fetch_one(&data.db) + .await; + + match rec { + Ok(s) => { + if Config::verify(&s.password, &payload.password)? { + runners::delete_user(&username, &data).await?; + id.forget(); + Ok(HttpResponse::Ok()) + } else { + Err(ServiceError::WrongPassword) + } + } + Err(RowNotFound) => Err(ServiceError::AccountNotFound), + Err(_) => Err(ServiceError::InternalServerError), + } +} + +pub mod runners { + + use super::*; + + pub async fn delete_user(name: &str, data: &AppData) -> ServiceResult<()> { + sqlx::query!("DELETE FROM survey_admins WHERE name = ($1)", name,) + .execute(&data.db) + .await?; + Ok(()) + } +} + +pub fn services(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(delete_account); +} diff --git a/src/api/v1/account/email.rs b/src/api/v1/account/email.rs new file mode 100644 index 0000000..cea4212 --- /dev/null +++ b/src/api/v1/account/email.rs @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2021 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::borrow::Cow; + +use actix_identity::Identity; +use actix_web::{web, HttpResponse, Responder}; +use serde::{Deserialize, Serialize}; + +use super::{AccountCheckPayload, AccountCheckResp}; +use crate::errors::*; +use crate::AppData; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Email { + pub email: String, +} + +#[my_codegen::post(path = "crate::V1_API_ROUTES.account.email_exists")] +pub async fn email_exists( + payload: web::Json, + data: AppData, +) -> ServiceResult { + let res = sqlx::query!( + "SELECT EXISTS (SELECT 1 from survey_admins WHERE email = $1)", + &payload.val, + ) + .fetch_one(&data.db) + .await?; + + let mut resp = AccountCheckResp { exists: false }; + + if let Some(x) = res.exists { + if x { + resp.exists = true; + } + } + + Ok(HttpResponse::Ok().json(resp)) +} + +/// update email +#[my_codegen::post( + path = "crate::V1_API_ROUTES.account.update_email", + wrap = "crate::api::v1::get_admin_check_login()" +)] +async fn set_email( + id: Identity, + payload: web::Json, + data: AppData, +) -> ServiceResult { + let username = id.identity().unwrap(); + + data.creds.email(&payload.email)?; + + let res = sqlx::query!( + "UPDATE survey_admins set email = $1 + WHERE name = $2", + &payload.email, + &username, + ) + .execute(&data.db) + .await; + if res.is_err() { + if let Err(sqlx::Error::Database(err)) = res { + if err.code() == Some(Cow::from("23505")) + && err.message().contains("survey_admins_email_key") + { + return Err(ServiceError::EmailTaken); + } else { + return Err(sqlx::Error::Database(err).into()); + } + }; + } + Ok(HttpResponse::Ok()) +} + +pub fn services(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(email_exists); + cfg.service(set_email); +} diff --git a/src/api/v1/account/mod.rs b/src/api/v1/account/mod.rs new file mode 100644 index 0000000..7a57456 --- /dev/null +++ b/src/api/v1/account/mod.rs @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2021 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 serde::{Deserialize, Serialize}; + +pub mod delete; +pub mod email; +pub mod password; +pub mod secret; +#[cfg(test)] +pub mod test; +pub mod username; + +pub use super::auth; + +pub mod routes { + + pub struct Account { + pub delete: &'static str, + pub email_exists: &'static str, + pub get_secret: &'static str, + pub update_email: &'static str, + pub update_password: &'static str, + pub update_secret: &'static str, + pub username_exists: &'static str, + pub update_username: &'static str, + } + + impl Account { + pub const fn new() -> Account { + let get_secret = "/api/v1/account/secret/get"; + let update_secret = "/api/v1/account/secret/update"; + let delete = "/api/v1/account/delete"; + let email_exists = "/api/v1/account/email/exists"; + let username_exists = "/api/v1/account/username/exists"; + let update_username = "/api/v1/account/username/update"; + let update_email = "/api/v1/account/email/update"; + let update_password = "/api/v1/account/password/update"; + Account { + delete, + email_exists, + get_secret, + update_email, + update_password, + update_secret, + username_exists, + update_username, + } + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AccountCheckPayload { + pub val: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AccountCheckResp { + pub exists: bool, +} + +pub fn services(cfg: &mut actix_web::web::ServiceConfig) { + delete::services(cfg); + email::services(cfg); + username::services(cfg); + secret::services(cfg); + password::services(cfg); +} diff --git a/src/api/v1/account/password.rs b/src/api/v1/account/password.rs new file mode 100644 index 0000000..32a8afe --- /dev/null +++ b/src/api/v1/account/password.rs @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2021 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 actix_identity::Identity; +use actix_web::{web, HttpResponse, Responder}; +use argon2_creds::Config; +use serde::{Deserialize, Serialize}; +use sqlx::Error::RowNotFound; + +use crate::api::v1::auth::runners::Password; +use crate::errors::*; +use crate::*; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ChangePasswordReqest { + pub password: String, + pub new_password: String, + pub confirm_new_password: String, +} + +pub struct UpdatePassword { + pub new_password: String, + pub confirm_new_password: String, +} + +impl From for UpdatePassword { + fn from(s: ChangePasswordReqest) -> Self { + UpdatePassword { + new_password: s.new_password, + confirm_new_password: s.confirm_new_password, + } + } +} + +async fn update_password_runner( + user: &str, + update: UpdatePassword, + data: &Data, +) -> ServiceResult<()> { + if update.new_password != update.confirm_new_password { + return Err(ServiceError::PasswordsDontMatch); + } + + let new_hash = data.creds.password(&update.new_password)?; + + sqlx::query!( + "UPDATE survey_admins set password = $1 + WHERE name = $2", + &new_hash, + &user, + ) + .execute(&data.db) + .await?; + + Ok(()) +} + +#[my_codegen::post( + path = "crate::V1_API_ROUTES.account.update_password", + wrap = "crate::api::v1::get_admin_check_login()" +)] +async fn update_user_password( + id: Identity, + data: AppData, + payload: web::Json, +) -> ServiceResult { + if payload.new_password != payload.confirm_new_password { + return Err(ServiceError::PasswordsDontMatch); + } + + let username = id.identity().unwrap(); + + let rec = sqlx::query_as!( + Password, + r#"SELECT password FROM survey_admins WHERE name = ($1)"#, + &username, + ) + .fetch_one(&data.db) + .await; + + match rec { + Ok(s) => { + if Config::verify(&s.password, &payload.password)? { + let update: UpdatePassword = payload.into_inner().into(); + update_password_runner(&username, update, &data).await?; + Ok(HttpResponse::Ok()) + } else { + Err(ServiceError::WrongPassword) + } + } + Err(RowNotFound) => Err(ServiceError::AccountNotFound), + Err(_) => Err(ServiceError::InternalServerError), + } +} + +pub fn services(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(update_user_password); +} + +#[cfg(test)] +mod tests { + use super::*; + + use actix_web::http::StatusCode; + use actix_web::test; + + use crate::api::v1::ROUTES; + use crate::data::Data; + use crate::tests::*; + + #[actix_rt::test] + async fn update_password_works() { + const NAME: &str = "updatepassuser"; + const PASSWORD: &str = "longpassword2"; + const EMAIL: &str = "updatepassuser@a.com"; + + { + let data = Data::new().await; + delete_user(NAME, &data).await; + } + + let (data, _, signin_resp) = register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(data).await; + + let new_password = "newpassword"; + + let update_password = ChangePasswordReqest { + password: PASSWORD.into(), + new_password: new_password.into(), + confirm_new_password: PASSWORD.into(), + }; + + let res = update_password_runner(NAME, update_password.into(), &data).await; + assert!(res.is_err()); + assert_eq!(res, Err(ServiceError::PasswordsDontMatch)); + + let update_password = ChangePasswordReqest { + password: PASSWORD.into(), + new_password: new_password.into(), + confirm_new_password: new_password.into(), + }; + + assert!(update_password_runner(NAME, update_password.into(), &data) + .await + .is_ok()); + + let update_password = ChangePasswordReqest { + password: new_password.into(), + new_password: new_password.into(), + confirm_new_password: PASSWORD.into(), + }; + + bad_post_req_test( + NAME, + new_password, + ROUTES.account.update_password, + &update_password, + ServiceError::PasswordsDontMatch, + ) + .await; + + let update_password = ChangePasswordReqest { + password: PASSWORD.into(), + new_password: PASSWORD.into(), + confirm_new_password: PASSWORD.into(), + }; + + bad_post_req_test( + NAME, + new_password, + ROUTES.account.update_password, + &update_password, + ServiceError::WrongPassword, + ) + .await; + + let update_password = ChangePasswordReqest { + password: new_password.into(), + new_password: PASSWORD.into(), + confirm_new_password: PASSWORD.into(), + }; + + let update_password_resp = test::call_service( + &app, + post_request!(&update_password, ROUTES.account.update_password) + .cookie(cookies) + .to_request(), + ) + .await; + assert_eq!(update_password_resp.status(), StatusCode::OK); + } +} diff --git a/src/api/v1/account/secret.rs b/src/api/v1/account/secret.rs new file mode 100644 index 0000000..e187c86 --- /dev/null +++ b/src/api/v1/account/secret.rs @@ -0,0 +1,90 @@ +/* +* Copyright (C) 2021 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::borrow::Cow; + +use actix_identity::Identity; +use actix_web::{HttpResponse, Responder}; +use serde::{Deserialize, Serialize}; + +use crate::api::v1::get_random; +use crate::errors::*; +use crate::AppData; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Secret { + pub secret: String, +} + +#[my_codegen::get( + path = "crate::V1_API_ROUTES.account.get_secret", + wrap = "crate::api::v1::get_admin_check_login()" +)] +async fn get_secret(id: Identity, data: AppData) -> ServiceResult { + let username = id.identity().unwrap(); + + let secret = sqlx::query_as!( + Secret, + r#"SELECT secret FROM survey_admins WHERE name = ($1)"#, + &username, + ) + .fetch_one(&data.db) + .await?; + + Ok(HttpResponse::Ok().json(secret)) +} + +#[my_codegen::post( + path = "crate::V1_API_ROUTES.account.update_secret", + wrap = "crate::api::v1::get_admin_check_login()" +)] +async fn update_user_secret( + id: Identity, + data: AppData, +) -> ServiceResult { + let username = id.identity().unwrap(); + + let mut secret; + + loop { + secret = get_random(32); + let res = sqlx::query!( + "UPDATE survey_admins set secret = $1 + WHERE name = $2", + &secret, + &username, + ) + .execute(&data.db) + .await; + if res.is_ok() { + break; + } else if let Err(sqlx::Error::Database(err)) = res { + if err.code() == Some(Cow::from("23505")) + && err.message().contains("survey_admins_secret_key") + { + continue; + } else { + return Err(sqlx::Error::Database(err).into()); + } + } + } + Ok(HttpResponse::Ok()) +} + +pub fn services(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(get_secret); + cfg.service(update_user_secret); +} diff --git a/src/api/v1/account/test.rs b/src/api/v1/account/test.rs new file mode 100644 index 0000000..7861808 --- /dev/null +++ b/src/api/v1/account/test.rs @@ -0,0 +1,249 @@ +/* +* Copyright (C) 2021 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 actix_web::http::StatusCode; +use actix_web::test; + +use super::email::*; +use super::username::Username; +use super::*; +use crate::api::v1::auth::runners::Password; +use crate::api::v1::ROUTES; +use crate::data::Data; +use crate::*; + +use crate::errors::*; +use crate::tests::*; + +#[actix_rt::test] +async fn uname_email_exists_works() { + const NAME: &str = "testuserexists"; + const PASSWORD: &str = "longpassword2"; + const EMAIL: &str = "testuserexists@a.com2"; + + { + let data = Data::new().await; + delete_user(NAME, &data).await; + } + + let (data, _, signin_resp) = register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(data).await; + + // chech if get user secret works + let resp = test::call_service( + &app, + test::TestRequest::get() + .cookie(cookies.clone()) + .uri(ROUTES.account.get_secret) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // chech if get user secret works + let resp = test::call_service( + &app, + test::TestRequest::post() + .cookie(cookies.clone()) + .uri(ROUTES.account.update_secret) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let mut payload = AccountCheckPayload { val: NAME.into() }; + + let user_exists_resp = test::call_service( + &app, + post_request!(&payload, ROUTES.account.username_exists) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + assert_eq!(user_exists_resp.status(), StatusCode::OK); + let mut resp: AccountCheckResp = test::read_body_json(user_exists_resp).await; + assert!(resp.exists); + + payload.val = PASSWORD.into(); + + let user_doesnt_exist = test::call_service( + &app, + post_request!(&payload, ROUTES.account.username_exists) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + assert_eq!(user_doesnt_exist.status(), StatusCode::OK); + resp = test::read_body_json(user_doesnt_exist).await; + assert!(!resp.exists); + + let email_doesnt_exist = test::call_service( + &app, + post_request!(&payload, ROUTES.account.email_exists) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + assert_eq!(email_doesnt_exist.status(), StatusCode::OK); + resp = test::read_body_json(email_doesnt_exist).await; + assert!(!resp.exists); + + payload.val = EMAIL.into(); + + let email_exist = test::call_service( + &app, + post_request!(&payload, ROUTES.account.email_exists) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + assert_eq!(email_exist.status(), StatusCode::OK); + resp = test::read_body_json(email_exist).await; + assert!(resp.exists); +} + +#[actix_rt::test] +async fn email_udpate_password_validation_del_userworks() { + const NAME: &str = "testuser2"; + const PASSWORD: &str = "longpassword2"; + const EMAIL: &str = "testuser1@a.com2"; + const NAME2: &str = "eupdauser"; + const EMAIL2: &str = "eupdauser@a.com"; + + { + let data = Data::new().await; + delete_user(NAME, &data).await; + delete_user(NAME2, &data).await; + } + + let _ = register_and_signin(NAME2, EMAIL2, PASSWORD).await; + let (data, _creds, signin_resp) = register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(data).await; + + // update email + let mut email_payload = Email { + email: EMAIL.into(), + }; + let email_update_resp = test::call_service( + &app, + post_request!(&email_payload, 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(); + bad_post_req_test( + NAME, + PASSWORD, + ROUTES.account.update_email, + &email_payload, + ServiceError::EmailTaken, + ) + .await; + + // wrong password while deleteing account + let mut payload = Password { + password: NAME.into(), + }; + bad_post_req_test( + NAME, + PASSWORD, + ROUTES.account.delete, + &payload, + ServiceError::WrongPassword, + ) + .await; + + // delete account + payload.password = PASSWORD.into(); + let delete_user_resp = test::call_service( + &app, + post_request!(&payload, 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 = test::call_service( + &app, + post_request!(&payload, ROUTES.account.delete) + .cookie(cookies) + .to_request(), + ) + .await; + assert_eq!(account_not_found_resp.status(), StatusCode::NOT_FOUND); + let txt: ErrorToResponse = test::read_body_json(account_not_found_resp).await; + assert_eq!(txt.error, format!("{}", ServiceError::AccountNotFound)); +} + +#[actix_rt::test] +async fn username_update_works() { + const NAME: &str = "testuserupda"; + const EMAIL: &str = "testuserupda@sss.com"; + const EMAIL2: &str = "testuserupda2@sss.com"; + const PASSWORD: &str = "longpassword2"; + const NAME2: &str = "terstusrtds"; + const NAME_CHANGE: &str = "terstusrtdsxx"; + + { + let data = Data::new().await; + + futures::join!( + delete_user(NAME, &data), + delete_user(NAME2, &data), + delete_user(NAME_CHANGE, &data) + ); + } + + let _ = register_and_signin(NAME2, EMAIL2, PASSWORD).await; + let (data, _creds, signin_resp) = register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(data).await; + + // update username + let mut username_udpate = Username { + username: NAME_CHANGE.into(), + }; + let username_update_resp = test::call_service( + &app, + post_request!(&username_udpate, 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(); + bad_post_req_test( + NAME_CHANGE, + PASSWORD, + ROUTES.account.update_username, + &username_udpate, + ServiceError::UsernameTaken, + ) + .await; +} diff --git a/src/api/v1/account/username.rs b/src/api/v1/account/username.rs new file mode 100644 index 0000000..9132f2f --- /dev/null +++ b/src/api/v1/account/username.rs @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2021 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::borrow::Cow; + +use actix_identity::Identity; +use actix_web::{web, HttpResponse, Responder}; +use serde::{Deserialize, Serialize}; + +use super::{AccountCheckPayload, AccountCheckResp}; +use crate::errors::*; +use crate::AppData; + +#[my_codegen::post(path = "crate::V1_API_ROUTES.account.username_exists")] +async fn username_exists( + payload: web::Json, + data: AppData, +) -> ServiceResult { + let resp = runners::username_exists(&payload, &data).await?; + Ok(HttpResponse::Ok().json(resp)) +} + +pub mod runners { + use super::*; + + pub async fn username_exists( + payload: &AccountCheckPayload, + data: &AppData, + ) -> ServiceResult { + let res = sqlx::query!( + "SELECT EXISTS (SELECT 1 from survey_admins WHERE name = $1)", + &payload.val, + ) + .fetch_one(&data.db) + .await?; + + let mut resp = AccountCheckResp { exists: false }; + + if let Some(x) = res.exists { + if x { + resp.exists = true; + } + } + + Ok(resp) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Username { + pub username: String, +} + +/// update username +#[my_codegen::post( + path = "crate::V1_API_ROUTES.account.update_username", + wrap = "crate::api::v1::get_admin_check_login()" +)] +async fn set_username( + id: Identity, + payload: web::Json, + data: AppData, +) -> ServiceResult { + let username = id.identity().unwrap(); + + let processed_uname = data.creds.username(&payload.username)?; + + let res = sqlx::query!( + "UPDATE survey_admins set name = $1 + WHERE name = $2", + &processed_uname, + &username, + ) + .execute(&data.db) + .await; + + if res.is_err() { + if let Err(sqlx::Error::Database(err)) = res { + if err.code() == Some(Cow::from("23505")) + && err.message().contains("survey_admins_name_key") + { + return Err(ServiceError::UsernameTaken); + } else { + return Err(sqlx::Error::Database(err).into()); + } + }; + } + id.forget(); + id.remember(processed_uname); + + Ok(HttpResponse::Ok()) +} + +pub fn services(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(username_exists); + cfg.service(set_username); +} diff --git a/src/api/v1/auth.rs b/src/api/v1/auth.rs index b0733f3..81c5bdf 100644 --- a/src/api/v1/auth.rs +++ b/src/api/v1/auth.rs @@ -221,11 +221,10 @@ async fn login( id.remember(username); Ok(HttpResponse::Ok()) } -fn get_check_login() -> crate::CheckLogin { - crate::CheckLogin::new(crate::V1_API_ROUTES.auth.register) -} - -#[my_codegen::get(path = "crate::V1_API_ROUTES.auth.logout", wrap = "get_check_login()")] +#[my_codegen::get( + path = "crate::V1_API_ROUTES.auth.logout", + wrap = "crate::api::v1::get_admin_check_login()" +)] async fn signout(id: Identity) -> impl Responder { if id.identity().is_some() { id.forget(); diff --git a/src/api/v1/meta.rs b/src/api/v1/meta.rs index fd90971..274025e 100644 --- a/src/api/v1/meta.rs +++ b/src/api/v1/meta.rs @@ -103,22 +103,22 @@ mod tests { assert_eq!(resp.status(), StatusCode::OK); } - // #[actix_rt::test] - // async fn health_works() { - // println!("{}", V1_API_ROUTES.meta.health); - // let data = Data::new().await; - // let app = get_app!(data).await; - // - // let resp = test::call_service( - // &app, - // test::TestRequest::get() - // .uri(V1_API_ROUTES.meta.health) - // .to_request(), - // ) - // .await; - // assert_eq!(resp.status(), StatusCode::OK); - // - // let health_resp: Health = test::read_body_json(resp).await; - // assert!(health_resp.db); - // } + #[actix_rt::test] + async fn health_works() { + println!("{}", V1_API_ROUTES.meta.health); + let data = Data::new().await; + let app = get_app!(data).await; + + let resp = test::call_service( + &app, + test::TestRequest::get() + .uri(V1_API_ROUTES.meta.health) + .to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let health_resp: Health = test::read_body_json(resp).await; + assert!(health_resp.db); + } } diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index 925849c..d27be2e 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -17,16 +17,20 @@ use actix_web::web::ServiceConfig; use uuid::Uuid; +pub mod account; pub mod auth; pub mod bench; mod meta; pub mod routes; pub use routes::ROUTES; +#[cfg(test)] +mod tests; pub fn services(cfg: &mut ServiceConfig) { meta::services(cfg); auth::services(cfg); bench::services(cfg); + account::services(cfg); } pub fn get_random(len: usize) -> String { @@ -45,3 +49,7 @@ pub fn get_random(len: usize) -> String { pub fn get_uuid() -> Uuid { Uuid::new_v4() } + +pub fn get_admin_check_login() -> crate::CheckLogin { + crate::CheckLogin::new(crate::V1_API_ROUTES.auth.register) +} diff --git a/src/api/v1/routes.rs b/src/api/v1/routes.rs index 620e214..583d184 100644 --- a/src/api/v1/routes.rs +++ b/src/api/v1/routes.rs @@ -14,6 +14,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +use super::account::routes::Account; use super::auth::routes::Auth; use super::bench::routes::Benches; use super::meta::routes::Meta; @@ -22,6 +23,7 @@ pub const ROUTES: Routes = Routes::new(); pub struct Routes { pub auth: Auth, + pub account: Account, pub meta: Meta, pub benches: Benches, } @@ -29,6 +31,7 @@ pub struct Routes { impl Routes { const fn new() -> Routes { Routes { + account: Account::new(), auth: Auth::new(), meta: Meta::new(), benches: Benches::new(), diff --git a/src/api/v1/tests/auth.rs b/src/api/v1/tests/auth.rs new file mode 100644 index 0000000..b29bd35 --- /dev/null +++ b/src/api/v1/tests/auth.rs @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2021 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 actix_web::http::{header, StatusCode}; +use actix_web::test; + +use crate::api::v1::auth::runners::{Login, Register}; +use crate::api::v1::ROUTES; +use crate::data::Data; +use crate::errors::*; +use crate::*; + +use crate::tests::*; + +#[actix_rt::test] +async fn auth_works() { + let data = Data::new().await; + const NAME: &str = "testuser"; + const PASSWORD: &str = "longpassword"; + const EMAIL: &str = "testuser1@a.com"; + + let app = get_app!(data).await; + + delete_user(NAME, &data).await; + + // 1. Register with email == None + let msg = Register { + username: NAME.into(), + password: PASSWORD.into(), + confirm_password: PASSWORD.into(), + email: None, + }; + let resp = + test::call_service(&app, post_request!(&msg, ROUTES.auth.register).to_request()) + .await; + assert_eq!(resp.status(), StatusCode::OK); + // delete user + delete_user(NAME, &data).await; + + // 1. Register and signin + let (_, _, signin_resp) = register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + + // Sign in with email + signin(EMAIL, PASSWORD).await; + + // 2. check if duplicate username is allowed + let mut msg = Register { + username: NAME.into(), + password: PASSWORD.into(), + confirm_password: PASSWORD.into(), + email: Some(EMAIL.into()), + }; + bad_post_req_test( + NAME, + PASSWORD, + ROUTES.auth.register, + &msg, + ServiceError::UsernameTaken, + ) + .await; + + let name = format!("{}dupemail", NAME); + msg.username = name; + bad_post_req_test( + NAME, + PASSWORD, + ROUTES.auth.register, + &msg, + ServiceError::EmailTaken, + ) + .await; + + // 3. sigining in with non-existent user + let mut creds = Login { + login: "nonexistantuser".into(), + password: msg.password.clone(), + }; + bad_post_req_test( + NAME, + PASSWORD, + ROUTES.auth.login, + &creds, + ServiceError::AccountNotFound, + ) + .await; + + creds.login = "nonexistantuser@example.com".into(); + bad_post_req_test( + NAME, + PASSWORD, + ROUTES.auth.login, + &creds, + ServiceError::AccountNotFound, + ) + .await; + + // 4. trying to signin with wrong password + creds.login = NAME.into(); + creds.password = NAME.into(); + + bad_post_req_test( + NAME, + PASSWORD, + ROUTES.auth.login, + &creds, + ServiceError::WrongPassword, + ) + .await; + + // 5. signout + let signout_resp = test::call_service( + &app, + test::TestRequest::get() + .uri(ROUTES.auth.logout) + .cookie(cookies) + .to_request(), + ) + .await; + assert_eq!(signout_resp.status(), StatusCode::FOUND); + let headers = signout_resp.headers(); + assert_eq!( + headers.get(header::LOCATION).unwrap(), + crate::middleware::auth::AUTH + ) +} + +#[actix_rt::test] +async fn serverside_password_validation_works() { + const NAME: &str = "testuser542"; + const PASSWORD: &str = "longpassword2"; + + let data = Data::new().await; + delete_user(NAME, &data).await; + + let app = get_app!(data).await; + + // checking to see if server-side password validation (password == password_config) + // works + let register_msg = Register { + username: NAME.into(), + password: PASSWORD.into(), + confirm_password: NAME.into(), + email: None, + }; + let resp = test::call_service( + &app, + post_request!(®ister_msg, ROUTES.auth.register).to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let txt: ErrorToResponse = test::read_body_json(resp).await; + assert_eq!(txt.error, format!("{}", ServiceError::PasswordsDontMatch)); +} diff --git a/src/api/v1/tests/mod.rs b/src/api/v1/tests/mod.rs new file mode 100644 index 0000000..09f3aa9 --- /dev/null +++ b/src/api/v1/tests/mod.rs @@ -0,0 +1,18 @@ +/* +* Copyright (C) 2021 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 . +*/ +mod auth; +mod protected; diff --git a/src/api/v1/tests/protected.rs b/src/api/v1/tests/protected.rs new file mode 100644 index 0000000..6b9cbb8 --- /dev/null +++ b/src/api/v1/tests/protected.rs @@ -0,0 +1,70 @@ +/* +* Copyright (C) 2021 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 actix_web::http::StatusCode; +use actix_web::test; + +use crate::data::Data; +use crate::*; + +use crate::tests::*; + +#[actix_rt::test] +async fn protected_routes_work() { + const NAME: &str = "testuser619"; + const PASSWORD: &str = "longpassword2"; + const EMAIL: &str = "testuser119@a.com2"; + + let _post_protected_urls = [ + "/api/v1/account/secret/", + "/api/v1/account/email/", + "/api/v1/account/delete", + ]; + + let get_protected_urls = ["/logout"]; + + { + let data = Data::new().await; + delete_user(NAME, &data).await; + } + + let (data, _, signin_resp) = register_and_signin(NAME, EMAIL, PASSWORD).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(data).await; + + for url in get_protected_urls.iter() { + let resp = + test::call_service(&app, test::TestRequest::get().uri(url).to_request()) + .await; + assert_eq!(resp.status(), StatusCode::FOUND); + + let authenticated_resp = test::call_service( + &app, + test::TestRequest::get() + .uri(url) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + + if url == &V1_API_ROUTES.auth.logout { + assert_eq!(authenticated_resp.status(), StatusCode::FOUND); + } else { + assert_eq!(authenticated_resp.status(), StatusCode::OK); + } + } +} diff --git a/src/main.rs b/src/main.rs index 0da0474..d01a80b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,9 +32,9 @@ mod middleware; //mod pages; mod settings; //mod static_assets; -//#[cfg(test)] -//#[macro_use] -//mod tests; +#[cfg(test)] +#[macro_use] +mod tests; pub use crate::data::Data; pub use api::v1::ROUTES as V1_API_ROUTES; diff --git a/src/tests.rs b/src/tests.rs index c462ea1..a19d4ba 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -22,10 +22,10 @@ use actix_web::{dev::ServiceResponse, error::ResponseError, http::StatusCode}; use serde::Serialize; use super::*; -use crate::api::v1::feedback::{RatingReq, RatingResp}; -use crate::api::v1::ROUTES; +use crate::api::v1::auth::runners::{Login, Register}; use crate::data::Data; use crate::errors::*; +use crate::V1_API_ROUTES; #[macro_export] macro_rules! get_cookie { @@ -35,7 +35,7 @@ macro_rules! get_cookie { } pub async fn delete_user(name: &str, data: &Data) { - let r = sqlx::query!("DELETE FROM kaizen_users WHERE name = ($1)", name,) + let r = sqlx::query!("DELETE FROM survey_admins WHERE name = ($1)", name,) .execute(&data.db) .await; println!(); @@ -97,75 +97,79 @@ macro_rules! get_app { }; } -///// register and signin utility -//pub async fn register_and_signin( -// name: &str, -// email: &str, -// password: &str, -//) -> (Arc, Login, ServiceResponse) { -// register(name, email, password).await; -// signin(name, password).await -//} -// -///// register utility -//pub async fn register(name: &str, email: &str, password: &str) { -// let data = Data::new().await; -// let app = get_app!(data).await; -// -// // 1. Register -// let msg = Register { -// username: name.into(), -// password: password.into(), -// confirm_password: password.into(), -// email: Some(email.into()), -// }; -// let resp = -// test::call_service(&app, post_request!(&msg, ROUTES.auth.register).to_request()) -// .await; -// assert_eq!(resp.status(), StatusCode::OK); -//} -// -///// signin util -//pub async fn signin(name: &str, password: &str) -> (Arc, Login, ServiceResponse) { -// let data = Data::new().await; -// let app = get_app!(data.clone()).await; -// -// // 2. signin -// let creds = Login { -// login: name.into(), -// password: password.into(), -// }; -// let signin_resp = -// test::call_service(&app, post_request!(&creds, ROUTES.auth.login).to_request()) -// .await; -// assert_eq!(signin_resp.status(), StatusCode::OK); -// (data, creds, signin_resp) -//} -// -///// pub duplicate test -//pub async fn bad_post_req_test( -// name: &str, -// password: &str, -// url: &str, -// payload: &T, -// err: ServiceError, -//) { -// let (data, _, signin_resp) = signin(name, password).await; -// let cookies = get_cookie!(signin_resp); -// let app = get_app!(data).await; -// -// let resp = test::call_service( -// &app, -// post_request!(&payload, url) -// .cookie(cookies.clone()) -// .to_request(), -// ) -// .await; -// assert_eq!(resp.status(), err.status_code()); -// let resp_err: ErrorToResponse = test::read_body_json(resp).await; -// //println!("{}", txt.error); -// assert_eq!(resp_err.error, format!("{}", err)); -//} +/// register and signin utility +pub async fn register_and_signin( + name: &str, + email: &str, + password: &str, +) -> (Arc, Login, ServiceResponse) { + register(name, email, password).await; + signin(name, password).await +} + +/// register utility +pub async fn register(name: &str, email: &str, password: &str) { + let data = Data::new().await; + let app = get_app!(data).await; + + // 1. Register + let msg = Register { + username: name.into(), + password: password.into(), + confirm_password: password.into(), + email: Some(email.into()), + }; + let resp = test::call_service( + &app, + post_request!(&msg, V1_API_ROUTES.auth.register).to_request(), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); +} + +/// signin util +pub async fn signin(name: &str, password: &str) -> (Arc, Login, ServiceResponse) { + let data = Data::new().await; + let app = get_app!(data.clone()).await; + + // 2. signin + let creds = Login { + login: name.into(), + password: password.into(), + }; + let signin_resp = test::call_service( + &app, + post_request!(&creds, V1_API_ROUTES.auth.login).to_request(), + ) + .await; + assert_eq!(signin_resp.status(), StatusCode::OK); + (data, creds, signin_resp) +} + +/// pub duplicate test +pub async fn bad_post_req_test( + name: &str, + password: &str, + url: &str, + payload: &T, + err: ServiceError, +) { + let (data, _, signin_resp) = signin(name, password).await; + let cookies = get_cookie!(signin_resp); + let app = get_app!(data).await; + + let resp = test::call_service( + &app, + post_request!(&payload, url) + .cookie(cookies.clone()) + .to_request(), + ) + .await; + assert_eq!(resp.status(), err.status_code()); + let resp_err: ErrorToResponse = test::read_body_json(resp).await; + //println!("{}", txt.error); + assert_eq!(resp_err.error, format!("{}", err)); +} // ///// bad post req test without payload //pub async fn bad_post_req_test_witout_payload(