Add a way to reset password
This commit is contained in:
parent
5d343c273f
commit
ed979719b5
12 changed files with 352 additions and 57 deletions
|
|
@ -29,8 +29,8 @@ pub enum DBError {
|
|||
Sqlx(#[from] sqlx::Error),
|
||||
|
||||
#[error(
|
||||
"Unsupported database version: {0} (code version: {})",
|
||||
CURRENT_DB_VERSION
|
||||
"Unsupported database version: {0} (application version: {current})",
|
||||
current = CURRENT_DB_VERSION
|
||||
)]
|
||||
UnsupportedVersion(u32),
|
||||
|
||||
|
|
@ -76,6 +76,7 @@ pub enum AuthenticationResult {
|
|||
#[derive(Debug)]
|
||||
pub enum GetTokenResetPassword {
|
||||
PasswordAlreadyReset,
|
||||
EmailUnknown,
|
||||
Ok(String),
|
||||
}
|
||||
|
||||
|
|
@ -442,7 +443,7 @@ WHERE [id] = $1
|
|||
) -> Result<GetTokenResetPassword> {
|
||||
let mut tx = self.tx().await?;
|
||||
|
||||
if let Some(db_datetime) = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(
|
||||
if let Some(db_datetime_nullable) = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(
|
||||
r#"
|
||||
SELECT [password_reset_datetime]
|
||||
FROM [User]
|
||||
|
|
@ -450,12 +451,16 @@ WHERE [email] = $1
|
|||
"#,
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_one(&mut *tx)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
{
|
||||
if Utc::now() - db_datetime <= validation_time {
|
||||
return Ok(GetTokenResetPassword::PasswordAlreadyReset);
|
||||
if let Some(db_datetime) = db_datetime_nullable {
|
||||
if Utc::now() - db_datetime <= validation_time {
|
||||
return Ok(GetTokenResetPassword::PasswordAlreadyReset);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Ok(GetTokenResetPassword::EmailUnknown);
|
||||
}
|
||||
|
||||
let token = generate_token();
|
||||
|
|
@ -967,6 +972,22 @@ VALUES (
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ask_to_reset_password_for_unknown_email() -> Result<()> {
|
||||
let connection = Connection::new_in_memory().await?;
|
||||
|
||||
let email = "paul@atreides.com";
|
||||
|
||||
// Ask for password reset.
|
||||
match connection
|
||||
.get_token_reset_password(email, Duration::hours(1))
|
||||
.await?
|
||||
{
|
||||
GetTokenResetPassword::EmailUnknown => Ok(()), // Nominal case.
|
||||
other => panic!("{:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sign_up_then_send_validation_then_sign_out_then_ask_to_reset_password() -> Result<()> {
|
||||
let connection = Connection::new_in_memory().await?;
|
||||
|
|
|
|||
|
|
@ -32,10 +32,9 @@ impl From<lettre::error::Error> for Error {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn send_validation(
|
||||
site_url: &str,
|
||||
pub async fn send_email(
|
||||
email: &str,
|
||||
token: &str,
|
||||
message: &str,
|
||||
smtp_relay_address: &str,
|
||||
smtp_login: &str,
|
||||
smtp_password: &str,
|
||||
|
|
@ -45,10 +44,7 @@ pub async fn send_validation(
|
|||
.from("recipes@gburri.org".parse()?)
|
||||
.to(email.parse()?)
|
||||
.subject("recipes.gburri.org account validation")
|
||||
.body(format!(
|
||||
"Follow this link to confirm your inscription: {}/validation?validation_token={}",
|
||||
site_url, token
|
||||
))?;
|
||||
.body(message.to_string())?;
|
||||
|
||||
let credentials = Credentials::new(smtp_login.to_string(), smtp_password.to_string());
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,10 @@ async fn main() {
|
|||
"/ask_reset_password",
|
||||
get(services::ask_reset_password_get).post(services::ask_reset_password_post),
|
||||
)
|
||||
.route("/reset_password", get(services::reset_password))
|
||||
.route(
|
||||
"/reset_password",
|
||||
get(services::reset_password_get).post(services::reset_password_post),
|
||||
)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use askama::Template;
|
|||
use axum::{
|
||||
body::Body,
|
||||
debug_handler,
|
||||
extract::{ConnectInfo, Extension, Host, Path, Query, Request, State},
|
||||
extract::{connect_info, ConnectInfo, Extension, Host, Path, Query, Request, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Redirect, Response, Result},
|
||||
Form,
|
||||
|
|
@ -12,8 +12,14 @@ use axum::{
|
|||
use axum_extra::extract::cookie::{Cookie, CookieJar};
|
||||
use chrono::Duration;
|
||||
use serde::Deserialize;
|
||||
use tracing::{event, Level};
|
||||
|
||||
use crate::{config::Config, consts, data::db, email, model, utils, AppState};
|
||||
use crate::{
|
||||
config::Config,
|
||||
consts::{self, VALIDATION_PASSWORD_RESET_TOKEN_DURATION},
|
||||
data::db,
|
||||
email, model, utils, AppState,
|
||||
};
|
||||
|
||||
pub mod webapi;
|
||||
|
||||
|
|
@ -284,32 +290,15 @@ pub async fn sign_up_post(
|
|||
error_response(SignUpError::UserAlreadyExists, &form_data, user)
|
||||
}
|
||||
Ok(db::SignUpResult::UserCreatedWaitingForValidation(token)) => {
|
||||
let url = {
|
||||
let port: Option<u16> = 'p: {
|
||||
let split_port: Vec<&str> = host.split(':').collect();
|
||||
if split_port.len() == 2 {
|
||||
if let Ok(p) = split_port[1].parse::<u16>() {
|
||||
break 'p Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
format!(
|
||||
"http{}://{}",
|
||||
if port.is_some() && port.unwrap() != 443 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
},
|
||||
host
|
||||
)
|
||||
};
|
||||
let url = utils::get_url_from_host(&host);
|
||||
|
||||
let email = form_data.email.clone();
|
||||
match email::send_validation(
|
||||
&url,
|
||||
match email::send_email(
|
||||
&email,
|
||||
&token,
|
||||
&format!(
|
||||
"Follow this link to confirm your inscription: {}/validation?validation_token={}",
|
||||
url, token
|
||||
),
|
||||
&config.smtp_relay_address,
|
||||
&config.smtp_login,
|
||||
&config.smtp_password,
|
||||
|
|
@ -523,16 +512,218 @@ pub async fn ask_reset_password_get(
|
|||
}
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct AskResetPasswordForm {
|
||||
email: String,
|
||||
}
|
||||
|
||||
enum AskResetPasswordError {
|
||||
InvalidEmail,
|
||||
EmailAlreadyReset,
|
||||
EmailUnknown,
|
||||
UnableSendEmail,
|
||||
DatabaseError,
|
||||
}
|
||||
|
||||
#[debug_handler(state = AppState)]
|
||||
pub async fn ask_reset_password_post(
|
||||
Host(host): Host,
|
||||
State(connection): State<db::Connection>,
|
||||
State(config): State<Config>,
|
||||
Extension(user): Extension<Option<model::User>>,
|
||||
Form(form_data): Form<AskResetPasswordForm>,
|
||||
) -> Result<Response> {
|
||||
Ok("todo".into_response())
|
||||
fn error_response(
|
||||
error: AskResetPasswordError,
|
||||
email: &str,
|
||||
user: Option<model::User>,
|
||||
) -> Result<Response> {
|
||||
Ok(AskResetPasswordTemplate {
|
||||
user,
|
||||
email: email.to_string(),
|
||||
message_email: match error {
|
||||
AskResetPasswordError::InvalidEmail => "Invalid email",
|
||||
_ => "",
|
||||
}
|
||||
.to_string(),
|
||||
message: match error {
|
||||
AskResetPasswordError::EmailAlreadyReset => {
|
||||
"The password has already been reset for this email"
|
||||
}
|
||||
AskResetPasswordError::EmailUnknown => "Email unknown",
|
||||
AskResetPasswordError::UnableSendEmail => "Unable to send the reset password email",
|
||||
AskResetPasswordError::DatabaseError => "Database error",
|
||||
_ => "",
|
||||
}
|
||||
.to_string(),
|
||||
}
|
||||
.into_response())
|
||||
}
|
||||
|
||||
// Validation of email.
|
||||
if let common::utils::EmailValidation::NotValid =
|
||||
common::utils::validate_email(&form_data.email)
|
||||
{
|
||||
return error_response(AskResetPasswordError::InvalidEmail, &form_data.email, user);
|
||||
}
|
||||
|
||||
match connection
|
||||
.get_token_reset_password(
|
||||
&form_data.email,
|
||||
Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(db::GetTokenResetPassword::PasswordAlreadyReset) => error_response(
|
||||
AskResetPasswordError::EmailAlreadyReset,
|
||||
&form_data.email,
|
||||
user,
|
||||
),
|
||||
Ok(db::GetTokenResetPassword::EmailUnknown) => {
|
||||
error_response(AskResetPasswordError::EmailUnknown, &form_data.email, user)
|
||||
}
|
||||
Ok(db::GetTokenResetPassword::Ok(token)) => {
|
||||
let url = utils::get_url_from_host(&host);
|
||||
match email::send_email(
|
||||
&form_data.email,
|
||||
&format!(
|
||||
"Follow this link to reset your password: {}/reset_password?reset_token={}",
|
||||
url, token
|
||||
),
|
||||
&config.smtp_relay_address,
|
||||
&config.smtp_login,
|
||||
&config.smtp_password,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(MessageTemplate {
|
||||
user,
|
||||
message: "An email has been sent, follow the link to reset your password.",
|
||||
}
|
||||
.into_response()),
|
||||
Err(_) => {
|
||||
// error!("Email validation error: {}", error); // TODO: log
|
||||
error_response(
|
||||
AskResetPasswordError::UnableSendEmail,
|
||||
&form_data.email,
|
||||
user,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
event!(Level::ERROR, "{}", error);
|
||||
error_response(AskResetPasswordError::DatabaseError, &form_data.email, user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "reset_password.html")]
|
||||
struct ResetPasswordTemplate {
|
||||
user: Option<model::User>,
|
||||
reset_token: String,
|
||||
password_1: String,
|
||||
password_2: String,
|
||||
message: String,
|
||||
message_password: String,
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn reset_password() -> Result<Response> {
|
||||
Ok("todo".into_response())
|
||||
pub async fn reset_password_get(
|
||||
Extension(user): Extension<Option<model::User>>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
) -> Result<Response> {
|
||||
if let Some(reset_token) = query.get("reset_token") {
|
||||
Ok(ResetPasswordTemplate {
|
||||
user,
|
||||
reset_token: reset_token.to_string(),
|
||||
password_1: String::new(),
|
||||
password_2: String::new(),
|
||||
message: String::new(),
|
||||
message_password: String::new(),
|
||||
}
|
||||
.into_response())
|
||||
} else {
|
||||
Ok(MessageTemplate {
|
||||
user,
|
||||
message: "Reset token missing",
|
||||
}
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ResetPasswordForm {
|
||||
password_1: String,
|
||||
password_2: String,
|
||||
reset_token: String,
|
||||
}
|
||||
|
||||
enum ResetPasswordError {
|
||||
PasswordsNotEqual,
|
||||
InvalidPassword,
|
||||
DatabaseError,
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn reset_password_post(
|
||||
State(connection): State<db::Connection>,
|
||||
Extension(user): Extension<Option<model::User>>,
|
||||
Form(form_data): Form<ResetPasswordForm>,
|
||||
) -> Result<Response> {
|
||||
fn error_response(
|
||||
error: ResetPasswordError,
|
||||
form_data: &ResetPasswordForm,
|
||||
user: Option<model::User>,
|
||||
) -> Result<Response> {
|
||||
Ok(ResetPasswordTemplate {
|
||||
user,
|
||||
reset_token: form_data.reset_token.clone(),
|
||||
password_1: String::new(),
|
||||
password_2: String::new(),
|
||||
message_password: match error {
|
||||
ResetPasswordError::PasswordsNotEqual => "Passwords don't match",
|
||||
ResetPasswordError::InvalidPassword => {
|
||||
"Password must have at least eight characters"
|
||||
}
|
||||
_ => "",
|
||||
}
|
||||
.to_string(),
|
||||
message: match error {
|
||||
ResetPasswordError::DatabaseError => "Database error",
|
||||
_ => "",
|
||||
}
|
||||
.to_string(),
|
||||
}
|
||||
.into_response())
|
||||
}
|
||||
|
||||
if form_data.password_1 != form_data.password_2 {
|
||||
return error_response(ResetPasswordError::PasswordsNotEqual, &form_data, user);
|
||||
}
|
||||
|
||||
if let common::utils::PasswordValidation::TooShort =
|
||||
common::utils::validate_password(&form_data.password_1)
|
||||
{
|
||||
return error_response(ResetPasswordError::InvalidPassword, &form_data, user);
|
||||
}
|
||||
|
||||
match connection
|
||||
.reset_password(
|
||||
&form_data.password_1,
|
||||
&form_data.reset_token,
|
||||
Duration::seconds(consts::VALIDATION_PASSWORD_RESET_TOKEN_DURATION),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(MessageTemplate {
|
||||
user,
|
||||
message: "Your password has been reset",
|
||||
}
|
||||
.into_response()),
|
||||
Err(_) => error_response(ResetPasswordError::DatabaseError, &form_data, user),
|
||||
}
|
||||
}
|
||||
|
||||
///// 404 /////
|
||||
|
|
|
|||
|
|
@ -18,3 +18,24 @@ pub fn get_ip_and_user_agent(headers: &HeaderMap, remote_address: SocketAddr) ->
|
|||
|
||||
(ip, user_agent)
|
||||
}
|
||||
|
||||
pub fn get_url_from_host(host: &str) -> String {
|
||||
let port: Option<u16> = 'p: {
|
||||
let split_port: Vec<&str> = host.split(':').collect();
|
||||
if split_port.len() == 2 {
|
||||
if let Ok(p) = split_port[1].parse::<u16>() {
|
||||
break 'p Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
format!(
|
||||
"http{}://{}",
|
||||
if port.is_some() && port.unwrap() != 443 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
},
|
||||
host
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue