api-tokens
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
DROP POLICY IF EXISTS tenant_api_token_policy ON tenant.api_tokens;
|
||||||
|
DROP FUNCTION IF EXISTS shared.current_api_token_prefix();
|
||||||
|
|
||||||
|
ALTER TABLE tenant.api_tokens DISABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tenant.api_tokens NO FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
ALTER TABLE tenant.api_tokens
|
||||||
|
DROP COLUMN IF EXISTS capabilities;
|
||||||
|
|
||||||
|
DROP TYPE IF EXISTS shared.api_token_capability;
|
||||||
|
|
||||||
|
ALTER TABLE tenant.api_tokens RENAME TO webdav_tokens;
|
||||||
|
ALTER INDEX tenant.api_tokens_token_prefix_key RENAME TO webdav_tokens_token_prefix_key;
|
||||||
|
ALTER INDEX tenant.api_tokens_user_tenant_idx RENAME TO webdav_tokens_user_tenant_idx;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$
|
||||||
|
SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '')
|
||||||
|
$$ LANGUAGE SQL STABLE;
|
||||||
|
|
||||||
|
ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens
|
||||||
|
USING (
|
||||||
|
tenant_id = shared.current_tenant_id()
|
||||||
|
OR (
|
||||||
|
shared.current_webdav_token_prefix() IS NOT NULL
|
||||||
|
AND token_prefix = shared.current_webdav_token_prefix()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (tenant_id = shared.current_tenant_id());
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
ALTER TABLE tenant.webdav_tokens RENAME TO api_tokens;
|
||||||
|
ALTER INDEX tenant.webdav_tokens_token_prefix_key RENAME TO api_tokens_token_prefix_key;
|
||||||
|
ALTER INDEX tenant.webdav_tokens_user_tenant_idx RENAME TO api_tokens_user_tenant_idx;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.api_tokens;
|
||||||
|
DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix();
|
||||||
|
|
||||||
|
CREATE TYPE shared.api_token_capability AS ENUM ('api', 'webdav');
|
||||||
|
|
||||||
|
ALTER TABLE tenant.api_tokens
|
||||||
|
ADD COLUMN capabilities shared.api_token_capability[] NOT NULL DEFAULT ARRAY['webdav']::shared.api_token_capability[];
|
||||||
|
|
||||||
|
ALTER TABLE tenant.api_tokens ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE tenant.api_tokens FORCE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION shared.current_api_token_prefix() RETURNS text AS $$
|
||||||
|
SELECT NULLIF(current_setting('papercrate.api_token_prefix', true), '')
|
||||||
|
$$ LANGUAGE SQL STABLE;
|
||||||
|
|
||||||
|
CREATE POLICY tenant_api_token_policy ON tenant.api_tokens
|
||||||
|
USING (
|
||||||
|
tenant_id = shared.current_tenant_id()
|
||||||
|
OR (
|
||||||
|
shared.current_api_token_prefix() IS NOT NULL
|
||||||
|
AND token_prefix = shared.current_api_token_prefix()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (tenant_id = shared.current_tenant_id());
|
||||||
@@ -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 jwt;
|
||||||
pub mod passkeys;
|
pub mod passkeys;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
pub mod webdav_tokens;
|
|
||||||
|
|
||||||
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
|
||||||
use axum_extra::headers::{authorization::Bearer, Authorization};
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+72
-5
@@ -4,14 +4,18 @@ use diesel::pg::{Pg, PgValue};
|
|||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::serialize::{IsNull, Output, ToSql};
|
use diesel::serialize::{IsNull, Output, ToSql};
|
||||||
use diesel::{deserialize, serialize, AsExpression, FromSqlRow};
|
use diesel::{deserialize, serialize, AsExpression, FromSqlRow};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::str;
|
use std::str;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::schema::sql_types::{
|
use crate::schema::sql_types::{
|
||||||
MagicTokenKind as MagicTokenKindSql, TenantStatus as TenantStatusSql,
|
ApiTokenCapability as ApiTokenCapabilitySql, MagicTokenKind as MagicTokenKindSql,
|
||||||
|
TenantStatus as TenantStatusSql,
|
||||||
};
|
};
|
||||||
use crate::schema::*;
|
use crate::schema::*;
|
||||||
|
|
||||||
@@ -52,6 +56,16 @@ pub enum MagicTokenKind {
|
|||||||
DemoLogin,
|
DemoLogin,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow, Serialize, Deserialize, ToSchema,
|
||||||
|
)]
|
||||||
|
#[diesel(sql_type = ApiTokenCapabilitySql)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ApiTokenCapability {
|
||||||
|
Api,
|
||||||
|
Webdav,
|
||||||
|
}
|
||||||
|
|
||||||
impl MagicTokenKind {
|
impl MagicTokenKind {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -65,12 +79,31 @@ impl MagicTokenKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ApiTokenCapability {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ApiTokenCapability::Api => "api",
|
||||||
|
ApiTokenCapability::Webdav => "webdav",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn variants() -> &'static [&'static str] {
|
||||||
|
&["api", "webdav"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl fmt::Display for MagicTokenKind {
|
impl fmt::Display for MagicTokenKind {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}", self.as_str())
|
write!(f, "{}", self.as_str())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ApiTokenCapability {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ToSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
impl ToSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
||||||
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
||||||
out.write_all(self.as_str().as_bytes())?;
|
out.write_all(self.as_str().as_bytes())?;
|
||||||
@@ -78,6 +111,13 @@ impl ToSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ToSql<ApiTokenCapabilitySql, Pg> for ApiTokenCapability {
|
||||||
|
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result {
|
||||||
|
out.write_all(self.as_str().as_bytes())?;
|
||||||
|
Ok(IsNull::No)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FromSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
impl FromSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
||||||
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
||||||
match std::str::from_utf8(bytes.as_bytes())? {
|
match std::str::from_utf8(bytes.as_bytes())? {
|
||||||
@@ -91,6 +131,19 @@ impl FromSql<MagicTokenKindSql, Pg> for MagicTokenKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FromSql<ApiTokenCapabilitySql, Pg> for ApiTokenCapability {
|
||||||
|
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
|
||||||
|
match std::str::from_utf8(bytes.as_bytes())? {
|
||||||
|
"api" => Ok(ApiTokenCapability::Api),
|
||||||
|
"webdav" => Ok(ApiTokenCapability::Webdav),
|
||||||
|
other => Err(Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
format!("invalid api_token_capability '{other}'"),
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl str::FromStr for MagicTokenKind {
|
impl str::FromStr for MagicTokenKind {
|
||||||
type Err = &'static str;
|
type Err = &'static str;
|
||||||
|
|
||||||
@@ -103,6 +156,18 @@ impl str::FromStr for MagicTokenKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl str::FromStr for ApiTokenCapability {
|
||||||
|
type Err = &'static str;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"api" => Ok(ApiTokenCapability::Api),
|
||||||
|
"webdav" => Ok(ApiTokenCapability::Webdav),
|
||||||
|
_ => Err("unsupported api token capability"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TenantStatus {
|
impl TenantStatus {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -243,10 +308,10 @@ pub struct NewWebauthnChallenge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
#[diesel(table_name = webdav_tokens)]
|
#[diesel(table_name = api_tokens)]
|
||||||
#[diesel(belongs_to(User))]
|
#[diesel(belongs_to(User))]
|
||||||
#[diesel(belongs_to(Tenant))]
|
#[diesel(belongs_to(Tenant))]
|
||||||
pub struct WebdavToken {
|
pub struct ApiToken {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
@@ -257,11 +322,12 @@ pub struct WebdavToken {
|
|||||||
pub last_used_at: Option<NaiveDateTime>,
|
pub last_used_at: Option<NaiveDateTime>,
|
||||||
pub expires_at: Option<NaiveDateTime>,
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
pub revoked_at: Option<NaiveDateTime>,
|
pub revoked_at: Option<NaiveDateTime>,
|
||||||
|
pub capabilities: Vec<ApiTokenCapability>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
#[diesel(table_name = webdav_tokens)]
|
#[diesel(table_name = api_tokens)]
|
||||||
pub struct NewWebdavToken {
|
pub struct NewApiToken {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
@@ -269,6 +335,7 @@ pub struct NewWebdavToken {
|
|||||||
pub token_hash: String,
|
pub token_hash: String,
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
pub expires_at: Option<NaiveDateTime>,
|
pub expires_at: Option<NaiveDateTime>,
|
||||||
|
pub capabilities: Vec<ApiTokenCapability>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
|
|||||||
@@ -68,10 +68,11 @@ pub mod schemas {
|
|||||||
DocumentVersionDetailResponse, DocumentVersionResponse,
|
DocumentVersionDetailResponse, DocumentVersionResponse,
|
||||||
};
|
};
|
||||||
pub use crate::documents::correspondents::DocumentCorrespondentResponse;
|
pub use crate::documents::correspondents::DocumentCorrespondentResponse;
|
||||||
|
pub use crate::models::ApiTokenCapability;
|
||||||
pub use crate::routes::auth::{
|
pub use crate::routes::auth::{
|
||||||
LoginRequest, LoginResponse, LoginResponseVariants, SignupFinishRequest,
|
ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants,
|
||||||
SignupStartRequest, SignupStartResponse, TenantListResponse, TenantSelectionRequest,
|
SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
|
||||||
TenantSelectionResponse, TenantSnippet,
|
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet,
|
||||||
};
|
};
|
||||||
pub use crate::routes::correspondents::{
|
pub use crate::routes::correspondents::{
|
||||||
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
|
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
|
||||||
@@ -91,8 +92,8 @@ pub mod schemas {
|
|||||||
FolderInfo, FolderResponse, UpdateFolderRequest,
|
FolderInfo, FolderResponse, UpdateFolderRequest,
|
||||||
};
|
};
|
||||||
pub use crate::routes::profile::{
|
pub use crate::routes::profile::{
|
||||||
CreateWebdavTokenRequest, RevokePasskeyQuery, WebdavTokenCreatedResponse,
|
ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery,
|
||||||
WebdavTokenResponse,
|
UpdateApiTokenCapabilitiesRequest,
|
||||||
};
|
};
|
||||||
pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest};
|
pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{
|
auth::{
|
||||||
|
api_tokens::{find_active_token_by_secret, touch_api_token},
|
||||||
passkeys::{
|
passkeys::{
|
||||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||||
@@ -26,7 +27,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::{
|
models::{
|
||||||
MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, TenantStatus, User,
|
ApiTokenCapability, MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken,
|
||||||
|
TenantStatus, User,
|
||||||
},
|
},
|
||||||
schema::{
|
schema::{
|
||||||
magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl,
|
magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl,
|
||||||
@@ -59,6 +61,11 @@ pub struct LoginRequest {
|
|||||||
pub preferred_tenant_id: Option<Uuid>,
|
pub preferred_tenant_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct ApiTokenExchangeRequest {
|
||||||
|
pub api_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, ToSchema)]
|
#[derive(Deserialize, Serialize, ToSchema)]
|
||||||
pub struct LoginResponse {
|
pub struct LoginResponse {
|
||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
@@ -120,6 +127,7 @@ pub enum LoginResponseVariants {
|
|||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
login,
|
login,
|
||||||
|
api_token_exchange,
|
||||||
signup_start,
|
signup_start,
|
||||||
signup_finish,
|
signup_finish,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -134,6 +142,7 @@ pub enum LoginResponseVariants {
|
|||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
|
ApiTokenExchangeRequest,
|
||||||
SignupStartRequest,
|
SignupStartRequest,
|
||||||
SignupStartResponse,
|
SignupStartResponse,
|
||||||
SignupFinishRequest,
|
SignupFinishRequest,
|
||||||
@@ -150,6 +159,7 @@ pub enum LoginResponseVariants {
|
|||||||
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
||||||
crate::auth::passkeys::PasskeyLoginStartPayload,
|
crate::auth::passkeys::PasskeyLoginStartPayload,
|
||||||
crate::auth::passkeys::PasskeyLoginFinishPayload,
|
crate::auth::passkeys::PasskeyLoginFinishPayload,
|
||||||
|
crate::models::ApiTokenCapability,
|
||||||
))
|
))
|
||||||
)]
|
)]
|
||||||
pub struct AuthApiDoc;
|
pub struct AuthApiDoc;
|
||||||
@@ -201,6 +211,69 @@ pub async fn login(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/api/auth/exchange-api-token",
|
||||||
|
request_body = ApiTokenExchangeRequest,
|
||||||
|
responses((status = 200, description = "Access token issued", body = LoginResponse)),
|
||||||
|
tag = "Auth"
|
||||||
|
)]
|
||||||
|
pub async fn api_token_exchange(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<ApiTokenExchangeRequest>,
|
||||||
|
) -> AppResult<Json<LoginResponse>> {
|
||||||
|
let secret = payload.api_token.trim();
|
||||||
|
if secret.is_empty() {
|
||||||
|
return Err(AppError::bad_request("api_token must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
|
let token = find_active_token_by_secret(&mut conn, None, secret, ApiTokenCapability::Api)?
|
||||||
|
.ok_or_else(AppError::unauthorized)?;
|
||||||
|
|
||||||
|
let user: User = dsl::users.find(token.user_id).first(&mut conn)?;
|
||||||
|
|
||||||
|
apply_user_guc(&mut conn, user.id)?;
|
||||||
|
let membership = memberships_dsl::user_memberships
|
||||||
|
.filter(memberships_dsl::user_id.eq(user.id))
|
||||||
|
.filter(memberships_dsl::tenant_id.eq(token.tenant_id))
|
||||||
|
.select(memberships_dsl::tenant_id)
|
||||||
|
.first::<Uuid>(&mut conn)
|
||||||
|
.optional()?;
|
||||||
|
clear_user_guc(&mut conn)?;
|
||||||
|
|
||||||
|
if membership.is_none() {
|
||||||
|
return Err(AppError::unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_tenant_guc(&mut conn, token.tenant_id)?;
|
||||||
|
touch_api_token(&mut conn, token.id)?;
|
||||||
|
|
||||||
|
let access_token = state
|
||||||
|
.jwt
|
||||||
|
.generate_token(user.id, token.tenant_id, &user.username)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let tenant_name: String = tenant_dsl::tenants
|
||||||
|
.find(token.tenant_id)
|
||||||
|
.select(tenant_dsl::name)
|
||||||
|
.first(&mut conn)
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
let response = LoginResponse {
|
||||||
|
access_token,
|
||||||
|
token_type: "Bearer".to_string(),
|
||||||
|
expires_in: state.config.jwt_expiry_minutes * 60,
|
||||||
|
tenant: TenantSnippet {
|
||||||
|
id: token.tenant_id,
|
||||||
|
name: tenant_name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/auth/signup/start",
|
path = "/api/auth/signup/start",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.route("/signup/start", post(auth::signup_start))
|
.route("/signup/start", post(auth::signup_start))
|
||||||
.route("/signup/finish", post(auth::signup_finish))
|
.route("/signup/finish", post(auth::signup_finish))
|
||||||
.route("/login", post(auth::login))
|
.route("/login", post(auth::login))
|
||||||
|
.route("/exchange-api-token", post(auth::api_token_exchange))
|
||||||
.route("/refresh", post(auth::refresh))
|
.route("/refresh", post(auth::refresh))
|
||||||
.route("/logout", post(auth::logout))
|
.route("/logout", post(auth::logout))
|
||||||
.route("/select-tenant", post(auth::select_tenant))
|
.route("/select-tenant", post(auth::select_tenant))
|
||||||
@@ -145,14 +146,17 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
|
|
||||||
let profile_routes = Router::new()
|
let profile_routes = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/webdav-tokens",
|
"/api-tokens",
|
||||||
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
|
get(profile::list_api_tokens).post(profile::create_api_token),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/webdav-tokens/:id/regenerate",
|
"/api-tokens/:id/regenerate",
|
||||||
post(profile::regenerate_webdav_token),
|
post(profile::regenerate_api_token),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api-tokens/:id",
|
||||||
|
patch(profile::update_api_token).delete(profile::delete_api_token),
|
||||||
)
|
)
|
||||||
.route("/webdav-tokens/:id", delete(profile::delete_webdav_token))
|
|
||||||
.route("/passkeys", get(profile::list_passkeys))
|
.route("/passkeys", get(profile::list_passkeys))
|
||||||
.route("/passkeys/:id", delete(profile::delete_passkey));
|
.route("/passkeys/:id", delete(profile::delete_passkey));
|
||||||
|
|
||||||
|
|||||||
+110
-50
@@ -9,24 +9,26 @@ use utoipa::ToSchema;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
passkeys::PasskeySummary,
|
api_tokens::{
|
||||||
webdav_tokens::{
|
create_api_token as issue_token, list_api_tokens as load_tokens,
|
||||||
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
|
regenerate_api_token as rotate_token, revoke_api_token as revoke_token,
|
||||||
regenerate_webdav_token as rotate_token, revoke_webdav_token as revoke_token,
|
update_api_token_capabilities as update_capabilities,
|
||||||
},
|
},
|
||||||
|
passkeys::PasskeySummary,
|
||||||
TenantScopedConn,
|
TenantScopedConn,
|
||||||
};
|
};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::WebdavToken;
|
use crate::models::{ApiToken, ApiTokenCapability};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use crate::utils::{db::no_content, time::to_iso};
|
use crate::utils::{db::no_content, time::to_iso};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, ToSchema)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
pub struct WebdavTokenResponse {
|
pub struct ApiTokenResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub tenant_id: Uuid,
|
pub tenant_id: Uuid,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
|
pub capabilities: Vec<ApiTokenCapability>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub last_used_at: Option<String>,
|
pub last_used_at: Option<String>,
|
||||||
@@ -37,17 +39,25 @@ pub struct WebdavTokenResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, ToSchema)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
pub struct WebdavTokenCreatedResponse {
|
pub struct ApiTokenCreatedResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub token_info: WebdavTokenResponse,
|
pub token_info: ApiTokenResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
pub struct CreateWebdavTokenRequest {
|
pub struct CreateApiTokenRequest {
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
#[schema(nullable)]
|
#[schema(nullable)]
|
||||||
pub expires_at: Option<String>,
|
pub expires_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[schema(nullable)]
|
||||||
|
pub capabilities: Option<Vec<ApiTokenCapability>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateApiTokenCapabilitiesRequest {
|
||||||
|
pub capabilities: Vec<ApiTokenCapability>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
@@ -80,55 +90,60 @@ pub async fn list_passkeys(
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/profile/webdav-tokens",
|
path = "/api/profile/api-tokens",
|
||||||
responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])),
|
responses((status = 200, description = "List API tokens", body = [ApiTokenResponse])),
|
||||||
tag = "Profile"
|
tag = "Profile"
|
||||||
)]
|
)]
|
||||||
pub async fn list_webdav_tokens(
|
pub async fn list_api_tokens(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
user_id,
|
user_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
) -> AppResult<Json<Vec<WebdavTokenResponse>>> {
|
) -> AppResult<Json<Vec<ApiTokenResponse>>> {
|
||||||
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
|
let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?;
|
||||||
let responses = tokens.into_iter().map(webdav_token_to_response).collect();
|
let responses = tokens.into_iter().map(api_token_to_response).collect();
|
||||||
Ok(Json(responses))
|
Ok(Json(responses))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/profile/webdav-tokens",
|
path = "/api/profile/api-tokens",
|
||||||
request_body = CreateWebdavTokenRequest,
|
request_body = CreateApiTokenRequest,
|
||||||
responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)),
|
responses((status = 201, description = "API token created", body = ApiTokenCreatedResponse)),
|
||||||
tag = "Profile"
|
tag = "Profile"
|
||||||
)]
|
)]
|
||||||
pub async fn create_webdav_token(
|
pub async fn create_api_token(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
user_id,
|
user_id,
|
||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Json(payload): Json<CreateWebdavTokenRequest>,
|
Json(payload): Json<CreateApiTokenRequest>,
|
||||||
) -> AppResult<(StatusCode, Json<WebdavTokenCreatedResponse>)> {
|
) -> AppResult<(StatusCode, Json<ApiTokenCreatedResponse>)> {
|
||||||
let expires_at = match payload.expires_at {
|
let expires_at = match payload.expires_at {
|
||||||
Some(ref value) => Some(parse_timestamp(value)?),
|
Some(ref value) => Some(parse_timestamp(value)?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let capabilities = payload
|
||||||
|
.capabilities
|
||||||
|
.unwrap_or_else(|| vec![ApiTokenCapability::Webdav]);
|
||||||
|
|
||||||
let issued = issue_token(
|
let issued = issue_token(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
user_id,
|
user_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
payload.label.clone(),
|
payload.label.clone(),
|
||||||
expires_at,
|
expires_at,
|
||||||
|
capabilities,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let response = WebdavTokenCreatedResponse {
|
let response = ApiTokenCreatedResponse {
|
||||||
token: issued.token,
|
token: issued.token,
|
||||||
token_info: webdav_token_to_response(issued.record),
|
token_info: api_token_to_response(issued.record),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(response)))
|
Ok((StatusCode::CREATED, Json(response)))
|
||||||
@@ -136,12 +151,12 @@ pub async fn create_webdav_token(
|
|||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/profile/webdav-tokens/{id}/regenerate",
|
path = "/api/profile/api-tokens/{id}/regenerate",
|
||||||
params(("id" = Uuid, Path, description = "WebDAV token ID")),
|
params(("id" = Uuid, Path, description = "API token ID")),
|
||||||
responses((status = 200, description = "WebDAV token regenerated", body = WebdavTokenCreatedResponse)),
|
responses((status = 200, description = "API token regenerated", body = ApiTokenCreatedResponse)),
|
||||||
tag = "Profile"
|
tag = "Profile"
|
||||||
)]
|
)]
|
||||||
pub async fn regenerate_webdav_token(
|
pub async fn regenerate_api_token(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn,
|
mut conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
@@ -149,24 +164,53 @@ pub async fn regenerate_webdav_token(
|
|||||||
..
|
..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
Path(token_id): Path<Uuid>,
|
Path(token_id): Path<Uuid>,
|
||||||
) -> AppResult<Json<WebdavTokenCreatedResponse>> {
|
) -> AppResult<Json<ApiTokenCreatedResponse>> {
|
||||||
let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?;
|
let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?;
|
||||||
let response = WebdavTokenCreatedResponse {
|
let response = ApiTokenCreatedResponse {
|
||||||
token: issued.token,
|
token: issued.token,
|
||||||
token_info: webdav_token_to_response(issued.record),
|
token_info: api_token_to_response(issued.record),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
delete,
|
patch,
|
||||||
path = "/api/profile/webdav-tokens/{id}",
|
path = "/api/profile/api-tokens/{id}",
|
||||||
params(("id" = Uuid, Path, description = "WebDAV token ID")),
|
params(("id" = Uuid, Path, description = "API token ID")),
|
||||||
responses((status = 204, description = "WebDAV token revoked")),
|
request_body = UpdateApiTokenCapabilitiesRequest,
|
||||||
|
responses((status = 200, description = "API token updated", body = ApiTokenResponse)),
|
||||||
tag = "Profile"
|
tag = "Profile"
|
||||||
)]
|
)]
|
||||||
pub async fn delete_webdav_token(
|
pub async fn update_api_token(
|
||||||
|
TenantScopedConn {
|
||||||
|
mut conn,
|
||||||
|
tenant_id,
|
||||||
|
user_id,
|
||||||
|
..
|
||||||
|
}: TenantScopedConn,
|
||||||
|
Path(token_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<UpdateApiTokenCapabilitiesRequest>,
|
||||||
|
) -> AppResult<Json<ApiTokenResponse>> {
|
||||||
|
let updated = update_capabilities(
|
||||||
|
&mut conn,
|
||||||
|
token_id,
|
||||||
|
user_id,
|
||||||
|
Some(tenant_id),
|
||||||
|
payload.capabilities,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(Json(api_token_to_response(updated)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/profile/api-tokens/{id}",
|
||||||
|
params(("id" = Uuid, Path, description = "API token ID")),
|
||||||
|
responses((status = 204, description = "API token revoked")),
|
||||||
|
tag = "Profile"
|
||||||
|
)]
|
||||||
|
pub async fn delete_api_token(
|
||||||
TenantScopedConn {
|
TenantScopedConn {
|
||||||
mut conn, user_id, ..
|
mut conn, user_id, ..
|
||||||
}: TenantScopedConn,
|
}: TenantScopedConn,
|
||||||
@@ -210,15 +254,28 @@ pub async fn delete_passkey(
|
|||||||
no_content()
|
no_content()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse {
|
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
|
||||||
WebdavTokenResponse {
|
let ApiToken {
|
||||||
id: token.id,
|
id,
|
||||||
tenant_id: token.tenant_id,
|
tenant_id,
|
||||||
label: token.label,
|
label,
|
||||||
created_at: to_iso(token.created_at),
|
created_at,
|
||||||
last_used_at: token.last_used_at.map(to_iso),
|
last_used_at,
|
||||||
expires_at: token.expires_at.map(to_iso),
|
expires_at,
|
||||||
revoked_at: token.revoked_at.map(to_iso),
|
revoked_at,
|
||||||
|
capabilities,
|
||||||
|
..
|
||||||
|
} = token;
|
||||||
|
|
||||||
|
ApiTokenResponse {
|
||||||
|
id,
|
||||||
|
tenant_id,
|
||||||
|
label,
|
||||||
|
capabilities,
|
||||||
|
created_at: to_iso(created_at),
|
||||||
|
last_used_at: last_used_at.map(to_iso),
|
||||||
|
expires_at: expires_at.map(to_iso),
|
||||||
|
revoked_at: revoked_at.map(to_iso),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,17 +288,20 @@ fn parse_timestamp(value: &str) -> AppResult<NaiveDateTime> {
|
|||||||
#[derive(utoipa::OpenApi)]
|
#[derive(utoipa::OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
crate::routes::profile::list_webdav_tokens,
|
crate::routes::profile::list_api_tokens,
|
||||||
crate::routes::profile::create_webdav_token,
|
crate::routes::profile::create_api_token,
|
||||||
crate::routes::profile::regenerate_webdav_token,
|
crate::routes::profile::regenerate_api_token,
|
||||||
crate::routes::profile::delete_webdav_token,
|
crate::routes::profile::update_api_token,
|
||||||
|
crate::routes::profile::delete_api_token,
|
||||||
crate::routes::profile::list_passkeys,
|
crate::routes::profile::list_passkeys,
|
||||||
crate::routes::profile::delete_passkey
|
crate::routes::profile::delete_passkey
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
crate::routes::profile::WebdavTokenResponse,
|
crate::models::ApiTokenCapability,
|
||||||
crate::routes::profile::WebdavTokenCreatedResponse,
|
crate::routes::profile::ApiTokenResponse,
|
||||||
crate::routes::profile::CreateWebdavTokenRequest,
|
crate::routes::profile::ApiTokenCreatedResponse,
|
||||||
|
crate::routes::profile::CreateApiTokenRequest,
|
||||||
|
crate::routes::profile::UpdateApiTokenCapabilitiesRequest,
|
||||||
crate::routes::profile::RevokePasskeyQuery,
|
crate::routes::profile::RevokePasskeyQuery,
|
||||||
crate::auth::passkeys::PasskeySummary
|
crate::auth::passkeys::PasskeySummary
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
|||||||
use quick_xml::Writer;
|
use quick_xml::Writer;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::webdav_tokens::{find_active_token_by_secret, touch_webdav_token};
|
use crate::auth::api_tokens::{find_active_token_by_secret, touch_api_token};
|
||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::models::{Document, DocumentVersion, Folder, User};
|
use crate::models::{ApiTokenCapability, Document, DocumentVersion, Folder, User};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
|
||||||
folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
|
||||||
@@ -425,34 +425,40 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (username, secret) = match credential_str.split_once(':') {
|
let (presented_username, secret) = match credential_str.split_once(':') {
|
||||||
Some((username, secret)) if !username.is_empty() => (username, secret),
|
Some((username, secret)) if !username.is_empty() => (username, secret),
|
||||||
_ => return Ok(None),
|
_ => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::debug!(%username, "attempting webdav login");
|
tracing::debug!(presented_username = %presented_username, "attempting webdav login");
|
||||||
let mut conn = state.db_unscoped()?;
|
let mut conn = state.db_unscoped()?;
|
||||||
|
|
||||||
let user: User = match users_dsl::users
|
let token = match find_active_token_by_secret(
|
||||||
.filter(users_dsl::username.eq(username))
|
&mut conn,
|
||||||
.first(&mut conn)
|
None,
|
||||||
{
|
secret,
|
||||||
|
ApiTokenCapability::Webdav,
|
||||||
|
)? {
|
||||||
|
Some(token) => token,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(presented_username = %presented_username, "webdav token invalid or expired");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user: User = match users_dsl::users.find(token.user_id).first(&mut conn) {
|
||||||
Ok(user) => user,
|
Ok(user) => user,
|
||||||
Err(diesel::result::Error::NotFound) => {
|
Err(diesel::result::Error::NotFound) => {
|
||||||
tracing::warn!(%username, "webdav user not found");
|
tracing::warn!(
|
||||||
|
presented_username = %presented_username,
|
||||||
|
user_id = %token.user_id,
|
||||||
|
"webdav token user missing"
|
||||||
|
);
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Err(err) => return Err(AppError::from(err)),
|
Err(err) => return Err(AppError::from(err)),
|
||||||
};
|
};
|
||||||
|
|
||||||
let token = match find_active_token_by_secret(&mut conn, user.id, None, secret)? {
|
|
||||||
Some(token) => token,
|
|
||||||
None => {
|
|
||||||
tracing::warn!(%username, "webdav token invalid or expired");
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
apply_user_guc(&mut conn, user.id)?;
|
apply_user_guc(&mut conn, user.id)?;
|
||||||
|
|
||||||
let membership_exists = memberships_dsl::user_memberships
|
let membership_exists = memberships_dsl::user_memberships
|
||||||
@@ -468,7 +474,8 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
|||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%username,
|
presented_username = %presented_username,
|
||||||
|
username = %user.username,
|
||||||
tenant_id = %token.tenant_id,
|
tenant_id = %token.tenant_id,
|
||||||
"webdav token tenant membership missing"
|
"webdav token tenant membership missing"
|
||||||
);
|
);
|
||||||
@@ -477,10 +484,11 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
|||||||
};
|
};
|
||||||
|
|
||||||
apply_tenant_guc(&mut conn, tenant_id)?;
|
apply_tenant_guc(&mut conn, tenant_id)?;
|
||||||
touch_webdav_token(&mut conn, token.id)?;
|
touch_api_token(&mut conn, token.id)?;
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
%username,
|
presented_username = %presented_username,
|
||||||
|
username = %user.username,
|
||||||
tenant_id = %tenant_id,
|
tenant_id = %tenant_id,
|
||||||
token_id = %token.id,
|
token_id = %token.id,
|
||||||
"webdav token login success"
|
"webdav token login success"
|
||||||
|
|||||||
+12
-4
@@ -8,6 +8,10 @@ pub mod sql_types {
|
|||||||
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||||
#[diesel(postgres_type(name = "tenant_status"))]
|
#[diesel(postgres_type(name = "tenant_status"))]
|
||||||
pub struct TenantStatus;
|
pub struct TenantStatus;
|
||||||
|
|
||||||
|
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
|
||||||
|
#[diesel(postgres_type(name = "api_token_capability"))]
|
||||||
|
pub struct ApiTokenCapability;
|
||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
@@ -246,7 +250,10 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
webdav_tokens (id) {
|
use diesel::sql_types::*;
|
||||||
|
use super::sql_types::ApiTokenCapability;
|
||||||
|
|
||||||
|
api_tokens (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
user_id -> Uuid,
|
user_id -> Uuid,
|
||||||
tenant_id -> Uuid,
|
tenant_id -> Uuid,
|
||||||
@@ -257,6 +264,7 @@ diesel::table! {
|
|||||||
last_used_at -> Nullable<Timestamptz>,
|
last_used_at -> Nullable<Timestamptz>,
|
||||||
expires_at -> Nullable<Timestamptz>,
|
expires_at -> Nullable<Timestamptz>,
|
||||||
revoked_at -> Nullable<Timestamptz>,
|
revoked_at -> Nullable<Timestamptz>,
|
||||||
|
capabilities -> Array<ApiTokenCapability>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,8 +293,8 @@ diesel::joinable!(user_memberships -> tenants (tenant_id));
|
|||||||
diesel::joinable!(user_memberships -> users (user_id));
|
diesel::joinable!(user_memberships -> users (user_id));
|
||||||
diesel::joinable!(user_passkeys -> users (user_id));
|
diesel::joinable!(user_passkeys -> users (user_id));
|
||||||
diesel::joinable!(webauthn_challenges -> users (user_id));
|
diesel::joinable!(webauthn_challenges -> users (user_id));
|
||||||
diesel::joinable!(webdav_tokens -> tenants (tenant_id));
|
diesel::joinable!(api_tokens -> tenants (tenant_id));
|
||||||
diesel::joinable!(webdav_tokens -> users (user_id));
|
diesel::joinable!(api_tokens -> users (user_id));
|
||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
correspondents,
|
correspondents,
|
||||||
@@ -306,5 +314,5 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
user_passkeys,
|
user_passkeys,
|
||||||
users,
|
users,
|
||||||
webauthn_challenges,
|
webauthn_challenges,
|
||||||
webdav_tokens,
|
api_tokens,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ pub fn clear_tenant_context(conn: &mut PgConnection) -> AppResult<()> {
|
|||||||
set_config('papercrate.tenant_id', '', false), \
|
set_config('papercrate.tenant_id', '', false), \
|
||||||
set_config('papercrate.user_id', '', false), \
|
set_config('papercrate.user_id', '', false), \
|
||||||
set_config('papercrate.refresh_token_hash', '', false), \
|
set_config('papercrate.refresh_token_hash', '', false), \
|
||||||
set_config('papercrate.webdav_token_prefix', '', false)",
|
set_config('papercrate.api_token_prefix', '', false)",
|
||||||
)
|
)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
@@ -178,16 +178,16 @@ pub fn clear_refresh_token_hash(conn: &mut PgConnection) -> AppResult<()> {
|
|||||||
.map_err(AppError::from)
|
.map_err(AppError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_webdav_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResult<()> {
|
pub fn apply_api_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResult<()> {
|
||||||
diesel::sql_query("SELECT set_config('papercrate.webdav_token_prefix', $1, false)")
|
diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', $1, false)")
|
||||||
.bind::<Text, _>(prefix)
|
.bind::<Text, _>(prefix)
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(AppError::from)
|
.map_err(AppError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_webdav_token_prefix(conn: &mut PgConnection) -> AppResult<()> {
|
pub fn clear_api_token_prefix(conn: &mut PgConnection) -> AppResult<()> {
|
||||||
diesel::sql_query("SELECT set_config('papercrate.webdav_token_prefix', '', false)")
|
diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', '', false)")
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(AppError::from)
|
.map_err(AppError::from)
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{header, Method, Request, StatusCode};
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use base64::Engine;
|
||||||
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use papercrate::models::{ApiToken, ApiTokenCapability};
|
||||||
|
use papercrate::routes::webdav;
|
||||||
|
use papercrate::schema::api_tokens;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TokenInfo {
|
||||||
|
id: Uuid,
|
||||||
|
label: Option<String>,
|
||||||
|
last_used_at: Option<String>,
|
||||||
|
revoked_at: Option<String>,
|
||||||
|
capabilities: Vec<ApiTokenCapability>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CreateTokenResponse {
|
||||||
|
token: String,
|
||||||
|
#[serde(rename = "token_info")]
|
||||||
|
info: TokenInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LoginResponseView {
|
||||||
|
access_token: String,
|
||||||
|
token_type: String,
|
||||||
|
expires_in: i64,
|
||||||
|
tenant: TenantView,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TenantView {
|
||||||
|
id: Uuid,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn api_token_crud_flow() -> Result<()> {
|
||||||
|
let _guard = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let username = "alice";
|
||||||
|
let password = "correct horse battery";
|
||||||
|
app.insert_user(username, password, "admin").await?;
|
||||||
|
let access_token = app.login_token(username, password).await?;
|
||||||
|
|
||||||
|
let created = create_token(&app, &access_token, json!({ "label": "dav" })).await?;
|
||||||
|
let token_id = created.info.id;
|
||||||
|
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
||||||
|
assert!(created.info.last_used_at.is_none());
|
||||||
|
assert_eq!(created.info.capabilities, vec![ApiTokenCapability::Webdav]);
|
||||||
|
|
||||||
|
let regenerated = regenerate_token(&app, &access_token, token_id).await?;
|
||||||
|
assert_eq!(regenerated.info.id, token_id);
|
||||||
|
assert_ne!(regenerated.token, created.token);
|
||||||
|
assert!(regenerated.info.last_used_at.is_none());
|
||||||
|
|
||||||
|
let updated = update_token_capabilities(
|
||||||
|
&app,
|
||||||
|
&access_token,
|
||||||
|
token_id,
|
||||||
|
json!({ "capabilities": ["webdav", "api"] }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(updated.capabilities.len(), 2);
|
||||||
|
assert!(updated.capabilities.contains(&ApiTokenCapability::Webdav));
|
||||||
|
assert!(updated.capabilities.contains(&ApiTokenCapability::Api));
|
||||||
|
|
||||||
|
let listed = list_tokens(&app, &access_token).await?;
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
assert_eq!(listed[0].id, token_id);
|
||||||
|
|
||||||
|
let tenant_id_for_token = app
|
||||||
|
.with_conn(move |conn| {
|
||||||
|
let tenant_id = api_tokens::table
|
||||||
|
.find(token_id)
|
||||||
|
.select(api_tokens::tenant_id)
|
||||||
|
.first::<Uuid>(conn)?;
|
||||||
|
Ok::<_, anyhow::Error>(tenant_id)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let exchange = exchange_token(&app, ®enerated.token).await?;
|
||||||
|
assert_eq!(exchange.token_type, "Bearer");
|
||||||
|
assert!(!exchange.access_token.is_empty());
|
||||||
|
assert!(exchange.expires_in > 0);
|
||||||
|
assert_eq!(exchange.tenant.id, tenant_id_for_token);
|
||||||
|
assert!(!exchange.tenant.name.is_empty());
|
||||||
|
|
||||||
|
delete_token(&app, &access_token, token_id).await?;
|
||||||
|
|
||||||
|
let listed_after = list_tokens(&app, &access_token).await?;
|
||||||
|
assert_eq!(listed_after.len(), 1);
|
||||||
|
assert_eq!(listed_after[0].id, token_id);
|
||||||
|
assert!(listed_after[0].revoked_at.is_some());
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
|
||||||
|
let _guard = acquire_db_lock().await;
|
||||||
|
let app = TestApp::new().await?;
|
||||||
|
|
||||||
|
let username = "bruce";
|
||||||
|
let password = "wayne";
|
||||||
|
app.insert_user(username, password, "admin").await?;
|
||||||
|
let access_token = app.login_token(username, password).await?;
|
||||||
|
|
||||||
|
let created = create_token(&app, &access_token, json!({ "label": "webdav" })).await?;
|
||||||
|
let token_id = created.info.id;
|
||||||
|
|
||||||
|
let router = webdav::create_router().with_state(app.state.clone());
|
||||||
|
let original_secret = created.token.clone();
|
||||||
|
let auth_header = format!(
|
||||||
|
"Basic {}",
|
||||||
|
BASE64.encode(format!("{}:{}", username, original_secret))
|
||||||
|
);
|
||||||
|
|
||||||
|
let propfind = Method::from_bytes(b"PROPFIND")?;
|
||||||
|
let success_request = Request::builder()
|
||||||
|
.method(propfind.clone())
|
||||||
|
.uri("/")
|
||||||
|
.header(header::AUTHORIZATION, auth_header.clone())
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let response = router.clone().oneshot(success_request).await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
||||||
|
|
||||||
|
let used = app
|
||||||
|
.with_conn(move |conn| {
|
||||||
|
let record = api_tokens::table.find(token_id).first::<ApiToken>(conn)?;
|
||||||
|
Ok::<_, anyhow::Error>(record.last_used_at)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
assert!(used.is_some());
|
||||||
|
|
||||||
|
let regenerated = regenerate_token(&app, &access_token, token_id).await?;
|
||||||
|
assert_ne!(regenerated.token, original_secret);
|
||||||
|
|
||||||
|
let unused_after_regen = app
|
||||||
|
.with_conn(move |conn| {
|
||||||
|
let record = api_tokens::table.find(token_id).first::<ApiToken>(conn)?;
|
||||||
|
Ok::<_, anyhow::Error>(record.last_used_at)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
assert!(unused_after_regen.is_none());
|
||||||
|
|
||||||
|
let old_secret_request = Request::builder()
|
||||||
|
.method(propfind.clone())
|
||||||
|
.uri("/")
|
||||||
|
.header(
|
||||||
|
header::AUTHORIZATION,
|
||||||
|
format!(
|
||||||
|
"Basic {}",
|
||||||
|
BASE64.encode(format!("{}:{}", username, original_secret))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let old_secret_response = router.clone().oneshot(old_secret_request).await?;
|
||||||
|
assert_eq!(old_secret_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let new_secret_header = format!(
|
||||||
|
"Basic {}",
|
||||||
|
BASE64.encode(format!("{}:{}", username, regenerated.token))
|
||||||
|
);
|
||||||
|
|
||||||
|
let success_request = Request::builder()
|
||||||
|
.method(propfind.clone())
|
||||||
|
.uri("/")
|
||||||
|
.header(header::AUTHORIZATION, new_secret_header.clone())
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let response = router.clone().oneshot(success_request).await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
||||||
|
|
||||||
|
delete_token(&app, &access_token, token_id).await?;
|
||||||
|
|
||||||
|
let failure_request = Request::builder()
|
||||||
|
.method(propfind)
|
||||||
|
.uri("/")
|
||||||
|
.header(header::AUTHORIZATION, new_secret_header)
|
||||||
|
.header("depth", "0")
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let failure_response = router.oneshot(failure_request).await?;
|
||||||
|
assert_eq!(failure_response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_token(
|
||||||
|
app: &TestApp,
|
||||||
|
access_token: &str,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> Result<CreateTokenResponse> {
|
||||||
|
let response = app
|
||||||
|
.post_json("/api/profile/api-tokens", &payload, Some(access_token))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
Ok(serde_json::from_slice(&body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn regenerate_token(
|
||||||
|
app: &TestApp,
|
||||||
|
access_token: &str,
|
||||||
|
token_id: Uuid,
|
||||||
|
) -> Result<CreateTokenResponse> {
|
||||||
|
let response = app
|
||||||
|
.post_json(
|
||||||
|
&format!("/api/profile/api-tokens/{token_id}/regenerate"),
|
||||||
|
&json!({}),
|
||||||
|
Some(access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
Ok(serde_json::from_slice(&body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_token_capabilities(
|
||||||
|
app: &TestApp,
|
||||||
|
access_token: &str,
|
||||||
|
token_id: Uuid,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> Result<TokenInfo> {
|
||||||
|
let response = app
|
||||||
|
.patch_json(
|
||||||
|
&format!("/api/profile/api-tokens/{token_id}"),
|
||||||
|
&payload,
|
||||||
|
Some(access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
Ok(serde_json::from_slice(&body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_tokens(app: &TestApp, access_token: &str) -> Result<Vec<TokenInfo>> {
|
||||||
|
let response = app
|
||||||
|
.get("/api/profile/api-tokens", Some(access_token))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
Ok(serde_json::from_slice(&body)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_token(app: &TestApp, access_token: &str, token_id: Uuid) -> Result<()> {
|
||||||
|
let response = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/profile/api-tokens/{token_id}"),
|
||||||
|
Some(access_token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn exchange_token(app: &TestApp, api_token: &str) -> Result<LoginResponseView> {
|
||||||
|
let response = app
|
||||||
|
.post_json(
|
||||||
|
"/api/auth/exchange-api-token",
|
||||||
|
&json!({ "api_token": api_token }),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
Ok(serde_json::from_slice(&body)?)
|
||||||
|
}
|
||||||
@@ -789,7 +789,7 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
|||||||
shared.jobs, \
|
shared.jobs, \
|
||||||
tenant.refresh_tokens, \
|
tenant.refresh_tokens, \
|
||||||
tenant.tags, \
|
tenant.tags, \
|
||||||
tenant.webdav_tokens, \
|
tenant.api_tokens, \
|
||||||
shared.webauthn_challenges, \
|
shared.webauthn_challenges, \
|
||||||
shared.user_passkeys, \
|
shared.user_passkeys, \
|
||||||
tenant.user_memberships, \
|
tenant.user_memberships, \
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
mod common;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::http::{header, Method, Request, StatusCode};
|
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
|
||||||
use base64::Engine;
|
|
||||||
use common::{acquire_db_lock, body_to_vec, TestApp};
|
|
||||||
use diesel::prelude::*;
|
|
||||||
use papercrate::models::WebdavToken;
|
|
||||||
use papercrate::routes::webdav;
|
|
||||||
use papercrate::schema::webdav_tokens;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
|
||||||
use tower::ServiceExt;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct TokenInfo {
|
|
||||||
id: Uuid,
|
|
||||||
label: Option<String>,
|
|
||||||
last_used_at: Option<String>,
|
|
||||||
revoked_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct CreateTokenResponse {
|
|
||||||
token: String,
|
|
||||||
#[serde(rename = "token_info")]
|
|
||||||
info: TokenInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn webdav_token_api_crud() -> Result<()> {
|
|
||||||
let _guard = acquire_db_lock().await;
|
|
||||||
let app = TestApp::new().await?;
|
|
||||||
|
|
||||||
let username = "alice";
|
|
||||||
let password = "correct horse battery";
|
|
||||||
app.insert_user(username, password, "admin").await?;
|
|
||||||
let access_token = app.login_token(username, password).await?;
|
|
||||||
|
|
||||||
let create_response = app
|
|
||||||
.post_json(
|
|
||||||
"/api/profile/webdav-tokens",
|
|
||||||
&json!({ "label": "dav" }),
|
|
||||||
Some(&access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
|
||||||
let create_body = body_to_vec(create_response.into_body()).await?;
|
|
||||||
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
|
|
||||||
let token_id = created.info.id;
|
|
||||||
assert_eq!(created.info.label.as_deref(), Some("dav"));
|
|
||||||
assert!(created.info.last_used_at.is_none());
|
|
||||||
|
|
||||||
let regenerate_response = app
|
|
||||||
.post_json(
|
|
||||||
&format!("/api/profile/webdav-tokens/{token_id}/regenerate"),
|
|
||||||
&json!({}),
|
|
||||||
Some(&access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(regenerate_response.status(), StatusCode::OK);
|
|
||||||
let regenerate_body = body_to_vec(regenerate_response.into_body()).await?;
|
|
||||||
let regenerated: CreateTokenResponse = serde_json::from_slice(®enerate_body)?;
|
|
||||||
assert_eq!(regenerated.info.id, token_id);
|
|
||||||
assert_ne!(regenerated.token, created.token);
|
|
||||||
assert!(regenerated.info.last_used_at.is_none());
|
|
||||||
|
|
||||||
let list_response = app
|
|
||||||
.get("/api/profile/webdav-tokens", Some(&access_token))
|
|
||||||
.await?;
|
|
||||||
assert_eq!(list_response.status(), StatusCode::OK);
|
|
||||||
let list_body = body_to_vec(list_response.into_body()).await?;
|
|
||||||
let listed: Vec<TokenInfo> = serde_json::from_slice(&list_body)?;
|
|
||||||
assert_eq!(listed.len(), 1);
|
|
||||||
assert_eq!(listed[0].id, token_id);
|
|
||||||
|
|
||||||
let delete_response = app
|
|
||||||
.delete(
|
|
||||||
&format!("/api/profile/webdav-tokens/{token_id}"),
|
|
||||||
Some(&access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
|
||||||
|
|
||||||
let list_after = app
|
|
||||||
.get("/api/profile/webdav-tokens", Some(&access_token))
|
|
||||||
.await?;
|
|
||||||
let list_after_body = body_to_vec(list_after.into_body()).await?;
|
|
||||||
let listed_after: Vec<TokenInfo> = serde_json::from_slice(&list_after_body)?;
|
|
||||||
assert_eq!(listed_after.len(), 1);
|
|
||||||
assert_eq!(listed_after[0].id, token_id);
|
|
||||||
assert!(listed_after[0].revoked_at.is_some());
|
|
||||||
|
|
||||||
app.cleanup().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn webdav_basic_auth_uses_tokens() -> Result<()> {
|
|
||||||
let _guard = acquire_db_lock().await;
|
|
||||||
let app = TestApp::new().await?;
|
|
||||||
|
|
||||||
let username = "bruce";
|
|
||||||
let password = "wayne";
|
|
||||||
app.insert_user(username, password, "admin").await?;
|
|
||||||
let access_token = app.login_token(username, password).await?;
|
|
||||||
|
|
||||||
let create_response = app
|
|
||||||
.post_json(
|
|
||||||
"/api/profile/webdav-tokens",
|
|
||||||
&json!({ "label": "webdav" }),
|
|
||||||
Some(&access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let create_body = body_to_vec(create_response.into_body()).await?;
|
|
||||||
let created: CreateTokenResponse = serde_json::from_slice(&create_body)?;
|
|
||||||
let token_id = created.info.id;
|
|
||||||
|
|
||||||
let router = webdav::create_router().with_state(app.state.clone());
|
|
||||||
let original_secret = created.token.clone();
|
|
||||||
let auth_header = format!(
|
|
||||||
"Basic {}",
|
|
||||||
BASE64.encode(format!("{}:{}", username, original_secret))
|
|
||||||
);
|
|
||||||
|
|
||||||
let propfind = Method::from_bytes(b"PROPFIND")?;
|
|
||||||
let success_request = Request::builder()
|
|
||||||
.method(propfind.clone())
|
|
||||||
.uri("/")
|
|
||||||
.header(header::AUTHORIZATION, auth_header.clone())
|
|
||||||
.header("depth", "0")
|
|
||||||
.body(Body::empty())?;
|
|
||||||
let response = router.clone().oneshot(success_request).await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
|
||||||
|
|
||||||
let used = app
|
|
||||||
.with_conn(move |conn| {
|
|
||||||
let record = webdav_tokens::table
|
|
||||||
.find(token_id)
|
|
||||||
.first::<WebdavToken>(conn)?;
|
|
||||||
Ok::<_, anyhow::Error>(record.last_used_at)
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
assert!(used.is_some());
|
|
||||||
|
|
||||||
let regenerate_response = app
|
|
||||||
.post_json(
|
|
||||||
&format!("/api/profile/webdav-tokens/{token_id}/regenerate"),
|
|
||||||
&json!({}),
|
|
||||||
Some(&access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(regenerate_response.status(), StatusCode::OK);
|
|
||||||
let regenerate_body = body_to_vec(regenerate_response.into_body()).await?;
|
|
||||||
let regenerated: CreateTokenResponse = serde_json::from_slice(®enerate_body)?;
|
|
||||||
assert_ne!(regenerated.token, original_secret);
|
|
||||||
|
|
||||||
let unused_after_regen = app
|
|
||||||
.with_conn(move |conn| {
|
|
||||||
let record = webdav_tokens::table
|
|
||||||
.find(token_id)
|
|
||||||
.first::<WebdavToken>(conn)?;
|
|
||||||
Ok::<_, anyhow::Error>(record.last_used_at)
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
assert!(unused_after_regen.is_none());
|
|
||||||
|
|
||||||
let old_secret_request = Request::builder()
|
|
||||||
.method(propfind.clone())
|
|
||||||
.uri("/")
|
|
||||||
.header(
|
|
||||||
header::AUTHORIZATION,
|
|
||||||
format!(
|
|
||||||
"Basic {}",
|
|
||||||
BASE64.encode(format!("{}:{}", username, original_secret))
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.header("depth", "0")
|
|
||||||
.body(Body::empty())?;
|
|
||||||
let old_secret_response = router.clone().oneshot(old_secret_request).await?;
|
|
||||||
assert_eq!(old_secret_response.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
|
|
||||||
let new_secret_header = format!(
|
|
||||||
"Basic {}",
|
|
||||||
BASE64.encode(format!("{}:{}", username, regenerated.token))
|
|
||||||
);
|
|
||||||
|
|
||||||
let success_request = Request::builder()
|
|
||||||
.method(propfind.clone())
|
|
||||||
.uri("/")
|
|
||||||
.header(header::AUTHORIZATION, new_secret_header.clone())
|
|
||||||
.header("depth", "0")
|
|
||||||
.body(Body::empty())?;
|
|
||||||
let response = router.clone().oneshot(success_request).await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
|
|
||||||
|
|
||||||
let delete_response = app
|
|
||||||
.delete(
|
|
||||||
&format!("/api/profile/webdav-tokens/{token_id}"),
|
|
||||||
Some(&access_token),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(delete_response.status(), StatusCode::NO_CONTENT);
|
|
||||||
|
|
||||||
let failure_request = Request::builder()
|
|
||||||
.method(propfind)
|
|
||||||
.uri("/")
|
|
||||||
.header(header::AUTHORIZATION, new_secret_header)
|
|
||||||
.header("depth", "0")
|
|
||||||
.body(Body::empty())?;
|
|
||||||
let response = router.oneshot(failure_request).await?;
|
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
|
|
||||||
app.cleanup().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user