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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -115,7 +117,7 @@ pub async fn forgot_password_email_form(
|
|||
return Ok(Flash::new(
|
||||
Redirect::to(uri!(forgot_password_page)),
|
||||
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
|
||||
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",
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue