refactor(blahd,webapi)!: overhaul error type

Error types are now collected into a single place. Similar errors are
merged.

Request invariant violations are now all under 400 with type
"invalid_request" if it's a client mistake; and if it's caused by a
server restrction, under 403 with type "disabled".
This commit is contained in:
oxalica 2024-09-24 19:04:30 -04:00
parent 5f03a4ca03
commit 0911d56e22
9 changed files with 267 additions and 312 deletions

7
Cargo.lock generated
View file

@ -322,6 +322,7 @@ dependencies = [
"humantime", "humantime",
"nix", "nix",
"parking_lot", "parking_lot",
"paste",
"rand", "rand",
"reqwest", "reqwest",
"rstest", "rstest",
@ -1466,6 +1467,12 @@ dependencies = [
"windows-targets", "windows-targets",
] ]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"

View file

@ -15,6 +15,7 @@ html-escape = "0.2"
http-body-util = "0.1" http-body-util = "0.1"
humantime = "2" humantime = "2"
parking_lot = "0.12" # Maybe no better performance, just that we hate poisoning. ¯\_(ツ)_/¯ parking_lot = "0.12" # Maybe no better performance, just that we hate poisoning. ¯\_(ツ)_/¯
paste = "1.0.15"
rand = "0.8" rand = "0.8"
reqwest = "0.12" reqwest = "0.12"
rusqlite = { version = "0.32", features = ["rusqlite-macros"] } rusqlite = { version = "0.32", features = ["rusqlite-macros"] }

View file

@ -2,7 +2,6 @@ use std::num::NonZero;
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{ensure, Context}; use anyhow::{ensure, Context};
use axum::http::StatusCode;
use blah_types::identity::UserIdentityDesc; use blah_types::identity::UserIdentityDesc;
use blah_types::{ use blah_types::{
ChatPayload, Id, MemberPermission, PubKey, RoomAttrs, RoomMetadata, ServerPermission, ChatPayload, Id, MemberPermission, PubKey, RoomAttrs, RoomMetadata, ServerPermission,
@ -13,7 +12,7 @@ use rusqlite::{named_params, params, prepare_cached_and_bind, Connection, OpenFl
use serde::Deserialize; use serde::Deserialize;
use serde_inline_default::serde_inline_default; use serde_inline_default::serde_inline_default;
use crate::ApiError; use crate::middleware::ApiError;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
@ -182,13 +181,7 @@ pub trait TransactionOps {
) )
.raw_query() .raw_query()
.next()? .next()?
.ok_or_else(|| { .ok_or(ApiError::UserNotFound)
error_response!(
StatusCode::NOT_FOUND,
"not_found",
"the user does not exist",
)
})
.and_then(|row| Ok((row.get(0)?, row.get(1)?))) .and_then(|row| Ok((row.get(0)?, row.get(1)?)))
} }
@ -203,13 +196,7 @@ pub trait TransactionOps {
) )
.raw_query() .raw_query()
.next()? .next()?
.ok_or_else(|| { .ok_or(ApiError::UserNotFound)
error_response!(
StatusCode::NOT_FOUND,
"user_not_found",
"the user does not exists",
)
})
.and_then(|row| Ok((row.get(0)?, row.get(1)?))) .and_then(|row| Ok((row.get(0)?, row.get(1)?)))
} }
@ -229,13 +216,7 @@ pub trait TransactionOps {
) )
.raw_query() .raw_query()
.next()? .next()?
.ok_or_else(|| { .ok_or(ApiError::RoomNotFound)
error_response!(
StatusCode::NOT_FOUND,
"room_not_found",
"the room does not exist or user is not a room member",
)
})
.and_then(|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) .and_then(|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
} }
@ -258,13 +239,7 @@ pub trait TransactionOps {
}) })
.transpose()? .transpose()?
.filter(|(attrs, _)| attrs.contains(filter)) .filter(|(attrs, _)| attrs.contains(filter))
.ok_or_else(|| { .ok_or(ApiError::RoomNotFound)
error_response!(
StatusCode::NOT_FOUND,
"room_not_found",
"the room does not exist"
)
})
} }
// FIXME: Eliminate this. // FIXME: Eliminate this.
@ -432,13 +407,9 @@ pub trait TransactionOps {
) )
.raw_query() .raw_query()
.next()? .next()?
.ok_or_else(|| { .ok_or(ApiError::Conflict(
error_response!( "racing registration, please try again later",
StatusCode::CONFLICT, ))
"conflict",
"racing register, please try again later",
)
})
.and_then(|row| Ok(row.get::<_, i64>(0)?))?; .and_then(|row| Ok(row.get::<_, i64>(0)?))?;
// Delete existing act_keys. // Delete existing act_keys.
@ -504,13 +475,10 @@ pub trait TransactionOps {
" "
) )
.raw_execute()?; .raw_execute()?;
if updated == 0 { api_ensure!(
return Err(error_response!( updated != 0,
StatusCode::CONFLICT, ApiError::Exists("peer chat room already exists")
"exists", );
"room already exists"
));
}
// TODO: Limit permission of the src user? // TODO: Limit permission of the src user?
let perm = MemberPermission::MAX_PEER_CHAT; let perm = MemberPermission::MAX_PEER_CHAT;
@ -548,11 +516,7 @@ pub trait TransactionOps {
) )
.raw_execute()?; .raw_execute()?;
if updated != 1 { if updated != 1 {
return Err(error_response!( return Err(ApiError::Exists("the user already joined the room"));
StatusCode::CONFLICT,
"exists",
"the user already joined the room",
));
} }
Ok(()) Ok(())
} }
@ -602,13 +566,8 @@ pub trait TransactionOps {
.map(|row| row.get(0)) .map(|row| row.get(0))
.transpose()? .transpose()?
.unwrap_or(Id(0)); .unwrap_or(Id(0));
if max_cid_in_room < cid { // FIXME: This leaks room existence information.
return Err(error_response!( api_ensure!(cid <= max_cid_in_room, "invalid cid");
StatusCode::BAD_REQUEST,
"invalid_request",
"invalid cid",
));
}
let updated = prepare_cached_and_bind!( let updated = prepare_cached_and_bind!(
self.conn(), self.conn(),
r" r"
@ -619,11 +578,7 @@ pub trait TransactionOps {
) )
.raw_execute()?; .raw_execute()?;
if updated != 1 { if updated != 1 {
return Err(error_response!( return Err(ApiError::RoomNotFound);
StatusCode::NOT_FOUND,
"room_not_found",
"the room does not exist or the user is not a room member",
));
} }
Ok(()) Ok(())

View file

@ -7,7 +7,7 @@ use std::sync::Arc;
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use std::time::Duration; use std::time::Duration;
use anyhow::{anyhow, bail, Context as _, Result}; use anyhow::{bail, Context as _, Result};
use axum::extract::ws::{Message, WebSocket}; use axum::extract::ws::{Message, WebSocket};
use blah_types::{AuthPayload, Signed, SignedChatMsg}; use blah_types::{AuthPayload, Signed, SignedChatMsg};
use futures_util::future::Either; use futures_util::future::Either;
@ -143,10 +143,7 @@ pub async fn handle_ws(st: Arc<AppState>, ws: &mut WebSocket) -> Result<Infallib
let auth = serde_json::from_str::<Signed<AuthPayload>>(&payload)?; let auth = serde_json::from_str::<Signed<AuthPayload>>(&payload)?;
st.verify_signed_data(&auth)?; st.verify_signed_data(&auth)?;
let (uid, _) = st let (uid, _) = st.db.with_read(|txn| txn.get_user(&auth.signee.user))?;
.db
.with_read(|txn| txn.get_user(&auth.signee.user))
.map_err(|err| anyhow!("{}", err.message))?;
uid uid
}; };

View file

@ -102,32 +102,20 @@ impl AppState {
} }
fn verify_signed_data<T: Serialize>(&self, data: &Signed<T>) -> Result<(), ApiError> { fn verify_signed_data<T: Serialize>(&self, data: &Signed<T>) -> Result<(), ApiError> {
let Ok(()) = data.verify() else { api_ensure!(data.verify().is_ok(), "signature verification failed");
return Err(error_response!(
StatusCode::BAD_REQUEST,
"invalid_signature",
"signature verification failed"
));
};
let timestamp_diff = SystemTime::now() let timestamp_diff = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH)
.expect("after UNIX epoch") .expect("after UNIX epoch")
.as_secs() .as_secs()
.abs_diff(data.signee.timestamp); .abs_diff(data.signee.timestamp);
if timestamp_diff > self.config.timestamp_tolerance_secs { api_ensure!(
return Err(error_response!( timestamp_diff <= self.config.timestamp_tolerance_secs,
StatusCode::BAD_REQUEST, "invalid timestamp",
"invalid_timestamp", );
"invalid timestamp, off by {timestamp_diff}s" api_ensure!(
)); self.used_nonces.lock().try_insert(data.signee.nonce),
} "used nonce",
if !self.used_nonces.lock().try_insert(data.signee.nonce) { );
return Err(error_response!(
StatusCode::BAD_REQUEST,
"duplicated_nonce",
"duplicated nonce",
));
}
Ok(()) Ok(())
} }
} }
@ -196,7 +184,7 @@ async fn user_get(
None => None, None => None,
Some(user) => st.db.with_read(|txn| txn.get_user(&user)).ok(), Some(user) => st.db.with_read(|txn| txn.get_user(&user)).ok(),
} }
.ok_or_else(|| error_response!(StatusCode::NOT_FOUND, "not_found", "user does not exist")) .ok_or(ApiError::UserNotFound)
})(); })();
match ret { match ret {
@ -288,23 +276,17 @@ async fn room_create_group(
user: &UserKey, user: &UserKey,
op: CreateGroup, op: CreateGroup,
) -> Result<Json<Id>, ApiError> { ) -> Result<Json<Id>, ApiError> {
if !RoomAttrs::GROUP_ATTRS.contains(op.attrs) { api_ensure!(
return Err(error_response!( RoomAttrs::GROUP_ATTRS.contains(op.attrs),
StatusCode::BAD_REQUEST, "invalid group attributes",
"deserialization", );
"invalid room attributes",
));
}
let rid = st.db.with_write(|conn| { let rid = st.db.with_write(|conn| {
let (uid, perm) = conn.get_user(user)?; let (uid, perm) = conn.get_user(user)?;
if !perm.contains(ServerPermission::CREATE_ROOM) { api_ensure!(
return Err(error_response!( perm.contains(ServerPermission::CREATE_ROOM),
StatusCode::FORBIDDEN, ApiError::PermissionDenied("the user does not have permission to create room"),
"permission_denied", );
"the user does not have permission to create room",
));
}
let rid = Id::gen(); let rid = Id::gen();
conn.create_group(rid, &op.title, op.attrs)?; conn.create_group(rid, &op.title, op.attrs)?;
conn.add_room_member(rid, uid, MemberPermission::ALL)?; conn.add_room_member(rid, uid, MemberPermission::ALL)?;
@ -320,13 +302,10 @@ async fn room_create_peer_chat(
op: CreatePeerChat, op: CreatePeerChat,
) -> Result<Json<Id>, ApiError> { ) -> Result<Json<Id>, ApiError> {
let tgt_user_id_key = op.peer; let tgt_user_id_key = op.peer;
if tgt_user_id_key == src_user.id_key { api_ensure!(
return Err(error_response!( tgt_user_id_key != src_user.id_key,
StatusCode::NOT_IMPLEMENTED, ApiError::NotImplemented("self-chat is not implemented yet"),
"not_implemented", );
"self-chat is not implemented yet",
));
}
// TODO: Access control and throttling. // TODO: Access control and throttling.
let rid = st.db.with_write(|txn| { let rid = st.db.with_write(|txn| {
@ -335,13 +314,7 @@ async fn room_create_peer_chat(
.get_user_by_id_key(&tgt_user_id_key) .get_user_by_id_key(&tgt_user_id_key)
.ok() .ok()
.filter(|(_, perm)| perm.contains(ServerPermission::ACCEPT_PEER_CHAT)) .filter(|(_, perm)| perm.contains(ServerPermission::ACCEPT_PEER_CHAT))
.ok_or_else(|| { .ok_or(ApiError::PeerUserNotFound)?;
error_response!(
StatusCode::NOT_FOUND,
"peer_user_not_found",
"peer user does not exist or disallows peer chat",
)
})?;
let rid = Id::gen_peer_chat_rid(); let rid = Id::gen_peer_chat_rid();
txn.create_peer_room_with_members(rid, RoomAttrs::PEER_CHAT, src_uid, tgt_uid)?; txn.create_peer_room_with_members(rid, RoomAttrs::PEER_CHAT, src_uid, tgt_uid)?;
Ok(rid) Ok(rid)
@ -509,23 +482,14 @@ async fn room_msg_post(
R(Path(rid), _): RE<Path<Id>>, R(Path(rid), _): RE<Path<Id>>,
SignedJson(chat): SignedJson<ChatPayload>, SignedJson(chat): SignedJson<ChatPayload>,
) -> Result<Json<Id>, ApiError> { ) -> Result<Json<Id>, ApiError> {
if rid != chat.signee.payload.room { api_ensure!(rid == chat.signee.payload.room, "room id mismatch with URI");
return Err(error_response!(
StatusCode::BAD_REQUEST,
"invalid_request",
"URI and payload room id mismatch",
));
}
let (cid, members) = st.db.with_write(|txn| { let (cid, members) = st.db.with_write(|txn| {
let (uid, perm, ..) = txn.get_room_member(rid, &chat.signee.user)?; let (uid, perm, ..) = txn.get_room_member(rid, &chat.signee.user)?;
if !perm.contains(MemberPermission::POST_CHAT) { api_ensure!(
return Err(error_response!( perm.contains(MemberPermission::POST_CHAT),
StatusCode::FORBIDDEN, ApiError::PermissionDenied("the user does not have permission to post in the room"),
"permission_denied", );
"the user does not have permission to post in the room",
));
}
let cid = Id::gen(); let cid = Id::gen();
txn.add_room_chat_msg(rid, uid, cid, &chat)?; txn.add_room_chat_msg(rid, uid, cid, &chat)?;
@ -556,47 +520,26 @@ async fn room_admin(
R(Path(rid), _): RE<Path<Id>>, R(Path(rid), _): RE<Path<Id>>,
SignedJson(op): SignedJson<RoomAdminPayload>, SignedJson(op): SignedJson<RoomAdminPayload>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
if rid != op.signee.payload.room { api_ensure!(rid == op.signee.payload.room, "room id mismatch with URI");
return Err(error_response!( api_ensure!(!rid.is_peer_chat(), "cannot operate on a peer chat room");
StatusCode::BAD_REQUEST,
"invalid_request",
"URI and payload room id mismatch",
));
}
if rid.is_peer_chat() {
return Err(error_response!(
StatusCode::BAD_REQUEST,
"invalid_request",
"operation not permitted on peer chat rooms",
));
}
match op.signee.payload.op { match op.signee.payload.op {
RoomAdminOp::AddMember { user, permission } => { RoomAdminOp::AddMember { user, permission } => {
if user != op.signee.user.id_key { api_ensure!(
return Err(error_response!( user == op.signee.user.id_key,
StatusCode::NOT_IMPLEMENTED, ApiError::NotImplemented("only self-adding is implemented yet"),
"not_implemented", );
"only self-adding is implemented yet", api_ensure!(
)); MemberPermission::MAX_SELF_ADD.contains(permission),
} "invalid initial permission",
if !MemberPermission::MAX_SELF_ADD.contains(permission) { );
return Err(error_response!(
StatusCode::BAD_REQUEST,
"deserialization",
"invalid permission",
));
}
room_join(&st, rid, &op.signee.user, permission).await?; room_join(&st, rid, &op.signee.user, permission).await?;
} }
RoomAdminOp::RemoveMember { user } => { RoomAdminOp::RemoveMember { user } => {
if user != op.signee.user.id_key { api_ensure!(
return Err(error_response!( user == op.signee.user.id_key,
StatusCode::NOT_IMPLEMENTED, ApiError::NotImplemented("only self-removal is implemented yet"),
"not_implemented", );
"only self-removing is implemented yet",
));
}
room_leave(&st, rid, &op.signee.user).await?; room_leave(&st, rid, &op.signee.user).await?;
} }
} }
@ -622,15 +565,11 @@ async fn room_join(
async fn room_leave(st: &AppState, rid: Id, user: &UserKey) -> Result<(), ApiError> { async fn room_leave(st: &AppState, rid: Id, user: &UserKey) -> Result<(), ApiError> {
st.db.with_write(|txn| { st.db.with_write(|txn| {
api_ensure!(!rid.is_peer_chat(), "cannot leave a peer chat room");
let (uid, ..) = txn.get_room_member(rid, user)?; let (uid, ..) = txn.get_room_member(rid, user)?;
let (attrs, _) = txn.get_room_having(rid, RoomAttrs::empty())?; let (attrs, _) = txn.get_room_having(rid, RoomAttrs::empty())?;
if attrs.contains(RoomAttrs::PEER_CHAT) { // Sanity check.
return Err(error_response!( assert!(!attrs.contains(RoomAttrs::PEER_CHAT));
StatusCode::BAD_REQUEST,
"invalid_operation",
"cannot leave a peer chat room without deleting it",
));
}
txn.remove_room_member(rid, uid)?; txn.remove_room_member(rid, uid)?;
Ok(()) Ok(())
}) })
@ -641,23 +580,14 @@ async fn room_delete(
R(Path(rid), _): RE<Path<Id>>, R(Path(rid), _): RE<Path<Id>>,
SignedJson(op): SignedJson<DeleteRoomPayload>, SignedJson(op): SignedJson<DeleteRoomPayload>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
if rid != op.signee.payload.room { api_ensure!(rid == op.signee.payload.room, "room id mismatch with URI");
return Err(error_response!(
StatusCode::BAD_REQUEST,
"invalid_request",
"URI and payload room id mismatch",
));
}
st.db.with_write(|txn| { st.db.with_write(|txn| {
// TODO: Should we only shadow delete here? // TODO: Should we only shadow delete here?
let (_uid, perm, ..) = txn.get_room_member(rid, &op.signee.user)?; let (_uid, perm, ..) = txn.get_room_member(rid, &op.signee.user)?;
if !perm.contains(MemberPermission::DELETE_ROOM) { api_ensure!(
return Err(error_response!( perm.contains(MemberPermission::DELETE_ROOM),
StatusCode::FORBIDDEN, ApiError::PermissionDenied("the user does not have permission to delete the room")
"permission_denied", );
"the user does not have permission to delete the room",
));
}
txn.delete_room(rid)?; txn.delete_room(rid)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
}) })

View file

@ -10,56 +10,116 @@ use axum::response::{IntoResponse, Response};
use axum::{async_trait, Json}; use axum::{async_trait, Json};
use blah_types::{AuthPayload, Signed, UserKey}; use blah_types::{AuthPayload, Signed, UserKey};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::Serialize;
use crate::AppState; use crate::AppState;
macro_rules! define_api_error {
(
$(#[$meta:meta])*
$vis:vis enum $name:ident {
$(
$variant:ident
$(= ($status1:expr, $message1:expr))?
$(($(;$marker2:tt)? $ty:ty) = ($status2:expr))?
,
)*
}
) => {
$(#[$meta])*
$vis enum $name {
$($variant $(($ty))?,)*
}
impl $name {
fn to_response_tuple(&self) -> (StatusCode, &'static str, &str) {
paste::paste! {
match self {
$(
Self::$variant
$(=> ($status1, stringify!([<$variant:snake>]), $message1))?
$((message) => ($status2, stringify!([<$variant:snake>]), message))?
,
)*
}
}
}
}
};
}
define_api_error! {
/// Error response body for json endpoints. /// Error response body for json endpoints.
/// #[derive(Debug, Clone, PartialEq, Eq)]
/// Mostly following: <https://learn.microsoft.com/en-us/graph/errors>
#[derive(Debug, Serialize, Deserialize)]
#[must_use] #[must_use]
pub struct ApiError { pub enum ApiError {
#[serde(skip, default)] InvalidRequest(Box<str>) = (StatusCode::BAD_REQUEST),
pub status: StatusCode, Unauthorized(&'static str) = (StatusCode::UNAUTHORIZED),
pub code: String, PermissionDenied(&'static str) = (StatusCode::FORBIDDEN),
pub message: String, Disabled(&'static str) = (StatusCode::FORBIDDEN),
UserNotFound = (StatusCode::NOT_FOUND, "the user does not exist"),
RoomNotFound = (StatusCode::NOT_FOUND, "the room does not exist or the user is not a room member"),
PeerUserNotFound = (StatusCode::NOT_FOUND, "peer user does not exist or disallows peer chat"),
Conflict(&'static str) = (StatusCode::CONFLICT),
Exists(&'static str) = (StatusCode::CONFLICT),
FetchIdDescription(Box<str>) = (StatusCode::UNPROCESSABLE_ENTITY),
InvalidIdDescription(Box<str>) = (StatusCode::UNPROCESSABLE_ENTITY),
ServerError = (StatusCode::INTERNAL_SERVER_ERROR, "internal server error"),
NotImplemented(&'static str) = (StatusCode::NOT_IMPLEMENTED),
} }
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"api error status={} code={}: {}",
self.status, self.code, self.message,
)
}
} }
impl std::error::Error for ApiError {} macro_rules! api_ensure {
($assertion:expr, $msg:literal $(,)?) => {
macro_rules! error_response { if !$assertion {
($status:expr, $code:literal, $msg:literal $(, $msg_args:expr)* $(,)?) => { return Err($crate::middleware::ApiError::InvalidRequest($msg.into()));
$crate::middleware::ApiError { }
status: $status, };
code: $code.to_owned(), ($assertion:expr, $err:expr $(,)?) => {
message: ::std::format!($msg $(, $msg_args)*), if !$assertion {
return Err($err);
} }
}; };
} }
/// Response structure mostly follows:
/// <https://learn.microsoft.com/en-us/graph/errors>
/// Only `error/{code,message}` are provided and are always available.
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
#[derive(Serialize)] #[derive(Serialize)]
struct Resp<'a> { struct Resp<'a> {
error: &'a ApiError, error: Error<'a>,
} }
let mut resp = Json(Resp { error: &self }).into_response(); #[derive(Serialize)]
*resp.status_mut() = self.status; struct Error<'a> {
code: &'a str,
message: &'a str,
}
let (status, code, message) = self.to_response_tuple();
let mut resp = Json(Resp {
error: Error { code, message },
})
.into_response();
*resp.status_mut() = status;
resp resp
} }
} }
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let (_, code, message) = self.to_response_tuple();
write!(f, "({code}) {message}")
}
}
impl std::error::Error for ApiError {}
// For infallible extractors. // For infallible extractors.
impl From<Infallible> for ApiError { impl From<Infallible> for ApiError {
fn from(v: Infallible) -> Self { fn from(v: Infallible) -> Self {
@ -72,14 +132,7 @@ macro_rules! define_from_deser_rejection {
$( $(
impl From<$ty> for ApiError { impl From<$ty> for ApiError {
fn from(rej: $ty) -> Self { fn from(rej: $ty) -> Self {
tracing::debug!(?rej, "rejected"); ApiError::InvalidRequest(format!(concat!("invalid ", $name, "{}"), rej).into())
error_response!(
StatusCode::BAD_REQUEST,
"deserialization",
"invalid {}: {}",
$name,
rej,
)
} }
} }
)* )*
@ -95,11 +148,7 @@ define_from_deser_rejection! {
impl From<rusqlite::Error> for ApiError { impl From<rusqlite::Error> for ApiError {
fn from(err: rusqlite::Error) -> Self { fn from(err: rusqlite::Error) -> Self {
tracing::error!(%err, backtrace = %Backtrace::force_capture(), "database error"); tracing::error!(%err, backtrace = %Backtrace::force_capture(), "database error");
error_response!( ApiError::ServerError
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"internal server error",
)
} }
} }
@ -133,11 +182,7 @@ pub enum AuthRejection {
impl From<AuthRejection> for ApiError { impl From<AuthRejection> for ApiError {
fn from(rej: AuthRejection) -> Self { fn from(rej: AuthRejection) -> Self {
match rej { match rej {
AuthRejection::None => error_response!( AuthRejection::None => ApiError::Unauthorized("missing authorization header"),
StatusCode::UNAUTHORIZED,
"unauthorized",
"missing authorization header"
),
AuthRejection::Invalid(err) => err, AuthRejection::Invalid(err) => err,
} }
} }
@ -188,11 +233,9 @@ where
let st = <Arc<AppState>>::from_ref(state); let st = <Arc<AppState>>::from_ref(state);
let data = let data =
serde_json::from_slice::<Signed<AuthPayload>>(auth.as_bytes()).map_err(|err| { serde_json::from_slice::<Signed<AuthPayload>>(auth.as_bytes()).map_err(|_err| {
AuthRejection::Invalid(error_response!( AuthRejection::Invalid(ApiError::InvalidRequest(
StatusCode::BAD_REQUEST, "invalid authorization header".into(),
"deserialization",
"invalid authorization header: {err}",
)) ))
})?; })?;
st.verify_signed_data(&data) st.verify_signed_data(&data)

View file

@ -151,37 +151,20 @@ pub async fn user_register(
msg: Signed<UserRegisterPayload>, msg: Signed<UserRegisterPayload>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
if !st.config.register.enable_public { if !st.config.register.enable_public {
return Err(error_response!( return Err(ApiError::Disabled("public registration is disabled"));
StatusCode::FORBIDDEN,
"disabled",
"public registration is disabled",
));
} }
let reg = &msg.signee.payload; let reg = &msg.signee.payload;
// Basic validity check. // Basic validity check.
if reg.server_url != st.config.base_url { api_ensure!(reg.server_url == st.config.base_url, "server url mismatch");
return Err(error_response!(
StatusCode::BAD_REQUEST,
"invalid_server_url",
"unexpected server url in payload",
));
}
if let Err(err) = st.config.register.validate_id_url(&reg.id_url) { if let Err(err) = st.config.register.validate_id_url(&reg.id_url) {
return Err(error_response!( return Err(ApiError::Disabled(err));
StatusCode::BAD_REQUEST,
"invalid_id_url",
"{err}",
));
}
if !st.register.nonce().contains(&reg.challenge_nonce) {
return Err(error_response!(
StatusCode::BAD_REQUEST,
"invalid_challenge_nonce",
"invalid or outdated challenge nonce",
));
} }
api_ensure!(
st.register.nonce().contains(&reg.challenge_nonce),
"invalid challenge nonce",
);
// Challenge verification. // Challenge verification.
let expect_bits = st.register.config.difficulty; let expect_bits = st.register.config.difficulty;
@ -197,13 +180,7 @@ pub async fn user_register(
let (bytes, bits) = (expect_bits as usize / 8, expect_bits as usize % 8); let (bytes, bits) = (expect_bits as usize / 8, expect_bits as usize % 8);
// NB. Shift by 8 would overflow and wrap around for u8. Convert it to u32 first. // NB. Shift by 8 would overflow and wrap around for u8. Convert it to u32 first.
let ok = hash[..bytes].iter().all(|&b| b == 0) && (hash[bytes] as u32) >> (8 - bits) == 0; let ok = hash[..bytes].iter().all(|&b| b == 0) && (hash[bytes] as u32) >> (8 - bits) == 0;
if !ok { api_ensure!(ok, "hash challenge failed");
return Err(error_response!(
StatusCode::BAD_REQUEST,
"invalid_challenge_hash",
"challenge failed",
));
}
} }
// TODO: Limit concurrency for the same domain and/or id_key? // TODO: Limit concurrency for the same domain and/or id_key?
@ -235,13 +212,13 @@ pub async fn user_register(
let id_desc = match fut.await { let id_desc = match fut.await {
Ok(id_desc) => id_desc, Ok(id_desc) => id_desc,
Err(err) => { Err(err) => {
return Err(error_response!( return Err(ApiError::FetchIdDescription(
StatusCode::UNAUTHORIZED, format!(
"fetch_id_description", "failed to fetch identity description from {}: {}",
"failed to fetch identity description from {}: {}", reg.id_url, err,
reg.id_url, )
err, .into(),
)) ));
} }
}; };
@ -250,11 +227,7 @@ pub async fn user_register(
id_desc.verify(Some(&reg.id_url), fetch_time)?; id_desc.verify(Some(&reg.id_url), fetch_time)?;
Ok(()) Ok(())
})() { })() {
return Err(error_response!( return Err(ApiError::InvalidIdDescription(err.to_string().into()));
StatusCode::UNAUTHORIZED,
"invalid_id_description",
"{err}",
));
} }
// Now the identity is verified. // Now the identity is verified.

View file

@ -16,7 +16,7 @@ use blah_types::{
ServerPermission, SignExt, Signed, SignedChatMsg, UserKey, UserRegisterPayload, WithMsgId, ServerPermission, SignExt, Signed, SignedChatMsg, UserKey, UserRegisterPayload, WithMsgId,
X_BLAH_DIFFICULTY, X_BLAH_NONCE, X_BLAH_DIFFICULTY, X_BLAH_NONCE,
}; };
use blahd::{ApiError, AppState, Database, RoomList, RoomMsgs}; use blahd::{AppState, Database, RoomList, RoomMsgs};
use ed25519_dalek::SigningKey; use ed25519_dalek::SigningKey;
use expect_test::expect; use expect_test::expect;
use futures_util::future::BoxFuture; use futures_util::future::BoxFuture;
@ -91,33 +91,46 @@ enum NoContent {}
trait ResultExt { trait ResultExt {
fn expect_api_err(self, status: StatusCode, code: &str); fn expect_api_err(self, status: StatusCode, code: &str);
fn expect_invalid_request(self, message: &str);
} }
impl<T: fmt::Debug> ResultExt for Result<T> { impl<T: fmt::Debug> ResultExt for Result<T> {
#[track_caller] #[track_caller]
fn expect_api_err(self, status: StatusCode, code: &str) { fn expect_api_err(self, status: StatusCode, code: &str) {
let err = self let err = self.unwrap_err().downcast::<ApiErrorWithHeaders>().unwrap();
.unwrap_err()
.downcast::<ApiErrorWithHeaders>()
.unwrap()
.error;
assert_eq!( assert_eq!(
(err.status, &*err.code), (err.status, &*err.code),
(status, code), (status, code),
"unexpecteed API error: {err:?}", "unexpecteed API error: {err}",
);
}
#[track_caller]
fn expect_invalid_request(self, message: &str) {
let err = self.unwrap_err().downcast::<ApiErrorWithHeaders>().unwrap();
assert_eq!(
(err.status, &*err.code, &*err.message),
(StatusCode::BAD_REQUEST, "invalid_request", message),
"unexpected API error: {err}"
); );
} }
} }
#[derive(Debug)] #[derive(Debug)]
pub struct ApiErrorWithHeaders { pub struct ApiErrorWithHeaders {
error: ApiError, status: StatusCode,
code: String,
message: String,
headers: HeaderMap, headers: HeaderMap,
} }
impl fmt::Display for ApiErrorWithHeaders { impl fmt::Display for ApiErrorWithHeaders {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.error.fmt(f) write!(
f,
"status={} code={}: {}",
self.status, self.code, self.message,
)
} }
} }
@ -193,12 +206,23 @@ impl Server {
if !status.is_success() { if !status.is_success() {
#[derive(Deserialize)] #[derive(Deserialize)]
struct Resp { struct Resp {
error: ApiError, error: RespErr,
} }
let Resp { mut error } = serde_json::from_str(&resp_str) #[derive(Deserialize)]
struct RespErr {
code: String,
message: String,
}
let resp = serde_json::from_str::<Resp>(&resp_str)
.with_context(|| format!("failed to parse response {resp_str:?}"))?; .with_context(|| format!("failed to parse response {resp_str:?}"))?;
error.status = status; Err(ApiErrorWithHeaders {
Err(ApiErrorWithHeaders { error, headers }.into()) status,
code: resp.error.code,
message: resp.error.message,
headers,
}
.into())
} else if resp_str.is_empty() { } else if resp_str.is_empty() {
Ok(None) Ok(None)
} else { } else {
@ -336,7 +360,7 @@ impl Server {
Ok(None) => Ok(()), Ok(None) => Ok(()),
Err(err) => { Err(err) => {
let err = err.downcast::<ApiErrorWithHeaders>().unwrap(); let err = err.downcast::<ApiErrorWithHeaders>().unwrap();
assert_eq!(err.error.status, StatusCode::NOT_FOUND); assert_eq!(err.status, StatusCode::NOT_FOUND);
if !err.headers.contains_key(X_BLAH_NONCE) { if !err.headers.contains_key(X_BLAH_NONCE) {
return Err(None); return Err(None);
} }
@ -555,7 +579,7 @@ async fn room_join_leave(server: Server) {
server server
.join_room(rid_priv, &BOB, MemberPermission::ALL) .join_room(rid_priv, &BOB, MemberPermission::ALL)
.await .await
.expect_api_err(StatusCode::BAD_REQUEST, "deserialization"); .expect_invalid_request("invalid initial permission");
// Bob is joined now. // Bob is joined now.
assert_eq!( assert_eq!(
@ -626,12 +650,12 @@ async fn room_chat_post_read(server: Server) {
// Duplicated chat. // Duplicated chat.
post(rid_pub, chat2.clone()) post(rid_pub, chat2.clone())
.await .await
.expect_api_err(StatusCode::BAD_REQUEST, "duplicated_nonce"); .expect_invalid_request("used nonce");
// Wrong room. // Wrong room.
post(rid_pub, chat(rid_priv, &ALICE, "wrong room")) post(rid_pub, chat(rid_priv, &ALICE, "wrong room"))
.await .await
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_request"); .expect_invalid_request("room id mismatch with URI");
// Not a member. // Not a member.
post(rid_pub, chat(rid_pub, &BOB, "not a member")) post(rid_pub, chat(rid_pub, &BOB, "not a member"))
@ -1066,12 +1090,14 @@ async fn register_flow(server: Server) {
register_fast(&req) register_fast(&req)
.await .await
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_server_url"); .expect_invalid_request("server url mismatch");
req.server_url = BASE_URL.parse().unwrap(); req.server_url = BASE_URL.parse().unwrap();
// Trailing dot in id_url.
// TODO: Rule this out in `IdUrl` parser?
register_fast(&req) register_fast(&req)
.await .await
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_id_url"); .expect_api_err(StatusCode::FORBIDDEN, "disabled");
// Test identity server. // Test identity server.
type DynHandler = Box<dyn FnMut() -> BoxFuture<'static, (StatusCode, String)> + Send>; type DynHandler = Box<dyn FnMut() -> BoxFuture<'static, (StatusCode, String)> + Send>;
@ -1108,19 +1134,19 @@ async fn register_flow(server: Server) {
register_fast(&req) register_fast(&req)
.await .await
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_challenge_nonce"); .expect_invalid_request("invalid challenge nonce");
req.challenge_nonce += 1; req.challenge_nonce += 1;
register(sign_with_difficulty(&req, false)) register(sign_with_difficulty(&req, false))
.await .await
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_challenge_hash"); .expect_invalid_request("hash challenge failed");
//// Starting here, early validation passed. //// //// Starting here, early validation passed. ////
// id_url 404 // id_url 404
register(sign_with_difficulty(&req, true)) register(sign_with_difficulty(&req, true))
.await .await
.expect_api_err(StatusCode::UNAUTHORIZED, "fetch_id_description"); .expect_api_err(StatusCode::UNPROCESSABLE_ENTITY, "fetch_id_description");
// Timeout // Timeout
set_handler! {{ set_handler! {{
@ -1130,7 +1156,7 @@ async fn register_flow(server: Server) {
let inst = Instant::now(); let inst = Instant::now();
register(sign_with_difficulty(&req, true)) register(sign_with_difficulty(&req, true))
.await .await
.expect_api_err(StatusCode::UNAUTHORIZED, "fetch_id_description"); .expect_api_err(StatusCode::UNPROCESSABLE_ENTITY, "fetch_id_description");
let elapsed = inst.elapsed(); let elapsed = inst.elapsed();
assert!( assert!(
elapsed.abs_diff(Duration::from_secs(1)) < TIME_TOLERANCE, elapsed.abs_diff(Duration::from_secs(1)) < TIME_TOLERANCE,
@ -1143,7 +1169,7 @@ async fn register_flow(server: Server) {
}} }}
register(sign_with_difficulty(&req, true)) register(sign_with_difficulty(&req, true))
.await .await
.expect_api_err(StatusCode::UNAUTHORIZED, "fetch_id_description"); .expect_api_err(StatusCode::UNPROCESSABLE_ENTITY, "fetch_id_description");
let set_id_desc = |desc: &UserIdentityDesc| { let set_id_desc = |desc: &UserIdentityDesc| {
let desc = serde_json::to_string(&desc).unwrap(); let desc = serde_json::to_string(&desc).unwrap();
@ -1182,14 +1208,14 @@ async fn register_flow(server: Server) {
set_id_desc(&id_desc); set_id_desc(&id_desc);
register(sign_with_difficulty(&req, true)) register(sign_with_difficulty(&req, true))
.await .await
.expect_api_err(StatusCode::UNAUTHORIZED, "invalid_id_description"); .expect_api_err(StatusCode::UNPROCESSABLE_ENTITY, "invalid_id_description");
// Still not registered. // Still not registered.
server.get_me(Some(&CAROL)).await.unwrap_err(); server.get_me(Some(&CAROL)).await.unwrap_err();
server server
.join_room(rid, &CAROL, MemberPermission::MAX_SELF_ADD) .join_room(rid, &CAROL, MemberPermission::MAX_SELF_ADD)
.await .await
.expect_api_err(StatusCode::NOT_FOUND, "not_found"); .expect_api_err(StatusCode::NOT_FOUND, "user_not_found");
// Finally pass. // Finally pass.
id_desc.profile = sign_profile(req.id_url.clone()); id_desc.profile = sign_profile(req.id_url.clone());
@ -1257,11 +1283,8 @@ unsafe_allow_id_url_single_label = {allow_single_label}
let ret = server let ret = server
.request::<_, ()>(Method::POST, "/user/me", None, Some(req)) .request::<_, ()>(Method::POST, "/user/me", None, Some(req))
.await; .await;
if !enabled { // Unpermitted due to server restriction.
ret.expect_api_err(StatusCode::FORBIDDEN, "disabled"); ret.expect_api_err(StatusCode::FORBIDDEN, "disabled");
} else {
ret.expect_api_err(StatusCode::BAD_REQUEST, "invalid_id_url");
}
} }
#[rstest] #[rstest]

View file

@ -108,7 +108,7 @@ paths:
description: User successfully registered. description: User successfully registered.
400: 400:
description: Invalid request format, or invalid challenge. description: Invalid request format or any invalid fields in the request.
content: content:
application/json: application/json:
schema: schema:
@ -123,6 +123,15 @@ paths:
schema: schema:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
403:
description: |
Server disallows registration, either due to server restriction or
unacceptable id_url.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
409: 409:
description: | description: |
User state changed during the operation. Could retry later. User state changed during the operation. Could retry later.
@ -131,6 +140,16 @@ paths:
schema: schema:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
422:
description: |
Fail to process identity description. Could be failure to fetch
remote description, unacceptable result from id_url, or any fields
(eg. signatures) in the returned description being invalid.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
/_blah/room: /_blah/room:
get: get:
summary: List rooms summary: List rooms
@ -283,6 +302,13 @@ paths:
schema: schema:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
404:
description: |
Room does not exist or the user does not have permission to access it.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
/_blah/room/{rid}/admin: /_blah/room/{rid}/admin:
post: post: