325 lines
9.4 KiB
Rust
325 lines
9.4 KiB
Rust
use argon2::{
|
|
password_hash::{rand_core::OsRng as PasswordHashOsRng, PasswordHasher, SaltString},
|
|
Argon2,
|
|
};
|
|
use chrono::{NaiveDateTime, Utc};
|
|
use diesel::prelude::*;
|
|
use rand::{rngs::OsRng, TryRngCore};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
auth::capability_sets::{load_capabilities_for_set, load_capability_set},
|
|
error::AppError,
|
|
models::{ApiCapability, ApiToken, CapabilitySet, 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>,
|
|
capability_set_id: Uuid,
|
|
) -> Result<IssuedApiToken, AppError> {
|
|
let capability_set =
|
|
validate_capability_set_belongs_to_tenant(conn, capability_set_id, tenant_id)?;
|
|
|
|
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::<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,
|
|
})
|
|
}
|
|
|
|
/// 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: Option<ApiCapability>,
|
|
) -> 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 let Some(required) = required_capability {
|
|
let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?;
|
|
if !capabilities.contains(&required) {
|
|
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 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 validate_capability_set_belongs_to_tenant(
|
|
conn: &mut PgPooledConnection,
|
|
capability_set_id: Uuid,
|
|
tenant_id: Uuid,
|
|
) -> Result<CapabilitySet, AppError> {
|
|
let capability_set = load_capability_set(conn, capability_set_id)?;
|
|
if capability_set.tenant_id != tenant_id {
|
|
return Err(AppError::bad_request(
|
|
"capability set does not belong to the tenant",
|
|
));
|
|
}
|
|
|
|
Ok(capability_set)
|
|
}
|
|
|
|
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 mut salt_rng = PasswordHashOsRng;
|
|
let salt = SaltString::generate(&mut salt_rng);
|
|
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);
|
|
}
|
|
}
|