use std::collections::HashMap;
use std::sync::Arc;
use rainier_database::{Model, Repository};
use rainier_orm::sea_query::Value;
use rainier_support::Result;
use crate::abilities::Abilities;
use crate::hashing::Hasher;
pub trait Authenticatable: Send + Sync + 'static {
fn auth_identifier(&self) -> String;
fn auth_password_hash(&self) -> Option<&str>;
fn auth_username_column() -> &'static str
where
Self: Sized,
{
"email"
}
fn auth_token_column() -> &'static str
where
Self: Sized,
{
"api_token"
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Credentials {
values: HashMap<String, String>,
}
impl Credentials {
pub fn new() -> Self {
Self::default()
}
pub fn password(username: impl Into<String>, password: impl Into<String>) -> Self {
Self::new().with("email", username).with("password", password)
}
pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.values.insert(key.into(), value.into());
self
}
pub fn get(&self, key: &str) -> Option<&str> {
self.values.get(key).map(String::as_str)
}
pub fn password_value(&self) -> Option<&str> {
self.get("password")
}
pub fn lookup_fields(&self) -> Vec<(&str, &str)> {
self.values
.iter()
.filter(|(key, _)| key.as_str() != "password")
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect()
}
pub fn is_empty(&self) -> bool {
self.lookup_fields().is_empty()
}
}
#[async_trait::async_trait]
pub trait UserProvider<U: Authenticatable>: Send + Sync + 'static {
async fn retrieve_by_id(&self, id: &str) -> Result<Option<U>>;
async fn retrieve_by_credentials(&self, credentials: &Credentials) -> Result<Option<U>>;
async fn validate_credentials(&self, user: &U, credentials: &Credentials) -> Result<bool>;
async fn retrieve_by_token(&self, token: &str) -> Result<Option<U>> {
let _ = token;
Ok(None)
}
async fn retrieve_abilities_by_token(&self, token: &str) -> Result<Abilities> {
let _ = token;
Ok(Abilities::everything())
}
}
pub struct RepositoryUserProvider<U: Model + Authenticatable> {
users: Arc<dyn Repository<U>>,
hasher: Arc<dyn Hasher>,
}
impl<U: Model + Authenticatable> RepositoryUserProvider<U> {
pub fn new(users: Arc<dyn Repository<U>>, hasher: Arc<dyn Hasher>) -> Self {
Self { users, hasher }
}
pub fn repository(&self) -> &Arc<dyn Repository<U>> {
&self.users
}
}
#[async_trait::async_trait]
impl<U> UserProvider<U> for RepositoryUserProvider<U>
where
U: Model + Authenticatable,
{
async fn retrieve_by_id(&self, id: &str) -> Result<Option<U>> {
let key: Value = match id.parse::<i64>() {
Ok(numeric) => numeric.into(),
Err(_) => id.into(),
};
self.users.find(key).await
}
async fn retrieve_by_credentials(&self, credentials: &Credentials) -> Result<Option<U>> {
let fields = credentials.lookup_fields();
if fields.is_empty() {
return Ok(None);
}
let mut criteria = rainier_database::Criteria::new();
for (column, value) in fields {
criteria = criteria.where_eq(column, value);
}
self.users.first_matching(criteria).await
}
async fn validate_credentials(&self, user: &U, credentials: &Credentials) -> Result<bool> {
let (Some(submitted), Some(stored)) =
(credentials.password_value(), user.auth_password_hash())
else {
return Ok(false);
};
Ok(self.hasher.verify(submitted, stored))
}
async fn retrieve_by_token(&self, token: &str) -> Result<Option<U>> {
if token.is_empty() {
return Ok(None);
}
self.users.first_by(U::auth_token_column(), token.into()).await
}
}
impl<U: Model + Authenticatable> std::fmt::Debug for RepositoryUserProvider<U> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RepositoryUserProvider").field("model", &U::model_name()).finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hashing::Argon2Hasher;
use rainier_database::testing::{fake_database, MemoryConnection};
use rainier_database::{EntityRepository, OwnedRow};
use rainier_orm::Dialect;
#[derive(rainier_orm::Entity, Clone, Debug, PartialEq)]
#[orm(table = "users")]
struct User {
#[orm(pk, auto_increment)]
id: u64,
#[orm(unique)]
email: String,
password: String,
api_token: Option<String>,
}
impl Model for User {}
impl Authenticatable for User {
fn auth_identifier(&self) -> String {
self.id.to_string()
}
fn auth_password_hash(&self) -> Option<&str> {
Some(&self.password)
}
}
fn hasher() -> Arc<dyn Hasher> {
Arc::new(Argon2Hasher::insecure_for_tests())
}
fn user_row(hash: &str) -> OwnedRow {
OwnedRow::new()
.with("id", 1_u64)
.with("email", "ada@example.com")
.with("password", hash)
.with("api_token", "tok_123")
}
fn provider(
connection: MemoryConnection,
) -> (RepositoryUserProvider<User>, Arc<MemoryConnection>) {
let (db, handle) = fake_database(connection);
let users: Arc<dyn Repository<User>> = Arc::new(EntityRepository::<User>::new(db));
(RepositoryUserProvider::new(users, hasher()), handle)
}
#[test]
fn credentials_exclude_the_password_from_lookups() {
let credentials = Credentials::password("ada@example.com", "secret");
assert_eq!(credentials.password_value(), Some("secret"));
assert_eq!(credentials.lookup_fields(), vec![("email", "ada@example.com")]);
assert!(!credentials.is_empty());
}
#[test]
fn credentials_can_carry_extra_lookup_fields() {
let credentials = Credentials::password("ada@example.com", "secret").with("tenant_id", "7");
let mut fields = credentials.lookup_fields();
fields.sort();
assert_eq!(fields, vec![("email", "ada@example.com"), ("tenant_id", "7")]);
}
#[test]
fn credentials_with_only_a_password_have_nothing_to_look_up_by() {
let credentials = Credentials::new().with("password", "secret");
assert!(credentials.is_empty());
}
#[tokio::test]
async fn retrieves_a_user_by_credentials() {
let (provider, connection) =
provider(MemoryConnection::new(Dialect::Sqlite).returning([user_row("$argon2id$x")]));
let found = provider
.retrieve_by_credentials(&Credentials::password("ada@example.com", "secret"))
.await
.unwrap();
assert_eq!(found.unwrap().email, "ada@example.com");
let sql = connection.last_statement().unwrap();
let (_, filters) = sql.split_once("WHERE").expect("the lookup should be filtered");
assert!(filters.contains("email"), "{sql}");
assert!(!filters.contains("password"), "the password must never be queried: {sql}");
assert_eq!(connection.bindings()[0][0], "ada@example.com".into());
}
#[tokio::test]
async fn empty_credentials_never_match_a_user() {
let (provider, connection) =
provider(MemoryConnection::new(Dialect::Sqlite).returning([user_row("$argon2id$x")]));
assert!(provider.retrieve_by_credentials(&Credentials::new()).await.unwrap().is_none());
assert_eq!(connection.statement_count(), 0, "and it should not even query");
}
#[tokio::test]
async fn validates_a_password_against_the_stored_hash() {
let hasher = Argon2Hasher::insecure_for_tests();
let stored = hasher.hash("secret").unwrap();
let (provider, _) = provider(MemoryConnection::new(Dialect::Sqlite));
let user =
User { id: 1, email: "ada@example.com".into(), password: stored, api_token: None };
assert!(provider
.validate_credentials(&user, &Credentials::password("ada@example.com", "secret"))
.await
.unwrap());
assert!(!provider
.validate_credentials(&user, &Credentials::password("ada@example.com", "wrong"))
.await
.unwrap());
}
#[tokio::test]
async fn a_missing_password_never_validates() {
let (provider, _) = provider(MemoryConnection::new(Dialect::Sqlite));
let user = User {
id: 1,
email: "ada@example.com".into(),
password: String::new(),
api_token: None,
};
let no_password = Credentials::new().with("email", "ada@example.com");
assert!(!provider.validate_credentials(&user, &no_password).await.unwrap());
}
#[tokio::test]
async fn retrieves_by_id_binding_a_numeric_key_as_a_number() {
let (provider, connection) =
provider(MemoryConnection::new(Dialect::Sqlite).returning([user_row("h")]));
assert!(provider.retrieve_by_id("1").await.unwrap().is_some());
assert_eq!(connection.bindings()[0][0], 1_i64.into(), "not the string \"1\"");
}
#[tokio::test]
async fn retrieves_by_token() {
let (provider, connection) =
provider(MemoryConnection::new(Dialect::Sqlite).returning([user_row("h")]));
assert!(provider.retrieve_by_token("tok_123").await.unwrap().is_some());
assert!(connection.last_statement().unwrap().contains("api_token"));
}
#[tokio::test]
async fn an_empty_token_never_matches() {
let (provider, connection) =
provider(MemoryConnection::new(Dialect::Sqlite).returning([user_row("h")]));
assert!(provider.retrieve_by_token("").await.unwrap().is_none());
assert_eq!(connection.statement_count(), 0);
}
}