feat(WIP): registration of users

This commit is contained in:
Max Hohlfeld 2024-09-03 23:39:36 +02:00
parent ae2cff0c3a
commit 2374f78a07
25 changed files with 463 additions and 213 deletions

1
Cargo.lock generated
View File

@ -791,6 +791,7 @@ dependencies = [
"serde_json",
"sqlx",
"static-files",
"thiserror",
"zxcvbn",
]

View File

@ -29,6 +29,7 @@ quick-xml = { version = "0.31.0", features = ["serde", "serialize"] }
actix-web-static-files = "4.0"
static-files = "0.2.1"
zxcvbn = "3.1.0"
thiserror = "1.0.63"
[build-dependencies]
built = "0.7.4"

View File

@ -21,8 +21,8 @@ CREATE TABLE user_
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
password TEXT NOT NULL,
salt TEXT NOT NULL,
password TEXT ,
salt TEXT ,
role role NOT NULL,
function function NOT NULL,
areaId INTEGER NOT NULL REFERENCES area (id),

View File

@ -31,7 +31,6 @@ pub fn init(cfg: &mut ServiceConfig) {
cfg.service(user::post_new::post_new);
cfg.service(user::get_edit::get_edit);
cfg.service(user::post_edit::post_edit);
cfg.service(user::patch::patch);
cfg.service(user::delete::delete);
cfg.service(user::get_logout::get);
cfg.service(user::get_login::get);

View File

@ -0,0 +1,42 @@
use actix_identity::Identity;
use actix_web::{get, http::header::LOCATION, web, HttpResponse, Responder};
use askama_actix::TemplateToResponse;
use serde::Deserialize;
use sqlx::PgPool;
use crate::models::Registration;
use super::ResetPasswordTemplate;
#[derive(Deserialize)]
struct TokenQuery {
token: String,
}
#[get("/register")]
pub async fn get(
user: Option<Identity>,
pool: web::Data<PgPool>,
query: web::Query<TokenQuery>,
) -> impl Responder {
if user.is_some() {
return HttpResponse::Found()
.insert_header((LOCATION, "/"))
.finish();
}
if let Ok(_) = Registration::does_token_exist(pool.get_ref(), &query.token).await {
let template = ResetPasswordTemplate {
token: &query.token,
title: "Brass - Registrierung",
endpoint: "/register",
new_password_label: "Passwort:",
retype_label: "Passwort wiederholen:",
submit_button_label: "Registrieren",
};
return template.to_response();
}
HttpResponse::NotFound().finish()
}

View File

@ -3,33 +3,41 @@ use actix_web::{get, http::header::LOCATION, web, HttpResponse, Responder};
use askama::Template;
use askama_actix::TemplateToResponse;
use serde::Deserialize;
use sqlx::PgPool;
use crate::models::PasswordReset;
use super::ResetPasswordTemplate;
#[derive(Template)]
#[template(path = "user/forgot_password.html")]
struct ForgotPasswordTemplate {}
#[derive(Template)]
#[template(path = "user/reset_password.html")]
struct ResetPasswordTemplate {
token: String
}
#[derive(Deserialize)]
struct TokenQuery {
token: Option<String>
token: Option<String>,
}
#[get("/reset-password")]
pub async fn get(user: Option<Identity>, query: web::Query<TokenQuery>) -> impl Responder {
pub async fn get(
user: Option<Identity>,
pool: web::Data<PgPool>,
query: web::Query<TokenQuery>,
) -> impl Responder {
if let Some(_) = user {
return HttpResponse::Found()
.insert_header((LOCATION, "/"))
.finish();
} else if let Some(token) = &query.token {
let token_exists = true;
if token_exists {
let template = ResetPasswordTemplate { token: token.to_string() };
if let Ok(_) = PasswordReset::does_token_exist(pool.get_ref(), token).await {
let template = ResetPasswordTemplate {
token,
title: "Brass - Passwort zurücksetzen",
endpoint: "/reset-password",
new_password_label: "/reset-password",
retype_label: "neues Passwort wiederholen:",
submit_button_label: "Passwort zurücksetzen",
};
return template.to_response();
}

View File

@ -11,13 +11,14 @@ pub mod get_new;
pub mod get_overview;
pub mod get_profile;
pub mod get_reset;
pub mod patch;
pub mod post_changepassword;
pub mod post_edit;
pub mod post_login;
pub mod post_new;
pub mod post_reset;
pub mod post_toggle;
pub mod get_register;
pub mod post_register;
#[derive(Template)]
#[template(path = "user/new_or_edit.html")]
@ -31,3 +32,14 @@ pub struct NewOrEditUserTemplate {
function: Option<u8>,
area_id: Option<i32>,
}
#[derive(Template)]
#[template(path = "user/change_password.html")]
struct ResetPasswordTemplate<'a> {
token: &'a str,
title: &'a str,
endpoint: &'a str,
new_password_label: &'a str,
retype_label: &'a str,
submit_button_label: &'a str
}

View File

@ -1,88 +0,0 @@
use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;
use serde_json::value::Value;
use sqlx::PgPool;
use crate::{
endpoints::IdPath,
models::{Role, User},
};
#[derive(Deserialize)]
pub struct JsonPatchDoc {
op: String,
path: String,
value: Value,
}
// TODO: deprecated route
#[actix_web::patch("/users/edit/{id}")]
pub async fn patch(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
path: web::Path<IdPath>,
patch_docs: web::Json<Vec<JsonPatchDoc>>,
) -> impl Responder {
let is_superuser = user.role != Role::AreaManager && user.role != Role::Admin;
if let Ok(user_in_db) = User::read_by_id(pool.get_ref(), path.id).await {
if user.role == Role::AreaManager && user.area_id != user_in_db.area_id {
return HttpResponse::Unauthorized().finish();
}
let mut changed = false;
let mut locked: Option<bool> = None;
let mut receive_notifications: Option<bool> = None;
for doc in patch_docs.iter() {
if doc.op.as_str() != "replace" {
continue;
}
match doc.path.as_str() {
"/locked" => {
if !is_superuser {
return HttpResponse::Unauthorized().finish();
}
changed = true;
if let Value::Bool(b) = doc.value {
locked = Some(b);
}
}
"/receiveNotifications" => {
changed = true;
if let Value::Bool(b) = doc.value {
receive_notifications = Some(b)
}
}
_ => return HttpResponse::BadRequest().body("Other PATCH paths are not supported!")
};
}
if changed {
if let Ok(_) = User::update(
pool.get_ref(),
path.id,
None,
None,
None,
None,
None,
None,
None,
receive_notifications,
locked,
)
.await
{
return HttpResponse::Ok().body("");
}
} else {
return HttpResponse::Ok().body("");
}
}
HttpResponse::BadRequest().body("Fehler bei User PATCH")
}

View File

@ -17,9 +17,13 @@ async fn post(
form: web::Form<ChangePasswordForm>,
pool: web::Data<PgPool>,
) -> impl Responder {
if user.password
== utils::hash_plain_password_with_salt(&form.currentpassword, &user.salt).unwrap()
{
if user.password.as_ref().is_some_and(|p|
p == &utils::hash_plain_password_with_salt(
&form.currentpassword,
user.salt.as_ref().unwrap(),
)
.unwrap()
) {
if form.password != form.passwordretyped {
return HttpResponse::BadRequest().body("Passwörter stimmen nicht überein!");
}

View File

@ -18,8 +18,10 @@ async fn post(
pool: web::Data<PgPool>,
) -> impl Responder {
if let Ok(user) = User::read_for_login(pool.get_ref(), &form.email).await {
let hash = hash_plain_password_with_salt(&form.password, &user.salt).unwrap();
if hash == user.password {
let salt = user.salt.unwrap();
let hash = hash_plain_password_with_salt(&form.password, &salt).unwrap();
if hash == user.password.unwrap() {
Identity::login(&request.extensions(), user.id.to_string()).unwrap();
User::update_login_timestamp(pool.get_ref(), user.id)

View File

@ -1,46 +1,58 @@
use actix_identity::Identity;
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use lettre::{SmtpTransport, Transport};
use serde::Deserialize;
use sqlx::PgPool;
use crate::{auth::utils::generate_salt_and_hash_plain_password, models::{Function, Role, User}};
use crate::{
models::{Function, Registration, Role, User},
utils::{email, ApplicationError},
};
#[derive(Deserialize)]
pub struct NewUserForm {
email: String,
name: String,
password: String,
role: u8,
function: u8,
area: Option<i32>
area: Option<i32>,
}
#[actix_web::post("/users/new")]
pub async fn post_new(user: Identity, pool: web::Data<PgPool>, form: web::Form<NewUserForm>) -> impl Responder {
let current_user = User::read_by_id(pool.get_ref(), user.id().unwrap().parse().unwrap()).await.unwrap();
if current_user.role != Role::AreaManager && current_user.role != Role::Admin {
return HttpResponse::Unauthorized().finish();
pub async fn post_new(
user: web::ReqData<User>,
pool: web::Data<PgPool>,
form: web::Form<NewUserForm>,
mailer: web::ReqData<SmtpTransport>,
) -> Result<impl Responder, ApplicationError> {
if user.role != Role::AreaManager && user.role != Role::Admin {
return Err(ApplicationError::Unauthorized);
}
let mut area_id = current_user.area_id;
let mut area_id = user.area_id;
if current_user.role == Role::Admin {
if let Some(id) = form.area {
area_id = id;
}
if user.role == Role::Admin && form.area.is_some() {
area_id = form.area.unwrap();
}
if let Ok((hash, salt)) = generate_salt_and_hash_plain_password(&form.password) {
if let Ok(role) = Role::try_from(form.role) {
if let Ok(function) = Function::try_from(form.function) {
match User::create(pool.get_ref(), &form.name, &form.email, &hash, &salt, role, function, area_id).await {
Ok(_) => return HttpResponse::Found().insert_header((LOCATION, "/users")).finish(),
Err(err) => println!("{}", err)
}
}
}
}
let role = Role::try_from(form.role)?;
let function = Function::try_from(form.function)?;
return HttpResponse::BadRequest().body("Fehler beim Erstellen des Nutzers");
let id = User::create(
pool.get_ref(),
&form.name,
&form.email,
role,
function,
area_id,
)
.await?;
let registration = Registration::insert_new_for_user(pool.get_ref(), id).await?;
let message = email::build_registration_message(&user, &registration.token);
mailer.send(&message).unwrap();
Ok(HttpResponse::Found()
.insert_header((LOCATION, "/users"))
.finish())
}

View File

@ -0,0 +1,85 @@
use actix_web::{post, web, HttpResponse, Responder};
use serde::Deserialize;
use sqlx::PgPool;
use zxcvbn::{zxcvbn, Score};
use crate::{
auth,
models::{Registration, User},
utils::{password_help, ApplicationError},
};
#[derive(Deserialize)]
struct RegisterForm {
token: String,
password: String,
passwordretyped: String,
dry: Option<bool>,
}
#[post("/register")]
async fn post(
form: web::Form<RegisterForm>,
pool: web::Data<PgPool>,
) -> Result<impl Responder, ApplicationError> {
let is_dry = form.dry.unwrap_or(false);
let token = Registration::does_token_exist(pool.get_ref(), &form.token).await?;
if form.password.chars().count() > 256 {
if is_dry {
return Ok(HttpResponse::BadRequest().body("<div id=\"password-strength\" class=\"mb-3 help content is-danger\">Password darf nicht länger als 256 Zeichen sein.</div>"));
} else {
return Ok(HttpResponse::NoContent().finish());
}
}
let user = User::read_by_id(pool.get_ref(), token.userid).await?;
let mut split_names: Vec<&str> = user.name.as_str().split_whitespace().collect();
let mut user_inputs = vec![user.email.as_str()];
user_inputs.append(&mut split_names);
let entropy = zxcvbn(&form.password, &user_inputs);
if entropy.score() < Score::Three {
if is_dry {
let message = password_help::generate_for_entropy(&entropy);
return Ok(HttpResponse::BadRequest().body(message));
} else {
return Ok(HttpResponse::NoContent().finish());
}
}
if is_dry {
if entropy.score() == Score::Three {
return Ok(HttpResponse::Ok()
.body("<div id=\"password-strength\" class=\"mb-3 help content is-success\">Sicheres Passwort.</div>"));
} else {
return Ok(HttpResponse::Ok()
.body("<div id=\"password-strength\" class=\"mb-3 help content is-success\">Sehr sicheres Passwort.</div>"));
}
}
if form.password != form.passwordretyped {
return Ok(HttpResponse::BadRequest().body("Passwörter stimmen nicht überein!"));
}
let (hash, salt) = auth::utils::generate_salt_and_hash_plain_password(&form.password).unwrap();
User::update(
pool.get_ref(),
token.userid,
None,
None,
Some(&hash),
Some(&salt),
None,
None,
None,
None,
None,
)
.await?;
Registration::delete(pool.get_ref(), &token.token).await?;
Ok(HttpResponse::Ok().body(r#"<div class="block">Registrierung abgeschlossen.</div><a class="block button is-primary" hx-boost="true" href="/login">Zum Login</a>"#))
}

View File

@ -1,10 +1,5 @@
use std::env;
use actix_web::{web, HttpResponse, Responder};
use lettre::{
message::{header::ContentType, MultiPart, SinglePart},
Message, SmtpTransport, Transport,
};
use lettre::{SmtpTransport, Transport};
use serde::Deserialize;
use sqlx::PgPool;
use zxcvbn::{zxcvbn, Score};
@ -12,7 +7,7 @@ use zxcvbn::{zxcvbn, Score};
use crate::{
auth::{self},
models::{PasswordReset, User},
utils::password_help,
utils::{email, password_help},
};
#[derive(Deserialize, Debug)]
@ -40,43 +35,7 @@ async fn post(
.await
.unwrap();
let hostname = env::var("HOSTNAME").unwrap();
let reset_url = format!("https://{}/reset-password?token={}", hostname, reset.token);
let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse().unwrap())
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse().unwrap())
.to(format!("{} <{}>", user.name, user.email).parse().unwrap())
.subject("Brass: Zurücksetzen des Passworts angefordert")
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(format!(r##"Hallo {},
du hast angefordert, dein Passwort zurückzusetzen. Kopiere dafür folgenden Link in deinen Browser:
{}
Bitte beachte, dass der Link nur 24 Stunden gültig ist. Solltest du nichts angefordert haben, dann musst du nichts weiter tun.
Viele Grüße"##, user.name, reset_url))
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(format!(r##"<p>Hallo {},</p>
<p>du hast angefordert, dein Passwort zurückzusetzen. Klicke dafür <a href="{}" target="_blank">hier</a> oder kopiere folgenden Link in deinen Browser:</p>
<p>{}</p>
<p>Bitte beachte, dass der Link <b>nur 24 Stunden gültig</b> ist. Solltest du nichts angefordert haben, dann musst du nichts weiter tun.</p>
<p>Viele Grüße</p>"##, user.name, reset_url, reset_url))
))
.unwrap();
let message = email::build_forgot_password_message(&user, &reset.token);
mailer.send(&message).unwrap();
}

View File

@ -2,6 +2,8 @@ use std::fmt::Display;
use serde::Serialize;
use crate::utils::ApplicationError;
#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[sqlx(type_name = "function", rename_all = "lowercase")]
pub enum Function {
@ -13,19 +15,22 @@ impl Display for Function {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Function::Posten => write!(f, "Posten"),
Function::Wachhabender => write!(f, "Wachhabender")
Function::Wachhabender => write!(f, "Wachhabender"),
}
}
}
impl TryFrom<u8> for Function {
type Error = ();
type Error = ApplicationError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Function::Posten),
10 => Ok(Function::Wachhabender),
_ => Err(()),
_ => Err(ApplicationError::UnsupportedEnumValue {
value: value.to_string(),
enum_name: String::from("Function"),
}),
}
}
}

View File

@ -8,6 +8,7 @@ mod role;
mod user;
mod vehicle;
mod password_reset;
mod registration;
pub use area::Area;
pub use availabillity::Availabillity;
@ -18,3 +19,6 @@ pub use role::Role;
pub use user::User;
pub use assignement::Assignment;
pub use password_reset::PasswordReset;
pub use registration::Registration;
type Result<T> = std::result::Result<T, sqlx::Error>;

View File

@ -1,8 +1,9 @@
use anyhow::Result;
use chrono::{NaiveDateTime, TimeDelta, Utc};
use rand::{distributions::Alphanumeric, rngs::OsRng, Rng as _};
use chrono::{NaiveDateTime, TimeDelta};
use sqlx::{query_as, PgPool};
use crate::utils::token_generation::generate_token_and_expiration;
#[derive(Debug)]
pub struct PasswordReset {
pub id: i32,
@ -13,14 +14,7 @@ pub struct PasswordReset {
impl PasswordReset {
pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result<PasswordReset>{
let value = std::iter::repeat(())
.map(|()| OsRng.sample(Alphanumeric))
.take(64)
.collect::<Vec<_>>();
let token = String::from_utf8(value).unwrap().try_into().unwrap();
let expires = Utc::now().naive_utc() + TimeDelta::hours(24);
let (token, expires) = generate_token_and_expiration(64, TimeDelta::hours(24));
let inserted = query_as!(
PasswordReset,

View File

@ -0,0 +1,50 @@
use chrono::{NaiveDateTime, TimeDelta};
use sqlx::{query_as, PgPool};
use crate::utils::token_generation::generate_token_and_expiration;
#[derive(Debug)]
pub struct Registration {
pub id: i32,
pub token: String,
pub userid: i32,
pub expires: NaiveDateTime,
}
use super::Result;
impl Registration {
pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result<Registration> {
let (token, expires) = generate_token_and_expiration(64, TimeDelta::hours(24));
let inserted = query_as!(
Registration,
"INSERT INTO registration (token, userId, expires) VALUES ($1, $2, $3) RETURNING *;",
token,
user_id,
expires
)
.fetch_one(pool)
.await?;
Ok(inserted)
}
pub async fn does_token_exist(pool: &PgPool, token: &str) -> Result<Registration> {
query_as!(
Registration,
"SELECT * FROM registration WHERE token = $1 AND expires > NOW();",
token
)
.fetch_one(pool)
.await
}
pub async fn delete(pool: &PgPool, token: &str) -> Result<()> {
sqlx::query!("DELETE FROM registration WHERE token = $1;", token)
.execute(pool)
.await?;
Ok(())
}
}

View File

@ -1,3 +1,5 @@
use crate::utils::ApplicationError;
#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq)]
#[sqlx(type_name = "role", rename_all = "lowercase")]
pub enum Role {
@ -7,14 +9,17 @@ pub enum Role {
}
impl TryFrom<u8> for Role {
type Error = ();
type Error = ApplicationError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Role::Staff),
10 => Ok(Role::AreaManager),
100 => Ok(Role::Admin),
_ => Err(()),
_ => Err(ApplicationError::UnsupportedEnumValue {
value: value.to_string(),
enum_name: String::from("Role"),
}),
}
}
}

View File

@ -1,15 +1,15 @@
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use super::{Area, Function, Role};
use super::{Area, Function, Result, Role};
#[derive(Clone)]
pub struct User {
pub id: i32,
pub name: String,
pub email: String,
pub password: String,
pub salt: String,
pub password: Option<String>,
pub salt: Option<String>,
pub role: Role,
pub function: Function,
pub area_id: i32,
@ -21,6 +21,31 @@ pub struct User {
impl User {
pub async fn create(
pool: &PgPool,
name: &str,
email: &str,
role: Role,
function: Function,
area_id: i32,
) -> Result<i32> {
sqlx::query!(
r#"
INSERT INTO user_ (name, email, role, function, areaId)
VALUES ($1, $2, $3, $4, $5)
RETURNING id;
"#,
name,
email,
role as Role,
function as Function,
area_id
)
.fetch_one(pool)
.await
.and_then(|r| Ok(r.id))
}
pub async fn create_with_password(
pool: &PgPool,
name: &str,
email: &str,
@ -29,8 +54,8 @@ impl User {
role: Role,
function: Function,
area_id: i32,
) -> anyhow::Result<i32> {
let created = sqlx::query!(
) -> Result<i32> {
let b = sqlx::query!(
r#"
INSERT INTO user_ (name, email, password, salt, role, function, areaId)
VALUES ($1, $2, $3, $4, $5, $6, $7)
@ -45,15 +70,13 @@ impl User {
area_id
)
.fetch_one(pool)
.await;
.await
.and_then(|r| Ok(r.id));
match created {
Ok(result) => Ok(result.id),
Err(err) => Err(err.into()),
}
b
}
pub async fn read_by_id(pool: &PgPool, id: i32) -> anyhow::Result<User> {
pub async fn read_by_id(pool: &PgPool, id: i32) -> Result<User> {
let record = sqlx::query!(
r#"
SELECT id,
@ -108,7 +131,7 @@ impl User {
lastLogin,
receiveNotifications
FROM user_
WHERE email = $1 AND locked = FALSE;
WHERE email = $1 AND locked = FALSE AND password IS NOT NULL AND salt IS NOT NULL;
"#,
email,
)
@ -277,7 +300,7 @@ impl User {
area_id: Option<i32>,
receive_notifications: Option<bool>,
locked: Option<bool>,
) -> anyhow::Result<()> {
) -> Result<()> {
let mut query_builder = sqlx::QueryBuilder::new("UPDATE user_ SET ");
let mut separated = query_builder.separated(", ");

View File

@ -0,0 +1,26 @@
use actix_web::{http::StatusCode, HttpResponse};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApplicationError {
#[error("unsupported value '{value}' for enum '{enum_name}'")]
UnsupportedEnumValue { value: String, enum_name: String },
#[error("unauthorized")]
Unauthorized,
#[error("database error")]
Database(#[from] sqlx::Error),
}
impl actix_web::error::ResponseError for ApplicationError {
fn status_code(&self) -> StatusCode {
match *self {
ApplicationError::UnsupportedEnumValue { .. } => StatusCode::BAD_REQUEST,
ApplicationError::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
ApplicationError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code()).body(self.to_string())
}
}

View File

@ -1,10 +1,13 @@
use std::env;
use lettre::{
message::{header::ContentType, MultiPart, SinglePart},
transport::smtp::{authentication::Credentials, extension::ClientId},
SmtpTransport,
Message, SmtpTransport,
};
use crate::models::User;
pub fn get_mailer() -> anyhow::Result<SmtpTransport> {
let server = &env::var("SMTP_SERVER")?;
let port = &env::var("SMTP_PORT")?.parse()?;
@ -33,3 +36,87 @@ pub fn get_mailer() -> anyhow::Result<SmtpTransport> {
Ok(mailer)
}
pub fn build_forgot_password_message(user: &User, token: &str) -> Message {
let hostname = env::var("HOSTNAME").unwrap();
let reset_url = format!("https://{hostname}/reset-password?token={token}");
let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse().unwrap())
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse().unwrap())
.to(format!("{} <{}>", user.name, user.email).parse().unwrap())
.subject("Brass: Zurücksetzen des Passworts angefordert")
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(format!(r##"Hallo {},
du hast angefordert, dein Passwort zurückzusetzen. Kopiere dafür folgenden Link in deinen Browser:
{reset_url}
Bitte beachte, dass der Link nur 24 Stunden gültig ist. Solltest du nichts angefordert haben, dann musst du nichts weiter tun.
Viele Grüße"##, user.name))
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(format!(r##"<p>Hallo {},</p>
<p>du hast angefordert, dein Passwort zurückzusetzen. Klicke dafür <a href="{reset_url}" target="_blank">hier</a> oder kopiere folgenden Link in deinen Browser:</p>
<p>{reset_url}</p>
<p>Bitte beachte, dass der Link <b>nur 24 Stunden gültig</b> ist. Solltest du nichts angefordert haben, dann musst du nichts weiter tun.</p>
<p>Viele Grüße</p>"##, user.name))
))
.unwrap();
return message;
}
pub fn build_registration_message(user: &User, token: &str) -> Message {
let hostname = env::var("HOSTNAME").unwrap();
let register_url = format!("https://{hostname}/register?token={token}");
let message = Message::builder()
.from("noreply <noreply@brasiwa-leipzig.de>".parse().unwrap())
.reply_to("noreply <noreply@brasiwa-leipzig.de>".parse().unwrap())
.to(format!("{} <{}>", user.name, user.email).parse().unwrap())
.subject("Brass: Registrierung deines Accounts")
.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(format!(r##"Hallo {},
dein Account für https:://{hostname} wurde erstellt. Du musst nur noch ein Passwort festlegen. Kopiere dafür folgenden Link in deinen Browser:
{register_url}
Bitte beachte, dass der Link nur 24 Stunden gültig ist.
Viele Grüße"##, user.name))
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(format!(r##"<p>Hallo {},</p>
<p>dein Account für <a href="https:://{hostname}" target="_blank">https://{hostname}</a> wurde erstellt. Du musst nur noch ein Passwort festlegen. Klicke dafür <a href="{register_url}" target="_blank">hier</a> oder kopiere folgenden Link in deinen Browser:</p>
<p>{register_url}</p>
<p>Bitte beachte, dass der Link <b>nur 24 Stunden gültig</b> ist.</p>
<p>Viele Grüße</p>"##, user.name))
))
.unwrap();
return message;
}

View File

@ -67,7 +67,7 @@ pub async fn handle_command(command: Option<Command>, pool: &Pool<Postgres>) ->
let (hash, salt) = generate_salt_and_hash_plain_password(&password)?;
User::create(
User::create_with_password(
&pool,
&name,
&email,

View File

@ -1,3 +1,7 @@
pub mod email;
pub mod manage_commands;
pub mod password_help;
pub mod token_generation;
mod application_error;
pub use application_error::ApplicationError;

View File

@ -0,0 +1,15 @@
use chrono::{NaiveDateTime, TimeDelta, Utc};
use rand::{distributions::Alphanumeric, rngs::OsRng, Rng};
pub fn generate_token_and_expiration(token_length_bytes: usize, validity: TimeDelta) -> (String, NaiveDateTime) {
let value = std::iter::repeat(())
.map(|()| OsRng.sample(Alphanumeric))
.take(token_length_bytes)
.collect::<Vec<_>>();
let token = String::from_utf8(value).unwrap().try_into().unwrap();
let expires = Utc::now().naive_utc() + validity;
return (token, expires);
}

View File

@ -3,15 +3,15 @@
{% block body %}
<section class="section">
<div class="container">
<h1 class="title">Brass - Passwort zurücksetzen</h1>
<form class="box" hx-post="/reset-password" hx-params="not dry" hx-target-400="#error-message-retype"
<h1 class="title">{{ title }}</h1>
<form class="box" hx-post="{{ endpoint }}" hx-params="not dry" hx-target-400="#error-message-retype"
hx-on:input="document.getElementById('error-message-retype').innerHTML = ''">
<input type="hidden" name="token" value="{{ token }}" />
<input type="hidden" name="dry" value="true" />
<div class="field">
<label class="label" for="password">neues Passwort:</label>
<label class="label" for="password">{{ new_password_label }}</label>
<div class="control">
<input class="input" hx-post="/reset-password?dry=true" hx-params="*" hx-trigger="keyup changed delay:500ms"
hx-target="#password-strength" hx-target-400="#password-strength" placeholder="**********" name="password"
@ -22,7 +22,7 @@
</div>
<div class="field">
<label class="label" for="passwordretyped">neues Passwort wiederholen:</label>
<label class="label" for="passwordretyped">{{ retype_label }}</label>
<div class="control">
<input class="input" placeholder="**********" name="passwordretyped" type="password" maxlength=256 required>
</div>
@ -30,7 +30,7 @@
</div>
<div class="level">
<input class="button is-primary level-left" type="submit" value="Passwort zurücksetzen" />
<input class="button is-primary level-left" type="submit" value="{{ submit_button_label }}" />
</div>
</form>
</div>