admin/users: force password reset (now lasts for 24 hours), send email, show expiration
This commit is contained in:
parent
e600405f22
commit
f8afea4e70
9 changed files with 288 additions and 12 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -273,6 +273,15 @@ dependencies = [
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono-humanize"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32dce1ea1988dbdf9f9815ff11425828523bd2a134ec0805d2ac8af26ee6096e"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono-tz"
|
name = "chrono-tz"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
|
|
@ -771,6 +780,7 @@ dependencies = [
|
||||||
"apps",
|
"apps",
|
||||||
"authorization_codes",
|
"authorization_codes",
|
||||||
"base64 0.21.0",
|
"base64 0.21.0",
|
||||||
|
"chrono-humanize",
|
||||||
"chrono-tz 0.8.2",
|
"chrono-tz 0.8.2",
|
||||||
"database_pool",
|
"database_pool",
|
||||||
"email",
|
"email",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
<mj-text>
|
<mj-text>
|
||||||
<h2>Reset password</h2>
|
<h2>Reset password</h2>
|
||||||
<p>You recently requested to reset the password of your ezidam account.</p>
|
<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-text>
|
||||||
<mj-button href="{{ reset_password_url }}" background-color="#3a88fe">Reset your password</mj-button>
|
<mj-button href="{{ reset_password_url }}" background-color="#3a88fe">Reset your password</mj-button>
|
||||||
<mj-text>
|
<mj-text>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ base64 = "0.21.0"
|
||||||
rocket_cors = "0.6.0-alpha2"
|
rocket_cors = "0.6.0-alpha2"
|
||||||
email_address = { workspace = true }
|
email_address = { workspace = true }
|
||||||
chrono-tz = "0.8.2"
|
chrono-tz = "0.8.2"
|
||||||
|
chrono-humanize = "0.2.2"
|
||||||
|
|
||||||
# local crates
|
# local crates
|
||||||
database_pool = { path = "../database_pool" }
|
database_pool = { path = "../database_pool" }
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ pub fn routes() -> Vec<Route> {
|
||||||
admin_users_list,
|
admin_users_list,
|
||||||
admin_users_view,
|
admin_users_view,
|
||||||
admin_users_archive,
|
admin_users_archive,
|
||||||
|
admin_users_password_reset,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,5 +97,6 @@ pub mod content {
|
||||||
pub user: JwtClaims,
|
pub user: JwtClaims,
|
||||||
pub jwt_duration: i64,
|
pub jwt_duration: i64,
|
||||||
pub local: User,
|
pub local: User,
|
||||||
|
pub password_recover_expiration: Option<String>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
use crate::routes::prelude::*;
|
use crate::routes::prelude::*;
|
||||||
|
use crate::routes::root::forgot_password::ResetPasswordEmail;
|
||||||
use crate::tokens::JWT_DURATION_MINUTES;
|
use crate::tokens::JWT_DURATION_MINUTES;
|
||||||
use authorization_codes::AuthorizationCode;
|
use authorization_codes::AuthorizationCode;
|
||||||
|
use chrono_humanize::Humanize;
|
||||||
|
use rocket::State;
|
||||||
use rocket::{get, post};
|
use rocket::{get, post};
|
||||||
use settings::Settings;
|
use settings::Settings;
|
||||||
|
use url::Url;
|
||||||
use users::totp_login_request::TotpLoginRequest;
|
use users::totp_login_request::TotpLoginRequest;
|
||||||
use users::User;
|
use users::{password_reset::PasswordResetToken, User};
|
||||||
|
|
||||||
#[get("/admin/users")]
|
#[get("/admin/users")]
|
||||||
pub async fn admin_users_list(
|
pub async fn admin_users_list(
|
||||||
|
|
@ -36,10 +40,17 @@ pub async fn admin_users_view(
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| Error::not_found(user_id.to_string()))?;
|
.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 {
|
let page = Page::AdminUsersView(super::content::AdminUsersView {
|
||||||
user: admin_not_current.0,
|
user: admin_not_current.0,
|
||||||
jwt_duration: JWT_DURATION_MINUTES,
|
jwt_duration: JWT_DURATION_MINUTES,
|
||||||
local: user,
|
local: user,
|
||||||
|
password_recover_expiration,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(flash
|
Ok(flash
|
||||||
|
|
@ -136,3 +147,185 @@ pub async fn admin_users_archive(
|
||||||
|
|
||||||
Ok(Flash::new(redirect, flash_kind, flash_message))
|
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,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,7 @@ pub async fn forgot_password_email_form(
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate reset token
|
// Generate reset token
|
||||||
let token_duration = 15;
|
let token = task::spawn_blocking(PasswordResetToken::generate).await?;
|
||||||
let token = task::spawn_blocking(move || PasswordResetToken::generate(token_duration)).await?;
|
|
||||||
|
|
||||||
// Save in database
|
// Save in database
|
||||||
user.set_password_reset_token(&mut transaction, Some(&token))
|
user.set_password_reset_token(&mut transaction, Some(&token))
|
||||||
|
|
@ -106,6 +105,9 @@ pub async fn forgot_password_email_form(
|
||||||
|
|
||||||
transaction.commit().await?;
|
transaction.commit().await?;
|
||||||
|
|
||||||
|
// Safety: safe to unwrap, the value is present
|
||||||
|
let token_duration = token.duration_hours().unwrap();
|
||||||
|
|
||||||
// Construct url to reset password
|
// Construct url to reset password
|
||||||
let token = RocketResetPasswordToken(token);
|
let token = RocketResetPasswordToken(token);
|
||||||
let uri = uri!(super::reset_password_page(token)).to_string();
|
let uri = uri!(super::reset_password_page(token)).to_string();
|
||||||
|
|
@ -115,7 +117,7 @@ pub async fn forgot_password_email_form(
|
||||||
return Ok(Flash::new(
|
return Ok(Flash::new(
|
||||||
Redirect::to(uri!(forgot_password_page)),
|
Redirect::to(uri!(forgot_password_page)),
|
||||||
FlashKind::Danger,
|
FlashKind::Danger,
|
||||||
"Failed to construct url to reset password ",
|
"Failed to construct url to reset password",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -260,7 +262,7 @@ pub async fn forgot_password_paper_key_form(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate reset token
|
// Generate reset token
|
||||||
let token = task::spawn_blocking(|| PasswordResetToken::generate(15)).await?;
|
let token = task::spawn_blocking(PasswordResetToken::generate).await?;
|
||||||
|
|
||||||
// Save in database
|
// Save in database
|
||||||
user.set_password_reset_token(&mut transaction, Some(&token))
|
user.set_password_reset_token(&mut transaction, Some(&token))
|
||||||
|
|
@ -272,6 +274,6 @@ pub async fn forgot_password_paper_key_form(
|
||||||
Ok(Flash::new(
|
Ok(Flash::new(
|
||||||
Redirect::to(uri!(super::reset_password_page(token))),
|
Redirect::to(uri!(super::reset_password_page(token))),
|
||||||
FlashKind::Success,
|
FlashKind::Success,
|
||||||
SUCCESS_MESSAGE,
|
"You can reset your password",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ pub async fn reset_password_page(
|
||||||
|
|
||||||
let user = User::get_one_from_password_reset_token(&mut **db, &token.0)
|
let user = User::get_one_from_password_reset_token(&mut **db, &token.0)
|
||||||
.await?
|
.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 {
|
let page = Page::ResetPassword(super::content::ResetPassword {
|
||||||
username: user.username().into(),
|
username: user.username().into(),
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@
|
||||||
{% if local.password_recover %}
|
{% if local.password_recover %}
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<span class="text-yellow">{% include "icons/progress" %}</span>
|
<span class="text-yellow">{% include "icons/progress" %}</span>
|
||||||
Password reset has been requested
|
Password reset requested, expiration {{ password_recover_expiration }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
@ -332,6 +332,59 @@
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% endblock content %}
|
||||||
|
|
||||||
{% block libs_js %}
|
{% block libs_js %}
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,23 @@ pub enum Error {
|
||||||
pub struct PasswordResetToken {
|
pub struct PasswordResetToken {
|
||||||
token: String,
|
token: String,
|
||||||
expires_at: DateTime<Utc>,
|
expires_at: DateTime<Utc>,
|
||||||
|
duration_hours: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PasswordResetToken {
|
impl PasswordResetToken {
|
||||||
pub fn generate(duration_minutes: i64) -> Self {
|
pub fn generate() -> Self {
|
||||||
use gen_passphrase::dictionary::EFF_LARGE;
|
use gen_passphrase::dictionary::EFF_LARGE;
|
||||||
use gen_passphrase::generate;
|
use gen_passphrase::generate;
|
||||||
|
|
||||||
|
let duration_hours = 24;
|
||||||
let token = generate(&[EFF_LARGE], 10, None);
|
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> {
|
pub fn parse(raw: &str) -> Result<Self, Error> {
|
||||||
|
|
@ -40,12 +46,21 @@ impl PasswordResetToken {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
token: token.to_string(),
|
token: token.to_string(),
|
||||||
expires_at,
|
expires_at,
|
||||||
|
duration_hours: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_expired(&self) -> bool {
|
pub fn has_expired(&self) -> bool {
|
||||||
self.expires_at < Utc::now()
|
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 {
|
impl fmt::Display for PasswordResetToken {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue