summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/liveops.rs2
-rw-r--r--src/api/mod.rs2
-rw-r--r--src/api/users.rs62
-rw-r--r--src/main.rs3
-rw-r--r--src/models/mod.rs3
-rw-r--r--src/models/user.rs44
-rw-r--r--src/services/crypto.rs20
-rw-r--r--src/services/db.rs50
-rw-r--r--src/services/id.rs19
-rw-r--r--src/services/mod.rs1
10 files changed, 198 insertions, 8 deletions
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<str>,
+ password: Box<str>,
+}
+
+#[derive(Debug, Clone, Hash, Error)]
+#[error("An account with the given username already exists.")]
+struct CreateUserError {
+ username: Box<str>,
+}
+
+impl ResponseError for CreateUserError {
+ fn status_code(&self) -> StatusCode {
+ StatusCode::CONFLICT
+ }
+}
+
+#[post("")]
+async fn create_user(
+ body: web::Json<CreateUser>,
+ conn: web::Data<MySqlPool>,
+) -> Result<HttpResponse, CreateUserError> {
+ 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<str>,
+ 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<H: std::hash::Hasher>(&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<H: std::hash::Hasher>(&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<bool, RawUnexpected> {
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<MySqlPool, RawUnexpected> {
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<bool, RawUnexpected> {
+ 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<bool, RawUnexpected> {
+ 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<sqlx::mysql::MySqlQueryResult, sqlx::Error> {
+ 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<Uuid, RawUnexpected> {
+ 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;