Add a way to reset password

This commit is contained in:
Greg Burri 2024-11-09 11:22:53 +01:00
parent 5d343c273f
commit ed979719b5
12 changed files with 352 additions and 57 deletions

View file

@ -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?;

View file

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

View file

@ -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(),

View file

@ -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 /////

View file

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