use crate::{get_now_iso, random_string, uid_to_dn, Accounts, AccountsReset, Config, State}; use chrono::{Duration, SecondsFormat, Utc}; use ldap3::{exop::PasswordModify, LdapConn}; 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()) } #[derive(Debug, Deserialize)] pub struct PassResetAuth { auth: String, pass: String, } pub async fn post_password_auth(mut req: Request) -> tide::Result { let PassResetAuth { auth, pass, } = req.body_json().await?; let config = &req.state().config; let db = &req.state().db; if db_pending_clear_expired(db).await.is_err() { return Ok(json!({"result": "success"}).into()); } // check if auth exists let details = match db_get_user_reset_auth(db, &auth).await { None => { return Ok(json!({"result": "success"}).into()); } Some(x) => x, }; if ldap_reset_pw(config, &details, &pass).await.is_err() { return Ok(json!({"result": "error", "error": "ldap error"}).into()); }; Ok(json!({"result": "success", "success": "Password set"}).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 } async fn db_get_user_reset_auth(pool: &Pool, auth: &str) -> Option { if let Ok(res) = sqlx::query_as::<_, AccountsReset>( r#" SELECT * FROM accounts_reset WHERE auth == ? "#, ) .bind(auth) .fetch_all(pool) .await { if !res.is_empty() { return Some(res[0].to_owned()); } } None } async fn ldap_reset_pw(config: &Config, details: &AccountsReset, pass: &str) -> Result<(), ldap3::LdapError> { let mut ldap = LdapConn::new(&config.ldap_host)?; ldap.simple_bind(&config.ldap_admin, &config.ldap_admin_pw)?.success()?; let dn = uid_to_dn(&details.user); // if so then set password let tmp = PasswordModify { // none as we are staying on the same connection user_id: Some(&dn), old_pass: None, new_pass: Some(pass), }; ldap.extended(tmp)?.success()?; ldap.unbind()?; Ok(()) } 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 }