admin/users: force password reset (now lasts for 24 hours), send email, show expiration

This commit is contained in:
Philippe Loctaux 2023-05-03 23:46:40 +02:00
parent e600405f22
commit f8afea4e70
9 changed files with 288 additions and 12 deletions

10
Cargo.lock generated
View file

@ -273,6 +273,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "chrono-humanize"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32dce1ea1988dbdf9f9815ff11425828523bd2a134ec0805d2ac8af26ee6096e"
dependencies = [
"chrono",
]
[[package]]
name = "chrono-tz"
version = "0.6.1"
@ -771,6 +780,7 @@ dependencies = [
"apps",
"authorization_codes",
"base64 0.21.0",
"chrono-humanize",
"chrono-tz 0.8.2",
"database_pool",
"email",

View file

@ -18,7 +18,7 @@
<mj-text>
<h2>Reset password</h2>
<p>You recently requested to reset the password of your ezidam account.</p>
<p>Use the button below to reset it. This message will expire in {{ token_duration }} minutes.</p>
<p>Use the button below to reset it. This message will expire in {{ token_duration }} hours.</p>
</mj-text>
<mj-button href="{{ reset_password_url }}" background-color="#3a88fe">Reset your password</mj-button>
<mj-text>

View file

@ -15,6 +15,7 @@ base64 = "0.21.0"
rocket_cors = "0.6.0-alpha2"
email_address = { workspace = true }
chrono-tz = "0.8.2"
chrono-humanize = "0.2.2"
# local crates
database_pool = { path = "../database_pool" }

View file

@ -26,6 +26,7 @@ pub fn routes() -> Vec<Route> {
admin_users_list,
admin_users_view,
admin_users_archive,
admin_users_password_reset,
]
}
@ -96,5 +97,6 @@ pub mod content {
pub user: JwtClaims,
pub jwt_duration: i64,
pub local: User,
pub password_recover_expiration: Option<String>,
}
}

View file

@ -1,10 +1,14 @@
use crate::routes::prelude::*;
use crate::routes::root::forgot_password::ResetPasswordEmail;
use crate::tokens::JWT_DURATION_MINUTES;
use authorization_codes::AuthorizationCode;
use chrono_humanize::Humanize;
use rocket::State;
use rocket::{get, post};
use settings::Settings;
use url::Url;
use users::totp_login_request::TotpLoginRequest;
use users::User;
use users::{password_reset::PasswordResetToken, User};
#[get("/admin/users")]
pub async fn admin_users_list(
@ -36,10 +40,17 @@ pub async fn admin_users_view(
.await?
.ok_or_else(|| Error::not_found(user_id.to_string()))?;
// If user has password reset token
let password_recover_expiration = user
.password_recover()
.map_err(|e| Error::internal_server_error(format!("Password recover: {e}")))?
.map(|token| token.expires_at().humanize());
let page = Page::AdminUsersView(super::content::AdminUsersView {
user: admin_not_current.0,
jwt_duration: JWT_DURATION_MINUTES,
local: user,
password_recover_expiration,
});
Ok(flash
@ -136,3 +147,185 @@ pub async fn admin_users_archive(
Ok(Flash::new(redirect, flash_kind, flash_message))
}
#[derive(Debug, FromForm)]
pub struct PasswordResetForm<'r> {
pub send_email: Option<&'r str>,
pub start_process: Option<bool>,
}
#[post("/admin/users/<id>/password_reset", data = "<form>")]
pub async fn admin_users_password_reset(
_admin_not_current: JwtAdminNotCurrent,
mut db: Connection<Database>,
id: RocketUserID,
form: Form<PasswordResetForm<'_>>,
email_config: &State<Option<email::Config>>,
) -> Result<Flash<Redirect>> {
match form.start_process {
Some(true) => {}
_ => {
return Ok(Flash::new(
Redirect::to(uri!(admin_users_view(id))),
FlashKind::Warning,
"Nothing to do.",
));
}
}
let mut transaction = db.begin().await?;
// Get settings
let settings = Settings::get(&mut transaction).await?;
// Get user
let user = User::get_by_id(&mut transaction, &id.0)
.await?
.ok_or_else(|| Error::not_found("Could not find user"))?;
// Generate reset token
let token = task::spawn_blocking(PasswordResetToken::generate).await?;
// Save in database
user.set_password_reset_token(&mut transaction, Some(&token))
.await?;
transaction.commit().await?;
// Get server url
let url = settings
.url()
.ok_or_else(|| Error::not_found("Server url"))?;
let url = match Url::parse(url) {
Ok(url) => url,
Err(_) => {
return Ok(Flash::new(
Redirect::to(uri!(admin_users_view(id))),
FlashKind::Danger,
"Failed to parse server url",
));
}
};
// Safety: safe to unwrap, the value is present
let token_duration = token.duration_hours().unwrap();
// Construct url to reset password
let token = RocketResetPasswordToken(token);
let uri = uri!(crate::routes::root::reset_password::reset_password_page(
token
))
.to_string();
let reset_url = match url.join(&uri) {
Ok(url) => url,
Err(_) => {
return Ok(Flash::new(
Redirect::to(uri!(admin_users_view(id))),
FlashKind::Danger,
"Failed to construct url to reset password",
));
}
};
let email = match (user.email(), form.send_email) {
(Some(email), Some("on")) => {
// Send email
email
}
_ => {
// Don't send email
return Ok(Flash::new(
Redirect::to(uri!(admin_users_view(id))),
FlashKind::Success,
format!(
"Password reset has been generated. Give this link to the user:\
<div class=\"mt-1 user-select-all\">{reset_url}</div>"
),
));
}
};
// Url to logo
let logo_url = match url.join("/logo") {
Ok(url) => url,
Err(_) => {
return Ok(Flash::new(
Redirect::to(uri!(admin_users_view(id))),
FlashKind::Danger,
"Failed to construct url to business logo",
));
}
};
let email_title = "Reset password - ezidam";
let content = ResetPasswordEmail {
title: email_title.into(),
ezidam_version: env!("CARGO_PKG_VERSION"),
logo_url: logo_url.to_string(),
token_duration,
reset_password_url: reset_url.to_string(),
user_email: email.to_string(),
user_timezone: user.timezone().into(),
};
// Render email template
let mjml = match email::render_template("password-reset", &content) {
Ok(mjml) => mjml,
Err(e) => {
return Ok(Flash::new(
Redirect::to(uri!(admin_users_view(id))),
FlashKind::Danger,
format!("Failed to create email: {e}"),
));
}
};
// Create html email
let html = match email::render_email(&mjml) {
Ok(html) => html,
Err(e) => {
return Ok(Flash::new(
Redirect::to(uri!(admin_users_view(id))),
FlashKind::Danger,
format!("Failed to render email: {e}"),
));
}
};
let user_for_email = match user.name() {
Some(name) => {
format!("{name} <{email}>")
}
None => email.to_string(),
};
// Send email
let (flash_kind, flash_message) = match email_config.inner() {
Some(email_config) => {
match email::send_email(email_config, &user_for_email, email_title, html).await {
Ok(okay) => {
if okay.is_positive() {
(
FlashKind::Success,
format!("Email is on it's way to <code>{email}</code>"),
)
} else {
(
FlashKind::Warning,
"Email should be on it's way, but it might not arrive".into(),
)
}
}
Err(e) => (FlashKind::Danger, e.to_string()),
}
}
None => (FlashKind::Warning, "Email sending is disabled".into()),
};
Ok(Flash::new(
Redirect::to(uri!(admin_users_view(id))),
flash_kind,
flash_message,
))
}

View file

@ -97,8 +97,7 @@ pub async fn forgot_password_email_form(
};
// Generate reset token
let token_duration = 15;
let token = task::spawn_blocking(move || PasswordResetToken::generate(token_duration)).await?;
let token = task::spawn_blocking(PasswordResetToken::generate).await?;
// Save in database
user.set_password_reset_token(&mut transaction, Some(&token))
@ -106,6 +105,9 @@ pub async fn forgot_password_email_form(
transaction.commit().await?;
// Safety: safe to unwrap, the value is present
let token_duration = token.duration_hours().unwrap();
// Construct url to reset password
let token = RocketResetPasswordToken(token);
let uri = uri!(super::reset_password_page(token)).to_string();
@ -260,7 +262,7 @@ pub async fn forgot_password_paper_key_form(
}
// Generate reset token
let token = task::spawn_blocking(|| PasswordResetToken::generate(15)).await?;
let token = task::spawn_blocking(PasswordResetToken::generate).await?;
// Save in database
user.set_password_reset_token(&mut transaction, Some(&token))
@ -272,6 +274,6 @@ pub async fn forgot_password_paper_key_form(
Ok(Flash::new(
Redirect::to(uri!(super::reset_password_page(token))),
FlashKind::Success,
SUCCESS_MESSAGE,
"You can reset your password",
))
}

View file

@ -14,7 +14,7 @@ pub async fn reset_password_page(
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"))?;
.ok_or_else(|| Error::not_found("Invalid or expired token"))?;
let page = Page::ResetPassword(super::content::ResetPassword {
username: user.username().into(),

View file

@ -160,7 +160,7 @@
{% if local.password_recover %}
<div class="mt-1">
<span class="text-yellow">{% include "icons/progress" %}</span>
Password reset has been requested
Password reset requested, expiration {{ password_recover_expiration }}
</div>
{% endif %}
@ -332,6 +332,59 @@
</div>
</div>
<!-- Password reset -->
<div class="modal modal-blur" tabindex="-1" id="modal-password-reset">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-status bg-danger"></div>
<form action="{{ local.id }}/password_reset" method="post">
<div class="modal-body text-center py-4">
<div class="text-danger mb-2">
{% include "icons/alert-triangle-large" %}
</div>
<h3>Do you want to start a password reset for this user?</h3>
{% if local.email %}
<div class="mt-4">
<label class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="send_email">
<span class="form-check-label">Send email to <code>{{ local.email }}</code></span>
</label>
</div>
{% endif %}
</div>
<div class="modal-footer">
<div class="w-100">
<div class="row">
<div class="col">
<a href="#" class="btn w-100" data-bs-dismiss="modal">Cancel</a>
</div>
<div class="col">
<button type="submit" name="start_process" value="true"
class="btn btn-danger w-100">
Reset password
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock content %}
{% block libs_js %}

View file

@ -18,17 +18,23 @@ pub enum Error {
pub struct PasswordResetToken {
token: String,
expires_at: DateTime<Utc>,
duration_hours: Option<i64>,
}
impl PasswordResetToken {
pub fn generate(duration_minutes: i64) -> Self {
pub fn generate() -> Self {
use gen_passphrase::dictionary::EFF_LARGE;
use gen_passphrase::generate;
let duration_hours = 24;
let token = generate(&[EFF_LARGE], 10, None);
let expires_at = Utc::now() + Duration::minutes(duration_minutes);
let expires_at = Utc::now() + Duration::hours(duration_hours);
Self { token, expires_at }
Self {
token,
expires_at,
duration_hours: Some(duration_hours),
}
}
pub fn parse(raw: &str) -> Result<Self, Error> {
@ -40,12 +46,21 @@ impl PasswordResetToken {
Ok(Self {
token: token.to_string(),
expires_at,
duration_hours: None,
})
}
pub fn has_expired(&self) -> bool {
self.expires_at < Utc::now()
}
pub fn duration_hours(&self) -> Option<i64> {
self.duration_hours
}
pub fn expires_at(&self) -> DateTime<Utc> {
self.expires_at
}
}
impl fmt::Display for PasswordResetToken {