diff --git a/src/main.rs b/src/main.rs index 1a5ab56..c93a7e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,6 @@ use skynet_ldap_backend::{ db_init, get_config, - methods::{ - account_new::post::{account, email}, - account_update::post_update_ldap, - password_reset::{post_password_auth, post_password_reset}, - }, + methods::{account_new, account_recover, account_update::post_update_ldap}, State, }; @@ -25,10 +21,10 @@ async fn main() -> tide::Result<()> { let mut app = tide::with_state(state); app.at("/ldap/update").post(post_update_ldap); - app.at("/ldap/new/email").post(email::submit); - app.at("/ldap/new/account").post(account::submit); - app.at("/ldap/reset").post(post_password_reset); - app.at("/ldap/reset/auth").post(post_password_auth); + app.at("/ldap/new/email").post(account_new::post::email::submit); + app.at("/ldap/new/account").post(account_new::post::account::submit); + app.at("/ldap/recover/password").post(account_recover::password::reset); + app.at("/ldap/recover/password/auth").post(account_recover::password::auth); app.listen(host_port).await?; Ok(()) diff --git a/src/methods/account_recover.rs b/src/methods/account_recover.rs new file mode 100644 index 0000000..22f3558 --- /dev/null +++ b/src/methods/account_recover.rs @@ -0,0 +1,306 @@ +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, +}; + +pub mod password { + use super::*; + + #[derive(Debug, Deserialize)] + struct PassReset { + user: Option, + email: Option, + } + + /// Handles password resets + /// All responses are success, never want to leak info + pub async fn 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 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": "error"}).into()); + } + + // check if auth exists + let details = match db_get_user_reset_auth(db, &auth).await { + None => { + return Ok(json!({"result": "error"}).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}/recovery_pass?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 + } +} diff --git a/src/methods/mod.rs b/src/methods/mod.rs index f223eda..8273b7b 100644 --- a/src/methods/mod.rs +++ b/src/methods/mod.rs @@ -1,3 +1,3 @@ pub mod account_new; +pub mod account_recover; pub mod account_update; -pub mod password_reset; diff --git a/src/methods/password_reset.rs b/src/methods/password_reset.rs deleted file mode 100644 index 60da73a..0000000 --- a/src/methods/password_reset.rs +++ /dev/null @@ -1,302 +0,0 @@ -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": "error"}).into()); - } - - // check if auth exists - let details = match db_get_user_reset_auth(db, &auth).await { - None => { - return Ok(json!({"result": "error"}).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}/recovery_pass?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 -}