Password reset functionality
On the `reset` page an email address can be submitted. If an account associated with the submitted email address an email is sent containing an URL. This URL can be used to set a new password. - Add GPLv3 for licensing - Add dependencies - `rocket_contrib` to be able to use handlebar templates - `anyhow` to handle errors - `log` for logging - `ldap3` to communicate with a LDAP server - `lettre` and `lettre_email` to handle the generation of emails and to send them - `rand` to generate random keys - Add `README.org` which is also used to generate `README.md` - Add configuration parameters - domain - LDAP - server - base - filter - bind - password - Change default development address to 0.0.0.0 - Add structs to handle data - Add functions to handle password reset actions - `reset_prepare()` to generate a new key, send it to the requestor and keep it in the memory - `set_password()` to check for the key and set the password - Add routes - Add tests - Add templates - `reset.html.hbs` to submit an email address - `reset_key.html.hbs` to set the new password
This commit is contained in:
parent
6d247c63ba
commit
7b0e4b4a31
10 changed files with 2566 additions and 35 deletions
312
src/main.rs
312
src/main.rs
|
@ -3,14 +3,320 @@
|
|||
#[macro_use]
|
||||
extern crate rocket;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use ldap3::{
|
||||
controls::{MakeCritical, RelaxRules},
|
||||
LdapConn, Mod, Scope, SearchEntry,
|
||||
};
|
||||
use lettre::{SmtpClient, Transport};
|
||||
use lettre_email::EmailBuilder;
|
||||
use log::{debug, error, info};
|
||||
use rand::{self, Rng};
|
||||
use rocket::{
|
||||
fairing::AdHoc,
|
||||
request::{FlashMessage, Form},
|
||||
response::{Flash, Redirect},
|
||||
State,
|
||||
};
|
||||
use rocket_contrib::templates::Template;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
struct LdapConfig {
|
||||
server: String,
|
||||
base: String,
|
||||
filter: String,
|
||||
bind: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
struct Ldap0rConfig {
|
||||
domain: String,
|
||||
ldap: LdapConfig,
|
||||
}
|
||||
|
||||
const BASE62: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
struct Keys {
|
||||
keys: Arc<Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
fn reset_prepare(config: &Ldap0rConfig, keys: &Keys, email_address: &str) -> Result<()> {
|
||||
// ldap lookup
|
||||
let mut ldap = LdapConn::new(&config.ldap.server)?;
|
||||
let result = ldap.search(
|
||||
&config.ldap.base,
|
||||
Scope::Subtree,
|
||||
&format!("(&{}(mail={}))", config.ldap.filter, email_address),
|
||||
vec![""],
|
||||
)?;
|
||||
ldap.unbind()?;
|
||||
let (rs, _res) = result.success()?;
|
||||
|
||||
if rs.len() == 1 {
|
||||
// generate key
|
||||
let mut key = String::with_capacity(64);
|
||||
let mut rng = rand::thread_rng();
|
||||
for _ in 0..64 {
|
||||
key.push(BASE62[rng.gen::<usize>() % 62] as char);
|
||||
}
|
||||
|
||||
// store key with id
|
||||
let keys = Arc::clone(&keys.keys);
|
||||
if let Ok(mut keys) = keys.lock() {
|
||||
keys.insert(key.to_string(), email_address.to_string());
|
||||
debug!("Generated new key for '{}'", email_address);
|
||||
|
||||
// generate email
|
||||
let email = EmailBuilder::new()
|
||||
.to(email_address)
|
||||
.from("ldap0r@example.com")
|
||||
.subject("LDAP password reset")
|
||||
.text(format!(
|
||||
"Use following url to set a new password: {}/reset/{}",
|
||||
config.domain, key
|
||||
))
|
||||
.build()?;
|
||||
|
||||
// send email
|
||||
let mut mailer = SmtpClient::new_unencrypted_localhost()?.transport();
|
||||
let result = mailer.send(email.into());
|
||||
|
||||
if result.is_ok() {
|
||||
info!("Password reset email was sent to '{}'", email_address);
|
||||
} else {
|
||||
error!(
|
||||
"Sending password reset email with reset URL to '{}' failed",
|
||||
email_address
|
||||
);
|
||||
}
|
||||
} else {
|
||||
error!("Could not aquire lock for keys");
|
||||
};
|
||||
} else {
|
||||
error!("Invalid password reset request for '{}'", email_address);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_password(
|
||||
config: &Ldap0rConfig,
|
||||
keys: &Keys,
|
||||
key: &str,
|
||||
passwords: &PasswordsForm,
|
||||
) -> Result<Flash<Redirect>> {
|
||||
if passwords.password == passwords.password_control {
|
||||
if passwords.password.len() >= 8 {
|
||||
// key lookup
|
||||
let keys = Arc::clone(&keys.keys);
|
||||
if let Ok(mut keys) = keys.lock() {
|
||||
let email = keys
|
||||
.get(key)
|
||||
.ok_or_else(|| anyhow!("Could not extract email"))?.to_string();
|
||||
|
||||
// ldap lookup
|
||||
let mut ldap = LdapConn::new(&config.ldap.server)?;
|
||||
let result = ldap.search(
|
||||
&config.ldap.base,
|
||||
Scope::Subtree,
|
||||
&format!("(&{}(mail={}))", &config.ldap.filter, &email),
|
||||
vec!["cn"],
|
||||
)?;
|
||||
let (mut rs, _res) = result.success()?;
|
||||
|
||||
// ldap set new password
|
||||
let user = SearchEntry::construct(
|
||||
rs.pop()
|
||||
.ok_or_else(|| anyhow!("Could extract not receive LDAP result"))?,
|
||||
)
|
||||
.attrs
|
||||
.get("cn")
|
||||
.ok_or_else(|| anyhow!("Could not extract 'cn' from LDAP entry"))?[0]
|
||||
.to_string();
|
||||
let mut password = HashSet::new();
|
||||
password.insert(passwords.password.as_str());
|
||||
ldap.simple_bind(&config.ldap.bind, &config.ldap.password)?
|
||||
.success()?;
|
||||
ldap.with_controls(RelaxRules.critical())
|
||||
.modify(
|
||||
&format!("cn={},{}", &user, &config.ldap.base),
|
||||
vec![Mod::Replace("userPassword", password)],
|
||||
)?
|
||||
.success()?;
|
||||
|
||||
ldap.unbind()?;
|
||||
keys.remove(key);
|
||||
|
||||
info!(
|
||||
"New password set for user '{}' with email address '{}'",
|
||||
&user, &email
|
||||
);
|
||||
return Ok(Flash::success(
|
||||
Redirect::to(uri!(reset)),
|
||||
"New password was saved",
|
||||
));
|
||||
} else {
|
||||
error!("Could not aquire lock for keys");
|
||||
};
|
||||
} else {
|
||||
return Ok(Flash::error(
|
||||
Redirect::to(uri!(reset_key: key)),
|
||||
"Password length has to be at least 8",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Ok(Flash::error(
|
||||
Redirect::to(uri!(reset_key: key)),
|
||||
"Password does not match the password verification field",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Flash::error(
|
||||
Redirect::to(uri!(reset_key: key)),
|
||||
"Setting new password failed",
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
struct EmailForm {
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromForm)]
|
||||
struct PasswordsForm {
|
||||
password: String,
|
||||
password_control: String,
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
fn index() -> &'static str {
|
||||
"Hello, ldap0r!"
|
||||
fn index() -> Redirect {
|
||||
Redirect::to(uri!(reset))
|
||||
}
|
||||
|
||||
#[get("/reset")]
|
||||
fn reset(flash: Option<FlashMessage>) -> Template {
|
||||
let mut context = HashMap::new();
|
||||
|
||||
if let Some(ref msg) = flash {
|
||||
context.insert("flash", msg.msg());
|
||||
if msg.name() == "error" {
|
||||
context.insert("flash_type", "Error");
|
||||
}
|
||||
}
|
||||
|
||||
Template::render("reset", &context)
|
||||
}
|
||||
|
||||
#[post("/reset", data = "<email>")]
|
||||
fn reset_email(
|
||||
config: State<Ldap0rConfig>,
|
||||
keys: State<Keys>,
|
||||
email: Form<EmailForm>,
|
||||
) -> Flash<Redirect> {
|
||||
if let Err(e) = reset_prepare(&config, &keys, &email.email) {
|
||||
error!("{}", e);
|
||||
}
|
||||
|
||||
Flash::success(
|
||||
Redirect::to(uri!(reset)),
|
||||
format!("Email was sent to {}", email.email),
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/reset/<key>")]
|
||||
fn reset_key(keys: State<Keys>, key: String, flash: Option<FlashMessage>) -> Option<Template> {
|
||||
let keys = Arc::clone(&keys.keys);
|
||||
if let Ok(keys) = keys.lock() {
|
||||
let mut context = HashMap::new();
|
||||
|
||||
if let Some(ref msg) = flash {
|
||||
context.insert("flash", msg.msg());
|
||||
if msg.name() == "error" {
|
||||
context.insert("flash_type", "Error");
|
||||
}
|
||||
}
|
||||
|
||||
if keys.contains_key(&key) {
|
||||
context.insert("key", &key);
|
||||
return Some(Template::render("reset_key", &context));
|
||||
}
|
||||
} else {
|
||||
error!("Could not aquire lock for keys");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[post("/reset/<key>", data = "<passwords>")]
|
||||
fn reset_password(
|
||||
config: State<Ldap0rConfig>,
|
||||
keys: State<Keys>,
|
||||
key: String,
|
||||
passwords: Form<PasswordsForm>,
|
||||
) -> Flash<Redirect> {
|
||||
match set_password(&config, &keys, &key, &passwords) {
|
||||
Ok(flash) => flash,
|
||||
Err(e) => {
|
||||
error!("{}", e);
|
||||
Flash::error(
|
||||
Redirect::to(uri!(reset_key: key)),
|
||||
"Setting new password failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
rocket::ignite().mount("/", routes![index]).launch();
|
||||
rocket::ignite()
|
||||
.mount(
|
||||
"/",
|
||||
routes![index, reset, reset_email, reset_key, reset_password],
|
||||
)
|
||||
.attach(AdHoc::on_attach("LDAP config", |rocket| {
|
||||
let ldap0r_config = Ldap0rConfig {
|
||||
domain: rocket
|
||||
.config()
|
||||
.get_str("domain")
|
||||
.unwrap_or("https://localhost")
|
||||
.to_string(),
|
||||
ldap: LdapConfig {
|
||||
server: rocket
|
||||
.config()
|
||||
.get_str("ldap_server")
|
||||
.unwrap_or("ldap://localhost:2389")
|
||||
.to_string(),
|
||||
base: rocket
|
||||
.config()
|
||||
.get_str("ldap_base")
|
||||
.unwrap_or("ou=Places,dc=example,dc=org")
|
||||
.to_string(),
|
||||
filter: rocket
|
||||
.config()
|
||||
.get_str("ldap_filter")
|
||||
.unwrap_or("(objectClass=person)")
|
||||
.to_string(),
|
||||
bind: rocket
|
||||
.config()
|
||||
.get_str("ldap_bind")
|
||||
.unwrap_or("cn=admin,dc=example,dc=org")
|
||||
.to_string(),
|
||||
password: rocket
|
||||
.config()
|
||||
.get_str("ldap_password")
|
||||
.unwrap_or("secure")
|
||||
.to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
Ok(rocket.manage(ldap0r_config))
|
||||
}))
|
||||
.attach(Template::fairing())
|
||||
.manage(Keys {
|
||||
keys: Arc::new(Mutex::new(HashMap::new())),
|
||||
})
|
||||
.launch();
|
||||
}
|
||||
|
|
20
src/tests.rs
20
src/tests.rs
|
@ -2,8 +2,24 @@ use rocket::{self, local::Client, routes};
|
|||
|
||||
#[test]
|
||||
fn index() {
|
||||
let rocket = rocket::ignite().mount("/", routes![super::index]);
|
||||
let rocket = rocket::ignite().mount("/", routes![super::index, super::reset]);
|
||||
let client = Client::new(rocket).unwrap();
|
||||
let mut response = client.get("/").dispatch();
|
||||
assert_eq!(response.body_string(), Some("Hello, ldap0r!".into()));
|
||||
assert_eq!(response.status().code, 303);
|
||||
assert!(response.body().is_none());
|
||||
for h in response.headers().iter() {
|
||||
match h.name.as_str() {
|
||||
"Location" => assert_eq!(h.value, "/reset"),
|
||||
"Content-Length" => assert_eq!(h.value.parse::<i32>().unwrap(), 0),
|
||||
_ => { /* let these through */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset() {
|
||||
let rocket = rocket::ignite().mount("/", routes![super::reset]);
|
||||
let client = Client::new(rocket).unwrap();
|
||||
let mut response = client.get("/reset").dispatch();
|
||||
assert_eq!(response.body_string(), Some("Reset ldap0r!".into()));
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue