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:
parent
e7126ae335
commit
4b205fa995
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE password_reset_requests;
|
@ -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);
|
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE password_reset_requests;
|
@ -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);
|
@ -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;
|
||||||
|
164
plume-models/src/password_reset_requests.rs
Normal file
164
plume-models/src/password_reset_requests.rs
Normal 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(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
9
templates/session/password_reset_request_expired.rs.html
Normal file
9
templates/session/password_reset_request_expired.rs.html
Normal 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>
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user