diff --git a/src/queries/badges.rs b/src/common_queries/badges.rs similarity index 100% rename from src/queries/badges.rs rename to src/common_queries/badges.rs diff --git a/src/common_queries/mod.rs b/src/common_queries/mod.rs new file mode 100644 index 0000000000..ca47e7283b --- /dev/null +++ b/src/common_queries/mod.rs @@ -0,0 +1,3 @@ +pub mod users; +pub mod badges; +pub mod sessions; \ No newline at end of file diff --git a/src/common_queries/sessions.rs b/src/common_queries/sessions.rs new file mode 100644 index 0000000000..e0260af3e2 --- /dev/null +++ b/src/common_queries/sessions.rs @@ -0,0 +1,41 @@ +use anyhow::anyhow; +use log::{info, warn}; +use crate::AnyhowResult; +use crate::util::tokens::random_token; +use sqlx::MySqlPool; + +pub async fn create_session_for_user(user_token: &str, ip_address: &str, mysql_pool: &MySqlPool) + -> AnyhowResult +{ + let session_token = random_token(32); + + let query_result = sqlx::query!( + r#" +INSERT INTO user_sessions ( + token, + user_token, + ip_address_creation, + expires_at +) +VALUES ( ?, ?, ?, NOW() + interval 1 year ) + "#, + session_token.to_string(), + user_token.to_string(), + ip_address.to_string(), + ) + .execute(mysql_pool) + .await; + + let record_id = match query_result { + Ok(res) => { + res.last_insert_id() + }, + Err(err) => { + return Err(anyhow!("session creation DB error: {:?}", err)); + } + }; + + info!("Created session id: {}", record_id); + + Ok(session_token) +} diff --git a/src/queries/users.rs b/src/common_queries/users.rs similarity index 100% rename from src/queries/users.rs rename to src/common_queries/users.rs diff --git a/src/endpoints/users/create_account.rs b/src/endpoints/users/create_account.rs index 9c9d728f3b..cd402b5436 100644 --- a/src/endpoints/users/create_account.rs +++ b/src/endpoints/users/create_account.rs @@ -112,8 +112,11 @@ pub async fn create_account_handler( let username = request.username.to_lowercase(); let display_name = request.username.to_string(); + let email_address = request.email_address.to_lowercase(); + let profile_markdown = ""; let profile_rendered_html = ""; + let ip_address = get_request_ip(&http_request); let query_result = sqlx::query!( @@ -136,7 +139,7 @@ VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) user_token.to_string(), username, display_name, - request.email_address.to_string(), + email_address, profile_markdown, profile_rendered_html, NEW_USER_ROLE, diff --git a/src/endpoints/users/login.rs b/src/endpoints/users/login.rs index 6ef9ddb9d5..c7a7917e78 100644 --- a/src/endpoints/users/login.rs +++ b/src/endpoints/users/login.rs @@ -18,6 +18,9 @@ use std::sync::Arc; use crate::validations::username::validate_username; use crate::validations::passwords::validate_passwords; use actix_web::cookie::Cookie; +use sqlx::MySqlPool; +use crate::AnyhowResult; +use crate::common_queries::sessions::create_session_for_user; #[derive(Deserialize)] pub struct LoginRequest { @@ -37,29 +40,23 @@ pub struct ErrorResponse { } #[derive(Debug, Display)] -pub enum CreateAccountError { - BadInput(String), - UsernameTaken, - EmailTaken, +pub enum LoginError { + WrongCredentials, ServerError, } -impl ResponseError for CreateAccountError { +impl ResponseError for LoginError { 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, + LoginError::WrongCredentials => StatusCode::BAD_REQUEST, + LoginError::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(), + LoginError::WrongCredentials => "wrong credentials".to_string(), + LoginError::ServerError => "server error".to_string(), }; let response = ErrorResponse { @@ -81,96 +78,119 @@ impl ResponseError for CreateAccountError { pub async fn login_handler( http_request: HttpRequest, request: web::Json, - server_state: web::Data>) -> Result + server_state: web::Data>) -> Result { - let try_password_hash = match bcrypt::hash(&request.password, bcrypt::DEFAULT_COST) { + let check_username_or_email = request.username_or_email.to_lowercase(); + + let maybe_user = if check_username_or_email.contains("@") { + lookup_by_email(&check_username_or_email, &server_state.mysql_pool).await + } else { + lookup_by_username(&check_username_or_email, &server_state.mysql_pool).await + }; + + let user = match maybe_user { + Ok(user) => user, + Err(e) => { + warn!("Login lookup error: {:?}", e); + return Err(LoginError::WrongCredentials); // TODO: This isn't necessarily user error. + } + }; + + info!("login user found"); + + let actual_hash = match String::from_utf8(user.password_hash.clone()) { Ok(hash) => hash, - Err(err) => { - warn!("Bcrypt error: {:?}", err); - return Err(ServerError); + Err(e) => { + warn!("Login hash hydration error: {:?}", e); + return Err(LoginError::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); - } - } - _ => {}, - } - }, - _ => {}, + let is_valid = match bcrypt::verify(&request.password, &actual_hash) { + Ok(is_valid) => { + if !is_valid { + info!("invalid credentials"); + return Err(LoginError::WrongCredentials); } - return Err(ServerError); + }, + Err(e) => { + warn!("Login hash comparison error: {:?}", e); + return Err(LoginError::ServerError); } }; + let ip_address = get_request_ip(&http_request); - info!("login session created"); + let create_session_result = + create_session_for_user(&user.token, &ip_address, &server_state.mysql_pool).await; - 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_token = match create_session_result { + Ok(token) => token, + Err(e) => { + warn!("login create session error : {:?}", e); + return Err(LoginError::ServerError); + } + }; - let response = CreateAccountSuccessResponse { + let session_cookie = match server_state.cookie_manager.create_cookie(&session_token) { + Ok(cookie) => cookie, + Err(_) => return Err(LoginError::ServerError), + }; + + let response = LoginSuccessResponse { success: true, }; let body = serde_json::to_string(&response) - .map_err(|e| BadInput("".to_string()))?; + .map_err(|e| LoginError::ServerError)?; Ok(HttpResponse::Ok() .cookie(session_cookie) .content_type("application/json") .body(body)) } + +#[derive(Debug)] +pub struct UserRecordForLogin { + token: String, + username: String, + email_address: String, + password_hash: Vec, +} + +async fn lookup_by_username(username: &str, pool: &MySqlPool) -> AnyhowResult +{ + // NB: Lookup failure is Err(RowNotFound). + let record = sqlx::query_as!( + UserRecordForLogin, + r#" +SELECT token, username, email_address, password_hash +FROM users +WHERE username = ? +LIMIT 1 + "#, + username.to_string(), + ) + .fetch_one(pool) + .await?; + + Ok(record) +} + +async fn lookup_by_email(email: &str, pool: &MySqlPool) -> AnyhowResult { + // NB: Lookup failure is Err(RowNotFound). + let record = sqlx::query_as!( + UserRecordForLogin, + r#" +SELECT token, username, email_address, password_hash +FROM users +WHERE email_address = ? +LIMIT 1 + "#, + email.to_string(), + ) + .fetch_one(pool) + .await?; + + Ok(record) +} diff --git a/src/endpoints/users/mod.rs b/src/endpoints/users/mod.rs index 5714e6443f..3eead82462 100644 --- a/src/endpoints/users/mod.rs +++ b/src/endpoints/users/mod.rs @@ -1,3 +1,4 @@ //pub mod login; //pub mod logout; -pub mod create_account; \ No newline at end of file +pub mod create_account; +pub mod login; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 60ca3e5d01..3eef84a522 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,9 @@ #[macro_use] extern crate lazy_static; #[macro_use] extern crate serde_derive; -pub mod server_state; +pub mod common_queries; pub mod endpoints; -pub mod queries; +pub mod server_state; pub mod util; pub mod validations; @@ -16,9 +16,8 @@ use std::sync::Arc; use crate::server_state::{ServerState, EnvConfig}; 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::endpoints::users::login::login_handler; use crate::util::cookies::CookieManager; const DEFAULT_BIND_ADDRESS : &'static str = "0.0.0.0:12345"; @@ -110,11 +109,11 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()> .route(web::post().to(create_account_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) - /*.service( + .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/queries/mod.rs b/src/queries/mod.rs deleted file mode 100644 index 2e6f0082b7..0000000000 --- a/src/queries/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod users; -pub mod badges; \ No newline at end of file