//! 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");
}
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
3.8 KiB · text
History
7a00c02