From ac6d756acb8ebec5f75ec7db529474e743c3b31b Mon Sep 17 00:00:00 2001 From: fef Date: Fri, 20 Jan 2023 20:23:13 +0100 Subject: [PATCH] use newtypes for clear and hashed passwords --- src/model/user.rs | 5 +- src/route/api/nyano/v1/auth.rs | 5 +- src/route/api/v1/accounts.rs | 7 ++- src/util/password.rs | 88 +++++++++++++++++++++++++++++----- 4 files changed, 86 insertions(+), 19 deletions(-) diff --git a/src/model/user.rs b/src/model/user.rs index 91998aa..40c14dd 100644 --- a/src/model/user.rs +++ b/src/model/user.rs @@ -4,6 +4,7 @@ use sqlx::FromRow; use crate::core::*; use crate::model::AccountId; use crate::util::keys::PrivateKeyDer; +use crate::util::password::HashedPassword; use crate::util::validate::{ResultBuilder, Validate}; pub type UserId = Id; @@ -13,7 +14,7 @@ pub struct User { pub id: UserId, pub account_id: AccountId, pub email: String, - pub password: String, + pub password: HashedPassword, pub reason: Option, pub locale: String, pub activated: bool, @@ -23,7 +24,7 @@ pub struct User { pub struct NewUser { pub account_id: AccountId, pub email: String, - pub password: String, + pub password: HashedPassword, pub reason: Option, pub locale: String, pub private_key: PrivateKeyDer, diff --git a/src/route/api/nyano/v1/auth.rs b/src/route/api/nyano/v1/auth.rs index 1f1879d..90d07ec 100644 --- a/src/route/api/nyano/v1/auth.rs +++ b/src/route/api/nyano/v1/auth.rs @@ -4,12 +4,13 @@ use serde::{Deserialize, Serialize}; use crate::core::*; use crate::model::Account; use crate::state::AppState; +use crate::util::password::ClearPassword; use crate::util::{password, token}; #[derive(Deserialize)] struct AuthRequest { email: String, - password: String, + password: ClearPassword, } #[derive(Serialize)] @@ -31,7 +32,7 @@ async fn auth(body: web::Json, state: AppState) -> Result Error::BadCredentials, e => e, })?; - password::verify(body.password.as_str(), user.password.as_str())?; + password::verify(&body.password, &user.password)?; let account = state.repo.accounts.by_id(user.account_id).await?; let token = token::issue(&state, &account)?; info!(target: "auth", "Successful login for user {}", &account.name); diff --git a/src/route/api/v1/accounts.rs b/src/route/api/v1/accounts.rs index 060ce68..e49131e 100644 --- a/src/route/api/v1/accounts.rs +++ b/src/route/api/v1/accounts.rs @@ -7,13 +7,14 @@ use crate::middle::AuthData; use crate::model::{AccountId, NewAccount, NewUser}; use crate::state::AppState; use crate::util::keys::generate_keypair; +use crate::util::password::{self, ClearPassword}; use crate::util::validate::{ResultBuilder, Validate}; #[derive(Deserialize)] struct SignupData { username: String, email: String, - password: String, + password: ClearPassword, agreement: bool, locale: String, reason: Option, @@ -60,9 +61,7 @@ impl Validate for SignupData { "You must accept the Terms of Service", |v| *v, ) - .field(&self.password, "password", |f| { - f.check("Must be at least 8 characters long", |v| v.len() >= 8) - }) + .field_auto(&self.password, "password") } } diff --git a/src/util/password.rs b/src/util/password.rs index fe17bbc..344d8e1 100644 --- a/src/util/password.rs +++ b/src/util/password.rs @@ -1,35 +1,101 @@ use argon2::password_hash::rand_core::OsRng; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use serde::{Deserialize, Serialize}; use crate::core::*; +use crate::util::validate::{FieldResultBuilder, ValidateField}; -pub fn hash(clear: &str) -> String { +#[derive(sqlx::Type, Serialize, Deserialize, Clone)] +#[sqlx(transparent)] +pub struct ClearPassword(String); + +#[derive(sqlx::Type, Serialize, Deserialize, Clone)] +#[sqlx(transparent)] +pub struct HashedPassword(String); + +pub fn hash(clear: &ClearPassword) -> HashedPassword { let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); - argon2 - .hash_password(clear.as_bytes(), &salt) - .unwrap() - .to_string() + HashedPassword( + argon2 + .hash_password(clear.0.as_bytes(), &salt) + .unwrap() + .to_string(), + ) } -pub fn verify(clear: &str, hash: &str) -> Result<()> { - let parsed_hash = PasswordHash::new(&hash).unwrap(); +pub fn verify(clear: &ClearPassword, hash: &HashedPassword) -> Result<()> { + let (clear, hash) = (clear.0.as_str(), hash.0.as_str()); + let parsed_hash = PasswordHash::new(hash).unwrap(); match Argon2::default().verify_password(clear.as_bytes(), &parsed_hash) { Ok(_) => Ok(()), Err(_) => Err(Error::BadCredentials), } } +impl ClearPassword { + pub fn len(&self) -> usize { + self.0.len() + } + + // these also match non ascii chars but whatever + + pub fn has_uppercase(&self) -> bool { + self.0.chars().any(|c| c.is_uppercase()) + } + + pub fn has_lowercase(&self) -> bool { + self.0.chars().any(|c| c.is_lowercase()) + } + + pub fn has_digit(&self) -> bool { + self.0.chars().any(|c| c.is_numeric()) + } + + pub fn has_special(&self) -> bool { + self.0.chars().any(|c| !c.is_ascii_alphanumeric()) + } +} + +impl From for ClearPassword { + fn from(val: String) -> ClearPassword { + ClearPassword(val) + } +} + +impl From for HashedPassword { + fn from(val: String) -> HashedPassword { + HashedPassword(val) + } +} + +impl From<&str> for HashedPassword { + fn from(val: &str) -> HashedPassword { + HashedPassword(String::from(val)) + } +} + +impl<'a> ValidateField<'a> for ClearPassword { + fn validate_field(builder: FieldResultBuilder<'a, Self>) -> FieldResultBuilder<'a, Self> { + builder + .check("Must be at least 8 characters long", |v| v.len() >= 8) + .check("Must contain an uppercase character", |v| v.has_uppercase()) + .check("Must contain a lowercase character", |v| v.has_lowercase()) + .check("Must contain a digit", |v| v.has_digit()) + .check("Must contain a special character", |v| v.has_special()) + } +} + #[cfg(test)] mod tests { use crate::util::password::{hash, verify}; #[test] fn validate_hashes() { - let h = hash("asdf"); - assert!(verify("asdf", h.as_str()).is_ok()); - assert!(verify("fdsa", h.as_str()).is_err()); - assert!(verify("asdf\0", h.as_str()).is_err()); + let h = hash(&String::from("asdf").into()); + assert!(verify(&String::from("asdf").into(), &h).is_ok()); + assert!(verify(&String::from("fdsa").into(), &h).is_err()); + assert!(verify(&String::from("asdf\0").into(), &h).is_err()); } }