From 8ec105595db9d2957c7327112e7a0b63d9d59400 Mon Sep 17 00:00:00 2001 From: mrw1593 Date: Sun, 19 Mar 2023 13:23:20 -0400 Subject: Create user --- Cargo.lock | 15 +++++++++ Cargo.toml | 6 +++- sqlx-data.json | 87 +++++++++++++++++++++++++++++++++++++++++++++++++- src/api/liveops.rs | 2 +- src/api/mod.rs | 2 ++ src/api/users.rs | 62 +++++++++++++++++++++++++++++++++++ src/main.rs | 3 ++ src/models/mod.rs | 3 ++ src/models/user.rs | 44 +++++++++++++++++++++++++ src/services/crypto.rs | 20 ++++++++---- src/services/db.rs | 50 ++++++++++++++++++++++++++++- src/services/id.rs | 19 +++++++++++ src/services/mod.rs | 1 + 13 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 src/api/users.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/user.rs create mode 100644 src/services/id.rs diff --git a/Cargo.lock b/Cargo.lock index ece075a..1fb82f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1112,6 +1112,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "raise" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5127de0ecc0dd007559117b06737ec010d7316513685c2d3adf2b8b8252ce589" + [[package]] name = "rand" version = "0.8.5" @@ -1221,9 +1227,13 @@ version = "0.1.0" dependencies = [ "actix-web", "exun", + "raise", "rand", "rust-argon2", + "serde", "sqlx", + "thiserror", + "uuid", ] [[package]] @@ -1741,6 +1751,11 @@ name = "uuid" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79" +dependencies = [ + "getrandom", + "rand", + "serde", +] [[package]] name = "version_check" diff --git a/Cargo.toml b/Cargo.toml index c30af2e..0c40ad7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,11 @@ edition = "2021" [dependencies] actix-web = "4" +serde = "1" +thiserror = "1" rust-argon2 = "1" -rand = { version = "0.8", features = [ "small_rng" ] } +uuid = { version = "1", features = [ "v4", "fast-rng", "serde" ] } +raise = "2" +rand = "0.8" sqlx = { version = "0.6", features = [ "runtime-actix-rustls", "mysql", "uuid", "offline" ] } exun = "0.1" diff --git a/sqlx-data.json b/sqlx-data.json index 4295bdb..b2c46e2 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -1,3 +1,88 @@ { - "db": "MySQL" + "db": "MySQL", + "1f6464fd7ab12c16c6b3d003471fc316d5aca3ec33e6cd2ebfc73a45a50f5d09": { + "describe": { + "columns": [ + { + "name": "e: bool", + "ordinal": 0, + "type_info": { + "char_set": 63, + "flags": { + "bits": 129 + }, + "max_size": 1, + "type": "LongLong" + } + } + ], + "nullable": [ + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "SELECT EXISTS(SELECT user_id FROM users WHERE email = ?) as \"e: bool\"" + }, + "2a874f8bf5448493f2724b31022f3f8ebb535de0d780c19bb11e70d0da3e12fb": { + "describe": { + "columns": [ + { + "name": "e: bool", + "ordinal": 0, + "type_info": { + "char_set": 63, + "flags": { + "bits": 129 + }, + "max_size": 1, + "type": "LongLong" + } + } + ], + "nullable": [ + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "SELECT EXISTS(SELECT user_id FROM users WHERE username = ?) as \"e: bool\"" + }, + "e7915f4cc41910baa074655a175f10bec198b69c4a4876cface46c875a1985c8": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 6 + } + }, + "query": "INSERT INTO users (user_id, username, email, password_hash, password_salt, password_version)\n\t\t\t\t\t VALUES (?, ?, ?, ?, ?, ?)" + }, + "ef52d73fde91d8771b14607544be1c12985e7db6c902062a2addd3b367502745": { + "describe": { + "columns": [ + { + "name": "e: bool", + "ordinal": 0, + "type_info": { + "char_set": 63, + "flags": { + "bits": 129 + }, + "max_size": 1, + "type": "LongLong" + } + } + ], + "nullable": [ + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "SELECT EXISTS(SELECT user_id FROM users WHERE user_id = ?) as \"e: bool\"" + } } \ No newline at end of file diff --git a/src/api/liveops.rs b/src/api/liveops.rs index ff44107..2dda41d 100644 --- a/src/api/liveops.rs +++ b/src/api/liveops.rs @@ -7,5 +7,5 @@ async fn ping() -> HttpResponse { } pub fn service() -> Scope { - web::scope("liveops/").service(ping) + web::scope("liveops").service(ping) } diff --git a/src/api/mod.rs b/src/api/mod.rs index f934d0e..7becfbd 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,3 +1,5 @@ mod liveops; +mod users; pub use liveops::service as liveops; +pub use users::service as users; diff --git a/src/api/users.rs b/src/api/users.rs new file mode 100644 index 0000000..5e409fd --- /dev/null +++ b/src/api/users.rs @@ -0,0 +1,62 @@ +use actix_web::http::{header, StatusCode}; +use actix_web::{post, web, HttpResponse, ResponseError, Scope}; +use raise::yeet; +use serde::Deserialize; +use sqlx::MySqlPool; +use thiserror::Error; + +use crate::models::User; +use crate::services::crypto::PasswordHash; +use crate::services::db::{new_user, username_is_used}; +use crate::services::id::new_user_id; + +#[derive(Clone, Deserialize)] +struct CreateUser { + username: Box, + password: Box, +} + +#[derive(Debug, Clone, Hash, Error)] +#[error("An account with the given username already exists.")] +struct CreateUserError { + username: Box, +} + +impl ResponseError for CreateUserError { + fn status_code(&self) -> StatusCode { + StatusCode::CONFLICT + } +} + +#[post("")] +async fn create_user( + body: web::Json, + conn: web::Data, +) -> Result { + let conn = conn.get_ref(); + + let user_id = new_user_id(conn).await.unwrap(); + let username = body.username.clone(); + let password = PasswordHash::new(&body.password).unwrap(); + + if username_is_used(conn, &body.username).await.unwrap() { + yeet!(CreateUserError { username }); + } + + let user = User { + user_id, + username, + password, + }; + + new_user(conn, user).await.unwrap(); + + let response = HttpResponse::Created() + .insert_header((header::LOCATION, format!("users/{user_id}"))) + .finish(); + Ok(response) +} + +pub fn service() -> Scope { + web::scope("users").service(create_user) +} diff --git a/src/main.rs b/src/main.rs index dc0f9a7..7cc694d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use actix_web::{web::Data, App, HttpServer}; use exun::RawUnexpected; mod api; +mod models; mod services; use services::*; @@ -9,10 +10,12 @@ use services::*; #[actix_web::main] async fn main() -> Result<(), RawUnexpected> { let sql_pool = db::initialize("password_database", "dbuser", "Demo1234").await?; + HttpServer::new(move || { App::new() .app_data(Data::new(sql_pool.clone())) .service(api::liveops()) + .service(api::users()) }) .shutdown_timeout(1) .bind(("127.0.0.1", 8080))? diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..4a9be81 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,3 @@ +mod user; + +pub use user::User; diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 0000000..f5fd20e --- /dev/null +++ b/src/models/user.rs @@ -0,0 +1,44 @@ +use std::hash::Hash; + +use uuid::Uuid; + +use crate::services::crypto::PasswordHash; + +#[derive(Debug, Clone)] +pub struct User { + pub user_id: Uuid, + pub username: Box, + pub password: PasswordHash, +} + +impl PartialEq for User { + fn eq(&self, other: &Self) -> bool { + self.user_id == other.user_id + } +} + +impl Eq for User {} + +impl Hash for User { + fn hash(&self, state: &mut H) { + state.write_u128(self.user_id.as_u128()) + } +} + +impl User { + pub fn username(&self) -> &str { + &self.username + } + + pub fn password_hash(&self) -> &[u8] { + self.password.hash() + } + + pub fn password_salt(&self) -> &[u8] { + self.password.salt() + } + + pub fn password_version(&self) -> u8 { + self.password.version() + } +} diff --git a/src/services/crypto.rs b/src/services/crypto.rs index 11a5149..580e83a 100644 --- a/src/services/crypto.rs +++ b/src/services/crypto.rs @@ -10,7 +10,7 @@ static PEPPER: [u8; 16] = [ /// The configuration used for hashing and verifying passwords static CONFIG: argon2::Config<'_> = argon2::Config { - hash_length: 256, + hash_length: 32, lanes: 4, mem_cost: 5333, time_cost: 4, @@ -27,13 +27,12 @@ static CONFIG: argon2::Config<'_> = argon2::Config { pub struct PasswordHash { hash: Box<[u8]>, salt: Box<[u8]>, + version: u8, } impl Hash for PasswordHash { fn hash(&self, state: &mut H) { - for byte in self.hash.iter() { - state.write_u8(*byte) - } + state.write(&self.hash) } } @@ -47,14 +46,19 @@ impl PasswordHash { let hash = hash_raw(password, &salt, &CONFIG)?.into_boxed_slice(); - Ok(Self { hash, salt }) + Ok(Self { + hash, + salt, + version: 0, + }) } /// Create this structure from a given hash and salt - pub fn from_hash_salt(hash: &[u8], salt: &[u8]) -> Self { + pub fn from_fields(hash: &[u8], salt: &[u8], version: u8) -> Self { Self { hash: Box::from(hash), salt: Box::from(salt), + version, } } @@ -68,6 +72,10 @@ impl PasswordHash { &self.salt } + pub fn version(&self) -> u8 { + self.version + } + /// Check if the given password is the one that was hashed pub fn check_password(&self, password: &str) -> Result { Ok(verify_raw( diff --git a/src/services/db.rs b/src/services/db.rs index a8e4918..efa9584 100644 --- a/src/services/db.rs +++ b/src/services/db.rs @@ -1,8 +1,56 @@ use exun::*; -use sqlx::MySqlPool; +use sqlx::{query, query_scalar, Executor, MySql, MySqlPool}; +use uuid::Uuid; + +use crate::models::User; /// Intialize the connection pool pub async fn initialize(db: &str, user: &str, password: &str) -> Result { let url = format!("mysql://{user}:{password}@localhost/{db}"); MySqlPool::connect(&url).await.unexpect() } + +pub async fn user_id_exists<'c>( + conn: impl Executor<'c, Database = MySql>, + id: Uuid, +) -> Result { + let exists = query_scalar!( + r#"SELECT EXISTS(SELECT user_id FROM users WHERE user_id = ?) as "e: bool""#, + id + ) + .fetch_one(conn) + .await?; + + Ok(exists) +} + +pub async fn username_is_used<'c>( + conn: impl Executor<'c, Database = MySql>, + username: &str, +) -> Result { + let exists = query_scalar!( + r#"SELECT EXISTS(SELECT user_id FROM users WHERE username = ?) as "e: bool""#, + username + ) + .fetch_one(conn) + .await?; + + Ok(exists) +} + +pub async fn new_user<'c>( + conn: impl Executor<'c, Database = MySql>, + user: User, +) -> Result { + query!( + r"INSERT INTO users (user_id, username, password_hash, password_salt, password_version) + VALUES (?, ?, ?, ?, ?)", + user.user_id, + user.username(), + user.password_hash(), + user.password_salt(), + user.password_version() + ) + .execute(conn) + .await +} diff --git a/src/services/id.rs b/src/services/id.rs new file mode 100644 index 0000000..7970c60 --- /dev/null +++ b/src/services/id.rs @@ -0,0 +1,19 @@ +use exun::RawUnexpected; +use sqlx::{Executor, MySql}; +use uuid::Uuid; + +use super::db; + +/// Create a unique user id, handling duplicate ID's +pub async fn new_user_id<'c>( + conn: impl Executor<'c, Database = MySql> + Clone, +) -> Result { + let uuid = loop { + let uuid = Uuid::new_v4(); + if !db::user_id_exists(conn.clone(), uuid).await? { + break uuid; + } + }; + + Ok(uuid) +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 7163603..57146d8 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,2 +1,3 @@ pub mod crypto; pub mod db; +pub mod id; -- cgit v1.2.3