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", "serde_json",
"sqlx", "sqlx",
"static-files", "static-files",
"thiserror",
"zxcvbn", "zxcvbn",
] ]

View File

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

View File

@ -21,8 +21,8 @@ CREATE TABLE user_
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
email TEXT NOT NULL, email TEXT NOT NULL,
password TEXT NOT NULL, password TEXT ,
salt TEXT NOT NULL, salt TEXT ,
role role NOT NULL, role role NOT NULL,
function function NOT NULL, function function NOT NULL,
areaId INTEGER NOT NULL REFERENCES area (id), 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::post_new::post_new);
cfg.service(user::get_edit::get_edit); cfg.service(user::get_edit::get_edit);
cfg.service(user::post_edit::post_edit); cfg.service(user::post_edit::post_edit);
cfg.service(user::patch::patch);
cfg.service(user::delete::delete); cfg.service(user::delete::delete);
cfg.service(user::get_logout::get); cfg.service(user::get_logout::get);
cfg.service(user::get_login::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::Template;
use askama_actix::TemplateToResponse; use askama_actix::TemplateToResponse;
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool;
use crate::models::PasswordReset;
use super::ResetPasswordTemplate;
#[derive(Template)] #[derive(Template)]
#[template(path = "user/forgot_password.html")] #[template(path = "user/forgot_password.html")]
struct ForgotPasswordTemplate {} struct ForgotPasswordTemplate {}
#[derive(Template)]
#[template(path = "user/reset_password.html")]
struct ResetPasswordTemplate {
token: String
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct TokenQuery { struct TokenQuery {
token: Option<String> token: Option<String>,
} }
#[get("/reset-password")] #[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 { if let Some(_) = user {
return HttpResponse::Found() return HttpResponse::Found()
.insert_header((LOCATION, "/")) .insert_header((LOCATION, "/"))
.finish(); .finish();
} else if let Some(token) = &query.token { } else if let Some(token) = &query.token {
let token_exists = true; if let Ok(_) = PasswordReset::does_token_exist(pool.get_ref(), token).await {
let template = ResetPasswordTemplate {
if token_exists { token,
let template = ResetPasswordTemplate { token: token.to_string() }; 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(); return template.to_response();
} }

View File

@ -11,13 +11,14 @@ pub mod get_new;
pub mod get_overview; pub mod get_overview;
pub mod get_profile; pub mod get_profile;
pub mod get_reset; pub mod get_reset;
pub mod patch;
pub mod post_changepassword; pub mod post_changepassword;
pub mod post_edit; pub mod post_edit;
pub mod post_login; pub mod post_login;
pub mod post_new; pub mod post_new;
pub mod post_reset; pub mod post_reset;
pub mod post_toggle; pub mod post_toggle;
pub mod get_register;
pub mod post_register;
#[derive(Template)] #[derive(Template)]
#[template(path = "user/new_or_edit.html")] #[template(path = "user/new_or_edit.html")]
@ -31,3 +32,14 @@ pub struct NewOrEditUserTemplate {
function: Option<u8>, function: Option<u8>,
area_id: Option<i32>, 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>, form: web::Form<ChangePasswordForm>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
) -> impl Responder { ) -> impl Responder {
if user.password if user.password.as_ref().is_some_and(|p|
== utils::hash_plain_password_with_salt(&form.currentpassword, &user.salt).unwrap() p == &utils::hash_plain_password_with_salt(
{ &form.currentpassword,
user.salt.as_ref().unwrap(),
)
.unwrap()
) {
if form.password != form.passwordretyped { if form.password != form.passwordretyped {
return HttpResponse::BadRequest().body("Passwörter stimmen nicht überein!"); return HttpResponse::BadRequest().body("Passwörter stimmen nicht überein!");
} }

View File

@ -18,8 +18,10 @@ async fn post(
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
) -> impl Responder { ) -> impl Responder {
if let Ok(user) = User::read_for_login(pool.get_ref(), &form.email).await { 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(); let salt = user.salt.unwrap();
if hash == user.password {
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(); Identity::login(&request.extensions(), user.id.to_string()).unwrap();
User::update_login_timestamp(pool.get_ref(), user.id) 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 actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use lettre::{SmtpTransport, Transport};
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; 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)] #[derive(Deserialize)]
pub struct NewUserForm { pub struct NewUserForm {
email: String, email: String,
name: String, name: String,
password: String,
role: u8, role: u8,
function: u8, function: u8,
area: Option<i32> area: Option<i32>,
} }
#[actix_web::post("/users/new")] #[actix_web::post("/users/new")]
pub async fn post_new(user: Identity, pool: web::Data<PgPool>, form: web::Form<NewUserForm>) -> impl Responder { pub async fn post_new(
let current_user = User::read_by_id(pool.get_ref(), user.id().unwrap().parse().unwrap()).await.unwrap(); user: web::ReqData<User>,
pool: web::Data<PgPool>,
if current_user.role != Role::AreaManager && current_user.role != Role::Admin { form: web::Form<NewUserForm>,
return HttpResponse::Unauthorized().finish(); 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 user.role == Role::Admin && form.area.is_some() {
if let Some(id) = form.area { area_id = form.area.unwrap();
area_id = id;
}
} }
if let Ok((hash, salt)) = generate_salt_and_hash_plain_password(&form.password) { let role = Role::try_from(form.role)?;
if let Ok(role) = Role::try_from(form.role) { let function = Function::try_from(form.function)?;
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)
}
}
}
}
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 actix_web::{web, HttpResponse, Responder};
use lettre::{ use lettre::{SmtpTransport, Transport};
message::{header::ContentType, MultiPart, SinglePart},
Message, SmtpTransport, Transport,
};
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use zxcvbn::{zxcvbn, Score}; use zxcvbn::{zxcvbn, Score};
@ -12,7 +7,7 @@ use zxcvbn::{zxcvbn, Score};
use crate::{ use crate::{
auth::{self}, auth::{self},
models::{PasswordReset, User}, models::{PasswordReset, User},
utils::password_help, utils::{email, password_help},
}; };
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@ -40,43 +35,7 @@ async fn post(
.await .await
.unwrap(); .unwrap();
let hostname = env::var("HOSTNAME").unwrap(); let message = email::build_forgot_password_message(&user, &reset.token);
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();
mailer.send(&message).unwrap(); mailer.send(&message).unwrap();
} }

View File

@ -2,6 +2,8 @@ use std::fmt::Display;
use serde::Serialize; use serde::Serialize;
use crate::utils::ApplicationError;
#[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(sqlx::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[sqlx(type_name = "function", rename_all = "lowercase")] #[sqlx(type_name = "function", rename_all = "lowercase")]
pub enum Function { pub enum Function {
@ -13,19 +15,22 @@ impl Display for Function {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Function::Posten => write!(f, "Posten"), Function::Posten => write!(f, "Posten"),
Function::Wachhabender => write!(f, "Wachhabender") Function::Wachhabender => write!(f, "Wachhabender"),
} }
} }
} }
impl TryFrom<u8> for Function { impl TryFrom<u8> for Function {
type Error = (); type Error = ApplicationError;
fn try_from(value: u8) -> Result<Self, Self::Error> { fn try_from(value: u8) -> Result<Self, Self::Error> {
match value { match value {
1 => Ok(Function::Posten), 1 => Ok(Function::Posten),
10 => Ok(Function::Wachhabender), 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 user;
mod vehicle; mod vehicle;
mod password_reset; mod password_reset;
mod registration;
pub use area::Area; pub use area::Area;
pub use availabillity::Availabillity; pub use availabillity::Availabillity;
@ -18,3 +19,6 @@ pub use role::Role;
pub use user::User; pub use user::User;
pub use assignement::Assignment; pub use assignement::Assignment;
pub use password_reset::PasswordReset; 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 anyhow::Result;
use chrono::{NaiveDateTime, TimeDelta, Utc}; use chrono::{NaiveDateTime, TimeDelta};
use rand::{distributions::Alphanumeric, rngs::OsRng, Rng as _};
use sqlx::{query_as, PgPool}; use sqlx::{query_as, PgPool};
use crate::utils::token_generation::generate_token_and_expiration;
#[derive(Debug)] #[derive(Debug)]
pub struct PasswordReset { pub struct PasswordReset {
pub id: i32, pub id: i32,
@ -13,14 +14,7 @@ pub struct PasswordReset {
impl PasswordReset { impl PasswordReset {
pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result<PasswordReset>{ pub async fn insert_new_for_user(pool: &PgPool, user_id: i32) -> Result<PasswordReset>{
let value = std::iter::repeat(()) let (token, expires) = generate_token_and_expiration(64, TimeDelta::hours(24));
.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 inserted = query_as!( let inserted = query_as!(
PasswordReset, 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)] #[derive(sqlx::Type, Debug, Clone, Copy, PartialEq)]
#[sqlx(type_name = "role", rename_all = "lowercase")] #[sqlx(type_name = "role", rename_all = "lowercase")]
pub enum Role { pub enum Role {
@ -7,14 +9,17 @@ pub enum Role {
} }
impl TryFrom<u8> for Role { impl TryFrom<u8> for Role {
type Error = (); type Error = ApplicationError;
fn try_from(value: u8) -> Result<Self, Self::Error> { fn try_from(value: u8) -> Result<Self, Self::Error> {
match value { match value {
1 => Ok(Role::Staff), 1 => Ok(Role::Staff),
10 => Ok(Role::AreaManager), 10 => Ok(Role::AreaManager),
100 => Ok(Role::Admin), 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 chrono::{DateTime, Utc};
use sqlx::PgPool; use sqlx::PgPool;
use super::{Area, Function, Role}; use super::{Area, Function, Result, Role};
#[derive(Clone)] #[derive(Clone)]
pub struct User { pub struct User {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
pub email: String, pub email: String,
pub password: String, pub password: Option<String>,
pub salt: String, pub salt: Option<String>,
pub role: Role, pub role: Role,
pub function: Function, pub function: Function,
pub area_id: i32, pub area_id: i32,
@ -21,6 +21,31 @@ pub struct User {
impl User { impl User {
pub async fn create( 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, pool: &PgPool,
name: &str, name: &str,
email: &str, email: &str,
@ -29,8 +54,8 @@ impl User {
role: Role, role: Role,
function: Function, function: Function,
area_id: i32, area_id: i32,
) -> anyhow::Result<i32> { ) -> Result<i32> {
let created = sqlx::query!( let b = sqlx::query!(
r#" r#"
INSERT INTO user_ (name, email, password, salt, role, function, areaId) INSERT INTO user_ (name, email, password, salt, role, function, areaId)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7)
@ -45,15 +70,13 @@ impl User {
area_id area_id
) )
.fetch_one(pool) .fetch_one(pool)
.await; .await
.and_then(|r| Ok(r.id));
match created { b
Ok(result) => Ok(result.id),
Err(err) => Err(err.into()),
}
} }
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!( let record = sqlx::query!(
r#" r#"
SELECT id, SELECT id,
@ -108,7 +131,7 @@ impl User {
lastLogin, lastLogin,
receiveNotifications receiveNotifications
FROM user_ 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, email,
) )
@ -277,7 +300,7 @@ impl User {
area_id: Option<i32>, area_id: Option<i32>,
receive_notifications: Option<bool>, receive_notifications: Option<bool>,
locked: Option<bool>, locked: Option<bool>,
) -> anyhow::Result<()> { ) -> Result<()> {
let mut query_builder = sqlx::QueryBuilder::new("UPDATE user_ SET "); let mut query_builder = sqlx::QueryBuilder::new("UPDATE user_ SET ");
let mut separated = query_builder.separated(", "); 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 std::env;
use lettre::{ use lettre::{
message::{header::ContentType, MultiPart, SinglePart},
transport::smtp::{authentication::Credentials, extension::ClientId}, transport::smtp::{authentication::Credentials, extension::ClientId},
SmtpTransport, Message, SmtpTransport,
}; };
use crate::models::User;
pub fn get_mailer() -> anyhow::Result<SmtpTransport> { pub fn get_mailer() -> anyhow::Result<SmtpTransport> {
let server = &env::var("SMTP_SERVER")?; let server = &env::var("SMTP_SERVER")?;
let port = &env::var("SMTP_PORT")?.parse()?; let port = &env::var("SMTP_PORT")?.parse()?;
@ -33,3 +36,87 @@ pub fn get_mailer() -> anyhow::Result<SmtpTransport> {
Ok(mailer) 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)?; let (hash, salt) = generate_salt_and_hash_plain_password(&password)?;
User::create( User::create_with_password(
&pool, &pool,
&name, &name,
&email, &email,

View File

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