diff --git a/Cargo.lock b/Cargo.lock index 9ea94c585b..898977a4e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -879,6 +879,16 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "crypto-mac" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4857fd85a0c34b3c3297875b747c1e02e06b6a0ea32dd892d8192b9ce0813ea6" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "ctor" version = "0.1.20" @@ -1286,6 +1296,16 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15" +dependencies = [ + "crypto-mac", + "digest", +] + [[package]] name = "hostname" version = "0.3.1" @@ -1400,6 +1420,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jwt" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e46349d67dc03bdbdb28da0337a355a53ca1d5156452722c36fe21d0e6389b" +dependencies = [ + "base64 0.13.0", + "crypto-mac", + "digest", + "hmac", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "kernel32-sys" version = "0.2.2" @@ -2476,7 +2511,9 @@ dependencies = [ "diesel", "easyenv", "futures", + "hmac", "hostname", + "jwt", "lazy_static", "log", "rand 0.8.3", @@ -2484,6 +2521,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", + "sha2", "sqlx", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index d59ba75c15..b9142b021c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.40" base64 = "0.13.0" -bcrypt = "0.8" chrono = { version = "0.4.19", features = ["serde"] } derive_more = "0.99.14" easyenv = "0.0.5" @@ -46,6 +45,12 @@ actix-http = "2.2.0" actix-rt = "2.2.0" actix-utils = "3.0.0" actix-web = "3.3.2" +jwt = "0.13.0" + +# Crypto +bcrypt = "0.8" +hmac = "0.10.0" +sha2 = "0.9.5" # Serialization serde = "1.0.125" diff --git a/src/endpoints/users/create_account.rs b/src/endpoints/users/create_account.rs index be5850b979..9c9d728f3b 100644 --- a/src/endpoints/users/create_account.rs +++ b/src/endpoints/users/create_account.rs @@ -7,7 +7,7 @@ use actix_web::{Responder, web, HttpResponse, error, HttpRequest}; use crate::endpoints::users::create_account::CreateAccountError::{BadInput, ServerError, UsernameTaken, EmailTaken}; use crate::server_state::ServerState; use crate::util::ip_address::get_request_ip; -use crate::util::random::random_token; +use crate::util::tokens::random_token; use derive_more::{Display, Error}; use log::{info, warn, log}; use regex::Regex; @@ -210,12 +210,10 @@ VALUES ( ?, ?, ?, NOW() + interval 1 year ) info!("new user session created"); - let session_cookie = Cookie::build("session", session_token) - .domain(".vo.codes") - .path("/") - .secure(true) // HTTPS-only - .http_only(true) // Not exposed to Javascript - .finish(); + let session_cookie = match server_state.cookie_manager.create_cookie(&session_token) { + Ok(cookie) => cookie, + Err(_) => return Err(ServerError), + }; let response = CreateAccountSuccessResponse { success: true, diff --git a/src/endpoints/users/login.rs b/src/endpoints/users/login.rs index e69de29bb2..6ef9ddb9d5 100644 --- a/src/endpoints/users/login.rs +++ b/src/endpoints/users/login.rs @@ -0,0 +1,176 @@ +use actix_http::Error; +use actix_http::http::header; +use actix_web::dev::HttpResponseBuilder; +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::{Responder, web, HttpResponse, error, HttpRequest}; +use crate::endpoints::users::create_account::CreateAccountError::{BadInput, ServerError, UsernameTaken, EmailTaken}; +use crate::server_state::ServerState; +use crate::util::ip_address::get_request_ip; +use crate::util::tokens::random_token; +use derive_more::{Display, Error}; +use log::{info, warn, log}; +use regex::Regex; +use sqlx::error::DatabaseError; +use sqlx::error::Error::Database; +use sqlx::mysql::MySqlDatabaseError; +use std::sync::Arc; +use crate::validations::username::validate_username; +use crate::validations::passwords::validate_passwords; +use actix_web::cookie::Cookie; + +#[derive(Deserialize)] +pub struct LoginRequest { + pub username_or_email: String, + pub password: String, +} + +#[derive(Serialize)] +pub struct LoginSuccessResponse { + pub success: bool, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub success: bool, + pub error_reason: String, +} + +#[derive(Debug, Display)] +pub enum CreateAccountError { + BadInput(String), + UsernameTaken, + EmailTaken, + ServerError, +} + +impl ResponseError for CreateAccountError { + fn status_code(&self) -> StatusCode { + match *self { + CreateAccountError::BadInput(_) => StatusCode::BAD_REQUEST, + CreateAccountError::UsernameTaken => StatusCode::BAD_REQUEST, + CreateAccountError::EmailTaken => StatusCode::BAD_REQUEST, + CreateAccountError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + let error_reason = match self { + BadInput(reason) => reason.to_string(), + CreateAccountError::UsernameTaken => "username already taken".to_string(), + CreateAccountError::EmailTaken => "email already taken".to_string(), + CreateAccountError::ServerError => "server error".to_string(), + }; + + let response = ErrorResponse { + success: false, + error_reason, + }; + + let body = match serde_json::to_string(&response) { + Ok(json) => json, + Err(_) => "{}".to_string(), + }; + + HttpResponseBuilder::new(self.status_code()) + .set_header(header::CONTENT_TYPE, "application/json") + .body(body) + } +} + +pub async fn login_handler( + http_request: HttpRequest, + request: web::Json, + server_state: web::Data>) -> Result +{ + let try_password_hash = match bcrypt::hash(&request.password, bcrypt::DEFAULT_COST) { + Ok(hash) => hash, + Err(err) => { + warn!("Bcrypt error: {:?}", err); + return Err(ServerError); + } + }; + + let query_result = sqlx::query!( + r#" +INSERT INTO users ( + token, + username, + display_name, + email_address, + profile_markdown, + profile_rendered_html, + user_role_slug, + password_hash, + ip_address_creation, + ip_address_last_login, + ip_address_last_update +) +VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) + "#, + user_token.to_string(), + username, + display_name, + request.email_address.to_string(), + profile_markdown, + profile_rendered_html, + NEW_USER_ROLE, + password_hash, + ip_address.to_string(), + ip_address.to_string(), + ip_address.to_string(), + ) + .execute(&server_state.mysql_pool) + .await; + + let record_id = match query_result { + Ok(res) => { + res.last_insert_id() + }, + Err(err) => { + warn!("New user creation DB error: {:?}", err); + + // NB: SQLSTATE[23000]: Integrity constraint violation + // NB: MySQL Error Code 1062: Duplicate key insertion (this is harder to access) + match err { + Database(err) => { + let maybe_code = err.code().map(|c| c.into_owned()); + match maybe_code.as_deref() { + Some("23000") => { + if err.message().contains("username") { + return Err(UsernameTaken); + } else if err.message().contains("email_address") { + return Err(EmailTaken); + } + } + _ => {}, + } + }, + _ => {}, + } + return Err(ServerError); + } + }; + + + info!("login session created"); + + let session_cookie = Cookie::build("session", session_token) + .domain(".vo.codes") + .path("/") + .secure(true) // HTTPS-only + .http_only(true) // Not exposed to Javascript + .finish(); + + let response = CreateAccountSuccessResponse { + success: true, + }; + + let body = serde_json::to_string(&response) + .map_err(|e| BadInput("".to_string()))?; + + Ok(HttpResponse::Ok() + .cookie(session_cookie) + .content_type("application/json") + .body(body)) +} diff --git a/src/endpoints/users/mod.rs b/src/endpoints/users/mod.rs index 07c35b615a..5714e6443f 100644 --- a/src/endpoints/users/mod.rs +++ b/src/endpoints/users/mod.rs @@ -1,3 +1,3 @@ -pub mod login; -pub mod logout; +//pub mod login; +//pub mod logout; pub mod create_account; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 7397584805..60ca3e5d01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,17 +18,14 @@ use sqlx::MySqlPool; use sqlx::mysql::MySqlPoolOptions; use crate::queries::badges::NewBadge; use crate::endpoints::users::create_account::create_account_handler; +//use crate::endpoints::users::login::login_handler; +use crate::util::cookies::CookieManager; const DEFAULT_BIND_ADDRESS : &'static str = "0.0.0.0:12345"; const DEFAULT_RUST_LOG: &'static str = "debug,actix_web=info"; pub type AnyhowResult = anyhow::Result; -//pub async fn main() -> AnyhowResult<()> { -//#[tokio::main] -//#[async_std::main] -//#[actix_web::main] -//pub async fn main() -> std::io::Result<()> { #[actix_web::main] async fn main() -> AnyhowResult<()> { easyenv::init_all_with_default_logging(Some(DEFAULT_RUST_LOG)); @@ -45,6 +42,9 @@ async fn main() -> AnyhowResult<()> { .connect(db_connection_string) .await?; + let hmac_secret = easyenv::get_env_string_or_default("COOKIE_SECRET", "notsecret"); + let cookie_manager = CookieManager::new(".vo.codes", &hmac_secret); + let server_state = ServerState { env_config: EnvConfig { num_workers: 4, @@ -52,6 +52,7 @@ async fn main() -> AnyhowResult<()> { }, hostname: server_hostname, mysql_pool: pool, + cookie_manager, }; serve(server_state) @@ -59,7 +60,6 @@ async fn main() -> AnyhowResult<()> { Ok(()) } -//#[actix_web::main] pub async fn serve(server_state: ServerState) -> AnyhowResult<()> { let bind_address = server_state.env_config.bind_address.clone(); @@ -110,6 +110,11 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()> .route(web::post().to(create_account_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) + /*.service( + web::resource("/login") + .route(web::post().to(login_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + )*/ .service( web::scope("/foo") .service( diff --git a/src/server_state.rs b/src/server_state.rs index 753b35066d..88c4d60891 100644 --- a/src/server_state.rs +++ b/src/server_state.rs @@ -1,13 +1,17 @@ use sqlx::MySqlPool; +use crate::util::cookies::CookieManager; /// State that is injected into every endpoint. #[derive(Clone)] pub struct ServerState { + /// Configuration from ENV vars. pub env_config: EnvConfig, pub hostname: String, pub mysql_pool: MySqlPool, + + pub cookie_manager: CookieManager, } #[derive(Clone)] diff --git a/src/util/cookies.rs b/src/util/cookies.rs new file mode 100644 index 0000000000..827b9803ea --- /dev/null +++ b/src/util/cookies.rs @@ -0,0 +1,46 @@ +use actix_web::cookie::Cookie; +use anyhow::anyhow; +use crate::AnyhowResult; +use hmac::Hmac; +use hmac::NewMac; +use std::collections::BTreeMap; +use sha2::Sha256; +use jwt::SignWithKey; + +const COOKIE_VERSION : u32 = 1; + +#[derive(Clone)] +pub struct CookieManager { + cookie_domain: String, + hmac_secret: String, +} + +impl CookieManager { + pub fn new(cookie_domain: &str, hmac_secret: &str) -> Self { + Self { + cookie_domain: cookie_domain.to_string(), + hmac_secret: hmac_secret.to_string(), + } + } + + pub fn create_cookie(&self, session_token: &str) -> AnyhowResult { + let key: Hmac = Hmac::new_varkey(self.hmac_secret.as_bytes()) + .map_err(|e| anyhow!("invalid hmac: {:?}", e))?; + + let cookie_version = COOKIE_VERSION.to_string(); + + let mut claims = BTreeMap::new(); + claims.insert("session_token", session_token); + claims.insert("cookie_version", &cookie_version); + + let jwt_string = claims.sign_with_key(&key)?; + + Ok(Cookie::build("session", jwt_string) + .domain(&self.cookie_domain) + .path("/") + .secure(true) // HTTPS-only + .http_only(true) // Not exposed to Javascript + .finish()) + } +} + diff --git a/src/util/mod.rs b/src/util/mod.rs index e388ba0dac..d169123365 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,2 +1,3 @@ -pub mod random; -pub mod ip_address; \ No newline at end of file +pub mod cookies; +pub mod ip_address; +pub mod tokens; \ No newline at end of file diff --git a/src/util/random.rs b/src/util/tokens.rs similarity index 93% rename from src/util/random.rs rename to src/util/tokens.rs index 79d448a61e..7551e50fcd 100644 --- a/src/util/random.rs +++ b/src/util/tokens.rs @@ -18,7 +18,7 @@ pub fn random_token(length: usize) -> String { #[cfg(test)] mod tests { - use crate::util::random::random_token; + use crate::util::tokens::random_token; #[test] fn random_token_length() {