Store password reset requests in database (#610)

* Store password reset requests in database

Signed-off-by: Rob Watson <rfwatson@users.noreply.github.com>

* Refactor password reset request expiry handling

* Integrate sqlite

* Fix formatting
This commit is contained in:
Rob Watson 2019-06-04 20:55:17 +02:00 committed by Baptiste Gelez
parent e7126ae335
commit 4b205fa995
9 changed files with 238 additions and 75 deletions

View file

@ -0,0 +1 @@
DROP TABLE password_reset_requests;

View file

@ -0,0 +1,9 @@
CREATE TABLE password_reset_requests (
id SERIAL PRIMARY KEY,
email VARCHAR NOT NULL,
token VARCHAR NOT NULL,
expiration_date TIMESTAMP NOT NULL
);
CREATE INDEX password_reset_requests_token ON password_reset_requests (token);
CREATE UNIQUE INDEX password_reset_requests_email ON password_reset_requests (email);

View file

@ -0,0 +1 @@
DROP TABLE password_reset_requests;

View file

@ -0,0 +1,9 @@
CREATE TABLE password_reset_requests (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
email VARCHAR NOT NULL,
token VARCHAR NOT NULL,
expiration_date DATETIME NOT NULL
);
CREATE INDEX password_reset_requests_token ON password_reset_requests (token);
CREATE UNIQUE INDEX password_reset_requests_email ON password_reset_requests (email);

View file

@ -65,6 +65,7 @@ pub enum Error {
Unauthorized, Unauthorized,
Url, Url,
Webfinger, Webfinger,
Expired,
} }
impl From<bcrypt::BcryptError> for Error { impl From<bcrypt::BcryptError> for Error {
@ -367,6 +368,7 @@ pub mod medias;
pub mod mentions; pub mod mentions;
pub mod migrations; pub mod migrations;
pub mod notifications; pub mod notifications;
pub mod password_reset_requests;
pub mod plume_rocket; pub mod plume_rocket;
pub mod post_authors; pub mod post_authors;
pub mod posts; pub mod posts;

View file

@ -0,0 +1,164 @@
use chrono::{offset::Utc, Duration, NaiveDateTime};
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
use schema::password_reset_requests;
use {Connection, Error, Result};
#[derive(Clone, Identifiable, Queryable)]
pub struct PasswordResetRequest {
pub id: i32,
pub email: String,
pub token: String,
pub expiration_date: NaiveDateTime,
}
#[derive(Insertable)]
#[table_name = "password_reset_requests"]
pub struct NewPasswordResetRequest {
pub email: String,
pub token: String,
pub expiration_date: NaiveDateTime,
}
const TOKEN_VALIDITY_HOURS: i64 = 2;
impl PasswordResetRequest {
pub fn insert(conn: &Connection, email: &str) -> Result<String> {
// first, delete other password reset tokens associated with this email:
let existing_requests =
password_reset_requests::table.filter(password_reset_requests::email.eq(email));
diesel::delete(existing_requests).execute(conn)?;
// now, generate a random token, set the expiry date,
// and insert it into the DB:
let token = plume_common::utils::random_hex();
let expiration_date = Utc::now()
.naive_utc()
.checked_add_signed(Duration::hours(TOKEN_VALIDITY_HOURS))
.expect("could not calculate expiration date");
let new_request = NewPasswordResetRequest {
email: email.to_owned(),
token: token.clone(),
expiration_date,
};
diesel::insert_into(password_reset_requests::table)
.values(new_request)
.execute(conn)
.map_err(Error::from)?;
Ok(token)
}
pub fn find_by_token(conn: &Connection, token: &str) -> Result<Self> {
let token = password_reset_requests::table
.filter(password_reset_requests::token.eq(token))
.first::<Self>(conn)
.map_err(Error::from)?;
if token.expiration_date < Utc::now().naive_utc() {
return Err(Error::Expired);
}
Ok(token)
}
pub fn find_and_delete_by_token(conn: &Connection, token: &str) -> Result<Self> {
let request = Self::find_by_token(&conn, &token)?;
let filter =
password_reset_requests::table.filter(password_reset_requests::id.eq(request.id));
diesel::delete(filter).execute(conn)?;
Ok(request)
}
}
#[cfg(test)]
mod tests {
use super::*;
use diesel::Connection;
use tests::db;
use users::tests as user_tests;
#[test]
fn test_insert_and_find_password_reset_request() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
user_tests::fill_database(&conn);
let admin_email = "admin@example.com";
let token = PasswordResetRequest::insert(&conn, admin_email)
.expect("couldn't insert new request");
let request = PasswordResetRequest::find_by_token(&conn, &token)
.expect("couldn't retrieve request");
assert!(&token.len() > &32);
assert_eq!(&request.email, &admin_email);
Ok(())
});
}
#[test]
fn test_insert_delete_previous_password_reset_request() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
user_tests::fill_database(&conn);
let admin_email = "admin@example.com";
PasswordResetRequest::insert(&conn, &admin_email).expect("couldn't insert new request");
PasswordResetRequest::insert(&conn, &admin_email)
.expect("couldn't insert second request");
let count = password_reset_requests::table.count().get_result(&*conn);
assert_eq!(Ok(1), count);
Ok(())
});
}
#[test]
fn test_find_password_reset_request_by_token_time() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
user_tests::fill_database(&conn);
let admin_email = "admin@example.com";
let token = "abcdef";
let now = Utc::now().naive_utc();
diesel::insert_into(password_reset_requests::table)
.values((
password_reset_requests::email.eq(&admin_email),
password_reset_requests::token.eq(&token),
password_reset_requests::expiration_date.eq(now),
))
.execute(&*conn)
.expect("could not insert request");
match PasswordResetRequest::find_by_token(&conn, &token) {
Err(Error::Expired) => (),
_ => panic!("Received unexpected result finding expired token"),
}
Ok(())
});
}
#[test]
fn test_find_and_delete_password_reset_request() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
user_tests::fill_database(&conn);
let admin_email = "admin@example.com";
let token = PasswordResetRequest::insert(&conn, &admin_email)
.expect("couldn't insert new request");
PasswordResetRequest::find_and_delete_by_token(&conn, &token)
.expect("couldn't find and delete request");
let count = password_reset_requests::table.count().get_result(&*conn);
assert_eq!(Ok(0), count);
Ok(())
});
}
}

View file

@ -141,6 +141,15 @@ table! {
} }
} }
table! {
password_reset_requests (id) {
id -> Int4,
email -> Varchar,
token -> Varchar,
expiration_date -> Timestamp,
}
}
table! { table! {
post_authors (id) { post_authors (id) {
id -> Int4, id -> Int4,
@ -247,6 +256,7 @@ allow_tables_to_appear_in_same_query!(
medias, medias,
mentions, mentions,
notifications, notifications,
password_reset_requests,
post_authors, post_authors,
posts, posts,
reshares, reshares,

View file

@ -16,10 +16,10 @@ use validator::{Validate, ValidationError, ValidationErrors};
use mail::{build_mail, Mailer}; use mail::{build_mail, Mailer};
use plume_models::{ use plume_models::{
password_reset_requests::*,
users::{User, AUTH_COOKIE}, users::{User, AUTH_COOKIE},
Error, PlumeRocket, CONFIG, Error, PlumeRocket, CONFIG,
}; };
use routes::errors::ErrorPage;
use template_utils::{IntoContext, Ructe}; use template_utils::{IntoContext, Ructe};
#[get("/login?<m>")] #[get("/login?<m>")]
@ -164,29 +164,17 @@ pub struct ResetForm {
pub fn password_reset_request( pub fn password_reset_request(
mail: State<Arc<Mutex<Mailer>>>, mail: State<Arc<Mutex<Mailer>>>,
form: Form<ResetForm>, form: Form<ResetForm>,
requests: State<Arc<Mutex<Vec<ResetRequest>>>>,
rockets: PlumeRocket, rockets: PlumeRocket,
) -> Ructe { ) -> Ructe {
let mut requests = requests.lock().unwrap(); if User::find_by_email(&*rockets.conn, &form.email).is_ok() {
// Remove outdated requests (more than 1 day old) to avoid the list to grow too much let token = PasswordResetRequest::insert(&*rockets.conn, &form.email)
requests.retain(|r| r.creation_date.elapsed().as_secs() < 24 * 60 * 60); .expect("password_reset_request::insert: error");
if User::find_by_email(&*rockets.conn, &form.email).is_ok() let url = format!("https://{}/password-reset/{}", CONFIG.base_url, token);
&& !requests.iter().any(|x| x.mail == form.email.clone())
{
let id = plume_common::utils::random_hex();
requests.push(ResetRequest {
mail: form.email.clone(),
id: id.clone(),
creation_date: Instant::now(),
});
let link = format!("https://{}/password-reset/{}", CONFIG.base_url, id);
if let Some(message) = build_mail( if let Some(message) = build_mail(
form.email.clone(), form.email.clone(),
i18n!(rockets.intl.catalog, "Password reset"), i18n!(rockets.intl.catalog, "Password reset"),
i18n!(rockets.intl.catalog, "Here is the link to reset your password: {0}"; link), i18n!(rockets.intl.catalog, "Here is the link to reset your password: {0}"; url),
) { ) {
if let Some(ref mut mail) = *mail.lock().unwrap() { if let Some(ref mut mail) = *mail.lock().unwrap() {
mail.send(message.into()) mail.send(message.into())
@ -199,17 +187,10 @@ pub fn password_reset_request(
} }
#[get("/password-reset/<token>")] #[get("/password-reset/<token>")]
pub fn password_reset_form( pub fn password_reset_form(token: String, rockets: PlumeRocket) -> Result<Ructe, Ructe> {
token: String, PasswordResetRequest::find_by_token(&*rockets.conn, &token)
requests: State<Arc<Mutex<Vec<ResetRequest>>>>, .map_err(|err| password_reset_error_response(err, &rockets))?;
rockets: PlumeRocket,
) -> Result<Ructe, ErrorPage> {
requests
.lock()
.unwrap()
.iter()
.find(|x| x.id == token.clone())
.ok_or(Error::NotFound)?;
Ok(render!(session::password_reset( Ok(render!(session::password_reset(
&rockets.to_context(), &rockets.to_context(),
&NewPasswordForm::default(), &NewPasswordForm::default(),
@ -239,23 +220,17 @@ fn passwords_match(form: &NewPasswordForm) -> Result<(), ValidationError> {
#[post("/password-reset/<token>", data = "<form>")] #[post("/password-reset/<token>", data = "<form>")]
pub fn password_reset( pub fn password_reset(
token: String, token: String,
requests: State<Arc<Mutex<Vec<ResetRequest>>>>,
form: Form<NewPasswordForm>, form: Form<NewPasswordForm>,
rockets: PlumeRocket, rockets: PlumeRocket,
) -> Result<Flash<Redirect>, Ructe> { ) -> Result<Flash<Redirect>, Ructe> {
form.validate() form.validate()
.and_then(|_| { .map_err(|err| render!(session::password_reset(&rockets.to_context(), &form, err)))?;
let mut requests = requests.lock().unwrap();
let req = requests PasswordResetRequest::find_and_delete_by_token(&*rockets.conn, &token)
.iter() .and_then(|request| User::find_by_email(&*rockets.conn, &request.email))
.find(|x| x.id == token.clone()) .and_then(|user| user.reset_password(&*rockets.conn, &form.password))
.ok_or_else(|| to_validation(0))? .map_err(|err| password_reset_error_response(err, &rockets))?;
.clone();
if req.creation_date.elapsed().as_secs() < 60 * 60 * 2 {
// Reset link is only valid for 2 hours
requests.retain(|r| *r != req);
let user = User::find_by_email(&*rockets.conn, &req.mail).map_err(to_validation)?;
user.reset_password(&*rockets.conn, &form.password).ok();
Ok(Flash::success( Ok(Flash::success(
Redirect::to(uri!( Redirect::to(uri!(
new: m = _ new: m = _
@ -265,30 +240,13 @@ pub fn password_reset(
"Your password was successfully reset." "Your password was successfully reset."
), ),
)) ))
} else {
Ok(Flash::error(
Redirect::to(uri!(
new: m = _
)),
i18n!(
rockets.intl.catalog,
"Sorry, but the link expired. Try again"
),
))
}
})
.map_err(|err| render!(session::password_reset(&rockets.to_context(), &form, err)))
} }
fn to_validation<T>(_: T) -> ValidationErrors { fn password_reset_error_response(err: Error, rockets: &PlumeRocket) -> Ructe {
let mut errors = ValidationErrors::new(); match err {
errors.add( Error::Expired => render!(session::password_reset_request_expired(
"", &rockets.to_context()
ValidationError { )),
code: Cow::from("server_error"), _ => render!(errors::not_found(&rockets.to_context())),
message: Some(Cow::from("An unknown error occured")), }
params: std::collections::HashMap::new(),
},
);
errors
} }

View file

@ -0,0 +1,9 @@
@use template_utils::*;
@use templates::base;
@(ctx: BaseContext)
@:base(ctx, i18n!(ctx.1, "Password reset"), {}, {}, {
<h1>@i18n!(ctx.1, "This token has expired")</h1>
<p>@i18n!(ctx.1, "Please start the process again by clicking") <a href="/password-reset">@i18n!(ctx.1, "here")</a>.</p>
})