forgot password: email and paper key, reset password

This commit is contained in:
Philippe Loctaux 2023-04-19 18:03:38 +02:00
parent 6ddbe013bc
commit 751a21485f
21 changed files with 688 additions and 4 deletions

1
Cargo.lock generated
View file

@ -3279,6 +3279,7 @@ dependencies = [
"chrono",
"database",
"email_address",
"gen_passphrase",
"hash",
"id",
"serde",

View file

@ -14,6 +14,7 @@ serde_json = "1"
nanoid = "0.4"
nanoid-dictionary = "0.4"
email_address = { version = "0.2", default-features = false }
gen_passphrase = "0.1.1"
[profile.dev.package.sqlx-macros]
opt-level = 3

View file

@ -0,0 +1,15 @@
select id,
created_at as "created_at: DateTime<Utc>",
updated_at as "updated_at: DateTime<Utc>",
is_admin as "is_admin: bool",
username,
name,
email,
password,
password_recover,
paper_key,
is_archived as "is_archived: bool",
timezone
from users
where password_recover is (?)

View file

@ -0,0 +1,5 @@
update users
set password_recover = ?
where id is ?

View file

@ -110,6 +110,16 @@
},
"query": "update apps\n\nset is_archived = 1\n\nwhere id is ?"
},
"32d35bdd1f4cf64ce0ff7beb7a11591e0f35eab7211692bcde8230c68e4cedf3": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Right": 2
}
},
"query": "update users\n\nset password_recover = ?\n\nwhere id is ?"
},
"35de1a35e6cf6c683a1b2ca3605791aea9cbb852ac1d3df151cc21c341046361": {
"describe": {
"columns": [
@ -284,6 +294,90 @@
},
"query": "update users\n\nset username = ?\n\nwhere id is ?"
},
"56a88e7e68cfa94a055008510e3bc4389d7a7f64b43479d5fc8e4495ade0f84a": {
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "updated_at: DateTime<Utc>",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "is_admin: bool",
"ordinal": 3,
"type_info": "Int64"
},
{
"name": "username",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "password",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "password_recover",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "paper_key",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "is_archived: bool",
"ordinal": 10,
"type_info": "Int64"
},
{
"name": "timezone",
"ordinal": 11,
"type_info": "Text"
}
],
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
false,
false
],
"parameters": {
"Right": 1
}
},
"query": "select id,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n is_admin as \"is_admin: bool\",\n username,\n name,\n email,\n password,\n password_recover,\n paper_key,\n is_archived as \"is_archived: bool\",\n timezone\nfrom users\n\nwhere password_recover is (?)\n"
},
"56a9c0dff010858189a95087d014c7d0ce930da5d841b9d788a9c0e84b580bc6": {
"describe": {
"columns": [

View file

@ -97,6 +97,20 @@ impl Users {
.map_err(handle_error)
}
pub async fn get_one_from_password_reset_token(
conn: impl SqliteExecutor<'_>,
token: &str,
) -> Result<Option<Self>, Error> {
sqlx::query_file_as!(
Self,
"queries/users/get_one_from_password_reset_token.sql",
token
)
.fetch_optional(conn)
.await
.map_err(handle_error)
}
pub async fn get_all(conn: impl SqliteExecutor<'_>) -> Result<Vec<Self>, Error> {
sqlx::query_file_as!(Self, "queries/users/get_all.sql")
.fetch_all(conn)
@ -185,4 +199,18 @@ impl Users {
Ok((query.rows_affected() == 1).then_some(()))
}
pub async fn set_password_reset_token(
conn: impl SqliteExecutor<'_>,
id: &str,
token: Option<&str>,
) -> Result<Option<()>, Error> {
let query: SqliteQueryResult =
sqlx::query_file!("queries/users/set_password_reset_token.sql", token, id)
.execute(conn)
.await
.map_err(handle_error)?;
Ok((query.rows_affected() == 1).then_some(()))
}
}

View file

@ -4,6 +4,7 @@ mod completed_setup;
mod jwt;
mod need_setup;
mod refresh_token;
mod reset_password_token;
pub use self::jwt::*;
pub use access_token::AccessToken;
@ -11,3 +12,4 @@ pub use basic_auth::BasicAuth;
pub use completed_setup::CompletedSetup;
pub use need_setup::NeedSetup;
pub use refresh_token::RefreshToken;
pub use reset_password_token::RocketResetPasswordToken;

View file

@ -0,0 +1,36 @@
use rocket::form;
use rocket::form::{FromFormField, ValueField};
use rocket::http::impl_from_uri_param_identity;
use rocket::http::uri::fmt::{Formatter, Query, UriDisplay};
use rocket::request::FromParam;
use rocket::serde::{Deserialize, Serialize};
use std::fmt;
use users::password_reset::{Error, PasswordResetToken};
#[derive(Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct RocketResetPasswordToken(pub PasswordResetToken);
impl<'r> FromParam<'r> for RocketResetPasswordToken {
type Error = Error;
fn from_param(param: &'r str) -> Result<Self, Self::Error> {
PasswordResetToken::parse(param).map(Self)
}
}
impl UriDisplay<Query> for RocketResetPasswordToken {
fn fmt(&self, f: &mut Formatter<Query>) -> fmt::Result {
UriDisplay::fmt(&self.0.to_string(), f)
}
}
impl_from_uri_param_identity!([Query] RocketResetPasswordToken);
#[rocket::async_trait]
impl<'r> FromFormField<'r> for RocketResetPasswordToken {
fn from_value(field: ValueField<'r>) -> form::Result<'r, Self> {
Ok(PasswordResetToken::parse(field.value)
.map(Self)
.map_err(|e| form::Error::validation(e.to_string()))?)
}
}

View file

@ -40,8 +40,11 @@ impl Icon {
"id-badge-2", IdBadge2, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-id-badge-2" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M7 12h3v4h-3z"></path><path d="M10 6h-6a1 1 0 0 0 -1 1v12a1 1 0 0 0 1 1h16a1 1 0 0 0 1 -1v-12a1 1 0 0 0 -1 -1h-6"></path><path d="M10 3m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z"></path><path d="M14 16h2"></path><path d="M14 12h4"></path></svg>"#,
"user", User, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-user" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0"></path><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"></path></svg>"#,
"at", At, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-at" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"></path><path d="M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28"></path></svg>"#,
"paperclip", Paperclip, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-paperclip" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"></path></svg>"#,
"paperclip-large", PaperclipLarge, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-lg icon-tabler icon-tabler-paperclip" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"></path></svg>"#,
"users", Users, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-users" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"></path><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path><path d="M21 21v-2a4 4 0 0 0 -3 -3.85"></path></svg>"#
"users", Users, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-users" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"></path><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path><path d="M21 21v-2a4 4 0 0 0 -3 -3.85"></path></svg>"#,
"mail", Mail, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-mail" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10z"></path><path d="M3 7l9 6l9 -6"></path></svg>"#,
"password", Password, r#"<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-password" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M12 10v4"></path><path d="M10 13l4 -2"></path><path d="M10 11l4 2"></path><path d="M5 10v4"></path><path d="M3 13l4 -2"></path><path d="M3 11l4 2"></path><path d="M19 10v4"></path><path d="M17 13l4 -2"></path><path d="M17 11l4 2"></path></svg>"#
}
}
@ -62,8 +65,11 @@ pub fn icons_to_templates(tera: &mut Tera) {
Icon::IdBadge2,
Icon::User,
Icon::At,
Icon::Paperclip,
Icon::PaperclipLarge,
Icon::Users,
Icon::Mail,
Icon::Password,
];
// For each icon, it will output: ("icons/name", "<svg>...</svg>")

View file

@ -25,6 +25,8 @@ pub enum Page {
UserSecuritySettings(UserSecuritySettings),
UserVisualSettings(UserVisualSettings),
AdminUsersList(AdminUsersList),
ForgotPassword,
ResetPassword(ResetPassword),
}
impl Page {
@ -46,6 +48,8 @@ impl Page {
Page::UserSecuritySettings(_) => "pages/settings/security",
Page::UserVisualSettings(_) => "pages/settings/visual",
Page::AdminUsersList(_) => "pages/admin/users/list",
Page::ForgotPassword => "pages/forgot-password",
Page::ResetPassword(_) => "pages/reset-password",
}
}
@ -67,6 +71,8 @@ impl Page {
Page::UserSecuritySettings(_) => "Security settings",
Page::UserVisualSettings(_) => "Visual settings",
Page::AdminUsersList(_) => "Users",
Page::ForgotPassword => "Forgot password",
Page::ResetPassword(_) => "Reset password",
}
}
@ -90,6 +96,8 @@ impl Page {
Page::UserSecuritySettings(_) => Some(UserMenu::Settings.into()),
Page::UserVisualSettings(_) => Some(UserMenu::Settings.into()),
Page::AdminUsersList(_) => Some(AdminMenu::Users.into()),
Page::ForgotPassword => None,
Page::ResetPassword(_) => None,
}
}
@ -111,6 +119,8 @@ impl Page {
Page::UserSecuritySettings(security) => Box::new(security),
Page::UserVisualSettings(visual) => Box::new(visual),
Page::AdminUsersList(list) => Box::new(list),
Page::ForgotPassword => Box::new(()),
Page::ResetPassword(reset) => Box::new(reset),
}
}
}

View file

@ -1,11 +1,15 @@
use super::prelude::*;
use forgot_password::*;
use home::*;
use logo::*;
use logout::*;
use reset_password::*;
pub mod forgot_password;
pub mod home;
pub mod logo;
pub mod logout;
pub mod reset_password;
pub fn routes() -> Vec<Route> {
routes![
@ -15,6 +19,11 @@ pub fn routes() -> Vec<Route> {
homepage_redirect,
redirect_to_setup,
request_logout,
forgot_password_page,
forgot_password_email_form,
forgot_password_paper_key_form,
reset_password_page,
reset_password_form,
]
}
@ -28,4 +37,11 @@ pub mod content {
pub struct Homepage {
pub user: JwtClaims,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
#[derive(Clone)]
pub struct ResetPassword {
pub username: String,
}
}

View file

@ -0,0 +1,150 @@
use crate::routes::prelude::*;
use email_address::EmailAddress;
use hash::PaperKey;
use rocket::{get, post};
use std::str::FromStr;
use users::{password_reset::PasswordResetToken, User};
#[get("/forgot-password")]
pub async fn forgot_password_page(flash: Option<FlashMessage<'_>>) -> Template {
let page = Page::ForgotPassword;
flash
.map(|flash| Page::with_flash(page.clone(), flash))
.unwrap_or_else(|| page.into())
}
#[derive(Debug, FromForm)]
pub struct ForgotPasswordEmailForm<'r> {
pub email: &'r str,
}
const SUCCESS_MESSAGE: &str = "An email is on the way with instructions to reset your password.";
#[post("/forgot-password/email", data = "<form>")]
pub async fn forgot_password_email_form(
mut db: Connection<Database>,
form: Form<ForgotPasswordEmailForm<'_>>,
) -> Result<Flash<Redirect>> {
if form.email.is_empty() {
return Ok(Flash::new(
Redirect::to(uri!(forgot_password_page)),
FlashKind::Danger,
"Please enter an email address",
));
}
// Parse email address
let email = match EmailAddress::from_str(form.email) {
Ok(email) => email,
Err(e) => {
return Ok(Flash::new(
Redirect::to(uri!(forgot_password_page)),
FlashKind::Danger,
e.to_string(),
));
}
};
let mut transaction = db.begin().await?;
// Get user
let user = match User::get_by_email(&mut transaction, &email).await? {
Some(user) => user,
None => {
return Ok(Flash::new(
Redirect::to(uri!(forgot_password_page)),
FlashKind::Success,
SUCCESS_MESSAGE,
));
}
};
// Generate reset token
let token = task::spawn_blocking(|| PasswordResetToken::generate(15)).await?;
// Save in database
user.set_password_reset_token(&mut transaction, Some(&token))
.await?;
transaction.commit().await?;
// TODO: send email here
Ok(Flash::new(
Redirect::to(uri!(forgot_password_page)),
FlashKind::Success,
SUCCESS_MESSAGE,
))
}
#[derive(Debug, FromForm)]
pub struct ForgotPasswordPaperKeyForm<'r> {
pub login: &'r str,
pub paper_key: &'r str,
}
#[post("/forgot-password/paper-key", data = "<form>")]
pub async fn forgot_password_paper_key_form(
mut db: Connection<Database>,
form: Form<ForgotPasswordPaperKeyForm<'_>>,
) -> Result<Flash<Redirect>> {
if form.login.is_empty() || form.paper_key.is_empty() {
return Ok(Flash::new(
Redirect::to(uri!(forgot_password_page)),
FlashKind::Danger,
"Please fill out the form",
));
}
let mut transaction = db.begin().await?;
// Get user
let user = match User::get_by_login(&mut transaction, form.login).await? {
Some(user) => user,
None => {
return Ok(Flash::new(
Redirect::to(uri!(forgot_password_page)),
FlashKind::Danger,
"User not found",
));
}
};
let paper_key = match user.paper_key_hashed() {
Some(paper_key) => PaperKey::from_hash(paper_key),
None => {
return Ok(Flash::new(
Redirect::to(uri!(forgot_password_page)),
FlashKind::Danger,
"Paper key not found",
));
}
};
// Verify paper key
let paper_key_input = form.paper_key.to_string();
if !task::spawn_blocking(move || paper_key.compare(&paper_key_input)).await?? {
return Ok(Flash::new(
Redirect::to(uri!(forgot_password_page)),
FlashKind::Danger,
"Invalid paper key",
));
}
// Generate reset token
let token = task::spawn_blocking(|| PasswordResetToken::generate(15)).await?;
// Save in database
user.set_password_reset_token(&mut transaction, Some(&token))
.await?;
transaction.commit().await?;
let token = RocketResetPasswordToken(token);
Ok(Flash::new(
Redirect::to(uri!(super::reset_password_page(token))),
FlashKind::Success,
SUCCESS_MESSAGE,
))
}

View file

@ -0,0 +1,81 @@
use crate::routes::prelude::*;
use rocket::{get, post};
use users::User;
#[get("/reset-password?<token>")]
pub async fn reset_password_page(
token: RocketResetPasswordToken,
mut db: Connection<Database>,
flash: Option<FlashMessage<'_>>,
) -> Result<Template> {
if token.0.has_expired() {
return Err(Error::bad_request("Reset password token has expired"));
}
let user = User::get_one_from_password_reset_token(&mut **db, &token.0)
.await?
.ok_or_else(|| Error::not_found("Failed to find user from token"))?;
let page = Page::ResetPassword(super::content::ResetPassword {
username: user.username().into(),
});
Ok(flash
.map(|flash| Page::with_flash(page.clone(), flash))
.unwrap_or_else(|| page.into()))
}
#[derive(Debug, FromForm)]
pub struct ResetPasswordForm<'r> {
pub password: &'r str,
pub confirm_password: &'r str,
}
#[post("/reset-password?<token>", data = "<form>")]
pub async fn reset_password_form(
token: RocketResetPasswordToken,
mut db: Connection<Database>,
form: Form<ResetPasswordForm<'_>>,
) -> Result<Flash<Redirect>> {
if form.password != form.confirm_password {
return Ok(Flash::new(
Redirect::to(uri!(reset_password_page(token))),
FlashKind::Danger,
"Password do not match",
));
}
if form.password.is_empty() {
return Ok(Flash::new(
Redirect::to(uri!(reset_password_page(token))),
FlashKind::Danger,
"Empty password",
));
}
// Hash password
let password = form.password.to_string();
let password = task::spawn_blocking(move || Password::new(&password)).await??;
let mut transaction = db.begin().await?;
// Get user info
let user = User::get_one_from_password_reset_token(&mut transaction, &token.0)
.await?
.ok_or_else(|| Error::not_found("Could not find user"))?;
// Set password
user.set_password(&mut transaction, Some(&password)).await?;
// Consume password reset token
user.set_password_reset_token(&mut transaction, None)
.await?;
transaction.commit().await?;
Ok(Flash::new(
Redirect::to(uri!(crate::routes::oauth::authorize::authorize_ezidam)),
FlashKind::Success,
"Your new password has been saved.",
))
}

View file

@ -0,0 +1,96 @@
{% extends "base" %}
{% block page %}
<body class=" d-flex flex-column">
<script src="/js/demo-theme.min.js"></script>
<div>
<div class="min-vh-100 d-flex flex-column justify-content-between">
<div class="container container-tight py-4">
<div class="text-center mb-4">
{% include "utils/logo" %}
</div>
{% if flash %}
<div class="alert alert-{{flash.0}}" role="alert">
<h4 class="alert-title">{{ flash.1 }}</h4>
</div>
{% endif %}
<div class="card">
<h2 class="card-title text-center my-4 h2">Reset your password</h2>
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs nav-fill" data-bs-toggle="tabs">
<li class="nav-item">
<a href="#tabs-email" class="nav-link active" data-bs-toggle="tab">Email</a>
</li>
<li class="nav-item">
<a href="#tabs-paper-key" class="nav-link" data-bs-toggle="tab">Paper key</a>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<div class="tab-pane active show" id="tabs-email">
<p class="mt-2 mb-4">
Enter your email address linked to your account. We will email you a link to reset your
password.
</p>
<form class="mb-2" action="/forgot-password/email" method="post" autocomplete="off"
novalidate>
<div class="mb-3">
<label class="form-label required" for="email">Email address</label>
<input id="email" type="email" name="email" class="form-control"
placeholder="Enter email"
required>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
{% include "icons/mail" %}
Request password reset
</button>
</div>
</form>
</div>
<div class="tab-pane" id="tabs-paper-key">
<p class="mt-2 mb-4">
Enter your login linked to your account, with your paper key.
</p>
<form class="mb-2" action="/forgot-password/paper-key" method="post" autocomplete="off"
novalidate>
<div class="mb-3">
<label class="form-label required" for="login">Login</label>
<input id="login" type="text" name="login" class="form-control"
placeholder="Email or username"
required>
</div>
<div class="mb-3">
<label class="form-label required" for="paper_key">Paper key</label>
<input id="paper_key" type="text" name="paper_key" class="form-control"
placeholder="Enter your paper key"
required>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
{% include "icons/paperclip" %}
Request password reset
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% include "shell/footer" %}
</div>
</div>
<!-- Libs JS -->
<!-- Tabler Core -->
<script src="/js/tabler.min.js" defer></script>
<script src="/js/demo.min.js" defer></script>
</body>
{% endblock page %}

View file

@ -60,7 +60,7 @@
{% if user %}
{% else %}
<div class="text-center text-muted mt-3">
<a href="./sign-up.html" tabindex="-1">Reset password</a>
<a href="/forgot-password" tabindex="-1">Forgot your password?</a>
</div>
{% endif %}
</div>

View file

@ -0,0 +1,56 @@
{% extends "base" %}
{% block page %}
<body class=" d-flex flex-column">
<script src="/js/demo-theme.min.js"></script>
<div>
<div class="min-vh-100 d-flex flex-column justify-content-between">
<div class="container container-tight py-4">
<div class="text-center mb-4">
{% include "utils/logo" %}
</div>
{% if flash %}
<div class="alert alert-{{flash.0}}" role="alert">
<h4 class="alert-title">{{ flash.1 }}</h4>
</div>
{% endif %}
<div class="card">
<h2 class="card-title text-center my-4 h2">Reset your password</h2>
<div class="card-body">
<p class="mb-4 text-center">Resetting password for <code>{{ username }}</code></p>
<form class="mb-2" action="" method="post" autocomplete="off" novalidate>
<div class="mb-3">
<label class="form-label required" for="password">New password</label>
<input name="password" id="password" type="password" class="form-control" placeholder="Enter new password" required>
</div>
<div class="mb-3">
<label class="form-label required" for="confirm_password">Confirm new password</label>
<input name="confirm_password" id="confirm_password" type="password" class="form-control" placeholder="Confirm new password" required>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary w-100">
{% include "icons/password" %}
Set new password
</button>
</div>
</form>
</div>
</div>
</div>
{% include "shell/footer" %}
</div>
</div>
<!-- Libs JS -->
<!-- Tabler Core -->
<script src="/js/tabler.min.js" defer></script>
<script src="/js/demo.min.js" defer></script>
</body>
{% endblock page %}

View file

@ -9,4 +9,4 @@ rand_core = { version = "0.6", features = ["std"] }
thiserror = { workspace = true }
nanoid = { workspace = true }
nanoid-dictionary = { workspace = true }
gen_passphrase = { version = "0.1", features = ["eff_short_2"] }
gen_passphrase = { workspace = true, features = ["eff_short_2"] }

View file

@ -11,3 +11,4 @@ thiserror = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
email_address = { workspace = true }
serde = { workspace = true }
gen_passphrase = { workspace = true, features = ["eff_large"] }

View file

@ -1,4 +1,5 @@
use crate::error::Error;
use crate::password_reset::PasswordResetToken;
use crate::User;
use database::sqlx::SqliteExecutor;
use database::Error as DatabaseError;
@ -53,7 +54,7 @@ impl User {
.map(Self::from))
}
async fn get_by_email(
pub async fn get_by_email(
conn: impl SqliteExecutor<'_>,
email: &EmailAddress,
) -> Result<Option<Self>, Error> {
@ -108,6 +109,17 @@ impl User {
.map(Self::from))
}
pub async fn get_one_from_password_reset_token(
conn: impl SqliteExecutor<'_>,
token: &PasswordResetToken,
) -> Result<Option<Self>, Error> {
Ok(
DatabaseUsers::get_one_from_password_reset_token(conn, token.to_string().as_str())
.await?
.map(Self::from),
)
}
pub async fn get_all(conn: impl SqliteExecutor<'_>) -> Result<Vec<Self>, Error> {
Ok(DatabaseUsers::get_all(conn)
.await?
@ -198,4 +210,19 @@ impl User {
Ok(())
}
pub async fn set_password_reset_token(
&self,
conn: impl SqliteExecutor<'_>,
token: Option<&PasswordResetToken>,
) -> Result<(), Error> {
DatabaseUsers::set_password_reset_token(
conn,
self.id.as_ref(),
token.map(|t| t.to_string()).as_deref(),
)
.await?;
Ok(())
}
}

View file

@ -1,5 +1,6 @@
mod database;
mod error;
pub mod password_reset;
use chrono::{DateTime, Utc};
use id::UserID;
@ -51,4 +52,7 @@ impl User {
pub fn updated_at(&self) -> DateTime<Utc> {
self.updated_at
}
pub fn paper_key_hashed(&self) -> Option<&str> {
self.paper_key.as_deref()
}
}

View file

@ -0,0 +1,55 @@
use chrono::{DateTime, Duration, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
// error
#[derive(thiserror::Error)]
// the rest
#[derive(Debug)]
pub enum Error {
#[error("Invalid token format")]
TokenFormat,
#[error("Invalid time format")]
TimeFormat,
}
#[derive(Serialize, Deserialize)]
pub struct PasswordResetToken {
token: String,
expires_at: DateTime<Utc>,
}
impl PasswordResetToken {
pub fn generate(duration_minutes: i64) -> Self {
use gen_passphrase::dictionary::EFF_LARGE;
use gen_passphrase::generate;
let token = generate(&[EFF_LARGE], 10, None);
let expires_at = Utc::now() + Duration::minutes(duration_minutes);
Self { token, expires_at }
}
pub fn parse(raw: &str) -> Result<Self, Error> {
let (token, timestamp_str) = raw.split_once('-').ok_or(Error::TokenFormat)?;
let expires_at = Utc
.datetime_from_str(timestamp_str, "%s")
.map_err(|_| Error::TimeFormat)?;
Ok(Self {
token: token.to_string(),
expires_at,
})
}
pub fn has_expired(&self) -> bool {
self.expires_at < Utc::now()
}
}
impl fmt::Display for PasswordResetToken {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}-{}", self.token, self.expires_at.timestamp())
}
}