diff --git a/Cargo.lock b/Cargo.lock index 2b6c7313..62e38d10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5282,6 +5282,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a3aaa69b04e5b66cc27309710a569ea23593612387d67daaf102e73aa974fd" dependencies = [ "rand 0.8.5", + "serde", ] [[package]] diff --git a/TODO.md b/TODO.md index 7e370fd6..7275c8ea 100644 --- a/TODO.md +++ b/TODO.md @@ -134,6 +134,8 @@ - [ ] cli tool for resetting api keys - [ ] cli tool for checking config - [ ] nice output when cargo doc is run +- [ ] Ulid instead of Uuid + - - [ ] if we request an old block, more servers can handle it than we currently use. - [ ] instead of the one list of just heads, store our intermediate mappings (rpcs_by_hash, rpcs_by_num, blocks_by_hash) in SyncedConnections. this shouldn't be too much slower than what we have now - [ ] remove the if/else where we optionally route to archive and refactor to require a BlockNumber enum diff --git a/web3_proxy/Cargo.toml b/web3_proxy/Cargo.toml index 28dfa4f0..a2ef2205 100644 --- a/web3_proxy/Cargo.toml +++ b/web3_proxy/Cargo.toml @@ -63,8 +63,6 @@ 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" +ulid = { version = "1.0.0", features = ["serde"] } url = "2.2.2" uuid = "1.1.2" - -# TODO: https://github.com/ulid/spec instead of uuid \ No newline at end of file diff --git a/web3_proxy/src/bin/web3_proxy.rs b/web3_proxy/src/bin/web3_proxy.rs index 3bb197b2..03c4d2c2 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, + default_requests_per_minute: 6_000_000, invite_code: None, redis_url: None, min_sum_soft_limit: 1, diff --git a/web3_proxy/src/config.rs b/web3_proxy/src/config.rs index 975821a8..62e80fab 100644 --- a/web3_proxy/src/config.rs +++ b/web3_proxy/src/config.rs @@ -45,6 +45,8 @@ pub struct AppConfig { pub chain_id: u64, pub db_url: Option, pub invite_code: Option, + #[serde(default = "default_default_requests_per_minute")] + pub default_requests_per_minute: u32, #[serde(default = "default_min_sum_soft_limit")] pub min_sum_soft_limit: u32, #[serde(default = "default_min_synced_rpcs")] @@ -60,6 +62,11 @@ pub struct AppConfig { pub redirect_user_url: String, } +// TODO: what should we default to? have different tiers for different paid amounts? +fn default_default_requests_per_minute() -> u32 { + 1_000_000 * 60 +} + fn default_min_sum_soft_limit() -> u32 { 1 } diff --git a/web3_proxy/src/frontend/errors.rs b/web3_proxy/src/frontend/errors.rs index b75df8bd..674ae796 100644 --- a/web3_proxy/src/frontend/errors.rs +++ b/web3_proxy/src/frontend/errors.rs @@ -6,6 +6,7 @@ use axum::{ }; use derive_more::From; use redis_rate_limit::{bb8::RunError, RedisError}; +use sea_orm::DbErr; use serde_json::value::RawValue; use std::error::Error; @@ -18,8 +19,9 @@ pub enum FrontendErrorResponse { Box(Box), // TODO: should we box these instead? Redis(RedisError), - RedisRunError(RunError), + RedisRun(RunError), Response(Response), + Database(DbErr), } impl IntoResponse for FrontendErrorResponse { @@ -31,10 +33,11 @@ impl IntoResponse for FrontendErrorResponse { Self::Anyhow(err) => err, Self::Box(err) => anyhow::anyhow!("Boxed error: {:?}", err), Self::Redis(err) => err.into(), - Self::RedisRunError(err) => err.into(), + Self::RedisRun(err) => err.into(), Self::Response(r) => { return r; } + Self::Database(err) => err.into(), }; let err = JsonRpcForwardedResponse::from_anyhow_error(err, null_id); diff --git a/web3_proxy/src/frontend/users.rs b/web3_proxy/src/frontend/users.rs index bc3c4205..2e1f9ebf 100644 --- a/web3_proxy/src/frontend/users.rs +++ b/web3_proxy/src/frontend/users.rs @@ -9,7 +9,8 @@ use super::errors::FrontendResult; use super::rate_limit::rate_limit_by_ip; -use crate::app::Web3ProxyApp; +use crate::{app::Web3ProxyApp, users::new_api_key}; +use anyhow::Context; use axum::{ extract::{Path, Query}, response::IntoResponse, @@ -18,13 +19,15 @@ use axum::{ use axum_auth::AuthBearer; use axum_client_ip::ClientIp; use axum_macros::debug_handler; +use http::StatusCode; +use uuid::Uuid; // use entities::sea_orm_active_enums::Role; -use entities::user; +use entities::{user, user_keys}; use ethers::{prelude::Address, types::Bytes}; use hashbrown::HashMap; use redis_rate_limit::redis::AsyncCommands; -use sea_orm::ActiveModelTrait; -use serde::Deserialize; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, TransactionTrait}; +use serde::{Deserialize, Serialize}; use siwe::Message; use std::ops::Add; use std::sync::Arc; @@ -122,6 +125,13 @@ pub struct PostLogin { // signer: String, } +#[derive(Serialize)] +pub struct PostLoginResponse { + bearer_token: Ulid, + // TODO: change this Ulid + api_key: Uuid, +} + #[debug_handler] /// Post to the user endpoint to register or login. pub async fn post_login( @@ -132,8 +142,6 @@ pub async fn post_login( ) -> FrontendResult { let _ip = rate_limit_by_ip(&app, ip).await?; - 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. @@ -159,48 +167,82 @@ pub async fn post_login( todo!("proper error message: {}", e) } - // TODO: create a new auth bearer token (ULID?) + let bearer_token = Ulid::new(); - let response = 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: limit columns or load whole user? + let u = user::Entity::find() + .filter(user::Column::Address.eq(our_msg.address.as_ref())) + .one(db) + .await + .unwrap(); - let user = user.insert(db).await.unwrap(); + let (u_id, response) = match u { + None => { + let txn = db.begin().await?; - let api_key = todo!("create an api key"); + // the only thing we need from them is an address + // everything else is optional + let u = user::ActiveModel { + address: sea_orm::Set(payload.address.to_fixed_bytes().into()), + ..Default::default() + }; - /* - let rpm = app.config.something; + let u = u.insert(&txn).await?; - // 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() - }; + let uk = user_keys::ActiveModel { + user_id: sea_orm::Set(u.id), + api_key: sea_orm::Set(new_api_key()), + requests_per_minute: sea_orm::Set(app.config.default_requests_per_minute), + ..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, revert adding the user, too + let uk = uk + .insert(&txn) + .await + .context("Failed saving new user key")?; - // TODO: set a cookie? + txn.commit().await?; - // TODO: do not expose user ids - // TODO: return an api key and a bearer token - (StatusCode::CREATED, Json(user)).into_response() - */ - } else { - todo!("load existing user from the database"); + let response_json = PostLoginResponse { + bearer_token, + api_key: uk.api_key, + }; + + let response = (StatusCode::CREATED, Json(response_json)).into_response(); + + (u.id, response) + } + Some(u) => { + // the user is already registered + // TODO: what if the user has multiple keys? + let uk = user_keys::Entity::find() + .filter(user_keys::Column::UserId.eq(u.id)) + .one(db) + .await + .context("failed loading user's key")? + .unwrap(); + + let response_json = PostLoginResponse { + bearer_token, + api_key: uk.api_key, + }; + + let response = (StatusCode::OK, Json(response_json)).into_response(); + + (u.id, response) + } }; - // TODO: save the auth bearer token in redis with a long (7 or 30 day?) expiry. + // TODO: set a session cookie with the bearer token? + // save the bearer token in redis with a long (7 or 30 day?) expiry. or in database? + let mut redis_conn = app.redis_conn().await?; + + let bearer_key = format!("bearer:{}", bearer_token); + + redis_conn.set(bearer_key, u_id.to_string()).await?; Ok(response) } @@ -217,7 +259,7 @@ pub struct PostUser { #[debug_handler] /// post to the user endpoint to modify your account pub async fn post_user( - AuthBearer(auth_token): AuthBearer, + AuthBearer(bearer_token): AuthBearer, ClientIp(ip): ClientIp, Extension(app): Extension>, Json(payload): Json, @@ -225,7 +267,7 @@ pub async fn post_user( let _ip = rate_limit_by_ip(&app, ip).await?; ProtectedAction::PostUser - .verify(app.as_ref(), auth_token, &payload.primary_address) + .verify(app.as_ref(), bearer_token, &payload.primary_address) .await?; // let user = user::ActiveModel { @@ -246,10 +288,17 @@ impl ProtectedAction { async fn verify( self, app: &Web3ProxyApp, - auth_token: String, + bearer_token: String, primary_address: &Address, ) -> anyhow::Result<()> { - // TODO: get the attached address from redis for the given auth_token. + // get the attached address from redis for the given auth_token. + let bearer_key = format!("bearer:{}", bearer_token); + + let mut redis_conn = app.redis_conn().await?; + + // TODO: is this type correct? + let u_id: Option = redis_conn.get(bearer_key).await?; + // TODO: if auth_address == primary_address, allow // TODO: if auth_address != primary_address, only allow if they are a secondary user with the correct role todo!("verify token for the given user");