Sessions Route + Password Auth (#649)

* Sessions Route + Password Auth

* run prep + fix clippy

* changing passwords + logging in

* register login
This commit is contained in:
Geometrically
2023-07-08 14:29:17 -07:00
committed by GitHub
parent ef9c90a43a
commit 6c0ad7fe1a
39 changed files with 1777 additions and 1206 deletions

View File

@@ -1,26 +1,33 @@
use crate::database::models::{generate_state_id, StateId};
use crate::models::ids::base62_impl::{parse_base62, to_base62};
use std::collections::HashMap;
use std::sync::Arc;
use crate::parse_strings_from_var;
use actix_web::web::{scope, Data, Query, ServiceConfig};
use actix_web::{get, HttpRequest, HttpResponse};
use chrono::Utc;
use reqwest::header::AUTHORIZATION;
use rust_decimal::Decimal;
use crate::auth::session::issue_session;
use crate::auth::AuthenticationError;
use crate::database::models::{generate_state_id, StateId};
use crate::file_hosting::FileHost;
use crate::models::ids::base62_impl::{parse_base62, to_base62};
use crate::models::users::{Badges, Role};
use crate::parse_strings_from_var;
use crate::routes::ApiError;
use crate::util::captcha::check_turnstile_captcha;
use crate::util::ext::{get_image_content_type, get_image_ext};
use crate::util::validate::{validation_errors_to_string, RE_URL_SAFE};
use actix_web::web::{scope, Data, Query, ServiceConfig};
use actix_web::{get, post, web, HttpRequest, HttpResponse};
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use chrono::Utc;
use rand_chacha::rand_core::SeedableRng;
use rand_chacha::ChaCha20Rng;
use reqwest::header::AUTHORIZATION;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use validator::Validate;
pub fn config(cfg: &mut ServiceConfig) {
cfg.service(scope("auth").service(auth_callback).service(init));
cfg.service(scope("auth").service(auth_callback).service(init))
.service(create_account_with_password)
.service(login_password);
}
#[derive(Serialize, Deserialize, Default, Eq, PartialEq)]
@@ -849,9 +856,11 @@ pub async fn auth_callback(
} else {
None
},
password: None,
username,
name: oauth_user.name,
email: oauth_user.email,
email_verified: true,
avatar_url,
bio: oauth_user.bio,
created: Utc::now(),
@@ -887,3 +896,155 @@ pub async fn auth_callback(
Err(AuthenticationError::InvalidCredentials)
}
}
#[derive(Deserialize, Validate)]
pub struct NewAccount {
#[validate(length(min = 1, max = 39), regex = "RE_URL_SAFE")]
pub username: String,
#[validate(length(min = 8, max = 256))]
pub password: String,
#[validate(email)]
pub email: String,
pub challenge: String,
}
#[post("create")]
pub async fn create_account_with_password(
req: HttpRequest,
pool: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
new_account: web::Json<NewAccount>,
) -> Result<HttpResponse, ApiError> {
new_account
.0
.validate()
.map_err(|err| ApiError::InvalidInput(validation_errors_to_string(err, None)))?;
if check_turnstile_captcha(&req, &new_account.challenge).await? {
return Err(ApiError::Turnstile);
}
if crate::database::models::User::get(&new_account.username, &**pool, &redis)
.await?
.is_some()
{
return Err(ApiError::InvalidInput("Username is taken!".to_string()));
}
let mut transaction = pool.begin().await?;
let user_id = crate::database::models::generate_user_id(&mut transaction).await?;
let new_account = new_account.0;
let score = zxcvbn::zxcvbn(
&new_account.password,
&[&new_account.username, &new_account.email],
)?;
if score.score() < 3 {
return Err(ApiError::InvalidInput(
if let Some(feedback) = score.feedback().clone().and_then(|x| x.warning()) {
format!("Password too weak: {}", feedback)
} else {
"Specified password is too weak! Please improve its strength.".to_string()
},
));
}
let hasher = Argon2::default();
let salt = SaltString::generate(&mut ChaCha20Rng::from_entropy());
let password_hash = hasher
.hash_password(new_account.password.as_bytes(), &salt)?
.to_string();
crate::database::models::User {
id: user_id,
github_id: None,
discord_id: None,
gitlab_id: None,
google_id: None,
steam_id: None,
microsoft_id: None,
password: Some(password_hash),
username: new_account.username.clone(),
name: Some(new_account.username),
email: Some(new_account.email),
email_verified: false,
avatar_url: None,
bio: None,
created: Utc::now(),
role: Role::Developer.to_string(),
badges: Badges::default(),
balance: Decimal::ZERO,
payout_wallet: None,
payout_wallet_type: None,
payout_address: None,
}
.insert(&mut transaction)
.await?;
let session = issue_session(req, user_id, &mut transaction, &redis).await?;
let res = crate::models::sessions::Session::from(session, true);
transaction.commit().await?;
Ok(HttpResponse::Ok().json(res))
}
#[derive(Deserialize, Validate)]
pub struct Login {
pub username: String,
pub password: String,
pub challenge: String,
}
#[post("login")]
pub async fn login_password(
req: HttpRequest,
pool: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
login: web::Json<Login>,
) -> Result<HttpResponse, ApiError> {
if check_turnstile_captcha(&req, &login.challenge).await? {
return Err(ApiError::Turnstile);
}
let (user_id, password) = if let Some(user) =
crate::database::models::User::get(&login.username, &**pool, &redis).await?
{
(
user.id,
user.password
.ok_or_else(|| AuthenticationError::InvalidCredentials)?,
)
} else {
let user_pass = sqlx::query!(
"
SELECT id, password FROM users
WHERE email = $1
",
login.username
)
.fetch_one(&**pool)
.await
.map_err(|_| AuthenticationError::InvalidCredentials)?;
(
crate::database::models::UserId(user_pass.id),
user_pass
.password
.ok_or_else(|| AuthenticationError::InvalidCredentials)?,
)
};
let hasher = Argon2::default();
hasher
.verify_password(login.password.as_bytes(), &PasswordHash::new(&password)?)
.map_err(|_| AuthenticationError::InvalidCredentials)?;
let mut transaction = pool.begin().await?;
let session = issue_session(req, user_id, &mut transaction, &redis).await?;
let res = crate::models::sessions::Session::from(session, true);
transaction.commit().await?;
Ok(HttpResponse::Ok().json(res))
}

View File

@@ -1,14 +1,13 @@
pub mod checks;
pub mod flows;
pub mod pat;
mod session;
pub mod session;
pub mod validate;
pub use checks::{
filter_authorized_projects, filter_authorized_versions, is_authorized, is_authorized_version,
};
pub use flows::config;
pub use pat::{generate_pat, get_user_from_pat, PersonalAccessToken};
// pub use pat::{generate_pat, PersonalAccessToken};
pub use validate::{check_is_moderator_from_headers, get_user_from_headers};
use crate::file_hosting::FileHostingError;
@@ -29,6 +28,8 @@ pub enum AuthenticationError {
SerDe(#[from] serde_json::Error),
#[error("Error while communicating to external oauth provider")]
Reqwest(#[from] reqwest::Error),
#[error("Error uploading user profile picture")]
FileHosting(#[from] FileHostingError),
#[error("Error while decoding PAT: {0}")]
Decoding(#[from] crate::models::ids::DecodingError),
#[error("Invalid Authentication Credentials")]
@@ -39,8 +40,6 @@ pub enum AuthenticationError {
InvalidClientId,
#[error("Invalid callback URL specified")]
Url,
#[error("Error uploading user profile picture")]
FileHosting(#[from] FileHostingError),
}
impl actix_web::ResponseError for AuthenticationError {

View File

@@ -1,115 +1,115 @@
use crate::auth::AuthenticationError;
use crate::database;
use crate::database::models::{DatabaseError, UserId};
use crate::models::users::{self, Badges, RecipientType, RecipientWallet};
use censor::Censor;
use chrono::{NaiveDateTime, Utc};
use rand::Rng;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct PersonalAccessToken {
pub id: String,
pub name: Option<String>,
pub access_token: Option<String>,
pub scope: i64,
pub user_id: users::UserId,
pub expires_at: NaiveDateTime,
}
// Find database user from PAT token
// Separate to user_items as it may yet include further behaviour.
pub async fn get_user_from_pat<'a, E>(
access_token: &str,
executor: E,
) -> Result<Option<database::models::User>, AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
{
let row = sqlx::query!(
"
SELECT pats.expires_at,
u.id, u.name, u.email,
u.avatar_url, u.username, u.bio,
u.created, u.role, u.badges,
u.balance, u.payout_wallet, u.payout_wallet_type, u.payout_address,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id
FROM pats LEFT OUTER JOIN users u ON pats.user_id = u.id
WHERE access_token = $1
",
access_token
)
.fetch_optional(executor)
.await?;
if let Some(row) = row {
if row.expires_at < Utc::now().naive_utc() {
return Ok(None);
}
return Ok(Some(database::models::User {
id: UserId(row.id),
name: row.name,
github_id: row.github_id,
discord_id: row.discord_id,
gitlab_id: row.gitlab_id,
google_id: row.google_id,
steam_id: row.steam_id,
microsoft_id: row.microsoft_id,
email: row.email,
avatar_url: row.avatar_url,
username: row.username,
bio: row.bio,
created: row.created,
role: row.role,
badges: Badges::from_bits(row.badges as u64).unwrap_or_default(),
balance: row.balance,
payout_wallet: row.payout_wallet.map(|x| RecipientWallet::from_string(&x)),
payout_wallet_type: row
.payout_wallet_type
.map(|x| RecipientType::from_string(&x)),
payout_address: row.payout_address,
}));
}
Ok(None)
}
// Generate a new 128 char PAT token starting with 'modrinth_pat_'
pub async fn generate_pat(
con: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<String, DatabaseError> {
let mut rng = rand::thread_rng();
let mut retry_count = 0;
let censor = Censor::Standard + Censor::Sex;
// First generate the PAT token as a random 128 char string. This may include uppercase and lowercase and numbers only.
loop {
let mut access_token = String::with_capacity(63);
access_token.push_str("modrinth_pat_");
for _ in 0..51 {
let c = rng.gen_range(0..62);
if c < 10 {
access_token.push(char::from_u32(c + 48).unwrap()); // 0-9
} else if c < 36 {
access_token.push(char::from_u32(c + 55).unwrap()); // A-Z
} else {
access_token.push(char::from_u32(c + 61).unwrap()); // a-z
}
}
let results = sqlx::query!(
"
SELECT EXISTS(SELECT 1 FROM pats WHERE access_token=$1)
",
access_token
)
.fetch_one(&mut *con)
.await?;
if !results.exists.unwrap_or(true) && !censor.check(&access_token) {
break Ok(access_token);
}
retry_count += 1;
if retry_count > 15 {
return Err(DatabaseError::RandomId);
}
}
}
// use crate::auth::AuthenticationError;
// use crate::database;
// use crate::database::models::{DatabaseError, UserId};
// use crate::models::users::{self, Badges, RecipientType, RecipientWallet};
// use censor::Censor;
// use chrono::{NaiveDateTime, Utc};
// use rand::Rng;
// use serde::{Deserialize, Serialize};
//
// #[derive(Serialize, Deserialize)]
// pub struct PersonalAccessToken {
// pub id: String,
// pub name: Option<String>,
// pub access_token: Option<String>,
// pub scope: i64,
// pub user_id: users::UserId,
// pub expires_at: NaiveDateTime,
// }
// // Find database user from PAT token
// // Separate to user_items as it may yet include further behaviour.
// pub async fn get_user_from_pat<'a, E>(
// access_token: &str,
// executor: E,
// ) -> Result<Option<database::models::User>, AuthenticationError>
// where
// E: sqlx::Executor<'a, Database = sqlx::Postgres>,
// {
// let row = sqlx::query!(
// "
// SELECT pats.expires_at,
// u.id, u.name, u.email,
// u.avatar_url, u.username, u.bio,
// u.created, u.role, u.badges,
// u.balance, u.payout_wallet, u.payout_wallet_type, u.payout_address,
// github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id
// FROM pats LEFT OUTER JOIN users u ON pats.user_id = u.id
// WHERE access_token = $1
// ",
// access_token
// )
// .fetch_optional(executor)
// .await?;
// if let Some(row) = row {
// if row.expires_at < Utc::now().naive_utc() {
// return Ok(None);
// }
//
// return Ok(Some(database::models::User {
// id: UserId(row.id),
// name: row.name,
// github_id: row.github_id,
// discord_id: row.discord_id,
// gitlab_id: row.gitlab_id,
// google_id: row.google_id,
// steam_id: row.steam_id,
// microsoft_id: row.microsoft_id,
// email: row.email,
// avatar_url: row.avatar_url,
// username: row.username,
// bio: row.bio,
// created: row.created,
// role: row.role,
// badges: Badges::from_bits(row.badges as u64).unwrap_or_default(),
// balance: row.balance,
// payout_wallet: row.payout_wallet.map(|x| RecipientWallet::from_string(&x)),
// payout_wallet_type: row
// .payout_wallet_type
// .map(|x| RecipientType::from_string(&x)),
// payout_address: row.payout_address,
// }));
// }
// Ok(None)
// }
//
// // Generate a new 128 char PAT token starting with 'modrinth_pat_'
// pub async fn generate_pat(
// con: &mut sqlx::Transaction<'_, sqlx::Postgres>,
// ) -> Result<String, DatabaseError> {
// let mut rng = rand::thread_rng();
// let mut retry_count = 0;
// let censor = Censor::Standard + Censor::Sex;
//
// // First generate the PAT token as a random 128 char string. This may include uppercase and lowercase and numbers only.
// loop {
// let mut access_token = String::with_capacity(63);
// access_token.push_str("modrinth_pat_");
// for _ in 0..51 {
// let c = rng.gen_range(0..62);
// if c < 10 {
// access_token.push(char::from_u32(c + 48).unwrap()); // 0-9
// } else if c < 36 {
// access_token.push(char::from_u32(c + 55).unwrap()); // A-Z
// } else {
// access_token.push(char::from_u32(c + 61).unwrap()); // a-z
// }
// }
// let results = sqlx::query!(
// "
// SELECT EXISTS(SELECT 1 FROM pats WHERE access_token=$1)
// ",
// access_token
// )
// .fetch_one(&mut *con)
// .await?;
//
// if !results.exists.unwrap_or(true) && !censor.check(&access_token) {
// break Ok(access_token);
// }
//
// retry_count += 1;
// if retry_count > 15 {
// return Err(DatabaseError::RandomId);
// }
// }
// }

View File

@@ -1,19 +1,43 @@
use crate::auth::AuthenticationError;
use crate::database::models::session_item::{Session, SessionBuilder};
use crate::auth::{get_user_from_headers, AuthenticationError};
use crate::database::models::session_item::Session as DBSession;
use crate::database::models::session_item::SessionBuilder;
use crate::database::models::UserId;
use crate::models::sessions::Session;
use crate::queue::session::SessionQueue;
use crate::routes::ApiError;
use crate::util::env::parse_var;
use actix_web::HttpRequest;
use actix_web::http::header::AUTHORIZATION;
use actix_web::web::{scope, Data, ServiceConfig};
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
use chrono::Utc;
use rand::distributions::Alphanumeric;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha20Rng;
use sqlx::PgPool;
use woothee::parser::Parser;
pub async fn issue_session(
req: HttpRequest,
user_id: UserId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
redis: &deadpool_redis::Pool,
) -> Result<Session, AuthenticationError> {
pub fn config(cfg: &mut ServiceConfig) {
cfg.service(
scope("session")
.service(list)
.service(delete)
.service(refresh),
);
}
pub struct SessionMetadata {
pub city: Option<String>,
pub country: Option<String>,
pub ip: String,
pub os: Option<String>,
pub platform: Option<String>,
pub user_agent: String,
}
pub async fn get_session_metadata(
req: &HttpRequest,
) -> Result<SessionMetadata, AuthenticationError> {
let conn_info = req.connection_info().clone();
let ip_addr = if parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) {
if let Some(header) = req.headers().get("CF-Connecting-IP") {
@@ -45,6 +69,26 @@ pub async fn issue_session(
None
};
Ok(SessionMetadata {
os: os.map(|x| x.0.to_string()),
platform: os.map(|x| x.1.to_string()),
city: city.map(|x| x.to_string()),
country: country.map(|x| x.to_string()),
ip: ip_addr
.ok_or_else(|| AuthenticationError::InvalidCredentials)?
.to_string(),
user_agent: user_agent.to_string(),
})
}
pub async fn issue_session(
req: HttpRequest,
user_id: UserId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
redis: &deadpool_redis::Pool,
) -> Result<DBSession, AuthenticationError> {
let metadata = get_session_metadata(&req).await?;
let session = ChaCha20Rng::from_entropy()
.sample_iter(&Alphanumeric)
.take(60)
@@ -56,25 +100,118 @@ pub async fn issue_session(
let id = SessionBuilder {
session,
user_id,
os: os.map(|x| x.0.to_string()),
platform: os.map(|x| x.1.to_string()),
city: city.map(|x| x.to_string()),
country: country.map(|x| x.to_string()),
ip: ip_addr
.ok_or_else(|| AuthenticationError::InvalidCredentials)?
.to_string(),
user_agent: user_agent.to_string(),
os: metadata.os,
platform: metadata.platform,
city: metadata.city,
country: metadata.country,
ip: metadata.ip,
user_agent: metadata.user_agent,
}
.insert(transaction)
.await?;
let session = Session::get_id(id, &mut *transaction, redis)
let session = DBSession::get_id(id, &mut *transaction, redis)
.await?
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
Ok(session)
}
// TODO: List user sessions route
// TODO: Delete User Session Route / logout
// TODO: Refresh session route
#[get("list")]
pub async fn list(
req: HttpRequest,
pool: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
session_queue: Data<SessionQueue>,
) -> Result<HttpResponse, ApiError> {
let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?;
let session_ids = DBSession::get_user_sessions(current_user.id.into(), &**pool, &redis).await?;
let sessions = DBSession::get_many_ids(&session_ids, &**pool, &redis)
.await?
.into_iter()
.filter(|x| x.expires > Utc::now())
.map(|x| Session::from(x, false))
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(sessions))
}
#[delete("{id}")]
pub async fn delete(
info: web::Path<(String,)>,
req: HttpRequest,
pool: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
session_queue: Data<SessionQueue>,
) -> Result<HttpResponse, ApiError> {
let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?;
let session = DBSession::get(info.into_inner().0, &**pool, &redis).await?;
if let Some(session) = session {
if session.user_id != current_user.id.into() {
let mut transaction = pool.begin().await?;
DBSession::remove(session.id, &mut transaction).await?;
DBSession::clear_cache(
vec![(
Some(session.id),
Some(session.session),
Some(session.user_id),
)],
&redis,
)
.await?;
transaction.commit().await?;
}
}
Ok(HttpResponse::NoContent().body(""))
}
#[post("refresh")]
pub async fn refresh(
req: HttpRequest,
pool: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
session_queue: Data<SessionQueue>,
) -> Result<HttpResponse, ApiError> {
let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?;
let session = req
.headers()
.get(AUTHORIZATION)
.and_then(|x| x.to_str().ok())
.ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidCredentials))?;
let session = DBSession::get(session, &**pool, &redis).await?;
if let Some(session) = session {
if current_user.id != session.user_id.into() || session.refresh_expires < Utc::now() {
return Err(ApiError::Authentication(
AuthenticationError::InvalidCredentials,
));
}
let mut transaction = pool.begin().await?;
DBSession::remove(session.id, &mut transaction).await?;
let new_session = issue_session(req, session.user_id, &mut transaction, &redis).await?;
DBSession::clear_cache(
vec![(
Some(session.id),
Some(session.session),
Some(session.user_id),
)],
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::Ok().json(Session::from(new_session, true)))
} else {
Err(ApiError::Authentication(
AuthenticationError::InvalidCredentials,
))
}
}

View File

@@ -1,29 +1,35 @@
use crate::auth::flows::AuthProvider;
use crate::auth::get_user_from_pat;
use crate::auth::session::get_session_metadata;
use crate::auth::AuthenticationError;
use crate::database::models::user_item;
use crate::models::users::{Role, User, UserId, UserPayoutData};
use actix_web::http::header::HeaderMap;
use crate::queue::session::SessionQueue;
use actix_web::HttpRequest;
use chrono::Utc;
use reqwest::header::{HeaderValue, AUTHORIZATION};
pub async fn get_user_from_headers<'a, E>(
headers: &HeaderMap,
req: &HttpRequest,
executor: E,
redis: &deadpool_redis::Pool,
session_queue: &SessionQueue,
) -> Result<User, AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let headers = req.headers();
let token: Option<&HeaderValue> = headers.get(AUTHORIZATION);
// Fetch DB user record and minos user from headers
let db_user = get_user_record_from_bearer_token(
req,
token
.ok_or_else(|| AuthenticationError::InvalidAuthMethod)?
.to_str()
.map_err(|_| AuthenticationError::InvalidCredentials)?,
executor,
redis,
session_queue,
)
.await?
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
@@ -55,24 +61,33 @@ where
}
pub async fn get_user_record_from_bearer_token<'a, 'b, E>(
req: &HttpRequest,
token: &str,
executor: E,
redis: &deadpool_redis::Pool,
session_queue: &SessionQueue,
) -> Result<Option<user_item::User>, AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let token: &str = token.trim_start_matches("Bearer ");
let possible_user = match token.split_once('_') {
Some(("modrinth", _)) => get_user_from_pat(token, executor).await?,
//Some(("modrinth", _)) => get_user_from_pat(token, executor).await?,
Some(("mra", _)) => {
let session =
crate::database::models::session_item::Session::get(token, executor, redis)
.await?
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
user_item::User::get_id(session.user_id, executor, redis).await?
if session.expires < Utc::now() {
return Err(AuthenticationError::InvalidCredentials);
}
let user = user_item::User::get_id(session.user_id, executor, redis).await?;
let metadata = get_session_metadata(req).await?;
session_queue.add(session.id, metadata).await;
user
}
Some(("github", _)) | Some(("gho", _)) | Some(("ghp", _)) => {
let user = AuthProvider::GitHub.get_user(token).await?;
@@ -91,14 +106,15 @@ where
}
pub async fn check_is_moderator_from_headers<'a, 'b, E>(
headers: &HeaderMap,
req: &HttpRequest,
executor: E,
redis: &deadpool_redis::Pool,
session_queue: &SessionQueue,
) -> Result<User, AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let user = get_user_from_headers(headers, executor, redis).await?;
let user = get_user_from_headers(req, executor, redis, session_queue).await?;
if user.role.is_mod() {
Ok(user)