mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-07-28 04:02:38 +00:00
refactor(api): extract common helpers module, extend API error types with auth methods
This commit is contained in:
Generated
+20
-20
@@ -6094,7 +6094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-api"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -6142,7 +6142,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-auth"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base32",
|
||||
@@ -6165,7 +6165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-cache"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6179,7 +6179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-comms"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6194,7 +6194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-config"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"confique",
|
||||
"serde",
|
||||
@@ -6202,7 +6202,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-crypto"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64 0.22.1",
|
||||
@@ -6218,7 +6218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -6235,7 +6235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-db-traits"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -6251,7 +6251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-infra"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -6262,7 +6262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-lexicon"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hickory-resolver",
|
||||
@@ -6280,7 +6280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -6303,7 +6303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-oauth-server"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -6336,7 +6336,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-pds"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -6424,7 +6424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-repo"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cid",
|
||||
@@ -6436,7 +6436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-ripple"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"backon",
|
||||
@@ -6461,7 +6461,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-scopes"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
@@ -6477,7 +6477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-server"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"clap",
|
||||
@@ -6497,7 +6497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-storage"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
@@ -6514,7 +6514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-sync"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -6536,7 +6536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tranquil-types"
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cid",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.5"
|
||||
version = "0.4.6"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
use bcrypt::DEFAULT_COST;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::HashMap;
|
||||
use tracing::error;
|
||||
use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository};
|
||||
use tranquil_pds::api::error::ApiError;
|
||||
use tranquil_pds::api::error::DbResultExt;
|
||||
use tranquil_pds::types::{AtIdentifier, Did, Handle};
|
||||
|
||||
pub struct ResolvedRepo {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub did: Did,
|
||||
pub handle: Handle,
|
||||
}
|
||||
|
||||
fn qualify_handle(handle: &Handle) -> Result<Handle, ApiError> {
|
||||
let raw = handle.as_str();
|
||||
let qualified = match raw.contains('.') {
|
||||
true => return Ok(handle.clone()),
|
||||
false => format!(
|
||||
"{}.{}",
|
||||
raw,
|
||||
tranquil_config::get().server.hostname_without_port()
|
||||
),
|
||||
};
|
||||
qualified
|
||||
.parse()
|
||||
.map_err(|_| ApiError::InvalidRequest("Invalid handle format".into()))
|
||||
}
|
||||
|
||||
pub async fn resolve_repo(
|
||||
user_repo: &dyn UserRepository,
|
||||
repo: &AtIdentifier,
|
||||
) -> Result<ResolvedRepo, ApiError> {
|
||||
let row = match repo {
|
||||
AtIdentifier::Did(did) => user_repo
|
||||
.get_by_did(did)
|
||||
.await
|
||||
.log_db_err("resolving repo by DID")?,
|
||||
AtIdentifier::Handle(handle) => {
|
||||
let qualified = qualify_handle(handle)?;
|
||||
user_repo
|
||||
.get_by_handle(&qualified)
|
||||
.await
|
||||
.log_db_err("resolving repo by handle")?
|
||||
}
|
||||
};
|
||||
row.map(|r| ResolvedRepo {
|
||||
user_id: r.id,
|
||||
did: r.did,
|
||||
handle: r.handle,
|
||||
})
|
||||
.ok_or(ApiError::RepoNotFound(Some("Repo not found".into())))
|
||||
}
|
||||
|
||||
pub async fn resolve_repo_user_id(
|
||||
user_repo: &dyn UserRepository,
|
||||
repo: &AtIdentifier,
|
||||
) -> Result<uuid::Uuid, ApiError> {
|
||||
let id = match repo {
|
||||
AtIdentifier::Did(did) => user_repo
|
||||
.get_id_by_did(did)
|
||||
.await
|
||||
.log_db_err("resolving repo user ID by DID")?,
|
||||
AtIdentifier::Handle(handle) => {
|
||||
let qualified = qualify_handle(handle)?;
|
||||
user_repo
|
||||
.get_id_by_handle(&qualified)
|
||||
.await
|
||||
.log_db_err("resolving repo user ID by handle")?
|
||||
}
|
||||
};
|
||||
id.ok_or(ApiError::RepoNotFound(Some("Repo not found".into())))
|
||||
}
|
||||
|
||||
pub fn group_invite_uses_by_code<U, F>(
|
||||
uses: Vec<tranquil_db_traits::InviteCodeUse>,
|
||||
map_use: F,
|
||||
) -> HashMap<String, Vec<U>>
|
||||
where
|
||||
F: Fn(tranquil_db_traits::InviteCodeUse) -> U,
|
||||
{
|
||||
uses.into_iter().fold(HashMap::new(), |mut acc, u| {
|
||||
let code = u.code.clone();
|
||||
acc.entry(code).or_default().push(map_use(u));
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_also_known_as(
|
||||
overrides: Option<&DidWebOverrides>,
|
||||
current_handle: &str,
|
||||
) -> Vec<String> {
|
||||
overrides
|
||||
.filter(|ovr| !ovr.also_known_as.is_empty())
|
||||
.map(|ovr| ovr.also_known_as.clone())
|
||||
.unwrap_or_else(|| vec![format!("at://{}", current_handle)])
|
||||
}
|
||||
|
||||
pub fn build_did_document(
|
||||
did: &str,
|
||||
also_known_as: Vec<String>,
|
||||
verification_methods: Vec<serde_json::Value>,
|
||||
service_endpoint: &str,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
||||
],
|
||||
"id": did,
|
||||
"alsoKnownAs": also_known_as,
|
||||
"verificationMethod": verification_methods,
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
|
||||
"serviceEndpoint": service_endpoint
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_channel_verified_flag(
|
||||
user_repo: &dyn UserRepository,
|
||||
user_id: uuid::Uuid,
|
||||
channel: CommsChannel,
|
||||
) -> Result<(), ApiError> {
|
||||
match channel {
|
||||
CommsChannel::Email => user_repo
|
||||
.set_email_verified_flag(user_id)
|
||||
.await
|
||||
.log_db_err("updating email verified status")?,
|
||||
CommsChannel::Discord => user_repo
|
||||
.set_discord_verified_flag(user_id)
|
||||
.await
|
||||
.log_db_err("updating discord verified status")?,
|
||||
CommsChannel::Telegram => user_repo
|
||||
.set_telegram_verified_flag(user_id)
|
||||
.await
|
||||
.log_db_err("updating telegram verified status")?,
|
||||
CommsChannel::Signal => user_repo
|
||||
.set_signal_verified_flag(user_id)
|
||||
.await
|
||||
.log_db_err("updating signal verified status")?,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct ChannelInput<'a> {
|
||||
pub email: Option<&'a str>,
|
||||
pub discord_username: Option<&'a str>,
|
||||
pub telegram_username: Option<&'a str>,
|
||||
pub signal_username: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub fn extract_verification_recipient(
|
||||
channel: CommsChannel,
|
||||
input: &ChannelInput<'_>,
|
||||
) -> Result<String, ApiError> {
|
||||
match channel {
|
||||
CommsChannel::Email => match input.email {
|
||||
Some(e) if !e.trim().is_empty() => Ok(e.trim().to_string()),
|
||||
_ => Err(ApiError::MissingEmail),
|
||||
},
|
||||
CommsChannel::Discord => match input.discord_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().to_lowercase();
|
||||
if !tranquil_pds::api::validation::is_valid_discord_username(&clean) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
|
||||
));
|
||||
}
|
||||
Ok(clean)
|
||||
}
|
||||
_ => Err(ApiError::MissingDiscordId),
|
||||
},
|
||||
CommsChannel::Telegram => match input.telegram_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
let clean = username.trim().trim_start_matches('@');
|
||||
if !tranquil_pds::api::validation::is_valid_telegram_username(clean) {
|
||||
return Err(ApiError::InvalidRequest(
|
||||
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
|
||||
));
|
||||
}
|
||||
Ok(clean.to_string())
|
||||
}
|
||||
_ => Err(ApiError::MissingTelegramUsername),
|
||||
},
|
||||
CommsChannel::Signal => match input.signal_username {
|
||||
Some(username) if !username.trim().is_empty() => {
|
||||
Ok(username.trim().trim_start_matches('@').to_lowercase())
|
||||
}
|
||||
_ => Err(ApiError::MissingSignalNumber),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_self_hosted_did_web(handle: &str) -> Result<String, ApiError> {
|
||||
if !tranquil_pds::util::is_self_hosted_did_web_enabled() {
|
||||
return Err(ApiError::SelfHostedDidWebDisabled);
|
||||
}
|
||||
let encoded_handle = handle.replace(':', "%3A");
|
||||
Ok(format!("did:web:{}", encoded_handle))
|
||||
}
|
||||
|
||||
pub enum CredentialMatch {
|
||||
MainPassword,
|
||||
AppPassword {
|
||||
name: String,
|
||||
scopes: Option<String>,
|
||||
controller_did: Option<Did>,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn verify_credential(
|
||||
session_repo: &dyn SessionRepository,
|
||||
user_id: uuid::Uuid,
|
||||
password: &str,
|
||||
password_hash: Option<&str>,
|
||||
) -> Option<CredentialMatch> {
|
||||
let main_valid = password_hash
|
||||
.map(|h| bcrypt::verify(password, h).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
if main_valid {
|
||||
return Some(CredentialMatch::MainPassword);
|
||||
}
|
||||
let app_passwords = session_repo
|
||||
.get_app_passwords_for_login(user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
app_passwords
|
||||
.into_iter()
|
||||
.find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false))
|
||||
.map(|app| CredentialMatch::AppPassword {
|
||||
name: app.name,
|
||||
scopes: app.scopes,
|
||||
controller_did: app.created_by_controller_did,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hash_or_internal_error(value: &str) -> Result<String, ApiError> {
|
||||
bcrypt::hash(value, DEFAULT_COST).map_err(|e| {
|
||||
error!("Bcrypt hash error: {:?}", e);
|
||||
ApiError::InternalError(None)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_token_hash(
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
stored_hash: &str,
|
||||
input_token: &str,
|
||||
expired_err: ApiError,
|
||||
invalid_err: ApiError,
|
||||
) -> Result<(), ApiError> {
|
||||
match expires_at {
|
||||
Some(exp) if exp < Utc::now() => Err(expired_err),
|
||||
_ => match bcrypt::verify(input_token, stored_hash).unwrap_or(false) {
|
||||
true => Ok(()),
|
||||
false => Err(invalid_err),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod actor;
|
||||
pub mod admin;
|
||||
pub mod age_assurance;
|
||||
pub mod common;
|
||||
pub mod delegation;
|
||||
pub mod discord_webhook;
|
||||
pub mod identity;
|
||||
@@ -10,7 +11,6 @@ pub mod repo;
|
||||
pub mod server;
|
||||
pub mod telegram_webhook;
|
||||
pub mod temp;
|
||||
pub mod verification;
|
||||
|
||||
use tranquil_pds::state::AppState;
|
||||
|
||||
@@ -386,7 +386,7 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/_account.confirmChannelVerification",
|
||||
post(verification::confirm_channel_verification),
|
||||
post(server::confirm_channel_verification),
|
||||
)
|
||||
.route("/_account.verifyToken", post(server::verify_token))
|
||||
.route(
|
||||
|
||||
@@ -112,6 +112,12 @@ pub enum ApiError {
|
||||
SsoLinkNotFound,
|
||||
AuthFactorTokenRequired,
|
||||
LegacyLoginBlocked,
|
||||
ReauthRequired {
|
||||
methods: Vec<String>,
|
||||
},
|
||||
MfaVerificationRequiredWithMethods {
|
||||
methods: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
@@ -131,7 +137,8 @@ impl ApiError {
|
||||
| Self::InvalidPassword(_)
|
||||
| Self::InvalidToken(_)
|
||||
| Self::PasskeyCounterAnomaly
|
||||
| Self::OAuthExpiredToken(_) => StatusCode::UNAUTHORIZED,
|
||||
| Self::OAuthExpiredToken(_)
|
||||
| Self::ReauthRequired { .. } => StatusCode::UNAUTHORIZED,
|
||||
Self::InvalidCode(_) => StatusCode::BAD_REQUEST,
|
||||
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
|
||||
Self::Forbidden
|
||||
@@ -142,6 +149,7 @@ impl ApiError {
|
||||
| Self::AccountMigrated
|
||||
| Self::AccountNotVerified
|
||||
| Self::MfaVerificationRequired
|
||||
| Self::MfaVerificationRequiredWithMethods { .. }
|
||||
| Self::AuthorizationError(_) => StatusCode::FORBIDDEN,
|
||||
Self::RateLimitExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
|
||||
@@ -310,6 +318,10 @@ impl ApiError {
|
||||
Self::SsoLinkNotFound => Cow::Borrowed("SsoLinkNotFound"),
|
||||
Self::AuthFactorTokenRequired => Cow::Borrowed("AuthFactorTokenRequired"),
|
||||
Self::LegacyLoginBlocked => Cow::Borrowed("MfaRequired"),
|
||||
Self::ReauthRequired { .. } => Cow::Borrowed("ReauthRequired"),
|
||||
Self::MfaVerificationRequiredWithMethods { .. } => {
|
||||
Cow::Borrowed("MfaVerificationRequired")
|
||||
}
|
||||
}
|
||||
}
|
||||
fn message(&self) -> String {
|
||||
@@ -471,6 +483,12 @@ impl ApiError {
|
||||
Self::AuthFactorTokenRequired => {
|
||||
"A sign-in code has been sent to your email address".into()
|
||||
}
|
||||
Self::ReauthRequired { .. } => {
|
||||
"Re-authentication required for this action".into()
|
||||
}
|
||||
Self::MfaVerificationRequiredWithMethods { .. } => {
|
||||
"This sensitive operation requires MFA verification".into()
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn from_upstream_response(status: StatusCode, body: &[u8]) -> Self {
|
||||
@@ -499,6 +517,31 @@ impl ApiError {
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
Self::ReauthRequired { ref methods } => {
|
||||
return (
|
||||
self.status_code(),
|
||||
Json(serde_json::json!({
|
||||
"error": "ReauthRequired",
|
||||
"message": "Re-authentication required for this action",
|
||||
"reauthMethods": methods,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Self::MfaVerificationRequiredWithMethods { ref methods } => {
|
||||
return (
|
||||
self.status_code(),
|
||||
Json(serde_json::json!({
|
||||
"error": "MfaVerificationRequired",
|
||||
"message": "This sensitive operation requires MFA verification",
|
||||
"reauthMethods": methods,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let body = ErrorBody {
|
||||
error: self.error_name(),
|
||||
message: self.message(),
|
||||
@@ -594,6 +637,12 @@ impl From<crate::auth::extractor::AuthError> for ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::auth::scope_verified::ScopeVerificationError> for ApiError {
|
||||
fn from(e: crate::auth::scope_verified::ScopeVerificationError) -> Self {
|
||||
Self::InsufficientScope(Some(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::handle::HandleResolutionError> for ApiError {
|
||||
fn from(e: crate::handle::HandleResolutionError) -> Self {
|
||||
match e {
|
||||
|
||||
@@ -7,6 +7,8 @@ pub mod validation;
|
||||
pub use error::ApiError;
|
||||
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_limit};
|
||||
pub use responses::{
|
||||
DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse,
|
||||
StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse,
|
||||
AccountsOutput, AuditLogOutput, ControllersOutput, DidResponse, EmailUpdateStatusOutput,
|
||||
EmptyResponse, EnabledResponse, HasPasswordResponse, InUseOutput, OptionsResponse,
|
||||
PasswordResetOutput, PreferredLocaleOutput, PresetsOutput, StatusResponse, SuccessResponse,
|
||||
TokenRequiredResponse, VerifiedResponse,
|
||||
};
|
||||
|
||||
@@ -247,7 +247,7 @@ async fn proxy_handler(
|
||||
&resolved.did,
|
||||
method,
|
||||
) {
|
||||
return e;
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
let key_bytes = match auth_user.key_bytes {
|
||||
|
||||
@@ -116,3 +116,60 @@ impl<T: Serialize> OptionsResponse<T> {
|
||||
Json(Self { options })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountsOutput<T: Serialize> {
|
||||
pub accounts: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuditLogOutput<T: Serialize> {
|
||||
pub entries: T,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ControllersOutput<T: Serialize> {
|
||||
pub controllers: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PresetsOutput<T: Serialize> {
|
||||
pub presets: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EmailUpdateStatusOutput {
|
||||
pub pending: bool,
|
||||
pub authorized: bool,
|
||||
pub new_email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InUseOutput {
|
||||
pub in_use: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PasswordResetOutput {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub multiple_accounts: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub account_count: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PreferredLocaleOutput {
|
||||
pub preferred_locale: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use super::AuthenticatedUser;
|
||||
use crate::api::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
@@ -22,7 +20,7 @@ impl<'a> AccountVerified<'a> {
|
||||
pub async fn require_verified_or_delegated<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<AccountVerified<'a>, Response> {
|
||||
) -> Result<AccountVerified<'a>, ApiError> {
|
||||
let is_verified = state
|
||||
.user_repo
|
||||
.has_verified_comms_channel(&user.did)
|
||||
@@ -43,19 +41,18 @@ pub async fn require_verified_or_delegated<'a>(
|
||||
return Ok(AccountVerified { user });
|
||||
}
|
||||
|
||||
Err(ApiError::AccountNotVerified.into_response())
|
||||
Err(ApiError::AccountNotVerified)
|
||||
}
|
||||
|
||||
pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), Response> {
|
||||
pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), ApiError> {
|
||||
match state.user_repo.is_account_migrated(did).await {
|
||||
Ok(true) => Err(ApiError::AccountMigrated.into_response()),
|
||||
Ok(true) => Err(ApiError::AccountMigrated),
|
||||
Ok(false) => Ok(()),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check migration status: {:?}", e);
|
||||
Err(
|
||||
ApiError::InternalError(Some("Failed to verify migration status".into()))
|
||||
.into_response(),
|
||||
)
|
||||
Err(ApiError::InternalError(Some(
|
||||
"Failed to verify migration status".into(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use super::{
|
||||
is_service_token, scope_verified::VerifyScope, validate_bearer_token_for_service_auth,
|
||||
};
|
||||
use crate::api::error::ApiError;
|
||||
use crate::oauth::scopes::{RepoAction, ScopePermissions};
|
||||
use crate::oauth::scopes::{AccountAction, AccountAttr, RepoAction, ScopePermissions};
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
use crate::util::build_full_url;
|
||||
@@ -130,6 +130,12 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option<Extra
|
||||
None
|
||||
}
|
||||
|
||||
pub fn extract_jti_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let auth_header = headers.get(AUTHORIZATION)?.to_str().ok()?;
|
||||
let token = extract_bearer_token_from_header(Some(auth_header))?;
|
||||
tranquil_auth::get_jti_from_token(&token).ok()
|
||||
}
|
||||
|
||||
pub trait AuthPolicy: Send + Sync + 'static {
|
||||
fn validate(user: &AuthenticatedUser) -> Result<(), AuthError>;
|
||||
}
|
||||
@@ -356,14 +362,26 @@ impl<P: AuthPolicy> Auth<P> {
|
||||
self.0.permissions()
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn check_repo_scope(&self, action: RepoAction, collection: &str) -> Result<(), Response> {
|
||||
pub fn check_repo_scope(&self, action: RepoAction, collection: &str) -> Result<(), ApiError> {
|
||||
if !self.needs_scope_check() {
|
||||
return Ok(());
|
||||
}
|
||||
self.permissions()
|
||||
.assert_repo(action, collection)
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
|
||||
}
|
||||
|
||||
pub fn check_account_scope(
|
||||
&self,
|
||||
attr: AccountAttr,
|
||||
action: AccountAction,
|
||||
) -> Result<(), ApiError> {
|
||||
if !self.needs_scope_check() {
|
||||
return Ok(());
|
||||
}
|
||||
self.permissions()
|
||||
.assert_account(attr, action)
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use axum::response::Response;
|
||||
use crate::api::error::ApiError;
|
||||
|
||||
use super::AuthenticatedUser;
|
||||
use crate::state::AppState;
|
||||
@@ -73,21 +73,29 @@ impl<'a> MfaVerified<'a> {
|
||||
pub async fn require_legacy_session_mfa<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<MfaVerified<'a>, Response> {
|
||||
use crate::auth::reauth::{check_legacy_session_mfa, legacy_mfa_required_response};
|
||||
) -> Result<MfaVerified<'a>, ApiError> {
|
||||
use crate::auth::reauth::check_legacy_session_mfa;
|
||||
|
||||
if check_legacy_session_mfa(&*state.session_repo, &user.did).await {
|
||||
Ok(MfaVerified::from_session_reauth(user))
|
||||
} else {
|
||||
Err(legacy_mfa_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
|
||||
let methods = crate::auth::reauth::get_available_reauth_methods(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&user.did,
|
||||
)
|
||||
.await;
|
||||
Err(ApiError::MfaVerificationRequiredWithMethods {
|
||||
methods: methods.iter().map(|m| m.as_str().to_string()).collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_reauth_window<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<MfaVerified<'a>, Response> {
|
||||
use crate::auth::reauth::{REAUTH_WINDOW_SECONDS, reauth_required_response};
|
||||
) -> Result<MfaVerified<'a>, ApiError> {
|
||||
use crate::auth::reauth::REAUTH_WINDOW_SECONDS;
|
||||
use chrono::Utc;
|
||||
|
||||
let status = state
|
||||
@@ -105,10 +113,26 @@ pub async fn require_reauth_window<'a>(
|
||||
return Ok(MfaVerified::from_session_reauth(user));
|
||||
}
|
||||
}
|
||||
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
|
||||
let methods = crate::auth::reauth::get_available_reauth_methods(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&user.did,
|
||||
)
|
||||
.await;
|
||||
Err(ApiError::ReauthRequired {
|
||||
methods: methods.iter().map(|m| m.as_str().to_string()).collect(),
|
||||
})
|
||||
}
|
||||
None => {
|
||||
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
|
||||
let methods = crate::auth::reauth::get_available_reauth_methods(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&user.did,
|
||||
)
|
||||
.await;
|
||||
Err(ApiError::ReauthRequired {
|
||||
methods: methods.iter().map(|m| m.as_str().to_string()).collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,8 +140,8 @@ pub async fn require_reauth_window<'a>(
|
||||
pub async fn require_reauth_window_if_available<'a>(
|
||||
state: &AppState,
|
||||
user: &'a AuthenticatedUser,
|
||||
) -> Result<Option<MfaVerified<'a>>, Response> {
|
||||
use crate::auth::reauth::{check_reauth_required_cached, reauth_required_response};
|
||||
) -> Result<Option<MfaVerified<'a>>, ApiError> {
|
||||
use crate::auth::reauth::check_reauth_required_cached;
|
||||
|
||||
let has_password = state
|
||||
.user_repo
|
||||
@@ -144,7 +168,15 @@ pub async fn require_reauth_window_if_available<'a>(
|
||||
}
|
||||
|
||||
if check_reauth_required_cached(&*state.session_repo, &state.cache, &user.did).await {
|
||||
Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await)
|
||||
let methods = crate::auth::reauth::get_available_reauth_methods(
|
||||
&*state.user_repo,
|
||||
&*state.session_repo,
|
||||
&user.did,
|
||||
)
|
||||
.await;
|
||||
Err(ApiError::ReauthRequired {
|
||||
methods: methods.iter().map(|m| m.as_str().to_string()).collect(),
|
||||
})
|
||||
} else {
|
||||
Ok(Some(MfaVerified::from_session_reauth(user)))
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ pub use account_verified::{AccountVerified, require_not_migrated, require_verifi
|
||||
pub use extractor::{
|
||||
Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, AuthScheme, ExtractedToken,
|
||||
NotTakendown, Permissive, ServiceAuth, extract_auth_token_from_header,
|
||||
extract_bearer_token_from_header,
|
||||
extract_bearer_token_from_header, extract_jti_from_headers,
|
||||
};
|
||||
pub use mfa_verified::{
|
||||
MfaMethod, MfaVerified, require_legacy_session_mfa, require_reauth_window,
|
||||
|
||||
@@ -17,6 +17,16 @@ pub enum ReauthMethod {
|
||||
Passkey,
|
||||
}
|
||||
|
||||
impl ReauthMethod {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Password => "password",
|
||||
Self::Totp => "totp",
|
||||
Self::Passkey => "passkey",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_reauth_required(last_reauth_at: Option<chrono::DateTime<Utc>>) -> bool {
|
||||
match last_reauth_at {
|
||||
None => true,
|
||||
@@ -27,7 +37,7 @@ fn is_reauth_required(last_reauth_at: Option<chrono::DateTime<Utc>>) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_available_reauth_methods(
|
||||
pub async fn get_available_reauth_methods(
|
||||
user_repo: &dyn UserRepository,
|
||||
_session_repo: &dyn SessionRepository,
|
||||
did: &crate::types::Did,
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
#![allow(clippy::result_large_err)]
|
||||
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
use crate::api::error::ApiError;
|
||||
use crate::oauth::scopes::{
|
||||
AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions,
|
||||
@@ -24,7 +20,7 @@ pub fn check_repo_scope(
|
||||
scope: Option<&str>,
|
||||
action: RepoAction,
|
||||
collection: &str,
|
||||
) -> Result<(), Response> {
|
||||
) -> Result<(), ApiError> {
|
||||
if !requires_scope_check(auth_source, scope) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -32,14 +28,14 @@ pub fn check_repo_scope(
|
||||
let permissions = ScopePermissions::from_scope_string(scope);
|
||||
permissions
|
||||
.assert_repo(action, collection)
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
|
||||
}
|
||||
|
||||
pub fn check_blob_scope(
|
||||
auth_source: &AuthSource,
|
||||
scope: Option<&str>,
|
||||
mime: &str,
|
||||
) -> Result<(), Response> {
|
||||
) -> Result<(), ApiError> {
|
||||
if !requires_scope_check(auth_source, scope) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -47,7 +43,7 @@ pub fn check_blob_scope(
|
||||
let permissions = ScopePermissions::from_scope_string(scope);
|
||||
permissions
|
||||
.assert_blob(mime)
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
|
||||
}
|
||||
|
||||
pub fn check_rpc_scope(
|
||||
@@ -55,7 +51,7 @@ pub fn check_rpc_scope(
|
||||
scope: Option<&str>,
|
||||
aud: &str,
|
||||
lxm: &str,
|
||||
) -> Result<(), Response> {
|
||||
) -> Result<(), ApiError> {
|
||||
if !requires_scope_check(auth_source, scope) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -63,7 +59,7 @@ pub fn check_rpc_scope(
|
||||
let permissions = ScopePermissions::from_scope_string(scope);
|
||||
permissions
|
||||
.assert_rpc(aud, lxm)
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
|
||||
}
|
||||
|
||||
pub fn check_account_scope(
|
||||
@@ -71,7 +67,7 @@ pub fn check_account_scope(
|
||||
scope: Option<&str>,
|
||||
attr: AccountAttr,
|
||||
action: AccountAction,
|
||||
) -> Result<(), Response> {
|
||||
) -> Result<(), ApiError> {
|
||||
if !requires_scope_check(auth_source, scope) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -79,14 +75,14 @@ pub fn check_account_scope(
|
||||
let permissions = ScopePermissions::from_scope_string(scope);
|
||||
permissions
|
||||
.assert_account(attr, action)
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
|
||||
}
|
||||
|
||||
pub fn check_identity_scope(
|
||||
auth_source: &AuthSource,
|
||||
scope: Option<&str>,
|
||||
attr: IdentityAttr,
|
||||
) -> Result<(), Response> {
|
||||
) -> Result<(), ApiError> {
|
||||
if !requires_scope_check(auth_source, scope) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -94,5 +90,5 @@ pub fn check_identity_scope(
|
||||
let permissions = ScopePermissions::from_scope_string(scope);
|
||||
permissions
|
||||
.assert_identity(attr)
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
|
||||
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user