Files
AstralRinth/src/auth/flows.rs
Geometrically 4bdf9bff3a 2FA + Add/Remove Auth Providers (#652)
* 2FA + Add/Remove Auth Providers

* fix fmt issue
2023-07-11 19:13:07 -07:00

1447 lines
50 KiB
Rust

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<String>,
pub avatar_url: Option<String>,
pub bio: Option<String>,
pub name: Option<String>,
}
impl AuthProvider {
pub fn get_redirect_url(&self, state: String) -> Result<String, AuthenticationError> {
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<String, String>,
) -> Result<String, AuthenticationError> {
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<TempUser, AuthenticationError> {
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<String>,
pub email: Option<String>,
pub bio: Option<String>,
}
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<String>,
pub global_name: Option<String>,
pub email: Option<String>,
}
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<String>,
pub mail: Option<String>,
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<String>,
pub avatar_url: Option<String>,
pub name: Option<String>,
pub bio: Option<String>,
}
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<String>,
pub bio: Option<String>,
pub picture: Option<String>,
}
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<Player>,
}
#[derive(Deserialize)]
struct Player {
steamid: String,
personaname: String,
profileurl: String,
avatar: Option<String>,
}
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<Option<crate::database::models::UserId>, 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::<i64>()
.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::<i64>()
.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::<i64>()
.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::<i64>()
.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<String>,
}
#[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<AuthorizationInit>, // callback url
client: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, AuthenticationError> {
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<HashMap<String, String>>,
client: Data<PgPool>,
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
redis: Data<deadpool_redis::Pool>,
) -> Result<HttpResponse, AuthenticationError> {
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::<i64>().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::<i64>().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::<i64>().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::<i64>().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<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();
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<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 = 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<String, AuthenticationError> {
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<PgPool>,
redis: Data<deadpool_redis::Pool>,
login: web::Json<Login2FA>,
) -> Result<HttpResponse, ApiError> {
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<PgPool>,
redis: Data<deadpool_redis::Pool>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
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<PgPool>,
redis: Data<deadpool_redis::Pool>,
login: web::Json<Login2FA>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
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<PgPool>,
redis: Data<deadpool_redis::Pool>,
login: web::Json<Login2FA>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
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())
}