Add a way to reset password
This commit is contained in:
parent
5d343c273f
commit
ed979719b5
12 changed files with 352 additions and 57 deletions
38
Cargo.lock
generated
38
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
2
backend/askama.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[general]
|
||||
whitespace = "suppress"
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
23
backend/templates/profile.html
Normal file
23
backend/templates/profile.html
Normal 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 %}
|
||||
20
backend/templates/reset_password.html
Normal file
20
backend/templates/reset_password.html
Normal 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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue