imitating user. login flow still needs to check for read-only at every checkpoint. also referral logic can be removed from the login_post function

This commit is contained in:
yenicelik 2023-01-30 17:45:24 +01:00
parent 04687b3392
commit 31788bb228
12 changed files with 582 additions and 29 deletions

@ -14,6 +14,7 @@ pub struct Model {
pub bearer_token: Uuid,
pub user_id: u64,
pub expires_at: DateTimeUtc,
pub read_only: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

@ -15,6 +15,7 @@ pub struct Model {
#[sea_orm(column_type = "Text")]
pub message: String,
pub expires_at: DateTimeUtc,
pub imitating_user: Option<u64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

@ -14,6 +14,8 @@ mod m20221211_124002_request_method_privacy;
mod m20221213_134158_move_login_into_database;
mod m20230117_191358_admin_table;
mod m20230119_204135_better_free_tier;
mod m20230130_124740_read_only_login_logic;
mod m20230130_165144_prepare_admin_imitation_pre_login;
pub struct Migrator;
@ -35,6 +37,8 @@ impl MigratorTrait for Migrator {
Box::new(m20221213_134158_move_login_into_database::Migration),
Box::new(m20230117_191358_admin_table::Migration),
Box::new(m20230119_204135_better_free_tier::Migration),
Box::new(m20230130_124740_read_only_login_logic::Migration),
Box::new(m20230130_165144_prepare_admin_imitation_pre_login::Migration),
]
}
}

@ -0,0 +1,43 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Replace the sample below with your own migration scripts
manager
.alter_table(
Table::alter()
.table(Alias::new("login"))
.add_column(
ColumnDef::new(Login::ReadOnly)
.boolean()
.not_null()
).to_owned()
).await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Replace the sample below with your own migration scripts
// Drop the column from the table ...
manager
.alter_table(
Table::alter()
.table(Alias::new("login"))
.drop_column(Alias::new("read_only"))
.to_owned()
).await
}
}
/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
enum Login {
Table,
Id,
BearerToken,
ReadOnly,
UserId,
}

@ -0,0 +1,58 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Replace the sample below with your own migration scripts
manager
.alter_table(
Table::alter()
.table(Alias::new("pending_login"))
.add_column(
ColumnDef::new(PendingLogin::ImitatingUser)
.big_unsigned()
)
.add_foreign_key(&TableForeignKey::new()
.name("fk-pending_login-imitating_user")
.from_tbl(PendingLogin::Table)
.to_tbl(User::Table)
.from_col(PendingLogin::ImitatingUser)
.to_col(User::Id)
)
.to_owned()
).await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Replace the sample below with your own migration scripts
manager
.alter_table(
Table::alter()
.table(Alias::new("pending_login"))
.drop_foreign_key(Alias::new("fk-pending_login-imitating_user"))
.drop_column(Alias::new("imitating_user"))
.to_owned()
).await
}
}
/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
enum PendingLogin {
Table,
Id,
Nonce,
Message,
ExpiresAt,
ImitatingUser,
}
/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
enum User {
Table,
Id
}

@ -0,0 +1,3 @@
sea-orm-cli migrate up
# sea-orm-cli generate entity -t <table_name>

@ -16,6 +16,11 @@ use migration::sea_orm::{self, ActiveModelTrait, ColumnTrait, EntityTrait, IntoA
use log::info;
use redis_rate_limiter::redis::AsyncCommands;
// TODO: Add some logic to check if the operating user is an admin
// If he is, return true
// If he is not, return false
// This function is used to give permission to certain users
pub async fn query_admin_modify_usertier<'a>(
app: &'a Web3ProxyApp,

@ -21,7 +21,7 @@ use axum_client_ip::ClientIp;
use axum_macros::debug_handler;
use chrono::{TimeZone, Utc};
use entities::sea_orm_active_enums::{LogLevel, Role};
use entities::{login, pending_login, revert_log, rpc_key, secondary_user, user, user_tier};
use entities::{admin, login, pending_login, revert_log, rpc_key, secondary_user, user, user_tier};
use ethers::{prelude::Address, types::Bytes};
use hashbrown::HashMap;
use http::{HeaderValue, StatusCode};
@ -43,16 +43,12 @@ use time::{Duration, OffsetDateTime};
use ulid::Ulid;
use crate::admin_queries::query_admin_modify_usertier;
use crate::frontend::errors::FrontendErrorResponse;
use crate::{PostLogin, PostLoginQuery};
/// `GET /admin/modify_role` -- Use a bearer token to get the user's key stats such as bandwidth used and methods requested.
/// `GET /admin/modify_role` -- As an admin, modify a user's user-tier
///
/// If no bearer is provided, detailed stats for all users will be shown.
/// View a single user with `?user_id=$x`.
/// View a single chain with `?chain_id=$x`.
///
/// Set `$x` to zero to see all.
///
/// TODO: this will change as we add better support for secondary users.
/// - user_address that is to be modified
/// - user_role_tier that is supposed to be adapted
#[debug_handler]
pub async fn admin_change_user_roles(
Extension(app): Extension<Arc<Web3ProxyApp>>,
@ -63,3 +59,432 @@ pub async fn admin_change_user_roles(
Ok(response)
}
/// `GET /admin/login/:user_address` -- Being an admin, login as a user in read-only mode
///
/// - user_address that is to be logged in by
/// We assume that the admin has already logged in, and has a bearer token ...
#[debug_handler]
pub async fn admin_login_get(
Extension(app): Extension<Arc<Web3ProxyApp>>,
ClientIp(ip): ClientIp,
Path(mut params): Path<HashMap<String, String>>,
) -> FrontendResult {
// First check if the login is authorized
login_is_authorized(&app, ip).await?;
// create a message and save it in redis
// TODO: how many seconds? get from config?
// Same parameters as when someone logs in as a user
let expire_seconds: usize = 20 * 60;
let nonce = Ulid::new();
let issued_at = OffsetDateTime::now_utc();
let expiration_time = issued_at.add(Duration::new(expire_seconds as i64, 0));
// The admin user is the one that basically logs in, on behalf of the user
// This will generate a login id for the admin, which we will be caching ...
// I suppose with this, the admin can be logged in to one session at a time
// let (caller, _semaphore) = app.bearer_is_authorized(bearer_token).await?;
// Finally, check if the user is an admin. If he is, return "true" as the third triplet.
// TODO: consider wrapping the output in a struct, instead of a triplet
// TODO: Could try to merge this into the above query ...
// This query will fail if it's not the admin...
// get the admin field ...
let admin_address: Address = params
.get("admin_address")
.ok_or_else(||
FrontendErrorResponse::StatusCode(
StatusCode::BAD_REQUEST,
"Unable to find admin_address key in request".to_string(),
None,
)
)?
.parse::<Address>()
.map_err(|err| {
FrontendErrorResponse::StatusCode(
StatusCode::BAD_REQUEST,
"Unable to parse user_address as an Address".to_string(),
Some(err.into())
)
})?;
// Fetch the user_address parameter from the login string ... (as who we want to be logging in ...)
let user_address: Vec<u8> = params
.get("user_address")
.ok_or_else(||
FrontendErrorResponse::StatusCode(
StatusCode::BAD_REQUEST,
"Unable to find user_address key in request".to_string(),
None,
)
)?
.parse::<Address>()
.map_err(|err| {
FrontendErrorResponse::StatusCode(
StatusCode::BAD_REQUEST,
"Unable to parse user_address as an Address".to_string(),
Some(err.into()),
)
})?
.to_fixed_bytes().into();
// We want to login to llamanodes.com
let login_domain = app
.config
.login_domain
.clone()
.unwrap_or_else(|| "llamanodes.com".to_string());
// Also there must basically be a token, that says that one admin logins _as a user_.
// I'm not yet fully sure how to handle with that logic specifically ...
// TODO: get most of these from the app config
// TODO: Let's check again who the message needs to be signed by;
// if the message does not have to be signed by the user, include the user ...
let message = Message {
// TODO: don't unwrap
// TODO: accept a login_domain from the request?
domain: login_domain.parse().unwrap(),
// In the case of the admin, the admin needs to sign the message, so we include this logic ...
address: admin_address.to_fixed_bytes(),// user_address.to_fixed_bytes(),
// TODO: config for statement
statement: Some("🦙🦙🦙🦙🦙".to_string()),
// TODO: don't unwrap
uri: format!("https://{}/", login_domain).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 db_conn = app.db_conn().context("login requires a database")?;
let db_replica = app.db_replica().context("login requires a replica database")?;
// Get the user that we want to imitate from the read-only database (their id ...)
// TODO: Only get the id, not the whole user object ...
let user = user::Entity::find()
.filter(user::Column::Address.eq(user_address))
.one(db_replica.conn())
.await?
.context("fetching admin from db by user_id")?;
// Can there be two login-sessions at the same time?
// I supposed if the user logs in, the admin would be logged out and vice versa
// massage types to fit in the database. sea-orm does not make this very elegant
let uuid = Uuid::from_u128(nonce.into());
// we add 1 to expire_seconds just to be sure the database has the key for the full expiration_time
let expires_at = Utc
.timestamp_opt(expiration_time.unix_timestamp() + 1, 0)
.unwrap();
// we do not store a maximum number of attempted logins. anyone can request so we don't want to allow DOS attacks
// add a row to the database for this user
let user_pending_login = pending_login::ActiveModel {
id: sea_orm::NotSet,
nonce: sea_orm::Set(uuid),
message: sea_orm::Set(message.to_string()),
expires_at: sea_orm::Set(expires_at),
imitating_user: sea_orm::Set(Some(user.id))
};
user_pending_login
.save(&db_conn)
.await
.context("saving user's pending_login")?;
// there are multiple ways to sign messages and not all wallets support them
// TODO: default message eip from config?
let message_eip = params
.remove("message_eip")
.unwrap_or_else(|| "eip4361".to_string());
let message: String = match message_eip.as_str() {
"eip191_bytes" => Bytes::from(message.eip191_bytes().unwrap()).to_string(),
"eip191_hash" => Bytes::from(&message.eip191_hash().unwrap()).to_string(),
"eip4361" => message.to_string(),
_ => {
// TODO: custom error that is handled a 401
return Err(anyhow::anyhow!("invalid message eip given").into());
}
};
Ok(message.into_response())
}
/// `POST /admin/login` - Register or login by posting a signed "siwe" message
/// It is recommended to save the returned bearer token in a cookie.
/// The bearer token can be used to authenticate other requests, such as getting user user's tats or modifying the user's profile
#[debug_handler]
pub async fn admin_login_post(
Extension(app): Extension<Arc<Web3ProxyApp>>,
ClientIp(ip): ClientIp,
Query(query): Query<PostLoginQuery>,
Json(payload): Json<PostLogin>,
) -> FrontendResult {
login_is_authorized(&app, ip).await?;
// Check for the signed bytes ..
// TODO: this seems too verbose. how can we simply convert a String into a [u8; 65]
let their_sig_bytes = Bytes::from_str(&payload.sig).context("parsing sig")?;
if their_sig_bytes.len() != 65 {
return Err(anyhow::anyhow!("checking signature length").into());
}
let mut their_sig: [u8; 65] = [0; 65];
for x in 0..65 {
their_sig[x] = their_sig_bytes[x]
}
// we can't trust that they didn't tamper with the message in some way. like some clients return it hex encoded
// TODO: checking 0x seems fragile, but I think it will be fine. siwe message text shouldn't ever start with 0x
let their_msg: Message = if payload.msg.starts_with("0x") {
let their_msg_bytes = Bytes::from_str(&payload.msg).context("parsing payload message")?;
// TODO: lossy or no?
String::from_utf8_lossy(their_msg_bytes.as_ref())
.parse::<siwe::Message>()
.context("parsing hex string message")?
} else {
payload
.msg
.parse::<siwe::Message>()
.context("parsing string message")?
};
// the only part of the message we will trust is their nonce
// TODO: this is fragile. have a helper function/struct for redis keys
let login_nonce = UserBearerToken::from_str(&their_msg.nonce)?;
// fetch the message we gave them from our database
let db_replica = app.db_replica().context("Getting database connection")?;
// massage type for the db
let login_nonce_uuid: Uuid = login_nonce.clone().into();
// TODO: Here we will need to re-find the parameter where the admin wants to log-in as the user ...
let user_pending_login = pending_login::Entity::find()
.filter(pending_login::Column::Nonce.eq(login_nonce_uuid))
.one(db_replica.conn())
.await
.context("database error while finding pending_login")?
.context("login nonce not found")?;
let our_msg: siwe::Message = user_pending_login
.message
.parse()
.context("parsing siwe message")?;
// default options are fine. the message includes timestamp and domain and nonce
let verify_config = VerificationOpts::default();
if let Err(err_1) = our_msg
.verify(&their_sig, &verify_config)
.await
.context("verifying signature against our local message")
{
// verification method 1 failed. try eip191
if let Err(err_191) = our_msg
.verify_eip191(&their_sig)
.context("verifying eip191 signature against our local message")
{
let db_conn = app
.db_conn()
.context("deleting expired pending logins requires a db")?;
// delete ALL expired rows.
let now = Utc::now();
let delete_result = pending_login::Entity::delete_many()
.filter(pending_login::Column::ExpiresAt.lte(now))
.exec(&db_conn)
.await?;
// TODO: emit a stat? if this is high something weird might be happening
debug!("cleared expired pending_logins: {:?}", delete_result);
return Err(anyhow::anyhow!(
"both the primary and eip191 verification failed: {:#?}; {:#?}",
err_1,
err_191
)
.into());
}
}
// TODO: Maybe add a context?
let imitating_user = user_pending_login.imitating_user.address?;
// TODO: limit columns or load whole user?
// TODO: Right now this loads the whole admin. I assume we might want to load the user though (?) figure this out as we go along...
let admin = user::Entity::find()
.filter(user::Column::Address.eq(our_msg.address.as_ref()))
.one(db_replica.conn())
.await
.unwrap();
let u = user::Entity::find()
.filter(user::Column::Address.eq(imitating_user_address.as_ref()))
.one(db_replica.conn())
.await
.unwrap();
let db_conn = app.db_conn().context("login requires a db")?;
let (u, uks, status_code) = match u {
None => {
// user does not exist yet
// check the invite code
// TODO: more advanced invite codes that set different request/minute and concurrency limits
if let Some(invite_code) = &app.config.invite_code {
if query.invite_code.as_ref() != Some(invite_code) {
return Err(anyhow::anyhow!("checking invite_code").into());
}
}
let txn = db_conn.begin().await?;
// the only thing we need from them is an address
// everything else is optional
// TODO: different invite codes should allow different levels
// TODO: maybe decrement a count on the invite code?
let u = user::ActiveModel {
address: sea_orm::Set(our_msg.address.into()),
..Default::default()
};
let u = u.insert(&txn).await?;
// create the user's first api key
let rpc_secret_key = RpcSecretKey::new();
let uk = rpc_key::ActiveModel {
user_id: sea_orm::Set(u.id),
secret_key: sea_orm::Set(rpc_secret_key.into()),
description: sea_orm::Set(None),
..Default::default()
};
let uk = uk
.insert(&txn)
.await
.context("Failed saving new user key")?;
let uks = vec![uk];
// save the user and key to the database
txn.commit().await?;
(u, uks, StatusCode::CREATED)
}
Some(u) => {
// the user is already registered
let uks = rpc_key::Entity::find()
.filter(rpc_key::Column::UserId.eq(u.id))
.all(db_replica.conn())
.await
.context("failed loading user's key")?;
(u, uks, StatusCode::OK)
}
};
// create a bearer token for the user.
let user_bearer_token = UserBearerToken::default();
// json response with everything in it
// we could return just the bearer token, but I think they will always request api keys and the user profile
let response_json = json!({
"rpc_keys": uks
.into_iter()
.map(|uk| (uk.id, uk))
.collect::<HashMap<_, _>>(),
"bearer_token": user_bearer_token,
"user": u,
});
let response = (status_code, Json(response_json)).into_response();
// add bearer to the database
// expire in 2 days, because this is more critical (and shouldn't need to be done so long!)
let expires_at = Utc::now()
.checked_add_signed(chrono::Duration::days(2))
.unwrap();
// TODO: Here, the bearer token should include a message
// TODO: Above, make sure that the calling address is an admin!
// TODO: Above, make sure that the signed is the admin (address field),
// but then in this request, the admin can pick which user to sign up as
let user_login = login::ActiveModel {
id: sea_orm::NotSet,
bearer_token: sea_orm::Set(user_bearer_token.uuid()),
user_id: sea_orm::Set(u.id), // Yes, this should be the user ... because the rest of the applications takes this item, from the initial user
expires_at: sea_orm::Set(expires_at),
read_only: sea_orm::Set(true)
};
user_login
.save(&db_conn)
.await
.context("saving user login")?;
if let Err(err) = user_pending_login
.into_active_model()
.delete(&db_conn)
.await
{
warn!("Failed to delete nonce:{}: {}", login_nonce.0, err);
}
Ok(response)
}
// TODO: This is basically an exact copy of the user endpoint, I should probabl refactor this code ...
/// `POST /admin/imitate-logout` - Forget the bearer token in the `Authentication` header.
#[debug_handler]
pub async fn admin_logout_post(
Extension(app): Extension<Arc<Web3ProxyApp>>,
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
) -> FrontendResult {
let user_bearer = UserBearerToken::try_from(bearer)?;
let db_conn = app.db_conn().context("database needed for user logout")?;
if let Err(err) = login::Entity::delete_many()
.filter(login::Column::BearerToken.eq(user_bearer.uuid()))
.exec(&db_conn)
.await
{
debug!("Failed to delete {}: {}", user_bearer.redis_key(), err);
}
let now = Utc::now();
// also delete any expired logins
let delete_result = login::Entity::delete_many()
.filter(login::Column::ExpiresAt.lte(now))
.exec(&db_conn)
.await;
debug!("Deleted expired logins: {:?}", delete_result);
// also delete any expired pending logins
let delete_result = login::Entity::delete_many()
.filter(login::Column::ExpiresAt.lte(now))
.exec(&db_conn)
.await;
debug!("Deleted expired pending logins: {:?}", delete_result);
// TODO: what should the response be? probably json something
Ok("goodbye".into_response())
}

@ -9,7 +9,7 @@ use axum::headers::authorization::Bearer;
use axum::headers::{Header, Origin, Referer, UserAgent};
use chrono::Utc;
use deferred_rate_limiter::DeferredRateLimitResult;
use entities::{login, rpc_key, user, user_tier};
use entities::{admin, login, rpc_key, user, user_tier};
use ethers::types::Bytes;
use ethers::utils::keccak256;
use futures::TryFutureExt;

@ -160,8 +160,15 @@ pub async fn serve(port: u16, proxy_app: Arc<Web3ProxyApp>) -> anyhow::Result<()
get(users::user_stats_aggregated_get),
)
.route("/user/stats/detailed", get(users::user_stats_detailed_get))
.route("/admin/modify_role", get(admin::admin_change_user_roles))
.route("/user/logout", post(users::user_logout_post))
.route("/admin/modify_role", get(admin::admin_change_user_roles))
.route("/admin/imitate-login/:user_address", get(admin::admin_login_get))
.route(
"/user/imitate-login/:user_address/:message_eip",
get(admin::admin_login_get),
)
.route("/admin/imitate-login", post(admin::admin_login_post))
.route("/admin/imitate-logout", post(admin::admin_login_post))
//
// Axum layers
// layers are ordered bottom up

@ -43,6 +43,7 @@ use time::{Duration, OffsetDateTime};
use ulid::Ulid;
use crate::admin_queries::query_admin_modify_usertier;
use crate::frontend::errors::FrontendErrorResponse;
use crate::{PostLogin, PostLoginQuery};
/// `GET /user/login/:user_address` or `GET /user/login/:user_address/:message_eip` -- Start the "Sign In with Ethereum" (siwe) login flow.
///
@ -131,6 +132,7 @@ pub async fn user_login_get(
nonce: sea_orm::Set(uuid),
message: sea_orm::Set(message.to_string()),
expires_at: sea_orm::Set(expires_at),
imitating_user: sea_orm::Set(None)
};
user_pending_login
@ -157,24 +159,6 @@ pub async fn user_login_get(
Ok(message.into_response())
}
/// Query params for our `post_login` handler.
#[derive(Debug, Deserialize)]
pub struct PostLoginQuery {
/// While we are in alpha/beta, we require users to supply an invite code.
/// The invite code (if any) is set in the application's config.
/// This may eventually provide some sort of referral bonus.
pub invite_code: Option<String>,
}
/// JSON body to our `post_login` handler.
/// Currently only siwe logins that send an address, msg, and sig are allowed.
/// Email/password and other login methods are planned.
#[derive(Debug, Deserialize)]
pub struct PostLogin {
sig: String,
msg: String,
}
/// `POST /user/login` - Register or login by posting a signed "siwe" message.
/// It is recommended to save the returned bearer token in a cookie.
/// The bearer token can be used to authenticate other requests, such as getting the user's stats or modifying the user's profile.
@ -368,6 +352,7 @@ pub async fn user_login_post(
bearer_token: sea_orm::Set(user_bearer_token.uuid()),
user_id: sea_orm::Set(u.id),
expires_at: sea_orm::Set(expires_at),
read_only: sea_orm::Set(false)
};
user_login

@ -11,3 +11,24 @@ pub mod pagerduty;
pub mod rpcs;
pub mod user_queries;
pub mod user_token;
use serde::Deserialize;
// Push some commonly used types here. Can establish a folder later on
/// Query params for our `post_login` handler.
#[derive(Debug, Deserialize)]
pub struct PostLoginQuery {
/// While we are in alpha/beta, we require users to supply an invite code.
/// The invite code (if any) is set in the application's config.
/// This may eventually provide some sort of referral bonus.
invite_code: Option<String>,
}
/// JSON body to our `post_login` handler.
/// Currently only siwe logins that send an address, msg, and sig are allowed.
/// Email/password and other login methods are planned.
#[derive(Debug, Deserialize)]
pub struct PostLogin {
sig: String,
msg: String,
}