use argon2::{ password_hash::{PasswordHasher, SaltString}, Argon2, }; use chrono::{NaiveDateTime, Utc}; use diesel::prelude::*; use rand::rngs::OsRng; use rand::RngCore; use uuid::Uuid; use crate::{ auth::capability_sets::{ ensure_capability_set, load_capabilities_for_set, normalize_capabilities, }, error::AppError, models::{ApiCapability, ApiToken, NewApiToken}, schema::api_tokens, state::PgPooledConnection, tenants::{apply_api_token_prefix, clear_api_token_prefix}, }; use crate::schema::api_tokens::dsl as api_tokens_dsl; const TOKEN_PREFIX_LENGTH: usize = 12; const TOKEN_SECRET_LENGTH: usize = 32; /// Represents a newly issued API token and the raw secret that was generated for it. pub struct IssuedApiToken { pub token: String, pub record: ApiToken, } /// Creates a new API token for the supplied user/tenant combination. pub fn create_api_token( conn: &mut PgPooledConnection, user_id: Uuid, tenant_id: Uuid, label: Option, expires_at: Option, capabilities: Vec, ) -> Result { let capabilities = normalize_capabilities(capabilities)?; let capability_set = ensure_capability_set(conn, tenant_id, &capabilities)?; let raw_secret = generate_secret()?; let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); let token_hash = hash_secret(&raw_secret)?; let new_token = NewApiToken { id: Uuid::new_v4(), user_id, tenant_id, token_prefix, token_hash, label, expires_at, capability_set_id: capability_set.id, }; let record = diesel::insert_into(api_tokens::table) .values(&new_token) .get_result::(conn)?; Ok(IssuedApiToken { token: raw_secret, record, }) } /// Lists API tokens belonging to a user within an optional tenant scope. pub fn list_api_tokens( conn: &mut PgPooledConnection, user_id: Uuid, tenant_id: Option, ) -> Result, AppError> { let mut query = api_tokens::table .filter(api_tokens::user_id.eq(user_id)) .into_boxed(); if let Some(tenant_id) = tenant_id { query = query.filter(api_tokens::tenant_id.eq(tenant_id)); } let tokens = query .order(api_tokens::created_at.asc()) .load::(conn)?; Ok(tokens) } /// Regenerates the secret value for an API token. pub fn regenerate_api_token( conn: &mut PgPooledConnection, token_id: Uuid, user_id: Uuid, tenant_id: Option, ) -> Result { let record = find_user_token(conn, token_id, user_id, tenant_id)?; if record.revoked_at.is_some() { return Err(AppError::bad_request( "cannot regenerate a revoked API token", )); } let raw_secret = generate_secret()?; let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); let token_hash = hash_secret(&raw_secret)?; let updated = diesel::update(api_tokens::table.find(record.id)) .set(( api_tokens::token_prefix.eq(&token_prefix), api_tokens::token_hash.eq(&token_hash), api_tokens::last_used_at.eq::>(None), )) .get_result::(conn)?; Ok(IssuedApiToken { token: raw_secret, record: updated, }) } /// Updates the set of capabilities associated with an API token. pub fn update_api_token_capabilities( conn: &mut PgPooledConnection, token_id: Uuid, user_id: Uuid, tenant_id: Option, capabilities: Vec, ) -> Result { let capabilities = normalize_capabilities(capabilities)?; let token = find_user_token(conn, token_id, user_id, tenant_id)?; if token.revoked_at.is_some() { return Err(AppError::bad_request( "cannot modify capabilities of a revoked API token", )); } let capability_set = ensure_capability_set(conn, token.tenant_id, &capabilities)?; let updated = diesel::update(api_tokens::table.find(token.id)) .set(api_tokens::capability_set_id.eq(capability_set.id)) .get_result::(conn)?; Ok(updated) } /// Attempts to resolve an API token by its secret value while ensuring it provides the /// requested capability. pub fn find_active_token_by_secret( conn: &mut PgPooledConnection, tenant_id: Option, secret: &str, required_capability: ApiCapability, ) -> Result, AppError> { if secret.len() < TOKEN_PREFIX_LENGTH { return Ok(None); } let prefix = &secret[..TOKEN_PREFIX_LENGTH]; let candidates = with_api_token_prefix(conn, prefix, |conn| { let mut query = api_tokens::table .filter(api_tokens::token_prefix.eq(prefix)) .filter(api_tokens::revoked_at.is_null()) .into_boxed(); let now = Utc::now().naive_utc(); query = query.filter( api_tokens::expires_at .is_null() .or(api_tokens::expires_at.gt(now)), ); if let Some(tenant_id) = tenant_id { query = query.filter(api_tokens::tenant_id.eq(tenant_id)); } query.load::(conn).map_err(AppError::from) })?; for token in candidates { let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?; if !capabilities.contains(&required_capability) { continue; } if verify_token_secret(secret, &token.token_hash)? { return Ok(Some(token)); } } Ok(None) } /// Revokes an API token belonging to the specified user. pub fn revoke_api_token( conn: &mut PgPooledConnection, token_id: Uuid, user_id: Uuid, ) -> Result<(), AppError> { let token = find_user_token(conn, token_id, user_id, None)?; diesel::update(api_tokens::table.find(token.id)) .set(api_tokens::revoked_at.eq(Utc::now().naive_utc())) .execute(conn)?; Ok(()) } /// Updates the last-used timestamp for a token. pub fn touch_api_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> { diesel::update(api_tokens::table.filter(api_tokens::id.eq(token_id))) .set(api_tokens::last_used_at.eq(Utc::now().naive_utc())) .execute(conn)?; Ok(()) } /// Verifies a secret against its stored hash representation. pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result { crate::auth::password::verify_password(secret, token_hash).map_err(|err| { tracing::error!(error = ?err, "failed to verify token"); AppError::internal("failed to verify token") }) } fn find_user_token( conn: &mut PgPooledConnection, token_id: Uuid, user_id: Uuid, tenant_id: Option, ) -> Result { let mut query = api_tokens_dsl::api_tokens .filter(api_tokens_dsl::id.eq(token_id)) .filter(api_tokens_dsl::user_id.eq(user_id)) .into_boxed(); if let Some(tid) = tenant_id { query = query.filter(api_tokens_dsl::tenant_id.eq(tid)); } query .first::(conn) .optional() .map_err(AppError::from)? .ok_or_else(AppError::not_found) } fn with_api_token_prefix( conn: &mut PgPooledConnection, prefix: &str, operation: F, ) -> Result where F: FnOnce(&mut PgPooledConnection) -> Result, { apply_api_token_prefix(conn, prefix)?; let operation_result = operation(conn); let clear_result = clear_api_token_prefix(conn); if let Err(err) = clear_result { return Err(err); } operation_result } fn generate_secret() -> Result { let mut buffer = [0u8; TOKEN_SECRET_LENGTH]; OsRng.try_fill_bytes(&mut buffer).map_err(|err| { tracing::error!(error = ?err, "failed to generate token"); AppError::internal("failed to generate token") })?; Ok(hex::encode(buffer)) } fn hash_secret(secret: &str) -> Result { let salt = SaltString::generate(&mut OsRng); let hash = Argon2::default() .hash_password(secret.as_bytes(), &salt) .map_err(|err| { tracing::error!(error = ?err, "failed to hash token"); AppError::internal("failed to hash token") })?; Ok(hash.to_string()) } #[cfg(test)] mod tests { use super::*; use crate::auth::capability_sets::{ compute_slug, normalize_capabilities, owner_capabilities, webdav_capabilities, }; #[test] fn generated_secret_has_expected_length() { let secret = generate_secret().unwrap(); assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2); } #[test] fn hash_and_verify_secret_round_trip() { let secret = generate_secret().unwrap(); let hash = hash_secret(&secret).unwrap(); assert!(verify_token_secret(&secret, &hash).unwrap()); assert!(!verify_token_secret("wrong", &hash).unwrap()); } #[test] fn normalize_capabilities_deduplicates() { let mut caps = owner_capabilities().to_vec(); caps.push(ApiCapability::DocumentsRead); let normalized = normalize_capabilities(caps).unwrap(); assert_eq!(normalized.len(), owner_capabilities().len()); } #[test] fn normalize_capabilities_rejects_empty() { assert!(normalize_capabilities(Vec::new()).is_err()); } #[test] fn compute_slug_matches_system_sets() { let owner_slug = compute_slug(owner_capabilities()); assert_eq!(owner_slug, "owner"); let webdav_slug = compute_slug(webdav_capabilities()); assert_eq!(webdav_slug, "webdav"); } #[test] fn prefix_length_is_less_than_secret_length() { assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2); } }