From 5af834d7104f0da461a9f297ac94d0e5da6d02f9 Mon Sep 17 00:00:00 2001 From: Bryan Stitt Date: Sun, 21 Aug 2022 08:18:57 +0000 Subject: [PATCH] it compiles --- Cargo.lock | 12 ++- web3_proxy/Cargo.toml | 1 + web3_proxy/src/bin/web3_proxy.rs | 1 + web3_proxy/src/config.rs | 1 + web3_proxy/src/frontend/mod.rs | 25 ++--- web3_proxy/src/frontend/users.rs | 165 ++++++++++++++++++++----------- 6 files changed, 131 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02191320..8e30e92e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5090,7 +5090,7 @@ dependencies = [ "http", "tower-layer", "tower-service", - "ulid", + "ulid 0.4.1", ] [[package]] @@ -5235,6 +5235,15 @@ dependencies = [ "rand 0.6.5", ] +[[package]] +name = "ulid" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a3aaa69b04e5b66cc27309710a569ea23593612387d67daaf102e73aa974fd" +dependencies = [ + "rand 0.8.5", +] + [[package]] name = "unicode-bidi" version = "0.3.8" @@ -5525,6 +5534,7 @@ dependencies = [ "tower-request-id", "tracing", "tracing-subscriber", + "ulid 1.0.0", "url", "uuid 1.1.2", ] diff --git a/web3_proxy/Cargo.toml b/web3_proxy/Cargo.toml index 6a744b31..a734e42c 100644 --- a/web3_proxy/Cargo.toml +++ b/web3_proxy/Cargo.toml @@ -62,6 +62,7 @@ tower-http = { version = "0.3.4", features = ["trace"] } tracing = "0.1.36" # TODO: tracing-subscriber has serde and serde_json features that we might want to use tracing-subscriber = { version = "0.3.15", features = ["env-filter", "parking_lot"] } +ulid = "1.0.0" url = "2.2.2" uuid = "1.1.2" diff --git a/web3_proxy/src/bin/web3_proxy.rs b/web3_proxy/src/bin/web3_proxy.rs index 7abe05b8..e6480fc4 100644 --- a/web3_proxy/src/bin/web3_proxy.rs +++ b/web3_proxy/src/bin/web3_proxy.rs @@ -215,6 +215,7 @@ mod tests { app: AppConfig { chain_id: 31337, db_url: None, + invite_code: None, redis_url: None, public_rate_limit_per_minute: 0, response_cache_max_bytes: 10_usize.pow(7), diff --git a/web3_proxy/src/config.rs b/web3_proxy/src/config.rs index 1277b192..abbff65c 100644 --- a/web3_proxy/src/config.rs +++ b/web3_proxy/src/config.rs @@ -44,6 +44,7 @@ pub struct AppConfig { // TODO: better type for chain_id? max of `u64::MAX / 2 - 36` https://github.com/ethereum/EIPs/issues/2294 pub chain_id: u64, pub db_url: Option, + pub invite_code: Option, pub redis_url: Option, #[serde(default = "default_public_rate_limit_per_minute")] pub public_rate_limit_per_minute: u64, diff --git a/web3_proxy/src/frontend/mod.rs b/web3_proxy/src/frontend/mod.rs index 105fffda..6f50790c 100644 --- a/web3_proxy/src/frontend/mod.rs +++ b/web3_proxy/src/frontend/mod.rs @@ -1,4 +1,4 @@ -/// this should move into web3_proxy once the basics are working +mod axum_ext; mod errors; mod http; mod http_proxy; @@ -55,32 +55,25 @@ pub async fn serve(port: u16, proxy_app: Arc) -> anyhow::Result<() ) }); - /* - let some_fallible_service = tower::service_fn(|_req| async { - // thing_that_might_fail().await?; - Ok::<_, anyhow::Error>(Response::new(Body::empty())) - }); - */ - - // build our application with a route - // order most to least common + // build our axum Router let app = Router::new() + // routes should be order most to least common .route("/", post(http_proxy::public_proxy_web3_rpc)) .route("/", get(ws_proxy::public_websocket_handler)) .route("/u/:user_key", post(http_proxy::user_proxy_web3_rpc)) .route("/u/:user_key", get(ws_proxy::user_websocket_handler)) .route("/health", get(http::health)) + // TODO: we probably want to remove /status in favor of the separate prometheus thread .route("/status", get(http::status)) .route("/login/:user_address", get(users::get_login)) .route("/login/:user_address/:message_eip", get(users::get_login)) - .route("/users", post(users::create_user)) - // .route( - // "/foo", - // HandleError::new(some_fallible_service, handle_anyhow_error), - // ) + .route("/users", post(users::post_user)) + // layers are ordered bottom up + // the last layer is first for requests and last for responses .layer(Extension(proxy_app)) - // create a unique id for each request and add it to our tracing logs + // add the request id to our tracing logs .layer(request_tracing_layer) + // create a unique id for each request .layer(RequestIdLayer) // 404 for any unknown routes .fallback(errors::handler_404.into_service()); diff --git a/web3_proxy/src/frontend/users.rs b/web3_proxy/src/frontend/users.rs index 7552a64d..62f7f256 100644 --- a/web3_proxy/src/frontend/users.rs +++ b/web3_proxy/src/frontend/users.rs @@ -13,7 +13,7 @@ use super::{ }; use crate::app::Web3ProxyApp; use axum::{ - extract::Path, + extract::{Path, Query}, response::{IntoResponse, Response}, Extension, Json, }; @@ -30,7 +30,10 @@ use siwe::Message; use std::ops::Add; use std::sync::Arc; use time::{Duration, OffsetDateTime}; -use uuid::Uuid; +use ulid::Ulid; + +#[allow(unused)] +use super::axum_ext::empty_string_as_none; // TODO: how do we customize axum's error response? I think we probably want an enum that implements IntoResponse instead #[debug_handler] @@ -52,15 +55,16 @@ pub async fn get_login( }; // at first i thought about checking that user_address is in our db - // but theres no need to separate the create_user and login flows + // but theres no need to separate the registration and login flows // its a better UX to just click "login with ethereum" and have the account created if it doesn't exist // we can prompt for an email and and payment after they log in + // create a message and save it in redis + // TODO: how many seconds? get from config? let expire_seconds: usize = 300; - // create a message and save it in redis - let nonce = Uuid::new_v4(); + let nonce = Ulid::new(); let issued_at = OffsetDateTime::now_utc(); @@ -88,18 +92,21 @@ pub async fn get_login( let session_key = format!("pending:{}", nonce); // TODO: if no redis server, store in local cache? - let redis_pool = app + let mut redis_conn = app .redis_pool .as_ref() - .expect("login requires a redis server"); + .expect("login requires a redis server") + .get() + .await?; - let mut redis_conn = redis_pool.get().await?; - - // TODO: the address isn't enough. we need to save the actual message + // the address isn't enough. we need to save the actual message so we can read the nonce + // TODO: what message format is the most efficient to store in redis? probably eip191_string redis_conn .set_ex(session_key, message.to_string(), expire_seconds) .await?; + drop(redis_conn); + // there are multiple ways to sign messages and not all wallets support them let message_eip = params .remove("message_eip") @@ -110,21 +117,38 @@ pub async fn get_login( // https://github.com/spruceid/siwe/issues/98 "eip191_string" => Bytes::from(message.eip191_string().unwrap()).to_string(), "eip191_hash" => Bytes::from(&message.eip191_hash().unwrap()).to_string(), - _ => todo!("return a proper error"), + _ => return Err(anyhow::anyhow!("invalid message eip given").into()), }; Ok(message.into_response()) } +/// Query params to our `post_login` handler. +#[derive(Debug, Deserialize)] +pub struct PostLoginQuery { + invite_code: Option, +} + +/// JSON body to our `post_login` handler. +#[derive(Deserialize)] +pub struct PostLogin { + address: Address, + msg: String, + sig: Bytes, + version: String, + signer: String, +} + #[debug_handler] -pub async fn create_user( - // this argument tells axum to parse the request body - // as JSON into a `CreateUser` type - Json(payload): Json, - Extension(app): Extension>, +/// Post to the user endpoint to register or login. +pub async fn post_login( ClientIp(ip): ClientIp, + Extension(app): Extension>, + Json(payload): Json, + Query(query): Query, ) -> Response { // TODO: return a Result instead + // TODO: dry this up ip checking up let _ip = match app.rate_limit_by_ip(ip).await { Ok(x) => match x.try_into_response().await { Ok(RateLimitResult::AllowedIp(x)) => x, @@ -134,11 +158,22 @@ pub async fn create_user( Err(err) => return anyhow_error_into_response(None, None, err), }; - // TODO: check invite_code against the app's config or database - if payload.invite_code != "llam4n0des!" { - todo!("proper error message") + let mut new_user = true; // TODO: check the database + + if let Some(invite_code) = &app.config.invite_code { + // we don't do per-user referral codes because we shouldn't collect what we don't need. + // we don't need to build a social graph between addresses like that. + if query.invite_code.as_ref() != Some(invite_code) { + todo!("if address is already registered, allow login! else, error") + } } + // we can't trust that they didn't tamper with the message in some way + let their_msg: siwe::Message = payload.msg.parse().unwrap(); + + let their_sig: [u8; 65] = payload.sig.as_ref().try_into().unwrap(); + + // fetch the message we gave them from our redis let redis_pool = app .redis_pool .as_ref() @@ -148,60 +183,76 @@ pub async fn create_user( // TODO: use getdel // TODO: do not unwrap. make this function return a FrontendResult - let message: String = redis_conn.get(payload.nonce.to_string()).await.unwrap(); + let our_msg: String = redis_conn.get(&their_msg.nonce).await.unwrap(); - let message: Message = message.parse().unwrap(); + let our_msg: siwe::Message = our_msg.parse().unwrap(); - // TODO: dont unwrap. proper error - let signature: [u8; 65] = payload.signature.as_ref().try_into().unwrap(); - - // TODO: calculate the expected message for the current user. include domain and a nonce. let timestamp be automatic - if let Err(e) = message.verify(signature, None, None, None) { + // check the domain and a nonce. let timestamp be automatic + if let Err(e) = their_msg.verify(their_sig, Some(&our_msg.domain), Some(&our_msg.nonce), None) { // message cannot be correctly authenticated todo!("proper error message: {}", e) } - let user = user::ActiveModel { - address: sea_orm::Set(payload.address.to_fixed_bytes().into()), - email: sea_orm::Set(payload.email), - ..Default::default() - }; + if new_user { + // the only thing we need from them is an address + // everything else is optional + let user = user::ActiveModel { + address: sea_orm::Set(payload.address.to_fixed_bytes().into()), + ..Default::default() + }; - let db = app.db_conn.as_ref().unwrap(); + let db = app.db_conn.as_ref().unwrap(); - // TODO: proper error message - let user = user.insert(db).await.unwrap(); + let user = user.insert(db).await.unwrap(); - // TODO: create - let api_key = todo!(); + let api_key = todo!("create an api key"); - /* - let rpm = app.config.something; + /* + let rpm = app.config.something; - // create a key for the new user - // TODO: requests_per_minute should be configurable - let uk = user_keys::ActiveModel { - user_id: u.id, - api_key: sea_orm::Set(api_key), - requests_per_minute: sea_orm::Set(rpm), - ..Default::default() - }; + // create a key for the new user + // TODO: requests_per_minute should be configurable + let uk = user_keys::ActiveModel { + user_id: u.id, + api_key: sea_orm::Set(api_key), + requests_per_minute: sea_orm::Set(rpm), + ..Default::default() + }; - // TODO: if this fails, rever adding the user, too - let uk = uk.save(&txn).await.context("Failed saving new user key")?; + // TODO: if this fails, rever adding the user, too + let uk = uk.save(&txn).await.context("Failed saving new user key")?; - // TODO: do not expose user ids - (StatusCode::CREATED, Json(user)).into_response() - */ + // TODO: set a cookie? + + // TODO: do not expose user ids + (StatusCode::CREATED, Json(user)).into_response() + */ + } else { + todo!("load existing user from the database"); + } } -// the input to our `create_user` handler +/// the JSON input to the `post_user` handler #[derive(Deserialize)] -pub struct CreateUser { +pub struct PostUser { address: Address, - // TODO: make sure the email address is valid + // TODO: make sure the email address is valid. probably have a "verified" column in the database email: Option, - signature: Bytes, - nonce: Uuid, - invite_code: String, + // TODO: make them sign this JSON? cookie in session id is hard because its on a different domain +} + +#[debug_handler] +/// post to the user endpoint to modify your account +pub async fn post_user( + Json(payload): Json, + Extension(app): Extension>, + ClientIp(ip): ClientIp, +) -> FrontendResult { + todo!("finish post_login"); + + // let user = user::ActiveModel { + // address: sea_orm::Set(payload.address.to_fixed_bytes().into()), + // email: sea_orm::Set(payload.email), + // ..Default::default() + // }; }