use axum::extract::FromRef;
use slave_framework::auth;
use slave_framework::database::Database;
use slave_framework::turso::Connection;
use slave_framework::turso::transaction::TransactionBehavior;
use crate::base::auth::{ADMIN_ROLE, USER_ROLE};
use crate::base::error::{AppError, AppResult};
use crate::base::state::AppState;
use crate::modules::auth::dto::auth::{LoginRequest, RegisterRequest};
use crate::modules::auth::repositories::token_repository;
use crate::modules::role::role_repository;
use crate::modules::user::{User, user_repository};
const MIN_PASSWORD_LEN: usize = 8;
const MAX_PASSWORD_LEN: usize = 256;
/// A freshly issued bearer token and the user it belongs to.
#[derive(Debug)]
pub struct Session {
pub user: User,
pub roles: Vec<String>,
pub token: String,
pub expires_at: String,
}
/// Registration, login and logout.
#[derive(Debug, Clone)]
pub struct AuthService {
database: Database,
token_ttl_days: u32,
}
impl FromRef<AppState> for AuthService {
fn from_ref(state: &AppState) -> Self {
Self {
database: state.database.clone(),
token_ttl_days: state.config.token_ttl_days,
}
}
}
impl AuthService {
/// Creates an account and signs it in. The first account becomes admin.
pub async fn register(&self, input: RegisterRequest) -> AppResult<Session> {
let name = input.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 100 {
return Err(AppError::Validation(
"name must be 1 to 100 characters".into(),
));
}
let email = normalize_email(&input.email)?;
validate_password(&input.password)?;
let password_hash = hash_password(input.password).await?;
let mut conn = self.database.connect().await?;
// IMMEDIATE takes the write lock up front, so two concurrent first
// registrations cannot both see an empty table and both become admin.
let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.await?;
if user_repository::find_by_email(&tx, &email).await?.is_some() {
return Err(AppError::Conflict("email is already registered".into()));
}
let is_first = user_repository::count(&tx).await? == 0;
let user = user_repository::insert(&tx, &name, &email, &password_hash).await?;
let role_name = if is_first { ADMIN_ROLE } else { USER_ROLE };
let role = role_repository::find_by_name(&tx, role_name)
.await?
.ok_or_else(|| {
AppError::Internal(format!(
"role `{role_name}` is missing; run `slave migrate`"
))
})?;
role_repository::assign(&tx, user.id, role.id).await?;
let session = self.issue(&tx, user).await?;
tx.commit().await?;
Ok(session)
}
/// Exchanges an email and password for a new bearer token.
pub async fn login(&self, input: LoginRequest) -> AppResult<Session> {
let email = input.email.trim().to_lowercase();
let conn = self.database.connect().await?;
let user = user_repository::find_by_email(&conn, &email).await?;
let stored = user.as_ref().map(|u| u.password_hash.clone());
let password = input.password;
let valid = tokio::task::spawn_blocking(move || {
auth::verify_password_or_dummy(&password, stored.as_deref())
})
.await
.map_err(|err| AppError::Internal(format!("password check failed: {err}")))?;
match user {
Some(user) if valid => {
token_repository::delete_expired_for_user(&conn, user.id).await?;
self.issue(&conn, user).await
}
_ => Err(AppError::InvalidCredentials),
}
}
/// Revokes the token identified by `token_hash`.
pub async fn logout(&self, token_hash: &str) -> AppResult<()> {
let conn = self.database.connect().await?;
token_repository::delete(&conn, token_hash).await?;
Ok(())
}
async fn issue(&self, conn: &Connection, user: User) -> AppResult<Session> {
let token = auth::generate_token()?;
let expires_at = token_repository::insert(
conn,
user.id,
&auth::hash_token(&token),
self.token_ttl_days,
)
.await?;
let roles = role_repository::for_user(conn, user.id)
.await?
.into_iter()
.map(|role| role.name)
.collect();
Ok(Session {
user,
roles,
token,
expires_at,
})
}
}
/// Argon2 is deliberately slow, so hashing runs off the async worker threads.
async fn hash_password(password: String) -> AppResult<String> {
tokio::task::spawn_blocking(move || auth::hash_password(&password))
.await
.map_err(|err| AppError::Internal(format!("password hashing failed: {err}")))?
.map_err(AppError::from)
}
fn normalize_email(email: &str) -> AppResult<String> {
let email = email.trim().to_lowercase();
let valid = email.len() <= 254
&& email.split_once('@').is_some_and(|(local, domain)| {
!local.is_empty() && domain.contains('.') && !domain.starts_with('.')
});
if valid {
Ok(email)
} else {
Err(AppError::Validation("email must be a valid address".into()))
}
}
fn validate_password(password: &str) -> AppResult<()> {
let len = password.chars().count();
if (MIN_PASSWORD_LEN..=MAX_PASSWORD_LEN).contains(&len) {
Ok(())
} else {
Err(AppError::Validation(format!(
"password must be {MIN_PASSWORD_LEN} to {MAX_PASSWORD_LEN} characters"
)))
}
}