web3-proxy/web3_proxy/src/frontend/users.rs

307 lines
10 KiB
Rust
Raw Normal View History

2022-07-14 00:49:57 +03:00
// So the API needs to show for any given user:
// - show balance in USD
// - show deposits history (currency, amounts, transaction id)
// - show number of requests used (so we can calculate average spending over a month, burn rate for a user etc, something like "Your balance will be depleted in xx days)
// - the email address of a user if he opted in to get contacted via email
// - all the monitoring and stats but that will come from someplace else if I understand corectly?
// I wonder how we handle payment
// probably have to do manual withdrawals
use super::errors::FrontendResult;
2022-08-21 12:39:38 +03:00
use super::rate_limit::rate_limit_by_ip;
2022-08-27 08:42:25 +03:00
use crate::{app::Web3ProxyApp, users::new_api_key};
use anyhow::Context;
2022-08-11 04:53:27 +03:00
use axum::{
2022-08-21 11:18:57 +03:00
extract::{Path, Query},
response::IntoResponse,
2022-08-11 04:53:27 +03:00
Extension, Json,
};
use axum_auth::AuthBearer;
2022-08-04 04:10:27 +03:00
use axum_client_ip::ClientIp;
2022-08-16 22:29:00 +03:00
use axum_macros::debug_handler;
2022-08-27 08:42:25 +03:00
use http::StatusCode;
use uuid::Uuid;
2022-08-24 02:13:56 +03:00
// use entities::sea_orm_active_enums::Role;
2022-08-27 08:42:25 +03:00
use entities::{user, user_keys};
2022-08-04 04:10:27 +03:00
use ethers::{prelude::Address, types::Bytes};
2022-08-19 23:18:12 +03:00
use hashbrown::HashMap;
use redis_rate_limit::redis::AsyncCommands;
2022-08-27 08:42:25 +03:00
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, TransactionTrait};
use serde::{Deserialize, Serialize};
use siwe::Message;
2022-08-27 05:13:36 +03:00
use std::ops::Add;
2022-08-04 04:10:27 +03:00
use std::sync::Arc;
use time::{Duration, OffsetDateTime};
2022-08-21 11:18:57 +03:00
use ulid::Ulid;
// TODO: how do we customize axum's error response? I think we probably want an enum that implements IntoResponse instead
2022-08-16 22:29:00 +03:00
#[debug_handler]
pub async fn get_login(
Extension(app): Extension<Arc<Web3ProxyApp>>,
2022-08-17 00:43:39 +03:00
ClientIp(ip): ClientIp,
// TODO: what does axum's error handling look like if the path fails to parse?
// TODO: allow ENS names here?
2022-08-19 23:18:12 +03:00
Path(mut params): Path<HashMap<String, String>>,
2022-08-17 00:43:39 +03:00
) -> FrontendResult {
2022-08-27 05:13:36 +03:00
let _ip = rate_limit_by_ip(&app, ip).await?;
2022-08-17 00:43:39 +03:00
// at first i thought about checking that user_address is in our db
2022-08-21 11:18:57 +03:00
// but theres no need to separate the registration and login flows
2022-08-17 00:43:39 +03:00
// 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
2022-08-21 11:18:57 +03:00
// create a message and save it in redis
// TODO: how many seconds? get from config?
// TODO: while developing, we put a giant number here
let expire_seconds: usize = 28800;
2022-08-21 11:18:57 +03:00
let nonce = Ulid::new();
let issued_at = OffsetDateTime::now_utc();
let expiration_time = issued_at.add(Duration::new(expire_seconds as i64, 0));
2022-08-19 23:18:12 +03:00
// TODO: proper errors. the first unwrap should be impossible, but the second will happen with bad input
let user_address: Address = params.remove("user_address").unwrap().parse().unwrap();
2022-08-17 00:43:39 +03:00
// TODO: get most of these from the app config
let message = Message {
domain: "staging.llamanodes.com".parse().unwrap(),
address: user_address.to_fixed_bytes(),
statement: Some("🦙🦙🦙🦙🦙".to_string()),
uri: "https://staging.llamanodes.com/".parse().unwrap(),
version: siwe::Version::V1,
chain_id: 1,
expiration_time: Some(expiration_time.into()),
issued_at: issued_at.into(),
nonce: nonce.to_string(),
not_before: None,
request_id: None,
resources: vec![],
};
let session_key = format!("pending:{}", nonce);
// TODO: if no redis server, store in local cache?
2022-08-21 11:18:57 +03:00
// 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_bytes
// we add 1 to expire_seconds just to be sure redis has the key for the full expiration_time
app.redis_conn()
.await?
.set_ex(session_key, message.to_string(), expire_seconds + 1)
.await?;
2022-08-19 23:18:12 +03:00
// there are multiple ways to sign messages and not all wallets support them
let message_eip = params
.remove("message_eip")
.unwrap_or_else(|| "eip4361".to_string());
let message: String = match message_eip.as_str() {
"eip4361" => message.to_string(),
// https://github.com/spruceid/siwe/issues/98
"eip191_bytes" => Bytes::from(message.eip191_bytes().unwrap()).to_string(),
2022-08-19 23:18:12 +03:00
"eip191_hash" => Bytes::from(&message.eip191_hash().unwrap()).to_string(),
2022-08-21 11:18:57 +03:00
_ => return Err(anyhow::anyhow!("invalid message eip given").into()),
2022-08-19 23:18:12 +03:00
};
Ok(message.into_response())
2022-08-16 22:29:00 +03:00
}
2022-08-04 02:17:02 +03:00
2022-08-21 11:18:57 +03:00
/// Query params to our `post_login` handler.
#[derive(Debug, Deserialize)]
pub struct PostLoginQuery {
invite_code: Option<String>,
}
/// JSON body to our `post_login` handler.
#[derive(Deserialize)]
pub struct PostLogin {
address: Address,
msg: String,
sig: Bytes,
2022-08-21 12:39:38 +03:00
// TODO: do we care about these? we should probably check the version is something we expect
// version: String,
// signer: String,
2022-08-21 11:18:57 +03:00
}
2022-08-27 08:42:25 +03:00
#[derive(Serialize)]
pub struct PostLoginResponse {
bearer_token: Ulid,
// TODO: change this Ulid
api_key: Uuid,
}
#[debug_handler]
2022-08-21 11:18:57 +03:00
/// Post to the user endpoint to register or login.
pub async fn post_login(
2022-08-04 04:10:27 +03:00
ClientIp(ip): ClientIp,
2022-08-21 11:18:57 +03:00
Extension(app): Extension<Arc<Web3ProxyApp>>,
Json(payload): Json<PostLogin>,
Query(query): Query<PostLoginQuery>,
2022-08-21 12:39:38 +03:00
) -> FrontendResult {
2022-08-27 05:13:36 +03:00
let _ip = rate_limit_by_ip(&app, ip).await?;
2022-08-04 04:10:27 +03:00
2022-08-21 11:18:57 +03:00
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")
}
2022-08-04 04:10:27 +03:00
}
2022-08-21 11:18:57 +03:00
// 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
2022-08-17 02:03:50 +03:00
// TODO: use getdel
let our_msg: String = app.redis_conn().await?.get(&their_msg.nonce).await?;
2022-08-17 02:03:50 +03:00
2022-08-21 11:18:57 +03:00
let our_msg: siwe::Message = our_msg.parse().unwrap();
2022-08-17 02:03:50 +03:00
2022-08-21 11:18:57 +03:00
// 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) {
2022-08-04 04:10:27 +03:00
// message cannot be correctly authenticated
todo!("proper error message: {}", e)
}
2022-08-27 08:42:25 +03:00
let bearer_token = Ulid::new();
2022-08-27 08:42:25 +03:00
let db = app.db_conn.as_ref().unwrap();
2022-07-14 00:49:57 +03:00
2022-08-27 08:42:25 +03:00
// 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();
2022-08-04 02:17:02 +03:00
2022-08-27 08:42:25 +03:00
let (u_id, response) = match u {
None => {
let txn = db.begin().await?;
2022-08-04 02:17:02 +03:00
2022-08-27 08:42:25 +03:00
// 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()
};
2022-08-17 02:03:50 +03:00
2022-08-27 08:42:25 +03:00
let u = u.insert(&txn).await?;
2022-08-17 02:03:50 +03:00
2022-08-27 08:42:25 +03:00
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()
};
2022-08-21 11:18:57 +03:00
2022-08-27 08:42:25 +03:00
// TODO: if this fails, revert adding the user, too
let uk = uk
.insert(&txn)
.await
.context("Failed saving new user key")?;
2022-08-17 02:03:50 +03:00
2022-08-27 08:42:25 +03:00
txn.commit().await?;
2022-08-16 20:47:04 +03:00
2022-08-27 08:42:25 +03:00
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)
}
2022-08-23 21:51:42 +03:00
};
2022-08-27 08:42:25 +03:00
// 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?;
2022-08-23 21:51:42 +03:00
Ok(response)
2022-07-14 00:49:57 +03:00
}
2022-08-21 11:18:57 +03:00
/// the JSON input to the `post_user` handler
2022-07-14 00:49:57 +03:00
#[derive(Deserialize)]
2022-08-21 11:18:57 +03:00
pub struct PostUser {
2022-08-23 22:08:47 +03:00
primary_address: Address,
2022-08-21 11:18:57 +03:00
// TODO: make sure the email address is valid. probably have a "verified" column in the database
2022-07-14 00:49:57 +03:00
email: Option<String>,
2022-08-21 11:18:57 +03:00
// 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(
2022-08-27 08:42:25 +03:00
AuthBearer(bearer_token): AuthBearer,
2022-08-23 21:51:42 +03:00
ClientIp(ip): ClientIp,
Extension(app): Extension<Arc<Web3ProxyApp>>,
Json(payload): Json<PostUser>,
2022-08-21 11:18:57 +03:00
) -> FrontendResult {
2022-08-27 05:13:36 +03:00
let _ip = rate_limit_by_ip(&app, ip).await?;
2022-08-21 11:18:57 +03:00
ProtectedAction::PostUser
2022-08-27 08:42:25 +03:00
.verify(app.as_ref(), bearer_token, &payload.primary_address)
.await?;
2022-08-21 11:18:57 +03:00
// let user = user::ActiveModel {
// address: sea_orm::Set(payload.address.to_fixed_bytes().into()),
// email: sea_orm::Set(payload.email),
// ..Default::default()
// };
2022-08-23 21:51:42 +03:00
todo!("finish post_user");
2022-07-14 00:49:57 +03:00
}
2022-08-23 22:08:47 +03:00
// TODO: what roles should exist?
enum ProtectedAction {
PostUser,
}
2022-08-23 22:08:47 +03:00
impl ProtectedAction {
async fn verify(
self,
app: &Web3ProxyApp,
2022-08-27 08:42:25 +03:00
bearer_token: String,
primary_address: &Address,
) -> anyhow::Result<()> {
2022-08-27 08:42:25 +03:00
// 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<u64> = 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");
}
2022-08-23 22:08:47 +03:00
}