- 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
16 | 16 | ||
17 | 17 | use mail::{build_mail, Mailer}; |
|
18 | 18 | use plume_models::{ |
|
19 | + | password_reset_requests::*, |
|
19 | 20 | users::{User, AUTH_COOKIE}, |
|
20 | 21 | Error, PlumeRocket, CONFIG, |
|
21 | 22 | }; |
|
22 | - | use routes::errors::ErrorPage; |
|
23 | 23 | use template_utils::{IntoContext, Ructe}; |
|
24 | 24 | ||
25 | 25 | #[get("/login?<m>")] |
164 | 164 | pub fn password_reset_request( |
|
165 | 165 | mail: State<Arc<Mutex<Mailer>>>, |
|
166 | 166 | form: Form<ResetForm>, |
|
167 | - | requests: State<Arc<Mutex<Vec<ResetRequest>>>>, |
|
168 | 167 | rockets: PlumeRocket, |
|
169 | 168 | ) -> Ructe { |
|
170 | - | let mut requests = requests.lock().unwrap(); |
|
171 | - | // Remove outdated requests (more than 1 day old) to avoid the list to grow too much |
|
172 | - | requests.retain(|r| r.creation_date.elapsed().as_secs() < 24 * 60 * 60); |
|
169 | + | if User::find_by_email(&*rockets.conn, &form.email).is_ok() { |
|
170 | + | let token = PasswordResetRequest::insert(&*rockets.conn, &form.email) |
|
171 | + | .expect("password_reset_request::insert: error"); |
|
173 | 172 | ||
174 | - | if User::find_by_email(&*rockets.conn, &form.email).is_ok() |
|
175 | - | && !requests.iter().any(|x| x.mail == form.email.clone()) |
|
176 | - | { |
|
177 | - | let id = plume_common::utils::random_hex(); |
|
178 | - | ||
179 | - | requests.push(ResetRequest { |
|
180 | - | mail: form.email.clone(), |
|
181 | - | id: id.clone(), |
|
182 | - | creation_date: Instant::now(), |
|
183 | - | }); |
|
184 | - | ||
185 | - | let link = format!("https://{}/password-reset/{}", CONFIG.base_url, id); |
|
173 | + | let url = format!("https://{}/password-reset/{}", CONFIG.base_url, token); |
|
186 | 174 | if let Some(message) = build_mail( |
|
187 | 175 | form.email.clone(), |
|
188 | 176 | i18n!(rockets.intl.catalog, "Password reset"), |
|
189 | - | i18n!(rockets.intl.catalog, "Here is the link to reset your password: {0}"; link), |
|
177 | + | i18n!(rockets.intl.catalog, "Here is the link to reset your password: {0}"; url), |
|
190 | 178 | ) { |
|
191 | 179 | if let Some(ref mut mail) = *mail.lock().unwrap() { |
|
192 | 180 | mail.send(message.into()) |
199 | 187 | } |
|
200 | 188 | ||
201 | 189 | #[get("/password-reset/<token>")] |
|
202 | - | pub fn password_reset_form( |
|
203 | - | token: String, |
|
204 | - | requests: State<Arc<Mutex<Vec<ResetRequest>>>>, |
|
205 | - | rockets: PlumeRocket, |
|
206 | - | ) -> Result<Ructe, ErrorPage> { |
|
207 | - | requests |
|
208 | - | .lock() |
|
209 | - | .unwrap() |
|
210 | - | .iter() |
|
211 | - | .find(|x| x.id == token.clone()) |
|
212 | - | .ok_or(Error::NotFound)?; |
|
190 | + | pub fn password_reset_form(token: String, rockets: PlumeRocket) -> Result<Ructe, Ructe> { |
|
191 | + | PasswordResetRequest::find_by_token(&*rockets.conn, &token) |
|
192 | + | .map_err(|err| password_reset_error_response(err, &rockets))?; |
|
193 | + | ||
213 | 194 | Ok(render!(session::password_reset( |
|
214 | 195 | &rockets.to_context(), |
|
215 | 196 | &NewPasswordForm::default(), |
239 | 220 | #[post("/password-reset/<token>", data = "<form>")] |
|
240 | 221 | pub fn password_reset( |
|
241 | 222 | token: String, |
|
242 | - | requests: State<Arc<Mutex<Vec<ResetRequest>>>>, |
|
243 | 223 | form: Form<NewPasswordForm>, |
|
244 | 224 | rockets: PlumeRocket, |
|
245 | 225 | ) -> Result<Flash<Redirect>, Ructe> { |
|
246 | 226 | form.validate() |
|
247 | - | .and_then(|_| { |
|
248 | - | let mut requests = requests.lock().unwrap(); |
|
249 | - | let req = requests |
|
250 | - | .iter() |
|
251 | - | .find(|x| x.id == token.clone()) |
|
252 | - | .ok_or_else(|| to_validation(0))? |
|
253 | - | .clone(); |
|
254 | - | if req.creation_date.elapsed().as_secs() < 60 * 60 * 2 { |
|
255 | - | // Reset link is only valid for 2 hours |
|
256 | - | requests.retain(|r| *r != req); |
|
257 | - | let user = User::find_by_email(&*rockets.conn, &req.mail).map_err(to_validation)?; |
|
258 | - | user.reset_password(&*rockets.conn, &form.password).ok(); |
|
259 | - | Ok(Flash::success( |
|
260 | - | Redirect::to(uri!( |
|
261 | - | new: m = _ |
|
262 | - | )), |
|
263 | - | i18n!( |
|
264 | - | rockets.intl.catalog, |
|
265 | - | "Your password was successfully reset." |
|
266 | - | ), |
|
267 | - | )) |
|
268 | - | } else { |
|
269 | - | Ok(Flash::error( |
|
270 | - | Redirect::to(uri!( |
|
271 | - | new: m = _ |
|
272 | - | )), |
|
273 | - | i18n!( |
|
274 | - | rockets.intl.catalog, |
|
275 | - | "Sorry, but the link expired. Try again" |
|
276 | - | ), |
|
277 | - | )) |
|
278 | - | } |
|
279 | - | }) |
|
280 | - | .map_err(|err| render!(session::password_reset(&rockets.to_context(), &form, err))) |
|
227 | + | .map_err(|err| render!(session::password_reset(&rockets.to_context(), &form, err)))?; |
|
228 | + | ||
229 | + | PasswordResetRequest::find_and_delete_by_token(&*rockets.conn, &token) |
|
230 | + | .and_then(|request| User::find_by_email(&*rockets.conn, &request.email)) |
|
231 | + | .and_then(|user| user.reset_password(&*rockets.conn, &form.password)) |
|
232 | + | .map_err(|err| password_reset_error_response(err, &rockets))?; |
|
233 | + | ||
234 | + | Ok(Flash::success( |
|
235 | + | Redirect::to(uri!( |
|
236 | + | new: m = _ |
|
237 | + | )), |
|
238 | + | i18n!( |
|
239 | + | rockets.intl.catalog, |
|
240 | + | "Your password was successfully reset." |
|
241 | + | ), |
|
242 | + | )) |
|
281 | 243 | } |
|
282 | 244 | ||
283 | - | fn to_validation<T>(_: T) -> ValidationErrors { |
|
284 | - | let mut errors = ValidationErrors::new(); |
|
285 | - | errors.add( |
|
286 | - | "", |
|
287 | - | ValidationError { |
|
288 | - | code: Cow::from("server_error"), |
|
289 | - | message: Some(Cow::from("An unknown error occured")), |
|
290 | - | params: std::collections::HashMap::new(), |
|
291 | - | }, |
|
292 | - | ); |
|
293 | - | errors |
|
245 | + | fn password_reset_error_response(err: Error, rockets: &PlumeRocket) -> Ructe { |
|
246 | + | match err { |
|
247 | + | Error::Expired => render!(session::password_reset_request_expired( |
|
248 | + | &rockets.to_context() |
|
249 | + | )), |
|
250 | + | _ => render!(errors::not_found(&rockets.to_context())), |
|
251 | + | } |
|
294 | 252 | } |
1 | + | use chrono::{offset::Utc, Duration, NaiveDateTime}; |
|
2 | + | use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl}; |
|
3 | + | use schema::password_reset_requests; |
|
4 | + | use {Connection, Error, Result}; |
|
5 | + | ||
6 | + | #[derive(Clone, Identifiable, Queryable)] |
|
7 | + | pub struct PasswordResetRequest { |
|
8 | + | pub id: i32, |
|
9 | + | pub email: String, |
|
10 | + | pub token: String, |
|
11 | + | pub expiration_date: NaiveDateTime, |
|
12 | + | } |
|
13 | + | ||
14 | + | #[derive(Insertable)] |
|
15 | + | #[table_name = "password_reset_requests"] |
|
16 | + | pub struct NewPasswordResetRequest { |
|
17 | + | pub email: String, |
|
18 | + | pub token: String, |
|
19 | + | pub expiration_date: NaiveDateTime, |
|
20 | + | } |
|
21 | + | ||
22 | + | const TOKEN_VALIDITY_HOURS: i64 = 2; |
|
23 | + | ||
24 | + | impl PasswordResetRequest { |
|
25 | + | pub fn insert(conn: &Connection, email: &str) -> Result<String> { |
|
26 | + | // first, delete other password reset tokens associated with this email: |
|
27 | + | let existing_requests = |
|
28 | + | password_reset_requests::table.filter(password_reset_requests::email.eq(email)); |
|
29 | + | diesel::delete(existing_requests).execute(conn)?; |
|
30 | + | ||
31 | + | // now, generate a random token, set the expiry date, |
|
32 | + | // and insert it into the DB: |
|
33 | + | let token = plume_common::utils::random_hex(); |
|
34 | + | let expiration_date = Utc::now() |
|
35 | + | .naive_utc() |
|
36 | + | .checked_add_signed(Duration::hours(TOKEN_VALIDITY_HOURS)) |
|
37 | + | .expect("could not calculate expiration date"); |
|
38 | + | let new_request = NewPasswordResetRequest { |
|
39 | + | email: email.to_owned(), |
|
40 | + | token: token.clone(), |
|
41 | + | expiration_date, |
|
42 | + | }; |
|
43 | + | diesel::insert_into(password_reset_requests::table) |
|
44 | + | .values(new_request) |
|
45 | + | .execute(conn) |
|
46 | + | .map_err(Error::from)?; |
|
47 | + | ||
48 | + | Ok(token) |
|
49 | + | } |
|
50 | + | ||
51 | + | pub fn find_by_token(conn: &Connection, token: &str) -> Result<Self> { |
|
52 | + | let token = password_reset_requests::table |
|
53 | + | .filter(password_reset_requests::token.eq(token)) |
|
54 | + | .first::<Self>(conn) |
|
55 | + | .map_err(Error::from)?; |
|
56 | + | ||
57 | + | if token.expiration_date < Utc::now().naive_utc() { |
|
58 | + | return Err(Error::Expired); |
|
59 | + | } |
|
60 | + | ||
61 | + | Ok(token) |
|
62 | + | } |
|
63 | + | ||
64 | + | pub fn find_and_delete_by_token(conn: &Connection, token: &str) -> Result<Self> { |
|
65 | + | let request = Self::find_by_token(&conn, &token)?; |
|
66 | + | ||
67 | + | let filter = |
|
68 | + | password_reset_requests::table.filter(password_reset_requests::id.eq(request.id)); |
|
69 | + | diesel::delete(filter).execute(conn)?; |
|
70 | + | ||
71 | + | Ok(request) |
|
72 | + | } |
|
73 | + | } |
|
74 | + | ||
75 | + | #[cfg(test)] |
|
76 | + | mod tests { |
|
77 | + | use super::*; |
|
78 | + | use diesel::Connection; |
|
79 | + | use tests::db; |
|
80 | + | use users::tests as user_tests; |
|
81 | + | ||
82 | + | #[test] |
|
83 | + | fn test_insert_and_find_password_reset_request() { |
|
84 | + | let conn = db(); |
|
85 | + | conn.test_transaction::<_, (), _>(|| { |
|
86 | + | user_tests::fill_database(&conn); |
|
87 | + | let admin_email = "admin@example.com"; |
|
88 | + | ||
89 | + | let token = PasswordResetRequest::insert(&conn, admin_email) |
|
90 | + | .expect("couldn't insert new request"); |
|
91 | + | let request = PasswordResetRequest::find_by_token(&conn, &token) |
|
92 | + | .expect("couldn't retrieve request"); |
|
93 | + | ||
94 | + | assert!(&token.len() > &32); |
|
95 | + | assert_eq!(&request.email, &admin_email); |
|
96 | + | ||
97 | + | Ok(()) |
|
98 | + | }); |
|
99 | + | } |
|
100 | + | ||
101 | + | #[test] |
|
102 | + | fn test_insert_delete_previous_password_reset_request() { |
|
103 | + | let conn = db(); |
|
104 | + | conn.test_transaction::<_, (), _>(|| { |
|
105 | + | user_tests::fill_database(&conn); |
|
106 | + | let admin_email = "admin@example.com"; |
|
107 | + | ||
108 | + | PasswordResetRequest::insert(&conn, &admin_email).expect("couldn't insert new request"); |
|
109 | + | PasswordResetRequest::insert(&conn, &admin_email) |
|
110 | + | .expect("couldn't insert second request"); |
|
111 | + | ||
112 | + | let count = password_reset_requests::table.count().get_result(&*conn); |
|
113 | + | assert_eq!(Ok(1), count); |
|
114 | + | ||
115 | + | Ok(()) |
|
116 | + | }); |
|
117 | + | } |
|
118 | + | ||
119 | + | #[test] |
|
120 | + | fn test_find_password_reset_request_by_token_time() { |
|
121 | + | let conn = db(); |
|
122 | + | conn.test_transaction::<_, (), _>(|| { |
|
123 | + | user_tests::fill_database(&conn); |
|
124 | + | let admin_email = "admin@example.com"; |
|
125 | + | let token = "abcdef"; |
|
126 | + | let now = Utc::now().naive_utc(); |
|
127 | + | ||
128 | + | diesel::insert_into(password_reset_requests::table) |
|
129 | + | .values(( |
|
130 | + | password_reset_requests::email.eq(&admin_email), |
|
131 | + | password_reset_requests::token.eq(&token), |
|
132 | + | password_reset_requests::expiration_date.eq(now), |
|
133 | + | )) |
|
134 | + | .execute(&*conn) |
|
135 | + | .expect("could not insert request"); |
|
136 | + | ||
137 | + | match PasswordResetRequest::find_by_token(&conn, &token) { |
|
138 | + | Err(Error::Expired) => (), |
|
139 | + | _ => panic!("Received unexpected result finding expired token"), |
|
140 | + | } |
|
141 | + | ||
142 | + | Ok(()) |
|
143 | + | }); |
|
144 | + | } |
|
145 | + | ||
146 | + | #[test] |
|
147 | + | fn test_find_and_delete_password_reset_request() { |
|
148 | + | let conn = db(); |
|
149 | + | conn.test_transaction::<_, (), _>(|| { |
|
150 | + | user_tests::fill_database(&conn); |
|
151 | + | let admin_email = "admin@example.com"; |
|
152 | + | ||
153 | + | let token = PasswordResetRequest::insert(&conn, &admin_email) |
|
154 | + | .expect("couldn't insert new request"); |
|
155 | + | PasswordResetRequest::find_and_delete_by_token(&conn, &token) |
|
156 | + | .expect("couldn't find and delete request"); |
|
157 | + | ||
158 | + | let count = password_reset_requests::table.count().get_result(&*conn); |
|
159 | + | assert_eq!(Ok(0), count); |
|
160 | + | ||
161 | + | Ok(()) |
|
162 | + | }); |
|
163 | + | } |
|
164 | + | } |
65 | 65 | Unauthorized, |
|
66 | 66 | Url, |
|
67 | 67 | Webfinger, |
|
68 | + | Expired, |
|
68 | 69 | } |
|
69 | 70 | ||
70 | 71 | impl From<bcrypt::BcryptError> for Error { |
367 | 368 | pub mod mentions; |
|
368 | 369 | pub mod migrations; |
|
369 | 370 | pub mod notifications; |
|
371 | + | pub mod password_reset_requests; |
|
370 | 372 | pub mod plume_rocket; |
|
371 | 373 | pub mod post_authors; |
|
372 | 374 | pub mod posts; |
Files | Coverage |
---|---|
plume-api/src | 0.00% |
plume-cli/src | 52.63% |
plume-common/src | 45.99% |
plume-models/src | 49.35% |
src | 4.16% |
plume-macro/src/lib.rs | 87.50% |
Project Totals (68 files) | 35.31% |
1 |
codecov: |
2 |
notify: |
3 |
require_ci_to_pass: yes |
4 |
|
5 |
coverage: |
6 |
precision: 2 |
7 |
round: down |
8 |
range: "70...100" |
9 |
|
10 |
status: |
11 |
project: no |
12 |
patch: no |
13 |
changes: no |
14 |
|
15 |
parsers: |
16 |
gcov: |
17 |
branch_detection: |
18 |
conditional: yes |
19 |
loop: yes |
20 |
method: no |
21 |
macro: no |
22 |
|
23 |
comment: |
24 |
layout: "header, diff" |
25 |
behavior: default |
26 |
require_changes: no |