mirror of
https://github.com/Blah-IM/blahrs.git
synced 2025-07-07 06:35:34 +00:00
feat(webapi): impl user registration and identity description format
This commit is contained in:
parent
7f74d73c8c
commit
fb76756482
11 changed files with 972 additions and 20 deletions
|
@ -3,17 +3,23 @@
|
|||
use std::cell::RefCell;
|
||||
use std::fmt;
|
||||
use std::future::{Future, IntoFuture};
|
||||
use std::ops::DerefMut;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::http::HeaderMap;
|
||||
use blah_types::{
|
||||
get_timestamp, AuthPayload, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id,
|
||||
MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata,
|
||||
ServerPermission, Signed, SignedChatMsg, UserKey, WithMsgId,
|
||||
ServerPermission, Signed, SignedChatMsg, UserActKeyDesc, UserIdentityDesc, UserKey,
|
||||
UserProfile, UserRegisterPayload, WithMsgId, X_BLAH_DIFFICULTY, X_BLAH_NONCE,
|
||||
};
|
||||
use blahd::{ApiError, AppState, Database, RoomList, RoomMsgs};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use futures_util::future::BoxFuture;
|
||||
use futures_util::TryFutureExt;
|
||||
use parking_lot::Mutex;
|
||||
use rand::rngs::mock::StepRng;
|
||||
use rand::RngCore;
|
||||
use reqwest::{header, Method, StatusCode};
|
||||
|
@ -21,15 +27,39 @@ use rstest::{fixture, rstest};
|
|||
use rusqlite::{params, Connection};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::net::TcpListener;
|
||||
use url::Url;
|
||||
|
||||
// Avoid name resolution.
|
||||
const LOCALHOST: &str = "127.0.0.1";
|
||||
// Register API requires a non-IP hostname.
|
||||
const LOCALHOST: &str = "localhost";
|
||||
const REGISTER_DIFFICULTY: u8 = 1;
|
||||
|
||||
const TIME_TOLERANCE: Duration = Duration::from_millis(100);
|
||||
|
||||
const CONFIG: fn(u16) -> String = |port| {
|
||||
format!(
|
||||
r#"
|
||||
base_url="http://{LOCALHOST}:{port}"
|
||||
|
||||
[register]
|
||||
enable_public = true
|
||||
difficulty = {REGISTER_DIFFICULTY}
|
||||
request_timeout_secs = 1
|
||||
unsafe_allow_id_url_http = true
|
||||
unsafe_allow_id_url_custom_port = true
|
||||
"#
|
||||
)
|
||||
};
|
||||
|
||||
static ALICE_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'A'; 32]));
|
||||
static ALICE: LazyLock<UserKey> = LazyLock::new(|| UserKey(ALICE_PRIV.verifying_key().to_bytes()));
|
||||
static BOB_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'B'; 32]));
|
||||
static BOB: LazyLock<UserKey> = LazyLock::new(|| UserKey(BOB_PRIV.verifying_key().to_bytes()));
|
||||
static CAROL_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'C'; 32]));
|
||||
static CAROL: LazyLock<UserKey> = LazyLock::new(|| UserKey(CAROL_PRIV.verifying_key().to_bytes()));
|
||||
|
||||
static CAROL_ACT_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'c'; 32]));
|
||||
|
||||
#[fixture]
|
||||
fn rng() -> impl RngCore {
|
||||
|
@ -46,12 +76,33 @@ trait ResultExt {
|
|||
impl<T: fmt::Debug> ResultExt for Result<T> {
|
||||
#[track_caller]
|
||||
fn expect_api_err(self, status: StatusCode, code: &str) {
|
||||
let err = self.unwrap_err().downcast::<ApiError>().unwrap();
|
||||
assert_eq!(err.status, status);
|
||||
assert_eq!(err.code, code);
|
||||
let err = self
|
||||
.unwrap_err()
|
||||
.downcast::<ApiErrorWithHeaders>()
|
||||
.unwrap()
|
||||
.error;
|
||||
assert_eq!(
|
||||
(err.status, &*err.code),
|
||||
(status, code),
|
||||
"unexpecteed API error: {err:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiErrorWithHeaders {
|
||||
error: ApiError,
|
||||
headers: HeaderMap,
|
||||
}
|
||||
|
||||
impl fmt::Display for ApiErrorWithHeaders {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.error.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ApiErrorWithHeaders {}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Server {
|
||||
port: u16,
|
||||
|
@ -64,6 +115,10 @@ impl Server {
|
|||
format!("http://{}:{}{}", LOCALHOST, self.port, rhs)
|
||||
}
|
||||
|
||||
fn rng(&self) -> impl DerefMut<Target = impl RngCore> + use<'_> {
|
||||
self.rng.borrow_mut()
|
||||
}
|
||||
|
||||
fn request<Req: Serialize, Resp: DeserializeOwned>(
|
||||
&self,
|
||||
method: Method,
|
||||
|
@ -82,6 +137,7 @@ impl Server {
|
|||
async move {
|
||||
let resp = b.send().await?;
|
||||
let status = resp.status();
|
||||
let headers = resp.headers().clone();
|
||||
let resp_str = resp.text().await?;
|
||||
|
||||
if !status.is_success() {
|
||||
|
@ -91,7 +147,7 @@ impl Server {
|
|||
}
|
||||
let Resp { mut error } = serde_json::from_str(&resp_str)?;
|
||||
error.status = status;
|
||||
Err(error.into())
|
||||
Err(ApiErrorWithHeaders { error, headers }.into())
|
||||
} else if resp_str.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
|
@ -206,8 +262,16 @@ fn server() -> Server {
|
|||
let mut add_user = conn
|
||||
.prepare(
|
||||
r"
|
||||
INSERT INTO `user` (`userkey`, `permission`)
|
||||
VALUES (?, ?)
|
||||
INSERT INTO `user` (`userkey`, `permission`, `last_fetch_time`, `id_desc`)
|
||||
VALUES (?, ?, 0, '{}')
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let mut add_act_key = conn
|
||||
.prepare(
|
||||
r"
|
||||
INSERT INTO `user_act_key` (`uid`, `act_key`, `expire_time`)
|
||||
VALUES (?, ?, ?)
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
|
@ -216,6 +280,8 @@ fn server() -> Server {
|
|||
(&BOB, ServerPermission::empty()),
|
||||
] {
|
||||
add_user.execute(params![user, perm]).unwrap();
|
||||
let uid = conn.last_insert_rowid();
|
||||
add_act_key.execute(params![uid, user, i64::MAX]).unwrap();
|
||||
}
|
||||
}
|
||||
let db = Database::from_raw(conn).unwrap();
|
||||
|
@ -227,7 +293,7 @@ fn server() -> Server {
|
|||
let listener = TcpListener::from_std(listener).unwrap();
|
||||
|
||||
// TODO: Testing config is hard to build because it does have a `Default` impl.
|
||||
let config = toml::from_str(&format!(r#"base_url="http://{LOCALHOST}:{port}""#)).unwrap();
|
||||
let config = toml::from_str(&CONFIG(port)).unwrap();
|
||||
let st = AppState::new(db, config);
|
||||
let router = blahd::router(Arc::new(st));
|
||||
|
||||
|
@ -717,3 +783,215 @@ async fn peer_chat(server: Server, ref mut rng: impl RngCore) {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn register(server: Server) {
|
||||
let rid = server
|
||||
.create_room(
|
||||
&ALICE_PRIV,
|
||||
RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE,
|
||||
"public room",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let get_me = |user: Option<&SigningKey>| {
|
||||
let auth = user.map(|user| auth(user, &mut *server.rng()));
|
||||
server
|
||||
.request::<(), ()>(Method::GET, "/user/me", auth.as_deref(), None)
|
||||
.map_ok(|_| ())
|
||||
.map_err(|err| {
|
||||
let err = err.downcast::<ApiErrorWithHeaders>().unwrap();
|
||||
assert_eq!(err.error.status, StatusCode::NOT_FOUND);
|
||||
let challenge_nonce = err.headers[X_BLAH_NONCE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.parse::<u32>()
|
||||
.unwrap();
|
||||
let difficulty = err.headers[X_BLAH_DIFFICULTY]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.parse::<u8>()
|
||||
.unwrap();
|
||||
(challenge_nonce, difficulty)
|
||||
})
|
||||
};
|
||||
|
||||
// Alice is registered.
|
||||
get_me(Some(&ALICE_PRIV)).await.unwrap();
|
||||
|
||||
// Carol is not registered.
|
||||
let (challenge_nonce, diff) = get_me(Some(&CAROL_PRIV)).await.unwrap_err();
|
||||
assert_eq!(diff, REGISTER_DIFFICULTY);
|
||||
|
||||
// Without token.
|
||||
let ret2 = get_me(None).await.unwrap_err();
|
||||
assert_eq!(ret2, (challenge_nonce, diff));
|
||||
|
||||
let mut req = UserRegisterPayload {
|
||||
id_key: CAROL.clone(),
|
||||
// Fake values.
|
||||
server_url: "http://invalid.example.com".parse().unwrap(),
|
||||
id_url: "file:///etc/passwd".parse().unwrap(),
|
||||
challenge_nonce: challenge_nonce - 1,
|
||||
};
|
||||
let register = |req: Signed<UserRegisterPayload>| {
|
||||
server
|
||||
.request::<_, ()>(Method::POST, "/user/me", None, Some(req))
|
||||
.map_ok(|_| {})
|
||||
};
|
||||
let sign_with_difficulty = |req: &UserRegisterPayload, pass: bool| loop {
|
||||
let signed = sign(&CAROL_PRIV, &mut *server.rng(), req.clone());
|
||||
let mut h = Sha256::new();
|
||||
h.update(signed.canonical_signee());
|
||||
let h = h.finalize();
|
||||
if (h[0] >> (8 - REGISTER_DIFFICULTY) == 0) == pass {
|
||||
return signed;
|
||||
}
|
||||
};
|
||||
let register_fast =
|
||||
|req: &UserRegisterPayload| register(sign(&CAROL_PRIV, &mut *server.rng(), req.clone()));
|
||||
|
||||
register_fast(&req)
|
||||
.await
|
||||
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_server_url");
|
||||
req.server_url = server.url("").parse().unwrap();
|
||||
|
||||
register_fast(&req)
|
||||
.await
|
||||
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_id_url");
|
||||
|
||||
// Test identity server.
|
||||
type DynHandler = Box<dyn FnMut() -> BoxFuture<'static, (StatusCode, String)> + Send>;
|
||||
type State = Arc<Mutex<DynHandler>>;
|
||||
let id_server_handler = {
|
||||
let handler = Box::new(|| {
|
||||
Box::pin(async move { (StatusCode::NOT_FOUND, "".into()) }) as BoxFuture<_>
|
||||
}) as DynHandler;
|
||||
let st = Arc::new(Mutex::new(handler)) as State;
|
||||
|
||||
let listener = TcpListener::bind(format!("{LOCALHOST}:0")).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
req.id_url = Url::parse(&format!("http://{LOCALHOST}:{port}")).unwrap();
|
||||
|
||||
let router = axum::Router::new()
|
||||
.route(
|
||||
UserIdentityDesc::WELL_KNOWN_PATH,
|
||||
axum::routing::get(move |state: axum::extract::State<State>| state.lock()()),
|
||||
)
|
||||
.with_state(st.clone());
|
||||
tokio::spawn(axum::serve(listener, router).into_future());
|
||||
st
|
||||
};
|
||||
macro_rules! set_handler {
|
||||
($([$before:stmt])? $h:block) => {
|
||||
*id_server_handler.lock() = Box::new(move || {
|
||||
$($before)?
|
||||
Box::pin(async move $h) as BoxFuture<_>
|
||||
}) as DynHandler;
|
||||
};
|
||||
}
|
||||
|
||||
register_fast(&req)
|
||||
.await
|
||||
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_challenge_nonce");
|
||||
req.challenge_nonce += 1;
|
||||
|
||||
register(sign_with_difficulty(&req, false))
|
||||
.await
|
||||
.expect_api_err(StatusCode::BAD_REQUEST, "invalid_challenge_hash");
|
||||
|
||||
//// Starting here, early validation passed. ////
|
||||
|
||||
// id_url 404
|
||||
register(sign_with_difficulty(&req, true))
|
||||
.await
|
||||
.expect_api_err(StatusCode::UNAUTHORIZED, "fetch_id_description");
|
||||
|
||||
// Timeout
|
||||
set_handler! {{
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
(StatusCode::OK, "".into())
|
||||
}}
|
||||
let inst = Instant::now();
|
||||
register(sign_with_difficulty(&req, true))
|
||||
.await
|
||||
.expect_api_err(StatusCode::UNAUTHORIZED, "fetch_id_description");
|
||||
let elapsed = inst.elapsed();
|
||||
assert!(
|
||||
elapsed.abs_diff(Duration::from_secs(1)) < TIME_TOLERANCE,
|
||||
"unexpected delay: {elapsed:?}",
|
||||
);
|
||||
|
||||
// Body too long.
|
||||
set_handler! {{
|
||||
(StatusCode::OK, " ".repeat(64 << 10)) // 64KiB
|
||||
}}
|
||||
register(sign_with_difficulty(&req, true))
|
||||
.await
|
||||
.expect_api_err(StatusCode::UNAUTHORIZED, "fetch_id_description");
|
||||
|
||||
let set_id_desc = |desc: &UserIdentityDesc| {
|
||||
let desc = serde_json::to_string(&desc).unwrap();
|
||||
set_handler! { [let desc = desc.clone()] {
|
||||
(StatusCode::OK, desc.clone())
|
||||
}}
|
||||
};
|
||||
let mut id_desc = {
|
||||
let act_key = sign(
|
||||
&CAROL_PRIV,
|
||||
&mut *server.rng(),
|
||||
UserActKeyDesc {
|
||||
act_key: UserKey(CAROL_ACT_PRIV.verifying_key().to_bytes()),
|
||||
expire_time: u64::MAX,
|
||||
comment: "comment".into(),
|
||||
},
|
||||
);
|
||||
let profile = sign(
|
||||
&CAROL_ACT_PRIV,
|
||||
&mut *server.rng(),
|
||||
UserProfile {
|
||||
preferred_chat_server_urls: Vec::new(),
|
||||
id_urls: vec![req.id_url.join("/mismatch").unwrap()],
|
||||
},
|
||||
);
|
||||
UserIdentityDesc {
|
||||
id_key: CAROL.clone(),
|
||||
act_keys: vec![act_key],
|
||||
profile,
|
||||
}
|
||||
};
|
||||
|
||||
// id_url mismatch
|
||||
set_id_desc(&id_desc);
|
||||
register(sign_with_difficulty(&req, true))
|
||||
.await
|
||||
.expect_api_err(StatusCode::UNAUTHORIZED, "invalid_id_description");
|
||||
|
||||
// Still not registered.
|
||||
get_me(Some(&CAROL_PRIV)).await.unwrap_err();
|
||||
server
|
||||
.join_room(rid, &CAROL_PRIV, MemberPermission::MAX_SELF_ADD)
|
||||
.await
|
||||
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
|
||||
|
||||
// Finally pass.
|
||||
id_desc.profile = sign(
|
||||
&CAROL_ACT_PRIV,
|
||||
&mut *server.rng(),
|
||||
UserProfile {
|
||||
preferred_chat_server_urls: Vec::new(),
|
||||
id_urls: vec![req.id_url.clone()],
|
||||
},
|
||||
);
|
||||
set_id_desc(&id_desc);
|
||||
register(sign_with_difficulty(&req, true)).await.unwrap();
|
||||
|
||||
// Registered now.
|
||||
get_me(Some(&CAROL_PRIV)).await.unwrap();
|
||||
server
|
||||
.join_room(rid, &CAROL_PRIV, MemberPermission::MAX_SELF_ADD)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue