api-tokens
This commit is contained in:
@@ -18,6 +18,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
api_tokens::{find_active_token_by_secret, touch_api_token},
|
||||
passkeys::{
|
||||
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
|
||||
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
|
||||
@@ -26,7 +27,8 @@ use crate::{
|
||||
},
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, TenantStatus, User,
|
||||
ApiTokenCapability, MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken,
|
||||
TenantStatus, User,
|
||||
},
|
||||
schema::{
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct ApiTokenExchangeRequest {
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
@@ -120,6 +127,7 @@ pub enum LoginResponseVariants {
|
||||
#[openapi(
|
||||
paths(
|
||||
login,
|
||||
api_token_exchange,
|
||||
signup_start,
|
||||
signup_finish,
|
||||
refresh,
|
||||
@@ -134,6 +142,7 @@ pub enum LoginResponseVariants {
|
||||
),
|
||||
components(schemas(
|
||||
LoginRequest,
|
||||
ApiTokenExchangeRequest,
|
||||
SignupStartRequest,
|
||||
SignupStartResponse,
|
||||
SignupFinishRequest,
|
||||
@@ -150,6 +159,7 @@ pub enum LoginResponseVariants {
|
||||
crate::auth::passkeys::PasskeyRegistrationFinishPayload,
|
||||
crate::auth::passkeys::PasskeyLoginStartPayload,
|
||||
crate::auth::passkeys::PasskeyLoginFinishPayload,
|
||||
crate::models::ApiTokenCapability,
|
||||
))
|
||||
)]
|
||||
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(
|
||||
post,
|
||||
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/finish", post(auth::signup_finish))
|
||||
.route("/login", post(auth::login))
|
||||
.route("/exchange-api-token", post(auth::api_token_exchange))
|
||||
.route("/refresh", post(auth::refresh))
|
||||
.route("/logout", post(auth::logout))
|
||||
.route("/select-tenant", post(auth::select_tenant))
|
||||
@@ -145,14 +146,17 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
|
||||
let profile_routes = Router::new()
|
||||
.route(
|
||||
"/webdav-tokens",
|
||||
get(profile::list_webdav_tokens).post(profile::create_webdav_token),
|
||||
"/api-tokens",
|
||||
get(profile::list_api_tokens).post(profile::create_api_token),
|
||||
)
|
||||
.route(
|
||||
"/webdav-tokens/:id/regenerate",
|
||||
post(profile::regenerate_webdav_token),
|
||||
"/api-tokens/:id/regenerate",
|
||||
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/:id", delete(profile::delete_passkey));
|
||||
|
||||
|
||||
+110
-50
@@ -9,24 +9,26 @@ use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{
|
||||
passkeys::PasskeySummary,
|
||||
webdav_tokens::{
|
||||
create_webdav_token as issue_token, list_webdav_tokens as load_tokens,
|
||||
regenerate_webdav_token as rotate_token, revoke_webdav_token as revoke_token,
|
||||
api_tokens::{
|
||||
create_api_token as issue_token, list_api_tokens as load_tokens,
|
||||
regenerate_api_token as rotate_token, revoke_api_token as revoke_token,
|
||||
update_api_token_capabilities as update_capabilities,
|
||||
},
|
||||
passkeys::PasskeySummary,
|
||||
TenantScopedConn,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::WebdavToken;
|
||||
use crate::models::{ApiToken, ApiTokenCapability};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{db::no_content, time::to_iso};
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct WebdavTokenResponse {
|
||||
pub struct ApiTokenResponse {
|
||||
pub id: Uuid,
|
||||
pub tenant_id: Uuid,
|
||||
#[schema(nullable)]
|
||||
pub label: Option<String>,
|
||||
pub capabilities: Vec<ApiTokenCapability>,
|
||||
pub created_at: String,
|
||||
#[schema(nullable)]
|
||||
pub last_used_at: Option<String>,
|
||||
@@ -37,17 +39,25 @@ pub struct WebdavTokenResponse {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct WebdavTokenCreatedResponse {
|
||||
pub struct ApiTokenCreatedResponse {
|
||||
pub token: String,
|
||||
pub token_info: WebdavTokenResponse,
|
||||
pub token_info: ApiTokenResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateWebdavTokenRequest {
|
||||
pub struct CreateApiTokenRequest {
|
||||
#[schema(nullable)]
|
||||
pub label: Option<String>,
|
||||
#[schema(nullable)]
|
||||
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)]
|
||||
@@ -80,55 +90,60 @@ pub async fn list_passkeys(
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/profile/webdav-tokens",
|
||||
responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])),
|
||||
path = "/api/profile/api-tokens",
|
||||
responses((status = 200, description = "List API tokens", body = [ApiTokenResponse])),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn list_webdav_tokens(
|
||||
pub async fn list_api_tokens(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
) -> AppResult<Json<Vec<WebdavTokenResponse>>> {
|
||||
) -> AppResult<Json<Vec<ApiTokenResponse>>> {
|
||||
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))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/profile/webdav-tokens",
|
||||
request_body = CreateWebdavTokenRequest,
|
||||
responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)),
|
||||
path = "/api/profile/api-tokens",
|
||||
request_body = CreateApiTokenRequest,
|
||||
responses((status = 201, description = "API token created", body = ApiTokenCreatedResponse)),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn create_webdav_token(
|
||||
pub async fn create_api_token(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
user_id,
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
Json(payload): Json<CreateWebdavTokenRequest>,
|
||||
) -> AppResult<(StatusCode, Json<WebdavTokenCreatedResponse>)> {
|
||||
Json(payload): Json<CreateApiTokenRequest>,
|
||||
) -> AppResult<(StatusCode, Json<ApiTokenCreatedResponse>)> {
|
||||
let expires_at = match payload.expires_at {
|
||||
Some(ref value) => Some(parse_timestamp(value)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let capabilities = payload
|
||||
.capabilities
|
||||
.unwrap_or_else(|| vec![ApiTokenCapability::Webdav]);
|
||||
|
||||
let issued = issue_token(
|
||||
&mut conn,
|
||||
user_id,
|
||||
tenant_id,
|
||||
payload.label.clone(),
|
||||
expires_at,
|
||||
capabilities,
|
||||
)?;
|
||||
|
||||
let response = WebdavTokenCreatedResponse {
|
||||
let response = ApiTokenCreatedResponse {
|
||||
token: issued.token,
|
||||
token_info: webdav_token_to_response(issued.record),
|
||||
token_info: api_token_to_response(issued.record),
|
||||
};
|
||||
|
||||
Ok((StatusCode::CREATED, Json(response)))
|
||||
@@ -136,12 +151,12 @@ pub async fn create_webdav_token(
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/profile/webdav-tokens/{id}/regenerate",
|
||||
params(("id" = Uuid, Path, description = "WebDAV token ID")),
|
||||
responses((status = 200, description = "WebDAV token regenerated", body = WebdavTokenCreatedResponse)),
|
||||
path = "/api/profile/api-tokens/{id}/regenerate",
|
||||
params(("id" = Uuid, Path, description = "API token ID")),
|
||||
responses((status = 200, description = "API token regenerated", body = ApiTokenCreatedResponse)),
|
||||
tag = "Profile"
|
||||
)]
|
||||
pub async fn regenerate_webdav_token(
|
||||
pub async fn regenerate_api_token(
|
||||
TenantScopedConn {
|
||||
mut conn,
|
||||
tenant_id,
|
||||
@@ -149,24 +164,53 @@ pub async fn regenerate_webdav_token(
|
||||
..
|
||||
}: TenantScopedConn,
|
||||
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 response = WebdavTokenCreatedResponse {
|
||||
let response = ApiTokenCreatedResponse {
|
||||
token: issued.token,
|
||||
token_info: webdav_token_to_response(issued.record),
|
||||
token_info: api_token_to_response(issued.record),
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/profile/webdav-tokens/{id}",
|
||||
params(("id" = Uuid, Path, description = "WebDAV token ID")),
|
||||
responses((status = 204, description = "WebDAV token revoked")),
|
||||
patch,
|
||||
path = "/api/profile/api-tokens/{id}",
|
||||
params(("id" = Uuid, Path, description = "API token ID")),
|
||||
request_body = UpdateApiTokenCapabilitiesRequest,
|
||||
responses((status = 200, description = "API token updated", body = ApiTokenResponse)),
|
||||
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 {
|
||||
mut conn, user_id, ..
|
||||
}: TenantScopedConn,
|
||||
@@ -210,15 +254,28 @@ pub async fn delete_passkey(
|
||||
no_content()
|
||||
}
|
||||
|
||||
fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse {
|
||||
WebdavTokenResponse {
|
||||
id: token.id,
|
||||
tenant_id: token.tenant_id,
|
||||
label: token.label,
|
||||
created_at: to_iso(token.created_at),
|
||||
last_used_at: token.last_used_at.map(to_iso),
|
||||
expires_at: token.expires_at.map(to_iso),
|
||||
revoked_at: token.revoked_at.map(to_iso),
|
||||
fn api_token_to_response(token: ApiToken) -> ApiTokenResponse {
|
||||
let ApiToken {
|
||||
id,
|
||||
tenant_id,
|
||||
label,
|
||||
created_at,
|
||||
last_used_at,
|
||||
expires_at,
|
||||
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)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::routes::profile::list_webdav_tokens,
|
||||
crate::routes::profile::create_webdav_token,
|
||||
crate::routes::profile::regenerate_webdav_token,
|
||||
crate::routes::profile::delete_webdav_token,
|
||||
crate::routes::profile::list_api_tokens,
|
||||
crate::routes::profile::create_api_token,
|
||||
crate::routes::profile::regenerate_api_token,
|
||||
crate::routes::profile::update_api_token,
|
||||
crate::routes::profile::delete_api_token,
|
||||
crate::routes::profile::list_passkeys,
|
||||
crate::routes::profile::delete_passkey
|
||||
),
|
||||
components(schemas(
|
||||
crate::routes::profile::WebdavTokenResponse,
|
||||
crate::routes::profile::WebdavTokenCreatedResponse,
|
||||
crate::routes::profile::CreateWebdavTokenRequest,
|
||||
crate::models::ApiTokenCapability,
|
||||
crate::routes::profile::ApiTokenResponse,
|
||||
crate::routes::profile::ApiTokenCreatedResponse,
|
||||
crate::routes::profile::CreateApiTokenRequest,
|
||||
crate::routes::profile::UpdateApiTokenCapabilitiesRequest,
|
||||
crate::routes::profile::RevokePasskeyQuery,
|
||||
crate::auth::passkeys::PasskeySummary
|
||||
))
|
||||
|
||||
@@ -16,9 +16,9 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
|
||||
use quick_xml::Writer;
|
||||
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::models::{Document, DocumentVersion, Folder, User};
|
||||
use crate::models::{ApiTokenCapability, Document, DocumentVersion, Folder, User};
|
||||
use crate::schema::{
|
||||
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,
|
||||
@@ -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),
|
||||
_ => 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 user: User = match users_dsl::users
|
||||
.filter(users_dsl::username.eq(username))
|
||||
.first(&mut conn)
|
||||
{
|
||||
let token = match find_active_token_by_secret(
|
||||
&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,
|
||||
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);
|
||||
}
|
||||
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)?;
|
||||
|
||||
let membership_exists = memberships_dsl::user_memberships
|
||||
@@ -468,7 +474,8 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
|
||||
Some(id) => id,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
%username,
|
||||
presented_username = %presented_username,
|
||||
username = %user.username,
|
||||
tenant_id = %token.tenant_id,
|
||||
"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)?;
|
||||
touch_webdav_token(&mut conn, token.id)?;
|
||||
touch_api_token(&mut conn, token.id)?;
|
||||
|
||||
tracing::debug!(
|
||||
%username,
|
||||
presented_username = %presented_username,
|
||||
username = %user.username,
|
||||
tenant_id = %tenant_id,
|
||||
token_id = %token.id,
|
||||
"webdav token login success"
|
||||
|
||||
Reference in New Issue
Block a user