[creation_datetime] TEXT NOT NULL, -- Updated when the validation email is sent.
[validation_token] TEXT, -- If not null then the user has not validated his account yet.
+ [password_reset_token] TEXT, -- If not null then the user can reset its password.
+ -- The time when the reset token has been created.
+ -- Password can only be reset during a certain duration after this time.
+ [password_reset_datetime] TEXT,
+
[is_admin] INTEGER NOT NULL DEFAULT FALSE
) STRICT;
[recipe_id] INTEGER NOT NULL,
[tag_id] INTEGER NOT NULL,
+ UNIQUE([recipe_id], [tag_id]),
+
FOREIGN KEY([recipe_id]) REFERENCES [Recipe]([id]) ON DELETE CASCADE,
FOREIGN KEY([tag_id]) REFERENCES [Tag]([id]) ON DELETE CASCADE
) STRICT;
CREATE TABLE [Tag] (
[id] INTEGER PRIMARY KEY,
- [recipe_tag_id] INTEGER,
- [name] TEXT NOT NULL,
-
- FOREIGN KEY([recipe_tag_id]) REFERENCES [RecipeTag]([id]) ON DELETE SET NULL
+ [name] TEXT NOT NULL
) STRICT;
CREATE UNIQUE INDEX [Tag_name_index] ON [Tag] ([name]);
Ok(i64), // Returns user id.
}
+#[derive(Debug)]
+pub enum GetTokenResetPassword {
+ PasswordAlreadyReset,
+ Ok(String),
+}
+
#[derive(Clone)]
pub struct Connection {
pool: Pool<Sqlite>,
) -> 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",
)
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?;
}
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)]
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?;
}
.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",
_ => "",
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 {