3.8 KiB · text History 02765fe
//! API tokens for the Android app and scripts: `Authorization: Bearer ff_…`.
//!
//! Tokens are random (`ff_` + 64 hex characters, from two v4 UUIDs) and only
//! their SHA-256 is stored, so the database can't hand out working tokens.
//! The full token is shown exactly once, when it's minted.
use anyhow::{Context, Result};
use chrono::Utc;
use sea_orm::*;
use sha2::{Digest, Sha256};
use uuid::Uuid;

use crate::entities::{api_tokens, users};

const PREFIX: &str = "ff_";

pub fn generate() -> String {
    format!("{PREFIX}{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}

pub fn hash(token: &str) -> String {
    Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
}

/// Mint a token for `user_id`. Returns the stored row and the token itself,
/// which exists nowhere else afterwards.
pub async fn create(
    db: &DatabaseConnection,
    user_id: Uuid,
    label: Option<String>,
    expires_in_days: Option<i64>,
) -> Result<(api_tokens::Model, String)> {
    let token = generate();
    let row = api_tokens::ActiveModel {
        id: Set(Uuid::now_v7()),
        user_id: Set(user_id),
        token_hash: Set(hash(&token)),
        prefix: Set(token[..PREFIX.len() + 6].to_string()),
        label: Set(label),
        last_used_at: Set(None),
        expires_at: Set(expires_in_days.map(|d| (Utc::now() + chrono::Duration::days(d)).into())),
        created_at: Set(Utc::now().into()),
    }
    .insert(db)
    .await
    .context("insert api token")?;
    Ok((row, token))
}

/// The active user a token belongs to, if it's known and unexpired. Bumps
/// `last_used_at`, best effort.
pub async fn validate(db: &DatabaseConnection, token: &str) -> Result<Option<users::Model>> {
    if !token.starts_with(PREFIX) {
        return Ok(None);
    }
    let Some(row) = api_tokens::Entity::find()
        .filter(api_tokens::Column::TokenHash.eq(hash(token)))
        .one(db)
        .await
        .context("query api token")?
    else {
        return Ok(None);
    };
    if row.expires_at.is_some_and(|e| e < Utc::now()) {
        return Ok(None);
    }
    let user_id = row.user_id;
    let mut seen: api_tokens::ActiveModel = row.into();
    seen.last_used_at = Set(Some(Utc::now().into()));
    let _ = seen.update(db).await;
    users::Entity::find_by_id(user_id)
        .filter(users::Column::IsActive.eq(true))
        .one(db)
        .await
        .context("query token user")
}

pub async fn list(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<api_tokens::Model>> {
    api_tokens::Entity::find()
        .filter(api_tokens::Column::UserId.eq(user_id))
        .order_by_desc(api_tokens::Column::CreatedAt)
        .all(db)
        .await
        .context("list api tokens")
}

/// Revoke one of `user_id`'s tokens. Returns whether it existed.
pub async fn revoke(db: &DatabaseConnection, user_id: Uuid, id: Uuid) -> Result<bool> {
    Ok(api_tokens::Entity::delete_many()
        .filter(api_tokens::Column::Id.eq(id))
        .filter(api_tokens::Column::UserId.eq(user_id))
        .exec(db)
        .await
        .context("revoke api token")?
        .rows_affected
        > 0)
}

/// Revoke the token presented on this request (the app's "sign out").
pub async fn revoke_token(db: &DatabaseConnection, token: &str) -> Result<()> {
    api_tokens::Entity::delete_many()
        .filter(api_tokens::Column::TokenHash.eq(hash(token)))
        .exec(db)
        .await
        .context("revoke api token")?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn tokens_are_prefixed_long_and_distinct() {
        let a = generate();
        assert!(a.starts_with("ff_") && a.len() == 3 + 64);
        assert_ne!(a, generate());
    }

    #[test]
    fn hash_is_stable_hex_sha256() {
        assert_eq!(hash("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
    }
}