From 3a5b96c4d909cc2647f30ebcfb39a2dcc0e0c44b Mon Sep 17 00:00:00 2001 From: Brendan Golden Date: Sun, 30 Jul 2023 21:14:36 +0100 Subject: [PATCH] feat: started teh password reset option --- src/lib.rs | 34 ++++- src/methods/mod.rs | 1 + src/methods/password_reset.rs | 227 ++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 src/methods/password_reset.rs diff --git a/src/lib.rs b/src/lib.rs index 05a11b0..1a15fc7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,7 +34,14 @@ pub struct AccountsNew { pub id_student: String, } -#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)] +#[derive(Debug, Clone, Deserialize, Serialize, sqlx::FromRow)] +pub struct AccountsReset { + pub user: String, + pub auth_code: String, + pub date_expiry: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, sqlx::FromRow)] pub struct Accounts { pub user: String, pub uid: i64, @@ -80,8 +87,29 @@ pub async fn db_init(config: &Config) -> Result, Error> { .execute(&pool) .await?; - sqlx::query("CREATE INDEX IF NOT EXISTS index_auth_code ON accounts_new (auth_code)").execute(&pool).await?; - sqlx::query("CREATE INDEX IF NOT EXISTS index_date_expiry ON accounts_new (date_expiry)").execute(&pool).await?; + sqlx::query("CREATE INDEX IF NOT EXISTS index_auth_code ON accounts_new (auth_code)") + .execute(&pool) + .await?; + sqlx::query("CREATE INDEX IF NOT EXISTS index_date_expiry ON accounts_new (date_expiry)") + .execute(&pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS accounts_reset ( + user text primary key, + auth_code text not null, + date_expiry text not null + )", + ) + .execute(&pool) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS index_auth_code ON accounts_reset (auth_code)") + .execute(&pool) + .await?; + sqlx::query("CREATE INDEX IF NOT EXISTS index_date_expiry ON accounts_reset (date_expiry)") + .execute(&pool) + .await?; // this is for active use sqlx::query( diff --git a/src/methods/mod.rs b/src/methods/mod.rs index f4ba1d1..f223eda 100644 --- a/src/methods/mod.rs +++ b/src/methods/mod.rs @@ -1,2 +1,3 @@ pub mod account_new; pub mod account_update; +pub mod password_reset; diff --git a/src/methods/password_reset.rs b/src/methods/password_reset.rs new file mode 100644 index 0000000..64589a2 --- /dev/null +++ b/src/methods/password_reset.rs @@ -0,0 +1,227 @@ +use crate::{get_now_iso, random_string, Accounts, AccountsReset, Config, State}; +use chrono::{Duration, SecondsFormat, Utc}; +use lettre::{ + message::{header, MultiPart, SinglePart}, + transport::smtp::{authentication::Credentials, response::Response, Error}, + Message, SmtpTransport, Transport, +}; +use maud::html; +use sqlx::{Pool, Sqlite}; +use tide::{ + prelude::{json, Deserialize}, + Request, +}; + +#[derive(Debug, Deserialize)] +pub struct PassReset { + user: Option, + email: Option, +} + +/// Handles password resets +/// All responses are success, never want to leak info +pub async fn post_password_reset(mut req: Request) -> tide::Result { + let PassReset { + user, + email, + } = req.body_json().await?; + + // check that any mail is not using @skynet.ie + if let Some(mail) = &email { + if mail.trim().ends_with("@skynet.ie") { + // all responses from this are a success + return Ok(json!({"result": "success"}).into()); + } + } + + let config = &req.state().config; + let db = &req.state().db; + + // considering the local db is updated hourly (or less) use that instead of teh ldap for lookups + let user_details = match db_get_user(db, &user, &email).await { + None => { + return Ok(json!({"result": "success"}).into()); + } + Some(x) => x, + }; + + // user does not have a different email address set + if user_details.mail.trim().ends_with("@skynet.ie") { + return Ok(json!({"result": "success"}).into()); + } + + // check if a recent password reset request happened lately + db_pending_clear_expired(db).await?; + + if db_get_user_reset(db, &user_details.user).await.is_some() { + // reset already requested within timeframe + return Ok(json!({"result": "success"}).into()); + } + + // send mail + let auth = random_string(50); + + if send_mail(config, &user_details, &auth).is_ok() { + // save to db + + save_to_db(db, &user_details, &auth).await?; + } + + Ok(json!({"result": "success"}).into()) +} + +async fn db_get_user(pool: &Pool, user_in: &Option, mail_in: &Option) -> Option { + let user = match user_in { + None => "", + Some(x) => x, + }; + let mail = match mail_in { + None => "", + Some(x) => x, + }; + + if let Ok(res) = sqlx::query_as::<_, Accounts>( + r#" + SELECT * + FROM accounts + WHERE user == ? OR mail ==? + "#, + ) + .bind(user) + .bind(mail) + .fetch_all(pool) + .await + { + if !res.is_empty() { + return Some(res[0].to_owned()); + } + } + + None +} + +async fn db_pending_clear_expired(pool: &Pool) -> Result, sqlx::Error> { + sqlx::query_as::<_, AccountsReset>( + r#" + DELETE + FROM accounts_reset + WHERE date_expiry < ? + "#, + ) + .bind(get_now_iso(false)) + .fetch_all(pool) + .await +} + +async fn db_get_user_reset(pool: &Pool, user: &str) -> Option { + if let Ok(res) = sqlx::query_as::<_, AccountsReset>( + r#" + SELECT * + FROM accounts_reset + WHERE user == ? + "#, + ) + .bind(user) + .fetch_all(pool) + .await + { + if !res.is_empty() { + return Some(res[0].to_owned()); + } + } + + None +} + +fn send_mail(config: &Config, record: &Accounts, auth: &str) -> Result { + let recipient = &record.user; + let mail = &record.mail; + let url_base = "https://sso.skynet.ie"; + let link_new = format!("{url_base}/reset_pw?auth={auth}"); + let discord = "https://discord.gg/mkuKJkCuyM"; + let sender = format!("UL Computer Society <{}>", &config.mail_user); + + // Create the html we want to send. + let html = html! { + head { + title { "Hello from Skynet!" } + style type="text/css" { + "h2, h4 { font-family: Arial, Helvetica, sans-serif; }" + } + } + div style="display: flex; flex-direction: column; align-items: center;" { + h2 { "Hello from Skynet!" } + // Substitute in the name of our recipient. + p { "Hi " (recipient) "," } + p { + "Here is your password reset link:" + br; + a href=(link_new) { (link_new) } + } + p { + "If did not request this please ignore." + } + p { + "UL Computer Society" + br; + "Skynet Team" + br; + a href=(discord) { (discord) } + } + } + }; + + let body_text = format!( + r#" + Hi {recipient} + + Here is your password reset link: + {link_new} + + If did not request this please ignore. + + UL Computer Society + Skynet Team + {discord} + "# + ); + + // Build the message. + let email = Message::builder() + .from(sender.parse().unwrap()) + .to(mail.parse().unwrap()) + .subject("Skynet: Password Reset") + .multipart( + // This is composed of two parts. + // also helps not trip spam settings (uneven number of url's + MultiPart::alternative() + .singlepart(SinglePart::builder().header(header::ContentType::TEXT_PLAIN).body(body_text)) + .singlepart(SinglePart::builder().header(header::ContentType::TEXT_HTML).body(html.into_string())), + ) + .expect("failed to build email"); + + let creds = Credentials::new(config.mail_user.clone(), config.mail_pass.clone()); + + // Open a remote connection to gmail using STARTTLS + let mailer = SmtpTransport::starttls_relay(&config.mail_smtp).unwrap().credentials(creds).build(); + + // Send the email + mailer.send(&email) +} + +async fn save_to_db(db: &Pool, record: &Accounts, auth: &str) -> Result, sqlx::Error> { + // lets start off a 4 hour timeout on password resets + let offset = Utc::now() + Duration::hours(4); + + sqlx::query_as::<_, AccountsReset>( + " + INSERT OR REPLACE INTO accounts_reset (user, auth_code, date_expiry) + VALUES (?1, ?2, ?3) + ", + ) + .bind(record.user.to_owned()) + .bind(auth.to_owned()) + .bind(offset.to_rfc3339_opts(SecondsFormat::Millis, true)) + .fetch_optional(db) + .await +}