feat(WIP): registration of users
This commit is contained in:
parent
ae2cff0c3a
commit
2374f78a07
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -791,6 +791,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"static-files",
|
||||
"thiserror",
|
||||
"zxcvbn",
|
||||
]
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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),
|
||||
|
@ -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);
|
||||
|
42
src/endpoints/user/get_register.rs
Normal file
42
src/endpoints/user/get_register.rs
Normal 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()
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
@ -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!");
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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, ®istration.token);
|
||||
|
||||
mailer.send(&message).unwrap();
|
||||
|
||||
Ok(HttpResponse::Found()
|
||||
.insert_header((LOCATION, "/users"))
|
||||
.finish())
|
||||
}
|
||||
|
85
src/endpoints/user/post_register.rs
Normal file
85
src/endpoints/user/post_register.rs
Normal 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>"#))
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -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"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
|
@ -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,
|
||||
|
50
src/models/registration.rs
Normal file
50
src/models/registration.rs
Normal 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(())
|
||||
}
|
||||
}
|
@ -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"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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(", ");
|
||||
|
||||
|
26
src/utils/application_error.rs
Normal file
26
src/utils/application_error.rs
Normal 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())
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
15
src/utils/token_generation.rs
Normal file
15
src/utils/token_generation.rs
Normal 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);
|
||||
}
|
@ -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>
|
Loading…
x
Reference in New Issue
Block a user