Skip to content

Commit

Permalink
Initial work on local password provider
Browse files Browse the repository at this point in the history
  • Loading branch information
TobiasDeBruijn committed Dec 25, 2024
1 parent 782623f commit 88c5a22
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 1 deletion.
43 changes: 43 additions & 0 deletions server/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions server/database/migrations/2_user_credentials.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE user_credentials (
user_id VARCHAR(64) NOT NULL,
password_hash VARCHAR(72) NOT NULL,
PRIMARY KEY (user_id)
);
42 changes: 42 additions & 0 deletions server/database/src/user.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::driver::Database;
use sqlx::{FromRow, Result};
use std::fmt::Debug;
use tracing::instrument;

#[derive(Debug, FromRow)]
Expand Down Expand Up @@ -43,6 +44,14 @@ impl User {
.await?)
}

#[instrument]
pub async fn get_by_email(driver: &Database, email: &str) -> Result<Option<Self>> {
Ok(sqlx::query_as("SELECT * FROM users WHERE email = ?")
.bind(email)
.fetch_optional(&**driver)
.await?)
}

#[instrument]
pub async fn list(driver: &Database) -> Result<Vec<Self>> {
Ok(sqlx::query_as("SELECT * FROM users")
Expand Down Expand Up @@ -81,4 +90,37 @@ impl User {

Ok(())
}

#[instrument(skip(password))]
pub async fn set_password_hash<P: AsRef<str> + Debug>(
&self,
driver: &Database,
password: P,
) -> Result<()> {
if self.get_password_hash(&driver).await?.is_some() {
sqlx::query("UPDATE user_credentials SET password_hash = ? WHERE user_id = ?")
.bind(password.as_ref())
.bind(&self.user_id)
.execute(&**driver)
.await?;
} else {
sqlx::query("INSERT INTO user_credentials (user_id, password_hash) VALUES (?, ?)")
.bind(password.as_ref())
.bind(&self.user_id)
.execute(&**driver)
.await?;
}

Ok(())
}

#[instrument]
pub async fn get_password_hash(&self, driver: &Database) -> Result<Option<String>> {
Ok(
sqlx::query_scalar("SELECT password_hash FROM user_credentials WHERE user_id = ?")
.bind(&self.user_id)
.fetch_optional(&**driver)
.await?,
)
}
}
3 changes: 2 additions & 1 deletion server/wilford/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ tracing-actix-web = "0.7.9"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
pem = "3.0.4"
tracing-error = "0.2.0"
rsa = "0.9.6"
rsa = "0.9.6"
bcrypt = "0.16.0"
102 changes: 102 additions & 0 deletions server/wilford/src/authorization/local_provider.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
use crate::authorization::{AuthorizationError, AuthorizationProvider, UserInformation};
use bcrypt::{hash_with_result, verify, Version};
use database::driver::Database;
use database::user::User;
use tap::TapOptional;
use thiserror::Error;
use tracing::{instrument, warn};

/// Credential provider utilizing the local database
pub struct LocalCredentialsProvider<'a> {
driver: &'a Database,
}

#[derive(Debug, Error)]
pub enum LocalCredentialsProviderError {
#[error(transparent)]
Database(#[from] database::driver::Error),
#[error(transparent)]
Hashing(#[from] bcrypt::BcryptError),
}

impl<'a> LocalCredentialsProvider<'a> {
/// Create a new local credentials provider.
pub fn new(driver: &'a Database) -> Self {
Self { driver }
}
}

impl<'a> AuthorizationProvider for LocalCredentialsProvider<'a> {
type Error = LocalCredentialsProviderError;

#[instrument(skip(self, password))]
async fn validate_credentials(
&self,
username: &str,
password: &str,
_: Option<&str>,
) -> Result<UserInformation, AuthorizationError<Self::Error>> {
// Fetch the user
let user = User::get_by_email(self.driver, username)
.await
.map_err(|e| Self::Error::from(e))?
.ok_or(AuthorizationError::InvalidCredentials)?;

// Fetch the hash
let stored_hash = user
.get_password_hash(self.driver)
.await
.map_err(|e| Self::Error::from(e))?
.tap_none(|| {
warn!("No credentials stored for user with email {username}, but user does exist")
})
.ok_or(AuthorizationError::InvalidCredentials)?;

// Use bcrypt to verify the hash is correct
let ok = verify(password, &stored_hash).map_err(|e| Self::Error::from(e))?;

if ok {
Ok(UserInformation {
id: user.user_id,
email: user.email,
name: user.name,
is_admin: user.is_admin,
})
} else {
Err(AuthorizationError::InvalidCredentials)
}
}

fn supports_password_change(&self) -> bool {
true
}

#[instrument(skip(self, new_password))]
async fn set_password(
&self,
user_id: &str,
new_password: &str,
) -> Result<(), AuthorizationError<Self::Error>> {
// Fetch the user
let user = User::get_by_id(self.driver, user_id)
.await
.map_err(|e| Self::Error::from(e))?
.ok_or(AuthorizationError::InvalidCredentials)?;

// Generate the new password hash.
// We are explicit with the format wanted, thus we use `hash_with_result`, rather than
// `hash`. Although at the moment this block is identical to bcrypt's `hash` function,
// this could change in the future. That would result in some rather annoying
// differences in the way we hash.
let new_password = hash_with_result(new_password, bcrypt::DEFAULT_COST)
.map_err(|e| Self::Error::from(e))?
.format_for_version(Version::TwoB);

// Finally, update the database
user.set_password_hash(self.driver, new_password)
.await
.map_err(|e| Self::Error::from(e))?;

Ok(())
}
}
68 changes: 68 additions & 0 deletions server/wilford/src/authorization/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
mod local_provider;

use std::error::Error;
use std::fmt::Debug;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AuthorizationError<E: Error + Debug> {
#[error("Invalid credentials provided")]
InvalidCredentials,
#[error("Two-factor authentication required")]
TotpNeeded,
#[error("Unsupported operation")]
UnsupportedOperation,
#[error(transparent)]
Other(#[from] E),
}

/// Information about the authorized user
#[derive(Debug)]
pub struct UserInformation {
/// The ID of the user. This ID should be used everywhere else,
/// the authorization provider is the ultimate source of truth for which users
/// can use the system.
pub id: String,
/// Whether the user is a global administrator.
/// If true, all scope checks should be ignored.
pub is_admin: bool,
/// The name of the user.
pub name: String,
/// The email address of the user.
pub email: String,
}

pub trait AuthorizationProvider {
/// Error that can be returned by the authorization provider
type Error: std::error::Error;

/// Validate the given credentials.
/// If the credentials are correct the associated user will be returned
///
/// # Errors
/// - If the credentials are invalid
/// - If an underlying operaiton fails
/// - If two-factor authentication is required
async fn validate_credentials(
&self,
username: &str,
password: &str,
totp_code: Option<&str>,
) -> Result<UserInformation, AuthorizationError<Self::Error>>;

/// Whether the authorization provider supports changing the user's password.
fn supports_password_change(&self) -> bool;

/// Change the password of the user.
/// Implementations do not have to support this operation, check this with [Self::supports_password_change].
///
/// # Errors
/// - If the operation is not supported
/// - If an underlying operation fails
/// - If the user does not exist
async fn set_password(
&self,
user_id: &str,
new_password: &str,
) -> Result<(), AuthorizationError<Self::Error>>;
}
1 change: 1 addition & 0 deletions server/wilford/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;

mod authorization;
mod config;
mod espo;
mod response_types;
Expand Down

0 comments on commit 88c5a22

Please sign in to comment.