Add a way to reset password (WIP)

This commit is contained in:
Greg Burri 2024-11-06 17:52:16 +01:00
parent ebdcb6a90a
commit 5d343c273f
8 changed files with 265 additions and 12 deletions

View file

@ -4,11 +4,14 @@ pub const FILE_CONF: &str = "conf.ron";
pub const DB_DIRECTORY: &str = "data";
pub const DB_FILENAME: &str = "recipes.sqlite";
pub const SQL_FILENAME: &str = "sql/version_{VERSION}.sql";
pub const VALIDATION_TOKEN_DURATION: i64 = 1 * 60 * 60; // 1 hour. [s].
pub const VALIDATION_TOKEN_DURATION: i64 = 1 * 60 * 60; // [s]. (1 jour).
pub const COOKIE_AUTH_TOKEN_NAME: &str = "auth_token";
// Number of alphanumeric characters for cookie authentication token.
pub const AUTHENTICATION_TOKEN_SIZE: usize = 32;
pub const VALIDATION_PASSWORD_RESET_TOKEN_DURATION: i64 = 1 * 60 * 60; // [s]. (1 jour).
// Number of alphanumeric characters for tokens
// (cookie authentication, password reset, validation token).
pub const TOKEN_SIZE: usize = 32;
pub const SEND_EMAIL_TIMEOUT: Duration = Duration::from_secs(60);

View file

@ -73,6 +73,12 @@ pub enum AuthenticationResult {
Ok(i64), // Returns user id.
}
#[derive(Debug)]
pub enum GetTokenResetPassword {
PasswordAlreadyReset,
Ok(String),
}
#[derive(Clone)]
pub struct Connection {
pool: Pool<Sqlite>,
@ -316,6 +322,7 @@ VALUES ($1, $2, $3, $4)
) -> Result<ValidationResult> {
let mut tx = self.tx().await?;
// There is no index on [validation_token]. Is it useful?
let user_id = match sqlx::query_as::<_, (i64, DateTime<Utc>)>(
"SELECT [id], [creation_datetime] FROM [User] WHERE [validation_token] = $1",
)
@ -428,6 +435,104 @@ WHERE [id] = $1
Ok(())
}
pub async fn get_token_reset_password(
&self,
email: &str,
validation_time: Duration,
) -> Result<GetTokenResetPassword> {
let mut tx = self.tx().await?;
if let Some(db_datetime) = sqlx::query_scalar::<_, Option<DateTime<Utc>>>(
r#"
SELECT [password_reset_datetime]
FROM [User]
WHERE [email] = $1
"#,
)
.bind(email)
.fetch_one(&mut *tx)
.await?
{
if Utc::now() - db_datetime <= validation_time {
return Ok(GetTokenResetPassword::PasswordAlreadyReset);
}
}
let token = generate_token();
sqlx::query(
r#"
UPDATE [User]
SET [password_reset_token] = $2, [password_reset_datetime] = $3
WHERE [email] = $1
"#,
)
.bind(email)
.bind(&token)
.bind(Utc::now())
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(GetTokenResetPassword::Ok(token))
}
pub async fn reset_password(
&self,
new_password: &str,
token: &str,
validation_time: Duration,
) -> Result<()> {
let mut tx = self.tx().await?;
// There is no index on [password_reset_token]. Is it useful?
if let (user_id, Some(db_datetime)) = sqlx::query_as::<_, (i64, Option<DateTime<Utc>>)>(
r#"
SELECT [id], [password_reset_datetime]
FROM [User]
WHERE [password_reset_token] = $1
"#,
)
.bind(token)
.fetch_one(&mut *tx)
.await?
{
if Utc::now() - db_datetime > validation_time {
return Err(DBError::Other(
"Can't reset password: validation time exceeded".to_string(),
));
}
// Remove all login tokens (for security reasons).
sqlx::query("DELETE FROM [UserLoginToken] WHERE [user_id] = $1")
.bind(user_id)
.execute(&mut *tx)
.await?;
let hashed_new_password = hash(new_password).map_err(|e| DBError::from_dyn_error(e))?;
sqlx::query(
r#"
UPDATE [User]
SET [password] = $2, [password_reset_token] = NULL, [password_reset_datetime] = NULL
WHERE [id] = $1
"#,
)
.bind(user_id)
.bind(hashed_new_password)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
} else {
Err(DBError::Other(
"Can't reset password: stored token or datetime not set (NULL)".to_string(),
))
}
}
pub async fn create_recipe(&self, user_id: i64) -> Result<i64> {
let mut tx = self.tx().await?;
@ -549,7 +654,7 @@ fn load_sql_file<P: AsRef<Path> + fmt::Display>(sql_file: P) -> Result<String> {
}
fn generate_token() -> String {
Alphanumeric.sample_string(&mut rand::thread_rng(), consts::AUTHENTICATION_TOKEN_SIZE)
Alphanumeric.sample_string(&mut rand::thread_rng(), consts::TOKEN_SIZE)
}
#[cfg(test)]
@ -862,6 +967,80 @@ VALUES (
Ok(())
}
#[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?;
let email = "paul@atreides.com";
let password = "12345";
let new_password = "54321";
// Sign up.
let validation_token = match connection.sign_up(email, password).await? {
SignUpResult::UserCreatedWaitingForValidation(token) => token, // Nominal case.
other => panic!("{:?}", other),
};
// Validation.
let (authentication_token_1, user_id_1) = match connection
.validation(
&validation_token,
Duration::hours(1),
"127.0.0.1",
"Mozilla",
)
.await?
{
ValidationResult::Ok(token, user_id) => (token, user_id),
other => panic!("{:?}", other),
};
// Check user login information.
let user_login_info_1 = connection
.get_user_login_info(&authentication_token_1)
.await?;
assert_eq!(user_login_info_1.ip, "127.0.0.1");
assert_eq!(user_login_info_1.user_agent, "Mozilla");
// Sign out.
connection.sign_out(&authentication_token_1).await?;
// Ask for password reset.
let token = match connection
.get_token_reset_password(email, Duration::hours(1))
.await?
{
GetTokenResetPassword::Ok(token) => token,
other => panic!("{:?}", other),
};
connection
.reset_password(&new_password, &token, Duration::hours(1))
.await?;
// Sign in.
let (authentication_token_2, user_id_2) = match connection
.sign_in(email, new_password, "192.168.1.1", "Chrome")
.await?
{
SignInResult::Ok(token, user_id) => (token, user_id),
other => panic!("{:?}", other),
};
assert_eq!(user_id_1, user_id_2);
assert_ne!(authentication_token_1, authentication_token_2);
// Check user login information.
let user_login_info_2 = connection
.get_user_login_info(&authentication_token_2)
.await?;
assert_eq!(user_login_info_2.ip, "192.168.1.1");
assert_eq!(user_login_info_2.user_agent, "Chrome");
Ok(())
}
#[tokio::test]
async fn create_a_new_recipe_then_update_its_title() -> Result<()> {
let connection = Connection::new_in_memory().await?;

View file

@ -87,6 +87,11 @@ async fn main() {
get(services::sign_in_get).post(services::sign_in_post),
)
.route("/signout", get(services::sign_out))
.route(
"/ask_reset_password",
get(services::ask_reset_password_get).post(services::ask_reset_password_post),
)
.route("/reset_password", get(services::reset_password))
.layer(TraceLayer::new_for_http())
.route_layer(middleware::from_fn_with_state(
state.clone(),

View file

@ -249,7 +249,7 @@ pub async fn sign_up_post(
}
.to_string(),
message: match error {
SignUpError::UserAlreadyExists => "This email is already taken",
SignUpError::UserAlreadyExists => "This email is not available",
SignUpError::DatabaseError => "Database error",
SignUpError::UnableSendEmail => "Unable to send the validation email",
_ => "",
@ -491,8 +491,51 @@ pub async fn sign_out(
Ok((jar, Redirect::to("/")))
}
///// 404 /////
///// RESET PASSWORD /////
#[derive(Template)]
#[template(path = "ask_reset_password.html")]
struct AskResetPasswordTemplate {
user: Option<model::User>,
email: String,
message: String,
message_email: String,
}
#[debug_handler]
pub async fn ask_reset_password_get(
Extension(user): Extension<Option<model::User>>,
) -> Result<Response> {
if user.is_some() {
Ok(MessageTemplate {
user,
message: "Can't ask to reset password when already logged in",
}
.into_response())
} else {
Ok(AskResetPasswordTemplate {
user,
email: String::new(),
message: String::new(),
message_email: String::new(),
}
.into_response())
}
}
#[debug_handler]
pub async fn ask_reset_password_post(
Extension(user): Extension<Option<model::User>>,
) -> Result<Response> {
Ok("todo".into_response())
}
#[debug_handler]
pub async fn reset_password() -> Result<Response> {
Ok("todo".into_response())
}
///// 404 /////
#[debug_handler]
pub async fn not_found() -> Result<impl IntoResponse> {
Ok(MessageWithoutUser {