api-tokens
This commit is contained in:
@@ -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,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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user