From 4bb47d7e019945c1c1f8cc1014c546f8e15dbddf Mon Sep 17 00:00:00 2001 From: Geometrically <18202329+Geometrically@users.noreply.github.com> Date: Tue, 18 Jul 2023 15:02:54 -0700 Subject: [PATCH] Finish authentication (#659) --- .env | 5 +- src/auth/flows.rs | 207 +++++++++++++++++++++------- src/auth/mod.rs | 2 +- src/auth/session.rs | 20 ++- src/database/models/ids.rs | 4 +- src/database/models/pat_item.rs | 13 +- src/database/models/session_item.rs | 13 +- src/main.rs | 3 + src/models/sessions.rs | 4 + src/queue/session.rs | 21 +-- 10 files changed, 217 insertions(+), 75 deletions(-) diff --git a/.env b/.env index 44a663fec..a8a148596 100644 --- a/.env +++ b/.env @@ -79,4 +79,7 @@ SMTP_PASSWORD=none SMTP_HOST=none SITE_VERIFY_EMAIL_PATH=none -SITE_RESET_PASSWORD_PATH=none \ No newline at end of file +SITE_RESET_PASSWORD_PATH=none + +BEEHIIV_PUBLICATION_ID=none +BEEHIIV_API_KEY=none \ No newline at end of file diff --git a/src/auth/flows.rs b/src/auth/flows.rs index 4fc5bc054..e908faa38 100644 --- a/src/auth/flows.rs +++ b/src/auth/flows.rs @@ -45,7 +45,8 @@ pub fn config(cfg: &mut ServiceConfig) { .service(change_password) .service(resend_verify_email) .service(set_email) - .service(verify_email), + .service(verify_email) + .service(subscribe_newsletter), ); } @@ -1022,11 +1023,17 @@ pub async fn auth_callback( 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) - }; + let redirect_url = format!( + "{}{}code={}{}", + url, + if url.contains('?') { '&' } else { '?' }, + session.session, + if user_id_opt.is_none() { + "&new_account=true" + } else { + "" + } + ); Ok(HttpResponse::TemporaryRedirect() .append_header(("Location", &*redirect_url)) @@ -1091,6 +1098,32 @@ pub async fn delete_auth_provider( Ok(HttpResponse::NoContent().finish()) } +pub async fn sign_up_beehiiv(email: &str) -> Result<(), AuthenticationError> { + let id = dotenvy::var("BEEHIIV_PUBLICATION_ID")?; + let api_key = dotenvy::var("BEEHIIV_API_KEY")?; + let site_url = dotenvy::var("SITE_URL")?; + + let client = reqwest::Client::new(); + client + .post(&format!( + "https://api.beehiiv.com/v2/publications/{id}/subscriptions" + )) + .header(AUTHORIZATION, format!("Bearer {}", api_key)) + .json(&serde_json::json!({ + "email": email, + "utm_source": "modrinth", + "utm_medium": "account_creation", + "referring_site": site_url, + })) + .send() + .await? + .error_for_status()? + .text() + .await?; + + Ok(()) +} + #[derive(Deserialize, Validate)] pub struct NewAccount { #[validate(length(min = 1, max = 39), regex = "RE_URL_SAFE")] @@ -1100,6 +1133,7 @@ pub struct NewAccount { #[validate(email)] pub email: String, pub challenge: String, + pub sign_up_newsletter: Option, } #[post("create")] @@ -1170,7 +1204,7 @@ pub async fn create_account_with_password( send_email_verify( new_account.email.clone(), flow, - &format!("Welcome to Modritnh, {}!", new_account.username), + &format!("Welcome to Modrinth, {}!", new_account.username), )?; crate::database::models::User { @@ -1185,7 +1219,7 @@ pub async fn create_account_with_password( totp_secret: None, username: new_account.username.clone(), name: Some(new_account.username), - email: Some(new_account.email), + email: Some(new_account.email.clone()), email_verified: false, avatar_url: None, bio: None, @@ -1201,7 +1235,12 @@ pub async fn create_account_with_password( .await?; let session = issue_session(req, user_id, &mut transaction, &redis).await?; - let res = crate::models::sessions::Session::from(session, true); + let res = crate::models::sessions::Session::from(session, true, None); + + if new_account.sign_up_newsletter.unwrap_or(false) { + sign_up_beehiiv(&new_account.email).await?; + } + transaction.commit().await?; Ok(HttpResponse::Ok().json(res)) @@ -1264,7 +1303,7 @@ pub async fn login_password( } 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); + let res = crate::models::sessions::Session::from(session, true, None); transaction.commit().await?; Ok(HttpResponse::Ok().json(res)) @@ -1277,7 +1316,15 @@ pub struct Login2FA { pub flow: String, } -fn get_2fa_code(secret: String) -> Result { +async fn validate_2fa_code( + input: String, + secret: String, + allow_backup: bool, + user_id: crate::database::models::UserId, + redis: &deadpool_redis::Pool, + pool: &PgPool, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result { let totp = totp_rs::TOTP::new( totp_rs::Algorithm::SHA1, 6, @@ -1292,7 +1339,34 @@ fn get_2fa_code(secret: String) -> Result { .generate_current() .map_err(|_| AuthenticationError::InvalidCredentials)?; - Ok(token) + if input == token { + Ok(true) + } else if allow_backup { + let backup_codes = crate::database::models::User::get_backup_codes(user_id, pool).await?; + + if !backup_codes.contains(&input) { + Ok(false) + } else { + let code = parse_base62(&input).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?; + + Ok(true) + } + } else { + Err(AuthenticationError::InvalidCredentials) + } } #[post("login/2fa")] @@ -1311,41 +1385,27 @@ pub async fn login_2fa( .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; - let token = get_2fa_code( + let mut transaction = pool.begin().await?; + if !validate_2fa_code( + login.code.clone(), 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?; - } + true, + user.id, + &redis, + &pool, + &mut transaction, + ) + .await? + { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); } 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); + let res = crate::models::sessions::Session::from(session, true, None); transaction.commit().await?; Ok(HttpResponse::Ok().json(res)) @@ -1424,16 +1484,25 @@ pub async fn finish_2fa_flow( )); } - let token = get_2fa_code(secret.clone())?; + let mut transaction = pool.begin().await?; - if token != login.code { + if !validate_2fa_code( + login.code.clone(), + secret.clone(), + false, + user.id.into(), + &redis, + &pool, + &mut transaction, + ) + .await? + { return Err(ApiError::Authentication( AuthenticationError::InvalidCredentials, )); } - Flow::remove(&login.flow, &redis).await?; - let mut transaction = pool.begin().await?; + Flow::remove(&login.flow, &redis).await?; sqlx::query!( " @@ -1528,18 +1597,26 @@ pub async fn remove_2fa( )); } - let token = get_2fa_code(user.totp_secret.ok_or_else(|| { - ApiError::InvalidInput("User does not have 2FA enabled on the account!".to_string()) - })?)?; + let mut transaction = pool.begin().await?; - if token != login.code { + if !validate_2fa_code( + login.code.clone(), + user.totp_secret.ok_or_else(|| { + ApiError::InvalidInput("User does not have 2FA enabled on the account!".to_string()) + })?, + true, + user.id, + &redis, + &pool, + &mut transaction, + ) + .await? + { return Err(ApiError::Authentication( AuthenticationError::InvalidCredentials, )); } - let mut transaction = pool.begin().await?; - sqlx::query!( " UPDATE users @@ -1930,6 +2007,34 @@ pub async fn verify_email( } } +#[post("email/subscribe")] +pub async fn subscribe_newsletter( + 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 let Some(email) = user.email { + sign_up_beehiiv(&email).await?; + + Ok(HttpResponse::NoContent().finish()) + } else { + Err(ApiError::InvalidInput( + "User does not have an email.".to_string(), + )) + } +} + fn send_email_verify( email: String, flow: String, diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 063c17ece..38de3a27a 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -27,7 +27,7 @@ pub enum AuthenticationError { Database(#[from] crate::database::models::DatabaseError), #[error("Error while parsing JSON: {0}")] SerDe(#[from] serde_json::Error), - #[error("Error while communicating to external oauth provider")] + #[error("Error while communicating to external provider")] Reqwest(#[from] reqwest::Error), #[error("Error uploading user profile picture")] FileHosting(#[from] FileHostingError), diff --git a/src/auth/session.rs b/src/auth/session.rs index d33bd6207..43931aa92 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -115,6 +115,16 @@ pub async fn issue_session( .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + DBSession::clear_cache( + vec![( + Some(session.id), + Some(session.session.clone()), + Some(session.user_id), + )], + redis, + ) + .await?; + Ok(session) } @@ -135,12 +145,18 @@ pub async fn list( .await? .1; + let session = req + .headers() + .get(AUTHORIZATION) + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + 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)) + .map(|x| Session::from(x, false, Some(session))) .collect::>(); Ok(HttpResponse::Ok().json(sessions)) @@ -227,7 +243,7 @@ pub async fn refresh( transaction.commit().await?; - Ok(HttpResponse::Ok().json(Session::from(new_session, true))) + Ok(HttpResponse::Ok().json(Session::from(new_session, true, None))) } else { Err(ApiError::Authentication( AuthenticationError::InvalidCredentials, diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index 21cd773d3..fbfa10358 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -182,7 +182,7 @@ pub struct ReportTypeId(pub i32); #[sqlx(transparent)] pub struct FileId(pub i64); -#[derive(Copy, Clone, Debug, Type, Deserialize, Serialize)] +#[derive(Copy, Clone, Debug, Type, Deserialize, Serialize, Eq, PartialEq, Hash)] #[sqlx(transparent)] pub struct PatId(pub i64); @@ -200,7 +200,7 @@ pub struct ThreadId(pub i64); #[sqlx(transparent)] pub struct ThreadMessageId(pub i64); -#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] #[sqlx(transparent)] pub struct SessionId(pub i64); diff --git a/src/database/models/pat_item.rs b/src/database/models/pat_item.rs index 822cfcb18..f8ff23d1e 100644 --- a/src/database/models/pat_item.rs +++ b/src/database/models/pat_item.rs @@ -146,7 +146,7 @@ impl PersonalAccessToken { } if !remaining_strings.is_empty() { - let pat_ids_parsed: Vec = pat_strings + let pat_ids_parsed: Vec = remaining_strings .iter() .flat_map(|x| parse_base62(&x.to_string()).ok()) .map(|x| x as i64) @@ -159,7 +159,7 @@ impl PersonalAccessToken { ORDER BY created DESC ", &pat_ids_parsed, - &pat_strings + &remaining_strings .into_iter() .map(|x| x.to_string()) .collect::>(), @@ -214,8 +214,9 @@ impl PersonalAccessToken { let mut redis = redis.get().await?; let res = cmd("GET") .arg(format!("{}:{}", PATS_USERS_NAMESPACE, user_id.0)) - .query_async::<_, Option>>(&mut redis) - .await?; + .query_async::<_, Option>(&mut redis) + .await? + .and_then(|x| serde_json::from_str::>(&x).ok()); if let Some(res) = res { return Ok(res.into_iter().map(PatId).collect()); @@ -251,6 +252,10 @@ impl PersonalAccessToken { clear_pats: Vec<(Option, Option, Option)>, redis: &deadpool_redis::Pool, ) -> Result<(), DatabaseError> { + if clear_pats.is_empty() { + return Ok(()); + } + let mut redis = redis.get().await?; let mut cmd = cmd("DEL"); diff --git a/src/database/models/session_item.rs b/src/database/models/session_item.rs index 362404970..e1f1843c6 100644 --- a/src/database/models/session_item.rs +++ b/src/database/models/session_item.rs @@ -187,7 +187,7 @@ impl Session { } if !remaining_strings.is_empty() { - let session_ids_parsed: Vec = session_strings + let session_ids_parsed: Vec = remaining_strings .iter() .flat_map(|x| parse_base62(&x.to_string()).ok()) .map(|x| x as i64) @@ -201,7 +201,7 @@ impl Session { ORDER BY created DESC ", &session_ids_parsed, - &session_strings.into_iter().map(|x| x.to_string()).collect::>(), + &remaining_strings.into_iter().map(|x| x.to_string()).collect::>(), ) .fetch_many(exec) .try_filter_map(|e| async { @@ -258,8 +258,9 @@ impl Session { let mut redis = redis.get().await?; let res = cmd("GET") .arg(format!("{}:{}", SESSIONS_USERS_NAMESPACE, user_id.0)) - .query_async::<_, Option>>(&mut redis) - .await?; + .query_async::<_, Option>(&mut redis) + .await? + .and_then(|x| serde_json::from_str::>(&x).ok()); if let Some(res) = res { return Ok(res.into_iter().map(SessionId).collect()); @@ -295,6 +296,10 @@ impl Session { clear_sessions: Vec<(Option, Option, Option)>, redis: &deadpool_redis::Pool, ) -> Result<(), DatabaseError> { + if clear_sessions.is_empty() { + return Ok(()); + } + let mut redis = redis.get().await?; let mut cmd = cmd("DEL"); diff --git a/src/main.rs b/src/main.rs index 8383dec81..32d5c4618 100644 --- a/src/main.rs +++ b/src/main.rs @@ -459,5 +459,8 @@ fn check_env_vars() -> bool { failed |= check_var::("SITE_VERIFY_EMAIL_PATH"); failed |= check_var::("SITE_RESET_PASSWORD_PATH"); + failed |= check_var::("BEEHIIV_PUBLICATION_ID"); + failed |= check_var::("BEEHIIV_API_KEY"); + failed } diff --git a/src/models/sessions.rs b/src/models/sessions.rs index 9d30a15a6..9cfb6d506 100644 --- a/src/models/sessions.rs +++ b/src/models/sessions.rs @@ -26,15 +26,19 @@ pub struct Session { pub city: Option, pub country: Option, pub ip: String, + + pub current: bool, } impl Session { pub fn from( data: crate::database::models::session_item::Session, include_session: bool, + current_session: Option<&str>, ) -> Self { Session { id: data.id.into(), + current: Some(&*data.session) == current_session, session: if include_session { Some(data.session) } else { diff --git a/src/queue/session.rs b/src/queue/session.rs index b648101ee..eb76ec394 100644 --- a/src/queue/session.rs +++ b/src/queue/session.rs @@ -4,41 +4,42 @@ use crate::database::models::session_item::Session; use crate::database::models::{DatabaseError, PatId, SessionId, UserId}; use chrono::Utc; use sqlx::PgPool; +use std::collections::{HashMap, HashSet}; use tokio::sync::Mutex; pub struct AuthQueue { - session_queue: Mutex>, - pat_queue: Mutex>, + session_queue: Mutex>, + pat_queue: Mutex>, } // Batches session accessing transactions every 30 seconds impl AuthQueue { pub fn new() -> Self { AuthQueue { - session_queue: Mutex::new(Vec::with_capacity(1000)), - pat_queue: Mutex::new(Vec::with_capacity(1000)), + session_queue: Mutex::new(HashMap::with_capacity(1000)), + pat_queue: Mutex::new(HashSet::with_capacity(1000)), } } pub async fn add_session(&self, id: SessionId, metadata: SessionMetadata) { - self.session_queue.lock().await.push((id, metadata)); + self.session_queue.lock().await.insert(id, metadata); } pub async fn add_pat(&self, id: PatId) { - self.pat_queue.lock().await.push(id); + self.pat_queue.lock().await.insert(id); } - pub async fn take_sessions(&self) -> Vec<(SessionId, SessionMetadata)> { + pub async fn take_sessions(&self) -> HashMap { let mut queue = self.session_queue.lock().await; let len = queue.len(); - std::mem::replace(&mut queue, Vec::with_capacity(len)) + std::mem::replace(&mut queue, HashMap::with_capacity(len)) } - pub async fn take_pats(&self) -> Vec { + pub async fn take_pats(&self) -> HashSet { let mut queue = self.pat_queue.lock().await; let len = queue.len(); - std::mem::replace(&mut queue, Vec::with_capacity(len)) + std::mem::replace(&mut queue, HashSet::with_capacity(len)) } pub async fn index(