arc-auth-db 0.7.2

Database identity and role provider plugin for Arc
Documentation
use arc_auth_core::{AuthError, Identity, IdentityStore};
use arc_web::{ArcAppBuilder, ArcPlugin, PluginSetupContext};
use argon2::{
    password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
    Argon2,
};
use async_trait::async_trait;
use diesel::{connection::SimpleConnection, prelude::*, sql_query};
use rand::rngs::OsRng;
use std::{io, sync::Arc};
use uuid::Uuid;

const IDENTITY_ROLES_MIGRATION: &str =
    include_str!("../migrations/90000000000000_identity_roles/up.sql");

#[derive(Clone)]
pub struct DbIdentityStore {
    database_url: String,
}
impl DbIdentityStore {
    pub fn new(database_url: impl Into<String>) -> Self {
        Self {
            database_url: database_url.into(),
        }
    }
    fn connect(&self) -> Result<SqliteConnection, AuthError> {
        SqliteConnection::establish(&self.database_url).map_err(|e| AuthError::Store(e.to_string()))
    }
}

#[derive(QueryableByName)]
struct UserRow {
    #[diesel(sql_type = diesel::sql_types::Text)]
    id: String,
    #[diesel(sql_type = diesel::sql_types::Text)]
    name: String,
    #[diesel(sql_type = diesel::sql_types::Text)]
    email: String,
    #[diesel(sql_type = diesel::sql_types::Text)]
    password_hash: String,
    #[diesel(sql_type = diesel::sql_types::Integer)]
    active: i32,
}
#[derive(QueryableByName)]
struct RoleRow {
    #[diesel(sql_type = diesel::sql_types::Text)]
    name: String,
}

fn now_us() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_micros() as i64
}
fn validate(name: &str, email: &str, password: Option<&str>) -> Result<String, AuthError> {
    if name.trim().is_empty() {
        return Err(AuthError::InvalidInput("name is required".into()));
    }
    let email = email.trim().to_ascii_lowercase();
    if !email
        .split_once('@')
        .is_some_and(|(l, r)| !l.is_empty() && r.contains('.'))
    {
        return Err(AuthError::InvalidInput("valid email is required".into()));
    }
    if password.is_some_and(|p| p.len() < 12) {
        return Err(AuthError::InvalidInput(
            "password must contain at least 12 characters".into(),
        ));
    }
    Ok(email)
}
fn hash(password: &str) -> Result<String, AuthError> {
    Argon2::default()
        .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
        .map(|h| h.to_string())
        .map_err(|e| AuthError::Store(e.to_string()))
}
fn roles(connection: &mut SqliteConnection, id: &str) -> Result<Vec<String>, AuthError> {
    sql_query("SELECT roles.name AS name FROM roles JOIN user_roles ON user_roles.role_id = roles.id WHERE user_roles.user_id = ? ORDER BY roles.name").bind::<diesel::sql_types::Text,_>(id).load::<RoleRow>(connection).map(|rows|rows.into_iter().map(|r|r.name).collect()).map_err(|e|AuthError::Store(e.to_string()))
}
fn identity(connection: &mut SqliteConnection, row: UserRow) -> Result<Identity, AuthError> {
    let assigned = roles(connection, &row.id)?;
    Ok(Identity {
        id: row.id,
        name: row.name,
        email: row.email,
        active: row.active != 0,
        roles: assigned,
    })
}
fn get_row(connection: &mut SqliteConnection, id: &str) -> Result<Option<UserRow>, AuthError> {
    sql_query("SELECT id,name,email,password_hash,active FROM users WHERE id = ?")
        .bind::<diesel::sql_types::Text, _>(id)
        .get_result::<UserRow>(connection)
        .optional()
        .map_err(|e| AuthError::Store(e.to_string()))
}

#[async_trait]
impl IdentityStore for DbIdentityStore {
    async fn authenticate(&self, email: &str, password: &str) -> Result<Identity, AuthError> {
        let mut c = self.connect()?;
        let row=sql_query("SELECT id,name,email,password_hash,active FROM users WHERE email = ? COLLATE NOCASE AND active = 1").bind::<diesel::sql_types::Text,_>(email.trim()).get_result::<UserRow>(&mut c).optional().map_err(|e|AuthError::Store(e.to_string()))?.ok_or(AuthError::InvalidCredentials)?;
        let parsed =
            PasswordHash::new(&row.password_hash).map_err(|_| AuthError::InvalidCredentials)?;
        Argon2::default()
            .verify_password(password.as_bytes(), &parsed)
            .map_err(|_| AuthError::InvalidCredentials)?;
        identity(&mut c, row)
    }
    async fn get(&self, id: &str) -> Result<Option<Identity>, AuthError> {
        let mut c = self.connect()?;
        get_row(&mut c, id)?
            .map(|row| identity(&mut c, row))
            .transpose()
    }
    async fn list(&self) -> Result<Vec<Identity>, AuthError> {
        let mut c = self.connect()?;
        let rows = sql_query("SELECT id,name,email,password_hash,active FROM users ORDER BY email")
            .load::<UserRow>(&mut c)
            .map_err(|e| AuthError::Store(e.to_string()))?;
        rows.into_iter().map(|row| identity(&mut c, row)).collect()
    }
    async fn has_users(&self) -> Result<bool, AuthError> {
        let mut c = self.connect()?;
        #[derive(QueryableByName)]
        struct Count {
            #[diesel(sql_type=diesel::sql_types::BigInt)]
            count: i64,
        }
        Ok(sql_query("SELECT COUNT(*) AS count FROM users")
            .get_result::<Count>(&mut c)
            .map_err(|e| AuthError::Store(e.to_string()))?
            .count
            > 0)
    }
    async fn create_user(
        &self,
        name: &str,
        email: &str,
        password: &str,
        assigned: &[String],
    ) -> Result<Identity, AuthError> {
        let email = validate(name, email, Some(password))?;
        let mut c = self.connect()?;
        let id = Uuid::new_v4().to_string();
        let password_hash = hash(password)?;
        let now = now_us();
        c.transaction::<_,diesel::result::Error,_>(|c|{sql_query("INSERT INTO users (id,name,email,password_hash,active,created_at,updated_at) VALUES (?,?,?,?,1,?,?)").bind::<diesel::sql_types::Text,_>(&id).bind::<diesel::sql_types::Text,_>(name.trim()).bind::<diesel::sql_types::Text,_>(&email).bind::<diesel::sql_types::Text,_>(&password_hash).bind::<diesel::sql_types::BigInt,_>(now).bind::<diesel::sql_types::BigInt,_>(now).execute(c)?;for role in assigned{sql_query("INSERT INTO user_roles (user_id,role_id) SELECT ?,id FROM roles WHERE name = ?").bind::<diesel::sql_types::Text,_>(&id).bind::<diesel::sql_types::Text,_>(role).execute(c)?;}Ok(())}).map_err(|e|if matches!(e,diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation,_)){AuthError::DuplicateEmail}else{AuthError::Store(e.to_string())})?;
        self.get(&id).await?.ok_or(AuthError::NotFound)
    }
    async fn update_profile(
        &self,
        id: &str,
        name: &str,
        email: &str,
    ) -> Result<Identity, AuthError> {
        let email = validate(name, email, None)?;
        let mut c = self.connect()?;
        sql_query("UPDATE users SET name=?,email=?,updated_at=? WHERE id=?")
            .bind::<diesel::sql_types::Text, _>(name.trim())
            .bind::<diesel::sql_types::Text, _>(&email)
            .bind::<diesel::sql_types::BigInt, _>(now_us())
            .bind::<diesel::sql_types::Text, _>(id)
            .execute(&mut c)
            .map_err(|e| AuthError::Store(e.to_string()))?;
        self.get(id).await?.ok_or(AuthError::NotFound)
    }
    async fn change_password(&self, id: &str, password: &str) -> Result<(), AuthError> {
        validate("valid", "v@e.co", Some(password))?;
        let mut c = self.connect()?;
        sql_query("UPDATE users SET password_hash=?,updated_at=? WHERE id=?")
            .bind::<diesel::sql_types::Text, _>(hash(password)?)
            .bind::<diesel::sql_types::BigInt, _>(now_us())
            .bind::<diesel::sql_types::Text, _>(id)
            .execute(&mut c)
            .map_err(|e| AuthError::Store(e.to_string()))?;
        Ok(())
    }
    async fn set_roles(&self, id: &str, assigned: &[String]) -> Result<Identity, AuthError> {
        let mut c = self.connect()?;
        c.transaction::<_, diesel::result::Error, _>(|c| {
            sql_query("DELETE FROM user_roles WHERE user_id=?")
                .bind::<diesel::sql_types::Text, _>(id)
                .execute(c)?;
            for role in assigned {
                sql_query(
                    "INSERT INTO user_roles (user_id,role_id) SELECT ?,id FROM roles WHERE name=?",
                )
                .bind::<diesel::sql_types::Text, _>(id)
                .bind::<diesel::sql_types::Text, _>(role)
                .execute(c)?;
            }
            Ok(())
        })
        .map_err(|e| AuthError::Store(e.to_string()))?;
        self.get(id).await?.ok_or(AuthError::NotFound)
    }
}

pub struct DbIdentityPlugin {
    store: Arc<dyn IdentityStore>,
}
impl DbIdentityPlugin {
    pub fn new(database_url: impl Into<String>) -> Self {
        Self {
            store: Arc::new(DbIdentityStore::new(database_url)),
        }
    }
}
#[async_trait]
impl ArcPlugin for DbIdentityPlugin {
    fn name(&self) -> &'static str {
        "auth-db"
    }
    fn register(&self, builder: ArcAppBuilder) -> ArcAppBuilder {
        builder.register_data(self.store.clone())
    }
    async fn setup(&self, context: &PluginSetupContext<'_>) -> io::Result<()> {
        let mut c = SqliteConnection::establish(context.database_url).map_err(io::Error::other)?;
        c.batch_execute(IDENTITY_ROLES_MIGRATION)
            .map_err(io::Error::other)?;
        if !self.store.has_users().await.map_err(io::Error::other)? {
            let name = std::env::var("ARC_SETUP_ADMIN_NAME").map_err(|_| io::Error::other("no users exist; set ARC_SETUP_ADMIN_NAME, ARC_SETUP_ADMIN_EMAIL, and ARC_SETUP_ADMIN_PASSWORD for setup"))?;
            let email = std::env::var("ARC_SETUP_ADMIN_EMAIL").map_err(|_| {
                io::Error::other("ARC_SETUP_ADMIN_EMAIL is required for first-admin setup")
            })?;
            let password = std::env::var("ARC_SETUP_ADMIN_PASSWORD").map_err(|_| {
                io::Error::other("ARC_SETUP_ADMIN_PASSWORD is required for first-admin setup")
            })?;
            self.store
                .create_user(&name, &email, &password, &["admin".into()])
                .await
                .map_err(io::Error::other)?;
        }
        Ok(())
    }
}