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

38
Cargo.lock generated
View file

@ -1297,9 +1297,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.161"
version = "0.2.162"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1"
checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398"
[[package]]
name = "libm"
@ -1712,7 +1712,7 @@ dependencies = [
"ron",
"serde",
"sqlx",
"thiserror",
"thiserror 2.0.1",
"tokio",
"tower",
"tower-http",
@ -2121,7 +2121,7 @@ dependencies = [
"sha2",
"smallvec",
"sqlformat",
"thiserror",
"thiserror 1.0.68",
"tokio",
"tokio-stream",
"tracing",
@ -2205,7 +2205,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
"thiserror",
"thiserror 1.0.68",
"tracing",
"whoami",
]
@ -2244,7 +2244,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
"thiserror",
"thiserror 1.0.68",
"tracing",
"whoami",
]
@ -2368,7 +2368,16 @@ version = "1.0.68"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892"
dependencies = [
"thiserror-impl",
"thiserror-impl 1.0.68",
]
[[package]]
name = "thiserror"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07c1e40dd48a282ae8edc36c732cbc219144b87fb6a4c7316d611c6b1f06ec0c"
dependencies = [
"thiserror-impl 2.0.1",
]
[[package]]
@ -2382,6 +2391,17 @@ dependencies = [
"syn",
]
[[package]]
name = "thiserror-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874aa7e446f1da8d9c3a5c95b1c5eb41d800045252121dc7f8e0ba370cee55f5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.8"
@ -2450,9 +2470,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.41.0"
version = "1.41.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb"
checksum = "22cfb5bee7a6a52939ca9224d6ac897bb669134078daa8735560897f69de4d33"
dependencies = [
"backtrace",
"bytes",

View file

@ -49,4 +49,4 @@ lettre = { version = "0.11", default-features = false, features = [
] }
derive_more = { version = "1", features = ["full"] }
thiserror = "1"
thiserror = "2"

2
backend/askama.toml Normal file
View file

@ -0,0 +1,2 @@
[general]
whitespace = "suppress"

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,13 +451,17 @@ WHERE [email] = $1
"#,
)
.bind(email)
.fetch_one(&mut *tx)
.fetch_optional(&mut *tx)
.await?
{
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
)
}

View file

@ -2,7 +2,7 @@
{% block main_container %}
<div class="content">
<form action="/signup" method="post">
<form action="/ask_reset_password" method="post">
<label for="email_field">Your email address</label>
<input id="email_field" type="text" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
{{ message_email }}

View file

@ -10,9 +10,7 @@
<span><a href="/edit_profile">{{ user.email }}</a> / <a href="/signout" />Sign out</a></span>
{% when None %}
<span>
<a href="/signin" >Sign in</a>/
<a href="/signup">Sign up</a>/
<a href="/lost_password">Lost password</a>
<a href="/signin" >Sign in</a>/<a href="/signup">Sign up</a>/<a href="/ask_reset_password">Lost password</a>
</span>
{% endmatch %}

View file

@ -0,0 +1,23 @@
{% extends "base_with_list.html" %}
{% block content %}
<label for="title_field">Title</label>
<input
id="title_field"
type="text"
name="title"
value="{{ current_recipe.title }}"
autocapitalize="none"
autocomplete="title"
autofocus="autofocus" />
<label for="description_field">Description</label>
<input
id="title_field"
type="text"
name="title"
value="{{ current_recipe.description }}"
autocapitalize="none"
autocomplete="title"
autofocus="autofocus" />
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "base_with_header.html" %}
{% block main_container %}
<div class="content">
<form action="/reset_password" method="post">
<label for="password_field_1">Choose a new password (minimum 8 characters)</label>
<input id="password_field_1" type="password" name="password_1" />
<label for="password_field_1">Re-enter password</label>
<input id="password_field_2" type="password" name="password_2" />
{{ message_password }}
<input type="hidden" name="reset_token" value="{{ reset_token }}" />
<input type="submit" name="commit" value="Reset password" />
</form>
{{ message }}
</div>
{% endblock %}