slave 0.2.0

Laravel-inspired CLI for Rust web apps: scaffold Axum projects with built-in auth, users, roles and Turso migrations
//! Request authentication: the [`AuthUser`] and [`AdminUser`] extractors.
//!
//! This lives in `base` rather than in the `auth` module because every module
//! guards its routes with it; keeping it here means modules never depend on
//! each other just to check who is calling.

use axum::extract::FromRequestParts;
use axum::http::header::AUTHORIZATION;
use axum::http::request::Parts;

use super::error::AppError;
use super::state::AppState;

/// Role with full access, granted to the first registered user.
pub const ADMIN_ROLE: &str = "admin";
/// Role granted to every other newly registered user.
pub const USER_ROLE: &str = "user";

/// The user making the request, authenticated by `Authorization: Bearer <token>`.
///
/// Add it as a handler argument to require a valid, unexpired token.
#[derive(Debug, Clone)]
pub struct AuthUser {
    pub id: i64,
    pub name: String,
    pub email: String,
    pub roles: Vec<String>,
    token_hash: String,
}

impl AuthUser {
    pub fn has_role(&self, role: &str) -> bool {
        self.roles.iter().any(|r| r == role)
    }

    pub fn is_admin(&self) -> bool {
        self.has_role(ADMIN_ROLE)
    }

    /// Stored hash of the token this request was authenticated with.
    pub fn token_hash(&self) -> &str {
        &self.token_hash
    }
}

impl FromRequestParts<AppState> for AuthUser {
    type Rejection = AppError;

    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, AppError> {
        let token = bearer_token(parts).ok_or(AppError::Unauthorized)?;
        let token_hash = slave_framework::auth::hash_token(token);
        let conn = state.database.connect().await?;

        let mut rows = conn
            .query(
                "SELECT u.id, u.name, u.email FROM auth_tokens t
                 JOIN users u ON u.id = t.user_id
                 WHERE t.token_hash = ?1 AND t.expires_at > CURRENT_TIMESTAMP",
                (token_hash.as_str(),),
            )
            .await?;
        let Some(row) = rows.next().await? else {
            return Err(AppError::Unauthorized);
        };
        let (id, name, email) = (row.get(0)?, row.get(1)?, row.get(2)?);
        drop(rows);

        let mut rows = conn
            .query(
                "SELECT r.name FROM roles r
                 JOIN user_roles ur ON ur.role_id = r.id
                 WHERE ur.user_id = ?1 ORDER BY r.name",
                (id,),
            )
            .await?;
        let mut roles = Vec::new();
        while let Some(row) = rows.next().await? {
            roles.push(row.get(0)?);
        }

        Ok(Self {
            id,
            name,
            email,
            roles,
            token_hash,
        })
    }
}

/// An [`AuthUser`] that has the `admin` role; other users get 403 Forbidden.
#[derive(Debug, Clone)]
pub struct AdminUser(pub AuthUser);

impl FromRequestParts<AppState> for AdminUser {
    type Rejection = AppError;

    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, AppError> {
        let user = AuthUser::from_request_parts(parts, state).await?;
        if user.is_admin() {
            Ok(Self(user))
        } else {
            Err(AppError::Forbidden)
        }
    }
}

fn bearer_token(parts: &Parts) -> Option<&str> {
    let value = parts.headers.get(AUTHORIZATION)?.to_str().ok()?;
    let (scheme, token) = value.split_once(' ')?;
    let token = token.trim();
    (scheme.eq_ignore_ascii_case("bearer") && !token.is_empty()).then_some(token)
}