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:
finga 2020-07-05 21:12:43 +02:00
parent 6d247c63ba
commit 7b0e4b4a31
10 changed files with 2566 additions and 35 deletions

View file

@ -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();
}

View file

@ -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()));
}