use crate::auth::session::issue_session; use crate::auth::validate::get_user_record_from_bearer_token; use crate::auth::{get_user_from_headers, AuthenticationError}; use crate::database::models::flow_item::Flow; use crate::file_hosting::FileHost; use crate::models::ids::base62_impl::{parse_base62, to_base62}; use crate::models::ids::random_base62_rng; use crate::models::pats::Scopes; use crate::models::users::{Badges, Role}; use crate::parse_strings_from_var; use crate::queue::session::AuthQueue; 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::{delete, get, post, web, HttpRequest, HttpResponse}; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use chrono::{Duration, 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)) .service(create_account_with_password) .service(login_password) .service(login_2fa) .service(begin_2fa_flow) .service(finish_2fa_flow) .service(remove_2fa); } #[derive(Serialize, Deserialize, Default, Eq, PartialEq, Clone, Copy)] #[serde(rename_all = "lowercase")] pub enum AuthProvider { #[default] GitHub, Discord, Microsoft, GitLab, Google, Steam, } #[derive(Debug)] pub struct TempUser { pub id: String, pub username: String, pub email: Option, pub avatar_url: Option, pub bio: Option, pub name: Option, } impl AuthProvider { pub fn get_redirect_url(&self, state: String) -> Result { let self_addr = dotenvy::var("SELF_ADDR")?; let raw_redirect_uri = format!("{}/v2/auth/callback", self_addr); let redirect_uri = urlencoding::encode(&raw_redirect_uri); Ok(match self { AuthProvider::GitHub => { let client_id = dotenvy::var("GITHUB_CLIENT_ID")?; format!( "https://github.com/login/oauth/authorize?client_id={}&state={}&scope=read%3Auser%20user%3Aemail&redirect_uri={}", client_id, state, redirect_uri, ) } AuthProvider::Discord => { let client_id = dotenvy::var("DISCORD_CLIENT_ID")?; format!("https://discord.com/api/oauth2/authorize?client_id={}&state={}&response_type=code&scope=identify%20email&redirect_uri={}", client_id, state, redirect_uri) } AuthProvider::Microsoft => { let client_id = dotenvy::var("MICROSOFT_CLIENT_ID")?; format!("https://login.live.com/oauth20_authorize.srf?client_id={}&response_type=code&scope=user.read&state={}&prompt=select_account&redirect_uri={}", client_id, state, redirect_uri) } AuthProvider::GitLab => { let client_id = dotenvy::var("GITLAB_CLIENT_ID")?; format!( "https://gitlab.com/oauth/authorize?client_id={}&state={}&scope=read_user+profile+email&response_type=code&redirect_uri={}", client_id, state, redirect_uri, ) } AuthProvider::Google => { let client_id = dotenvy::var("GOOGLE_CLIENT_ID")?; format!( "https://accounts.google.com/o/oauth2/v2/auth?client_id={}&state={}&scope={}&response_type=code&redirect_uri={}", client_id, state, urlencoding::encode("https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"), redirect_uri, ) } AuthProvider::Steam => { format!( "https://steamcommunity.com/openid/login?openid.ns={}&openid.mode={}&openid.return_to={}{}{}&openid.realm={}&openid.identity={}&openid.claimed_id={}", urlencoding::encode("http://specs.openid.net/auth/2.0"), "checkid_setup", redirect_uri, urlencoding::encode("?state="), state, self_addr, "http://specs.openid.net/auth/2.0/identifier_select", "http://specs.openid.net/auth/2.0/identifier_select", ) } }) } pub async fn get_token( &self, query: HashMap, ) -> Result { let redirect_uri = format!("{}/v2/auth/callback", dotenvy::var("SELF_ADDR")?); #[derive(Deserialize)] struct AccessToken { pub access_token: String, } let res = match self { AuthProvider::GitHub => { let code = query .get("code") .ok_or_else(|| AuthenticationError::InvalidCredentials)?; let client_id = dotenvy::var("GITHUB_CLIENT_ID")?; let client_secret = dotenvy::var("GITHUB_CLIENT_SECRET")?; let url = format!( "https://github.com/login/oauth/access_token?client_id={}&client_secret={}&code={}&redirect_uri={}", client_id, client_secret, code, redirect_uri ); let token: AccessToken = reqwest::Client::new() .post(&url) .header(reqwest::header::ACCEPT, "application/json") .send() .await? .json() .await?; token.access_token } AuthProvider::Discord => { let code = query .get("code") .ok_or_else(|| AuthenticationError::InvalidCredentials)?; let client_id = dotenvy::var("DISCORD_CLIENT_ID")?; let client_secret = dotenvy::var("DISCORD_CLIENT_SECRET")?; let mut map = HashMap::new(); map.insert("client_id", &*client_id); map.insert("client_secret", &*client_secret); map.insert("code", code); map.insert("grant_type", "authorization_code"); map.insert("redirect_uri", &redirect_uri); let token: AccessToken = reqwest::Client::new() .post("https://discord.com/api/v10/oauth2/token") .header(reqwest::header::ACCEPT, "application/json") .form(&map) .send() .await? .json() .await?; token.access_token } AuthProvider::Microsoft => { let code = query .get("code") .ok_or_else(|| AuthenticationError::InvalidCredentials)?; let client_id = dotenvy::var("MICROSOFT_CLIENT_ID")?; let client_secret = dotenvy::var("MICROSOFT_CLIENT_SECRET")?; let mut map = HashMap::new(); map.insert("client_id", &*client_id); map.insert("client_secret", &*client_secret); map.insert("code", code); map.insert("grant_type", "authorization_code"); map.insert("redirect_uri", &redirect_uri); let token: AccessToken = reqwest::Client::new() .post("https://login.live.com/oauth20_token.srf") .header(reqwest::header::ACCEPT, "application/json") .form(&map) .send() .await? .json() .await?; token.access_token } AuthProvider::GitLab => { let code = query .get("code") .ok_or_else(|| AuthenticationError::InvalidCredentials)?; let client_id = dotenvy::var("GITLAB_CLIENT_ID")?; let client_secret = dotenvy::var("GITLAB_CLIENT_SECRET")?; let mut map = HashMap::new(); map.insert("client_id", &*client_id); map.insert("client_secret", &*client_secret); map.insert("code", code); map.insert("grant_type", "authorization_code"); map.insert("redirect_uri", &redirect_uri); let token: AccessToken = reqwest::Client::new() .post("https://gitlab.com/oauth/token") .header(reqwest::header::ACCEPT, "application/json") .form(&map) .send() .await? .json() .await?; token.access_token } AuthProvider::Google => { let code = query .get("code") .ok_or_else(|| AuthenticationError::InvalidCredentials)?; let client_id = dotenvy::var("GOOGLE_CLIENT_ID")?; let client_secret = dotenvy::var("GOOGLE_CLIENT_SECRET")?; let mut map = HashMap::new(); map.insert("client_id", &*client_id); map.insert("client_secret", &*client_secret); map.insert("code", code); map.insert("grant_type", "authorization_code"); map.insert("redirect_uri", &redirect_uri); let token: AccessToken = reqwest::Client::new() .post("https://oauth2.googleapis.com/token") .header(reqwest::header::ACCEPT, "application/json") .form(&map) .send() .await? .json() .await?; token.access_token } AuthProvider::Steam => { let mut form = HashMap::new(); let signed = query .get("openid.signed") .ok_or_else(|| AuthenticationError::InvalidCredentials)?; form.insert( "openid.assoc_handle".to_string(), &**query .get("openid.assoc_handle") .ok_or_else(|| AuthenticationError::InvalidCredentials)?, ); form.insert("openid.signed".to_string(), &**signed); form.insert( "openid.sig".to_string(), &**query .get("openid.sig") .ok_or_else(|| AuthenticationError::InvalidCredentials)?, ); form.insert("openid.ns".to_string(), "http://specs.openid.net/auth/2.0"); form.insert("openid.mode".to_string(), "check_authentication"); for val in signed.split(',') { if let Some(arr_val) = query.get(&format!("openid.{}", val)) { form.insert(format!("openid.{}", val), &**arr_val); } } let res = reqwest::Client::new() .post("https://steamcommunity.com/openid/login") .header("Accept-language", "en") .form(&form) .send() .await? .text() .await?; if res.contains("is_valid:true") { let identity = query .get("openid.identity") .ok_or_else(|| AuthenticationError::InvalidCredentials)?; identity .rsplit('/') .next() .ok_or_else(|| AuthenticationError::InvalidCredentials)? .to_string() } else { return Err(AuthenticationError::InvalidCredentials); } } }; Ok(res) } pub async fn get_user(&self, token: &str) -> Result { let res = match self { AuthProvider::GitHub => { let response = reqwest::Client::new() .get("https://api.github.com/user") .header(reqwest::header::USER_AGENT, "Modrinth") .header(AUTHORIZATION, format!("token {token}")) .send() .await?; if token.starts_with("gho_") { let client_id = response .headers() .get("x-oauth-client-id") .and_then(|x| x.to_str().ok()); if client_id != Some(&*dotenvy::var("GITHUB_CLIENT_ID").unwrap()) { return Err(AuthenticationError::InvalidClientId); } } #[derive(Serialize, Deserialize, Debug)] pub struct GitHubUser { pub login: String, pub id: u64, pub avatar_url: String, pub name: Option, pub email: Option, pub bio: Option, } let github_user: GitHubUser = response.json().await?; TempUser { id: github_user.id.to_string(), username: github_user.login, email: github_user.email, avatar_url: Some(github_user.avatar_url), bio: github_user.bio, name: github_user.name, } } AuthProvider::Discord => { #[derive(Serialize, Deserialize, Debug)] pub struct DiscordUser { pub username: String, pub id: String, pub avatar: Option, pub global_name: Option, pub email: Option, } let discord_user: DiscordUser = reqwest::Client::new() .get("https://discord.com/api/v10/users/@me") .header(reqwest::header::USER_AGENT, "Modrinth") .header(AUTHORIZATION, format!("Bearer {token}")) .send() .await? .json() .await?; let id = discord_user.id.clone(); TempUser { id: discord_user.id, username: discord_user.username, email: discord_user.email, avatar_url: discord_user .avatar .map(|x| format!("https://cdn.discordapp.com/avatars/{}/{}.webp", id, x)), bio: None, name: discord_user.global_name, } } AuthProvider::Microsoft => { #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct MicrosoftUser { pub id: String, pub display_name: Option, pub mail: Option, pub user_principal_name: String, } let microsoft_user: MicrosoftUser = reqwest::Client::new() .get("https://graph.microsoft.com/v1.0/me?$select=id,displayName,mail,userPrincipalName") .header(reqwest::header::USER_AGENT, "Modrinth") .header(AUTHORIZATION, format!("Bearer {token}")) .send() .await?.json().await?; TempUser { id: microsoft_user.id, username: microsoft_user .user_principal_name .split('@') .next() .unwrap_or_default() .to_string(), email: microsoft_user.mail, avatar_url: None, bio: None, name: microsoft_user.display_name, } } AuthProvider::GitLab => { #[derive(Serialize, Deserialize, Debug)] pub struct GitLabUser { pub id: i32, pub username: String, pub email: Option, pub avatar_url: Option, pub name: Option, pub bio: Option, } let gitlab_user: GitLabUser = reqwest::Client::new() .get("https://gitlab.com/api/v4/user") .header(reqwest::header::USER_AGENT, "Modrinth") .header(AUTHORIZATION, format!("Bearer {token}")) .send() .await? .json() .await?; TempUser { id: gitlab_user.id.to_string(), username: gitlab_user.username, email: gitlab_user.email, avatar_url: gitlab_user.avatar_url, bio: gitlab_user.bio, name: gitlab_user.name, } } AuthProvider::Google => { #[derive(Deserialize, Debug)] pub struct GoogleUser { pub id: String, pub email: String, pub name: Option, pub bio: Option, pub picture: Option, } let google_user: GoogleUser = reqwest::Client::new() .get("https://www.googleapis.com/userinfo/v2/me") .header(reqwest::header::USER_AGENT, "Modrinth") .header(AUTHORIZATION, format!("Bearer {token}")) .send() .await? .json() .await?; TempUser { id: google_user.id, username: google_user .email .split('@') .next() .unwrap_or_default() .to_string(), email: Some(google_user.email), avatar_url: google_user.picture, bio: None, name: google_user.name, } } AuthProvider::Steam => { let api_key = dotenvy::var("STEAM_API_KEY")?; #[derive(Deserialize)] struct SteamResponse { response: Players, } #[derive(Deserialize)] struct Players { players: Vec, } #[derive(Deserialize)] struct Player { steamid: String, personaname: String, profileurl: String, avatar: Option, } let response: String = reqwest::get( &format!( "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key={}&steamids={}", api_key, token ) ) .await? .text() .await?; let mut response: SteamResponse = serde_json::from_str(&response)?; if let Some(player) = response.response.players.pop() { let username = player .profileurl .trim_matches('/') .rsplit('/') .next() .unwrap_or(&player.steamid) .to_string(); TempUser { id: player.steamid, username, email: None, avatar_url: player.avatar, bio: None, name: Some(player.personaname), } } else { return Err(AuthenticationError::InvalidCredentials); } } }; Ok(res) } pub async fn get_user_id<'a, 'b, E>( &self, id: &str, executor: E, ) -> Result, AuthenticationError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { Ok(match self { AuthProvider::GitHub => { let value = sqlx::query!( "SELECT id FROM users WHERE github_id = $1", id.parse::() .map_err(|_| AuthenticationError::InvalidCredentials)? ) .fetch_optional(executor) .await?; value.map(|x| crate::database::models::UserId(x.id)) } AuthProvider::Discord => { let value = sqlx::query!( "SELECT id FROM users WHERE discord_id = $1", id.parse::() .map_err(|_| AuthenticationError::InvalidCredentials)? ) .fetch_optional(executor) .await?; value.map(|x| crate::database::models::UserId(x.id)) } AuthProvider::Microsoft => { let value = sqlx::query!("SELECT id FROM users WHERE microsoft_id = $1", id) .fetch_optional(executor) .await?; value.map(|x| crate::database::models::UserId(x.id)) } AuthProvider::GitLab => { let value = sqlx::query!( "SELECT id FROM users WHERE gitlab_id = $1", id.parse::() .map_err(|_| AuthenticationError::InvalidCredentials)? ) .fetch_optional(executor) .await?; value.map(|x| crate::database::models::UserId(x.id)) } AuthProvider::Google => { let value = sqlx::query!("SELECT id FROM users WHERE google_id = $1", id) .fetch_optional(executor) .await?; value.map(|x| crate::database::models::UserId(x.id)) } AuthProvider::Steam => { let value = sqlx::query!( "SELECT id FROM users WHERE steam_id = $1", id.parse::() .map_err(|_| AuthenticationError::InvalidCredentials)? ) .fetch_optional(executor) .await?; value.map(|x| crate::database::models::UserId(x.id)) } }) } } #[derive(Serialize, Deserialize)] pub struct AuthorizationInit { pub url: String, #[serde(default)] pub provider: AuthProvider, pub token: Option, } #[derive(Serialize, Deserialize)] pub struct Authorization { pub code: String, pub state: String, } // Init link takes us to GitHub API and calls back to callback endpoint with a code and state // http://localhost:8000/auth/init?url=https://modrinth.com #[get("init")] pub async fn init( req: HttpRequest, Query(info): Query, // callback url client: Data, redis: Data, session_queue: Data, ) -> Result { let url = url::Url::parse(&info.url).map_err(|_| AuthenticationError::Url)?; let allowed_callback_urls = parse_strings_from_var("ALLOWED_CALLBACK_URLS").unwrap_or_default(); let domain = url.host_str().ok_or(AuthenticationError::Url)?; if !allowed_callback_urls.iter().any(|x| domain.ends_with(x)) && domain != "modrinth.com" { return Err(AuthenticationError::Url); } let user_id = if let Some(token) = info.token { let (_, user) = get_user_record_from_bearer_token( &req, Some(&token), &**client, &redis, &session_queue, ) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; Some(user.id) } else { None }; let state = Flow::OAuth { user_id, url: info.url, provider: info.provider, } .insert(Utc::now() + Duration::minutes(30), &redis) .await?; let url = info.provider.get_redirect_url(state)?; Ok(HttpResponse::TemporaryRedirect() .append_header(("Location", &*url)) .json(serde_json::json!({ "url": url }))) } #[get("callback")] pub async fn auth_callback( req: HttpRequest, Query(query): Query>, client: Data, file_host: Data>, redis: Data, ) -> Result { let state = query .get("state") .ok_or_else(|| AuthenticationError::InvalidCredentials)?; let flow = Flow::get(state, &redis).await?; // Extract cookie header from request if let Some(Flow::OAuth { user_id, provider, url, }) = flow { Flow::remove(state, &redis).await?; let token = provider.get_token(query).await?; let oauth_user = provider.get_user(&token).await?; let user_id_opt = provider.get_user_id(&oauth_user.id, &**client).await?; let mut transaction = client.begin().await?; if let Some(id) = user_id { if user_id_opt.is_some() { return Err(AuthenticationError::DuplicateUser); } match provider { AuthProvider::GitHub => { sqlx::query!( " UPDATE users SET github_id = $2 WHERE (id = $1) ", id as crate::database::models::UserId, oauth_user.id.parse::().ok(), ) .execute(&mut *transaction) .await?; } AuthProvider::Discord => { sqlx::query!( " UPDATE users SET discord_id = $2 WHERE (id = $1) ", id as crate::database::models::UserId, oauth_user.id.parse::().ok(), ) .execute(&mut *transaction) .await?; } AuthProvider::Microsoft => { sqlx::query!( " UPDATE users SET microsoft_id = $2 WHERE (id = $1) ", id as crate::database::models::UserId, oauth_user.id, ) .execute(&mut *transaction) .await?; } AuthProvider::GitLab => { sqlx::query!( " UPDATE users SET gitlab_id = $2 WHERE (id = $1) ", id as crate::database::models::UserId, oauth_user.id.parse::().ok(), ) .execute(&mut *transaction) .await?; } AuthProvider::Google => { sqlx::query!( " UPDATE users SET google_id = $2 WHERE (id = $1) ", id as crate::database::models::UserId, oauth_user.id, ) .execute(&mut *transaction) .await?; } AuthProvider::Steam => { sqlx::query!( " UPDATE users SET steam_id = $2 WHERE (id = $1) ", id as crate::database::models::UserId, oauth_user.id.parse::().ok(), ) .execute(&mut *transaction) .await?; } } crate::database::models::User::clear_caches(&[(id, None)], &redis).await?; transaction.commit().await?; Ok(HttpResponse::TemporaryRedirect() .append_header(("Location", &*url)) .json(serde_json::json!({ "url": url }))) } else { let user_id = if let Some(user_id) = user_id_opt { let user = crate::database::models::User::get_id(user_id, &**client, &redis) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; if user.totp_secret.is_some() { let flow = Flow::Login2FA { user_id: user.id } .insert(Utc::now() + Duration::minutes(30), &redis) .await?; let redirect_url = format!( "{}{}error=2fa_required&flow={}", url, if url.contains('?') { "&" } else { "?" }, flow ); return Ok(HttpResponse::TemporaryRedirect() .append_header(("Location", &*redirect_url)) .json(serde_json::json!({ "url": redirect_url }))); } user_id } else { if let Some(email) = &oauth_user.email { if crate::database::models::User::get_email(email, &**client) .await? .is_some() { return Err(AuthenticationError::DuplicateUser); } } let user_id = crate::database::models::generate_user_id(&mut transaction).await?; let mut username_increment: i32 = 0; let mut username = None; while username.is_none() { let test_username = format!( "{}{}", oauth_user.username, if username_increment > 0 { username_increment.to_string() } else { "".to_string() } ); let new_id = crate::database::models::User::get(&test_username, &**client, &redis) .await?; if new_id.is_none() { username = Some(test_username); } else { username_increment += 1; } } let avatar_url = if let Some(avatar_url) = oauth_user.avatar_url { let cdn_url = dotenvy::var("CDN_URL")?; let res = reqwest::get(&avatar_url).await?; let headers = res.headers().clone(); let img_data = if let Some(content_type) = headers .get(reqwest::header::CONTENT_TYPE) .and_then(|ct| ct.to_str().ok()) { get_image_ext(content_type).map(|ext| (ext, content_type)) } else if let Some(ext) = avatar_url.rsplit('.').next() { get_image_content_type(ext).map(|content_type| (ext, content_type)) } else { None }; if let Some((ext, content_type)) = img_data { let bytes = res.bytes().await?; let hash = sha1::Sha1::from(&bytes).hexdigest(); let upload_data = file_host .upload_file( content_type, &format!( "user/{}/{}.{}", crate::models::users::UserId::from(user_id), hash, ext ), bytes, ) .await?; Some(format!("{}/{}", cdn_url, upload_data.file_name)) } else { None } } else { None }; if let Some(username) = username { crate::database::models::User { id: user_id, github_id: if provider == AuthProvider::GitHub { Some( oauth_user .id .clone() .parse() .map_err(|_| AuthenticationError::InvalidCredentials)?, ) } else { None }, discord_id: if provider == AuthProvider::Discord { Some( oauth_user .id .parse() .map_err(|_| AuthenticationError::InvalidCredentials)?, ) } else { None }, gitlab_id: if provider == AuthProvider::GitLab { Some( oauth_user .id .parse() .map_err(|_| AuthenticationError::InvalidCredentials)?, ) } else { None }, google_id: if provider == AuthProvider::Google { Some(oauth_user.id.clone()) } else { None }, steam_id: if provider == AuthProvider::Steam { Some( oauth_user .id .parse() .map_err(|_| AuthenticationError::InvalidCredentials)?, ) } else { None }, microsoft_id: if provider == AuthProvider::Microsoft { Some(oauth_user.id) } else { None }, password: None, totp_secret: None, username, name: oauth_user.name, email: oauth_user.email, email_verified: true, avatar_url, bio: oauth_user.bio, 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?; user_id } else { return Err(AuthenticationError::InvalidCredentials); } }; let session = issue_session(req, user_id, &mut transaction, &redis).await?; transaction.commit().await?; let redirect_url = if url.contains('?') { format!("{}&code={}", url, session.session) } else { format!("{}?code={}", url, session.session) }; Ok(HttpResponse::TemporaryRedirect() .append_header(("Location", &*redirect_url)) .json(serde_json::json!({ "url": redirect_url }))) } } else { 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, redis: Data, new_account: web::Json, ) -> Result { 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(); if crate::database::models::User::get_email(&new_account.email, &**pool) .await? .is_some() { return Err(ApiError::InvalidInput( "Email is already registered on Modrinth!".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), totp_secret: None, 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, redis: Data, login: web::Json, ) -> Result { if check_turnstile_captcha(&req, &login.challenge).await? { return Err(ApiError::Turnstile); } let user = if let Some(user) = crate::database::models::User::get(&login.username, &**pool, &redis).await? { user } else { let user = crate::database::models::User::get_email(&login.username, &**pool) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; crate::database::models::User::get_id(user, &**pool, &redis) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)? }; let hasher = Argon2::default(); hasher .verify_password( login.password.as_bytes(), &PasswordHash::new( &user .password .ok_or_else(|| AuthenticationError::InvalidCredentials)?, )?, ) .map_err(|_| AuthenticationError::InvalidCredentials)?; if user.totp_secret.is_some() { let flow = Flow::Login2FA { user_id: user.id } .insert(Utc::now() + Duration::minutes(30), &redis) .await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "error": "2fa_required", "description": "2FA is required to complete this operation.", "flow": flow, }))) } else { 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)) } } #[derive(Deserialize, Validate)] pub struct Login2FA { pub code: String, pub flow: String, } fn get_2fa_code(secret: String) -> Result { let totp = totp_rs::TOTP::new( totp_rs::Algorithm::SHA1, 6, 1, 30, totp_rs::Secret::Encoded(secret) .to_bytes() .map_err(|_| AuthenticationError::InvalidCredentials)?, ) .map_err(|_| AuthenticationError::InvalidCredentials)?; let token = totp .generate_current() .map_err(|_| AuthenticationError::InvalidCredentials)?; Ok(token) } #[post("login/2fa")] pub async fn login_2fa( req: HttpRequest, pool: Data, redis: Data, login: web::Json, ) -> Result { let flow = Flow::get(&login.flow, &redis) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; if let Flow::Login2FA { user_id } = flow { let user = crate::database::models::User::get_id(user_id, &**pool, &redis) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; let token = get_2fa_code( user.totp_secret .ok_or_else(|| AuthenticationError::InvalidCredentials)?, )?; let mut transaction = pool.begin().await?; if token != login.code { let backup_codes = crate::database::models::User::get_backup_codes(user_id, &**pool).await?; if !backup_codes.contains(&login.code) { return Err(ApiError::Authentication( AuthenticationError::InvalidCredentials, )); } else { let code = parse_base62(&login.code).unwrap_or_default(); sqlx::query!( " DELETE FROM user_backup_codes WHERE user_id = $1 AND code = $2 ", user_id as crate::database::models::ids::UserId, code as i64, ) .execute(&mut *transaction) .await?; crate::database::models::User::clear_caches(&[(user_id, None)], &redis).await?; } } Flow::remove(&login.flow, &redis).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)) } else { Err(ApiError::Authentication( AuthenticationError::InvalidCredentials, )) } } #[get("2fa")] pub async fn begin_2fa_flow( req: HttpRequest, pool: Data, redis: Data, session_queue: Data, ) -> Result { let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::USER_AUTH_WRITE]), ) .await? .1; if !user.has_totp.unwrap_or(false) { let string = totp_rs::Secret::generate_secret(); let encoded = string.to_encoded(); let flow = Flow::Initialize2FA { user_id: user.id.into(), secret: encoded.to_string(), } .insert(Utc::now() + Duration::minutes(30), &redis) .await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "secret": encoded.to_string(), "flow": flow, }))) } else { Err(ApiError::InvalidInput( "User already has 2FA enabled on their account!".to_string(), )) } } #[post("2fa")] pub async fn finish_2fa_flow( req: HttpRequest, pool: Data, redis: Data, login: web::Json, session_queue: Data, ) -> Result { let flow = Flow::get(&login.flow, &redis) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; if let Flow::Initialize2FA { user_id, secret } = flow { let user = get_user_from_headers( &req, &**pool, &redis, &session_queue, Some(&[Scopes::USER_AUTH_WRITE]), ) .await? .1; if user.id != user_id.into() { return Err(ApiError::Authentication( AuthenticationError::InvalidCredentials, )); } let token = get_2fa_code(secret.clone())?; if token != login.code { return Err(ApiError::Authentication( AuthenticationError::InvalidCredentials, )); } Flow::remove(&login.flow, &redis).await?; let mut transaction = pool.begin().await?; sqlx::query!( " UPDATE users SET totp_secret = $1 WHERE (id = $2) ", secret, user_id as crate::database::models::ids::UserId, ) .execute(&mut *transaction) .await?; sqlx::query!( " DELETE FROM user_backup_codes WHERE user_id = $1 ", user_id as crate::database::models::ids::UserId, ) .execute(&mut *transaction) .await?; let mut codes = Vec::new(); for _ in 0..6 { let mut rng = ChaCha20Rng::from_entropy(); let val = random_base62_rng(&mut rng, 11); sqlx::query!( " INSERT INTO user_backup_codes ( user_id, code ) VALUES ( $1, $2 ) ", user_id as crate::database::models::ids::UserId, val as i64, ) .execute(&mut *transaction) .await?; codes.push(to_base62(val)); } crate::database::models::User::clear_caches(&[(user.id.into(), None)], &redis).await?; transaction.commit().await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "backup_codes": codes, }))) } else { Err(ApiError::Authentication( AuthenticationError::InvalidCredentials, )) } } #[derive(Deserialize)] pub struct Remove2FA { pub code: String, } #[delete("2fa")] pub async fn remove_2fa( req: HttpRequest, pool: Data, redis: Data, login: web::Json, session_queue: Data, ) -> Result { let (scopes, user) = get_user_record_from_bearer_token(&req, None, &**pool, &redis, &session_queue) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; if !scopes.contains(Scopes::USER_AUTH_WRITE) { return Err(ApiError::Authentication( AuthenticationError::InvalidCredentials, )); } let token = get_2fa_code(user.totp_secret.ok_or_else(|| { ApiError::InvalidInput("User does not have 2FA enabled on the account!".to_string()) })?)?; if token != login.code { return Err(ApiError::Authentication( AuthenticationError::InvalidCredentials, )); } let mut transaction = pool.begin().await?; sqlx::query!( " UPDATE users SET totp_secret = NULL WHERE (id = $1) ", user.id as crate::database::models::ids::UserId, ) .execute(&mut *transaction) .await?; sqlx::query!( " DELETE FROM user_backup_codes WHERE user_id = $1 ", user.id as crate::database::models::ids::UserId, ) .execute(&mut *transaction) .await?; crate::database::models::User::clear_caches(&[(user.id, None)], &redis).await?; transaction.commit().await?; Ok(HttpResponse::NoContent().finish()) }