Add a way to reset password (WIP)
This commit is contained in:
parent
ebdcb6a90a
commit
5d343c273f
8 changed files with 265 additions and 12 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,6 +6,7 @@ style.css.map
|
|||
backend/static/frontend.js
|
||||
backend/static/style.css
|
||||
backend/file.db
|
||||
frontend/dist/
|
||||
*.sqlite
|
||||
conf.ron
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ CREATE TABLE [User] (
|
|||
[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;
|
||||
|
||||
|
|
@ -67,16 +72,15 @@ CREATE TABLE [RecipeTag] (
|
|||
[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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
14
backend/templates/ask_reset_password.html
Normal file
14
backend/templates/ask_reset_password.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{% extends "base_with_header.html" %}
|
||||
|
||||
{% block main_container %}
|
||||
<div class="content">
|
||||
<form action="/signup" 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 }}
|
||||
|
||||
<input type="submit" name="commit" value="Ask reset" />
|
||||
</form>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -7,9 +7,13 @@
|
|||
{% match user %}
|
||||
{% when Some with (user) %}
|
||||
<a class="create-recipe" href="/recipe/new" >Create a new recipe</a>
|
||||
<span>{{ user.email }} / <a href="/signout" />Sign out</a></span>
|
||||
<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></span>
|
||||
<span>
|
||||
<a href="/signin" >Sign in</a>/
|
||||
<a href="/signup">Sign up</a>/
|
||||
<a href="/lost_password">Lost password</a>
|
||||
</span>
|
||||
{% endmatch %}
|
||||
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue