api-tokens

This commit is contained in:
2025-11-01 21:42:52 +01:00
parent f6ceff5444
commit 00f5d3adea
16 changed files with 999 additions and 556 deletions
+339
View File
@@ -0,0 +1,339 @@
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::{
error::AppError,
models::{ApiToken, ApiTokenCapability, 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<String>,
expires_at: Option<NaiveDateTime>,
capabilities: Vec<ApiTokenCapability>,
) -> Result<IssuedApiToken, AppError> {
let capabilities = normalize_capabilities(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,
capabilities,
};
let record = diesel::insert_into(api_tokens::table)
.values(&new_token)
.get_result::<ApiToken>(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<Uuid>,
) -> Result<Vec<ApiToken>, 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::<ApiToken>(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<Uuid>,
) -> Result<IssuedApiToken, AppError> {
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::<Option<NaiveDateTime>>(None),
))
.get_result::<ApiToken>(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<Uuid>,
capabilities: Vec<ApiTokenCapability>,
) -> Result<ApiToken, AppError> {
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 updated = diesel::update(api_tokens::table.find(token.id))
.set(api_tokens::capabilities.eq(capabilities))
.get_result::<ApiToken>(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<Uuid>,
secret: &str,
required_capability: ApiTokenCapability,
) -> Result<Option<ApiToken>, 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::<ApiToken>(conn).map_err(AppError::from)
})?;
for token in candidates {
if !token.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<bool, AppError> {
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 normalize_capabilities(
capabilities: Vec<ApiTokenCapability>,
) -> Result<Vec<ApiTokenCapability>, AppError> {
if capabilities.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let mut unique = Vec::new();
for capability in capabilities {
if !unique.contains(&capability) {
unique.push(capability);
}
}
Ok(unique)
}
fn find_user_token(
conn: &mut PgPooledConnection,
token_id: Uuid,
user_id: Uuid,
tenant_id: Option<Uuid>,
) -> Result<ApiToken, AppError> {
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::<ApiToken>(conn)
.optional()
.map_err(AppError::from)?
.ok_or_else(AppError::not_found)
}
fn with_api_token_prefix<T, F>(
conn: &mut PgPooledConnection,
prefix: &str,
operation: F,
) -> Result<T, AppError>
where
F: FnOnce(&mut PgPooledConnection) -> Result<T, AppError>,
{
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<String, AppError> {
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<String, AppError> {
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::*;
#[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 caps = normalize_capabilities(vec![
ApiTokenCapability::Api,
ApiTokenCapability::Webdav,
ApiTokenCapability::Api,
])
.unwrap();
assert_eq!(caps.len(), 2);
assert!(caps.contains(&ApiTokenCapability::Api));
assert!(caps.contains(&ApiTokenCapability::Webdav));
}
#[test]
fn normalize_capabilities_rejects_empty() {
assert!(normalize_capabilities(Vec::new()).is_err());
}
#[test]
fn prefix_length_is_less_than_secret_length() {
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
pub mod api_tokens;
pub mod jwt;
pub mod passkeys;
pub mod password;
pub mod webdav_tokens;
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use axum_extra::headers::{authorization::Bearer, Authorization};
-240
View File
@@ -1,240 +0,0 @@
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::{
error::AppError,
models::{NewWebdavToken, WebdavToken},
schema::webdav_tokens,
state::PgPooledConnection,
tenants::{apply_webdav_token_prefix, clear_webdav_token_prefix},
};
const TOKEN_PREFIX_LENGTH: usize = 12;
const TOKEN_SECRET_LENGTH: usize = 32;
pub struct IssuedWebdavToken {
pub token: String,
pub record: WebdavToken,
}
pub fn create_webdav_token(
conn: &mut PgPooledConnection,
user_id: Uuid,
tenant_id: Uuid,
label: Option<String>,
expires_at: Option<NaiveDateTime>,
) -> Result<IssuedWebdavToken, AppError> {
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 = NewWebdavToken {
id: Uuid::new_v4(),
user_id,
tenant_id,
token_prefix,
token_hash,
label,
expires_at,
};
let record = diesel::insert_into(webdav_tokens::table)
.values(&new_token)
.get_result::<WebdavToken>(conn)?;
Ok(IssuedWebdavToken {
token: raw_secret,
record,
})
}
pub fn list_webdav_tokens(
conn: &mut PgPooledConnection,
user_id: Uuid,
tenant_id: Option<Uuid>,
) -> Result<Vec<WebdavToken>, AppError> {
let mut query = webdav_tokens::table
.filter(webdav_tokens::user_id.eq(user_id))
.into_boxed();
if let Some(tenant_id) = tenant_id {
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
}
let tokens = query
.order(webdav_tokens::created_at.asc())
.load::<WebdavToken>(conn)?;
Ok(tokens)
}
pub fn regenerate_webdav_token(
conn: &mut PgPooledConnection,
token_id: Uuid,
user_id: Uuid,
tenant_id: Option<Uuid>,
) -> Result<IssuedWebdavToken, AppError> {
let mut query = webdav_tokens::table
.filter(webdav_tokens::id.eq(token_id))
.filter(webdav_tokens::user_id.eq(user_id))
.into_boxed();
if let Some(tenant) = tenant_id {
query = query.filter(webdav_tokens::tenant_id.eq(tenant));
}
let record = query
.first::<WebdavToken>(conn)
.optional()
.map_err(AppError::from)?
.ok_or_else(AppError::not_found)?;
if record.revoked_at.is_some() {
return Err(AppError::bad_request(
"cannot regenerate a revoked WebDAV 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(webdav_tokens::table.find(record.id))
.set((
webdav_tokens::token_prefix.eq(&token_prefix),
webdav_tokens::token_hash.eq(&token_hash),
webdav_tokens::last_used_at.eq::<Option<NaiveDateTime>>(None),
))
.get_result::<WebdavToken>(conn)?;
Ok(IssuedWebdavToken {
token: raw_secret,
record: updated,
})
}
pub fn find_active_token_by_secret(
conn: &mut PgPooledConnection,
user_id: Uuid,
tenant_id: Option<Uuid>,
secret: &str,
) -> Result<Option<WebdavToken>, AppError> {
if secret.len() < TOKEN_PREFIX_LENGTH {
return Ok(None);
}
let prefix = &secret[..TOKEN_PREFIX_LENGTH];
apply_webdav_token_prefix(conn, prefix)?;
let mut query = webdav_tokens::table
.filter(webdav_tokens::user_id.eq(user_id))
.filter(webdav_tokens::token_prefix.eq(prefix))
.filter(webdav_tokens::revoked_at.is_null())
.into_boxed();
let now = Utc::now().naive_utc();
query = query.filter(
webdav_tokens::expires_at
.is_null()
.or(webdav_tokens::expires_at.gt(now)),
);
if let Some(tenant_id) = tenant_id {
query = query.filter(webdav_tokens::tenant_id.eq(tenant_id));
}
let load_result = query.load::<WebdavToken>(conn);
let clear_result = clear_webdav_token_prefix(conn);
clear_result?;
let candidates = load_result?;
for token in candidates {
if verify_token_secret(secret, &token.token_hash)? {
return Ok(Some(token));
}
}
Ok(None)
}
pub fn revoke_webdav_token(
conn: &mut PgPooledConnection,
token_id: Uuid,
user_id: Uuid,
) -> Result<(), AppError> {
let affected = diesel::update(
webdav_tokens::table
.filter(webdav_tokens::id.eq(token_id))
.filter(webdav_tokens::user_id.eq(user_id)),
)
.set(webdav_tokens::revoked_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
if affected == 0 {
return Err(AppError::not_found());
}
Ok(())
}
pub fn touch_webdav_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> {
diesel::update(webdav_tokens::table.filter(webdav_tokens::id.eq(token_id)))
.set(webdav_tokens::last_used_at.eq(Utc::now().naive_utc()))
.execute(conn)?;
Ok(())
}
pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result<bool, AppError> {
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 generate_secret() -> Result<String, AppError> {
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<String, AppError> {
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())
}
fn _ensure_constants() {
assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2);
}
#[cfg(test)]
mod tests {
use super::*;
#[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());
}
}