feat: password reset without email

This commit is contained in:
Max Hohlfeld 2024-05-20 20:29:37 +02:00
parent 53b50a153c
commit d8ef8104c6
16 changed files with 295 additions and 34 deletions

View File

@ -21,5 +21,5 @@ askama_actix = "0.14.0"
futures-util = "0.3.30" futures-util = "0.3.30"
serde_json = "1.0.114" serde_json = "1.0.114"
pico-args = "0.5.0" pico-args = "0.5.0"
rand = "0.8.5" rand = { version = "0.8.5", features = ["getrandom"] }
async-trait = "0.1.79" async-trait = "0.1.79"

View File

@ -87,4 +87,14 @@ CREATE UNLOGGED TABLE session
expires TIMESTAMP expires TIMESTAMP
); );
CREATE INDEX idx_cache_key ON session (key); CREATE INDEX session_key_idx ON session (key);
CREATE UNLOGGED TABLE passwordReset
(
id SERIAL PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
userId INTEGER NOT NULL REFERENCES user_ (id),
expires TIMESTAMP NOT NULL
);
CREATE INDEX passwordReset_token_idx ON passwordReset (token);

View File

@ -48,7 +48,7 @@ where
let is_logged_in = request.get_identity().is_ok(); let is_logged_in = request.get_identity().is_ok();
// Don't forward to `/login` if we are already on `/login`. // Don't forward to `/login` if we are already on `/login`.
if !is_logged_in && request.path() != "/login" && !request.path().starts_with("/static") { if !is_logged_in && request.path() != "/login" && !request.path().starts_with("/reset-password") && !request.path().starts_with("/static") {
let (request, _pl) = request.into_parts(); let (request, _pl) = request.into_parts();
let response = HttpResponse::Found() let response = HttpResponse::Found()

View File

@ -32,6 +32,8 @@ pub fn init(cfg: &mut ServiceConfig) {
cfg.service(user::get_logout::get); cfg.service(user::get_logout::get);
cfg.service(user::get_login::get); cfg.service(user::get_login::get);
cfg.service(user::post_login::post); cfg.service(user::post_login::post);
cfg.service(user::get_reset::get);
cfg.service(user::post_reset::post);
cfg.service(events::get_new::get); cfg.service(events::get_new::get);
cfg.service(events::post_new::post); cfg.service(events::post_new::post);

View File

@ -0,0 +1,41 @@
use actix_identity::Identity;
use actix_web::{get, http::header::LOCATION, web, HttpResponse, Responder};
use askama::Template;
use askama_actix::TemplateToResponse;
use serde::Deserialize;
#[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>
}
#[get("/reset-password")]
pub async fn get(user: Option<Identity>, 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() };
return template.to_response();
}
}
let template = ForgotPasswordTemplate {};
return template.to_response();
}

View File

@ -8,3 +8,5 @@ pub mod delete;
pub mod get_logout; pub mod get_logout;
pub mod get_login; pub mod get_login;
pub mod post_login; pub mod post_login;
pub mod get_reset;
pub mod post_reset;

View File

@ -57,7 +57,7 @@ pub async fn patch(
} }
if changed { if changed {
if let Ok(_) = User::update(pool.get_ref(), path.id, None, None, None, None, None, locked).await { if let Ok(_) = User::update(pool.get_ref(), path.id, None, None, None, None, None, None, None, locked).await {
return HttpResponse::Ok().body(""); return HttpResponse::Ok().body("");
} }
} else { } else {

View File

@ -83,7 +83,7 @@ pub async fn post_edit(
}; };
if changed { if changed {
match User::update(pool.get_ref(), path.id, email, name, role, function, area, None).await { match User::update(pool.get_ref(), path.id, email, name, None, None, role, function, area, None).await {
Ok(_) => return HttpResponse::Found().insert_header((LOCATION, "/users")).finish(), Ok(_) => return HttpResponse::Found().insert_header((LOCATION, "/users")).finish(),
Err(err) => println!("{}", err) Err(err) => println!("{}", err)
} }

View File

@ -17,18 +17,18 @@ async fn post(
request: HttpRequest, request: HttpRequest,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
) -> impl Responder { ) -> impl Responder {
if let Ok(result) = User::read_for_login(pool.get_ref(), &form.email).await { if let Ok(user) = User::read_for_login(pool.get_ref(), &form.email).await {
if let Some(user) = result { let hash = hash_plain_password_with_salt(&form.password, &user.salt).unwrap();
let hash = hash_plain_password_with_salt(&form.password, &user.salt).unwrap(); if hash == user.password {
if hash == user.password { 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).await.unwrap(); User::update_login_timestamp(pool.get_ref(), user.id)
.await
.unwrap();
return HttpResponse::Found() return HttpResponse::Found()
.insert_header(("HX-LOCATION", "/")) .insert_header(("HX-LOCATION", "/"))
.finish(); .finish();
}
} }
} }

View File

@ -0,0 +1,78 @@
use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;
use sqlx::PgPool;
use crate::{
auth,
models::{PasswordReset, User},
};
#[derive(Deserialize)]
struct ResetPasswordForm {
email: Option<String>,
token: Option<String>,
password: Option<String>,
passwordretyped: Option<String>,
}
#[actix_web::post("/reset-password")]
async fn post(form: web::Form<ResetPasswordForm>, pool: web::Data<PgPool>) -> impl Responder {
if form.email.is_some()
&& form.token.is_none()
&& form.password.is_none()
&& form.passwordretyped.is_none()
{
if let Ok(user) = User::read_for_login(pool.get_ref(), form.email.as_ref().unwrap()).await {
let reset = PasswordReset::insert_new_for_user(pool.get_ref(), user.id)
.await
.unwrap();
// send email to user
println!("{reset:?}");
}
return HttpResponse::Ok().body("E-Mail versandt!");
} else if form.email.is_none()
&& form.token.is_some()
&& form.password.is_some()
&& form.passwordretyped.is_some()
{
let token =
PasswordReset::does_token_exist(pool.get_ref(), form.token.as_ref().unwrap()).await;
if token.is_err() {
return HttpResponse::BadRequest().body("Token existiert nicht bzw. ist abgelaufen!");
}
if form.password.as_ref().unwrap() != form.passwordretyped.as_ref().unwrap() {
return HttpResponse::BadRequest().body("Passwörter stimmen nicht überein!");
}
let (hash, salt) =
auth::utils::generate_salt_and_hash_plain_password(form.password.as_ref().unwrap())
.unwrap();
User::update(
pool.get_ref(),
token.as_ref().unwrap().id,
None,
None,
Some(&hash),
Some(&salt),
None,
None,
None,
None,
)
.await
.unwrap();
PasswordReset::delete(pool.get_ref(), &token.unwrap().token)
.await
.unwrap();
return HttpResponse::Ok().body(r#"<div class="block">Passwort wurde geändert.</div><a class="block button is-primary" hx-boost="true" href="/login">Zum Login</a>"#);
} else {
return HttpResponse::BadRequest().finish();
}
}

View File

@ -7,6 +7,7 @@ mod location;
mod role; mod role;
mod user; mod user;
mod vehicle; mod vehicle;
mod password_reset;
pub use area::Area; pub use area::Area;
pub use availabillity::Availabillity; pub use availabillity::Availabillity;
@ -16,3 +17,4 @@ pub use location::Location;
pub use role::Role; 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;

View File

@ -0,0 +1,55 @@
use anyhow::Result;
use chrono::{NaiveDateTime, TimeDelta, Utc};
use rand::{distributions::Alphanumeric, rngs::OsRng, Rng as _};
use sqlx::{query_as, PgPool};
#[derive(Debug)]
pub struct PasswordReset {
pub id: i32,
pub token: String,
pub userid: i32,
pub expires: NaiveDateTime
}
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 inserted = query_as!(
PasswordReset,
"INSERT INTO passwordReset (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<PasswordReset> {
let result = query_as!(PasswordReset,
"SELECT * FROM passwordReset WHERE token = $1 AND expires > NOW();", token
)
.fetch_one(pool)
.await?;
Ok(result)
}
pub async fn delete(pool: &PgPool, token: &str) -> anyhow::Result<()> {
sqlx::query!("DELETE FROM passwordReset WHERE token = $1;", token)
.execute(pool)
.await?;
Ok(())
}
}

View File

@ -93,7 +93,7 @@ impl User {
Ok(user) Ok(user)
} }
pub async fn read_for_login(pool: &PgPool, email: &str) -> anyhow::Result<Option<User>> { pub async fn read_for_login(pool: &PgPool, email: &str) -> anyhow::Result<User> {
let record = sqlx::query!( let record = sqlx::query!(
r#" r#"
SELECT id, SELECT id,
@ -112,25 +112,22 @@ impl User {
"#, "#,
email, email,
) )
.fetch_optional(pool) .fetch_one(pool)
.await?; .await?;
let result = match record { let result = User {
Some(record) => Some(User { id: record.id,
id: record.id, name: record.name,
name: record.name, email: record.email,
email: record.email, password: record.password,
password: record.password, salt: record.salt,
salt: record.salt, role: record.role,
role: record.role, function: record.function,
function: record.function, area_id: record.areaid,
area_id: record.areaid, area: None,
area: None, locked: record.locked,
locked: record.locked, last_login: record.lastlogin,
last_login: record.lastlogin, receive_notifications: record.receivenotifications,
receive_notifications: record.receivenotifications,
}),
None => None,
}; };
Ok(result) Ok(result)
@ -273,6 +270,8 @@ impl User {
id: i32, id: i32,
email: Option<&str>, email: Option<&str>,
name: Option<&str>, name: Option<&str>,
password: Option<&str>,
salt: Option<&str>,
role: Option<Role>, role: Option<Role>,
function: Option<Function>, function: Option<Function>,
area_id: Option<i32>, area_id: Option<i32>,
@ -291,6 +290,16 @@ impl User {
separated.push_bind_unseparated(name); separated.push_bind_unseparated(name);
} }
if let Some(password) = password {
separated.push("password = ");
separated.push_bind_unseparated(password);
}
if let Some(salt) = salt {
separated.push("salt = ");
separated.push_bind_unseparated(salt);
}
if let Some(role) = role { if let Some(role) = role {
separated.push("role = "); separated.push("role = ");
separated.push_bind_unseparated(role as Role); separated.push_bind_unseparated(role as Role);

View File

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block body %}
<section class="section">
<div class="container">
<h1 class="title">Brass - Passwort zurücksetzen</h1>
<article class="message is-info">
<div class="message-body">
Gib deine E-Mail Adresse ein und erhalte einen Link zum Zurücksetzen deines Passworts, sofern ein Account mit dieser Adresse existiert. Bei Problemen wende dich an <a href="mailto:mail@example.com">mail@example.com</a>.
</div>
</article>
<form class="box" hx-post="/reset-password">
<div class="field">
<label class="label" for="email">E-Mail:</label>
<div class="control">
<input class="input" placeholder="max.mustermann@example.com" name="email" type="text" required>
</div>
</div>
<div class="level">
<input class="button is-primary level-left" type="submit" value="Passwort zurücksetzen" />
<a class="button is-info is-light level-right" hx-boost="true" href="/login" >Zurück zur Anmeldung</a>
</div>
</form>
</div>
</section>
{% endblock %}

View File

@ -21,7 +21,10 @@
<div id="error-message" class="mb-3 help is-danger"></div> <div id="error-message" class="mb-3 help is-danger"></div>
<input class="button is-primary" type="submit" value="Anmelden"> <div class="level">
<input class="button is-primary level-left" type="submit" value="Anmelden" />
<a class="button is-info is-light level-right" hx-boost="true" href="/reset-password" >Passwort vergessen</a>
</div>
</form> </form>
</div> </div>
</section> </section>

View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block body %}
<section class="section">
<div class="container">
<h1 class="title">Brass - Passwort zurücksetzen</h1>
<form class="box" hx-post="/reset-password" hx-target-400="#error-message" hx-on:change="document.getElementById('error-message').innerHTML = ''">
<input type="hidden" name="token" value="{{ token }}"/>
<div class="field">
<label class="label" for="password">neues Passwort:</label>
<div class="control">
<input class="input" placeholder="**********" name="password" type="password" required>
</div>
</div>
<div class="field">
<label class="label" for="passwordretyped">neues Passwort wiederholen:</label>
<div class="control">
<input class="input" placeholder="**********" name="passwordretyped" type="password" required>
</div>
</div>
<div id="error-message" class="mb-3 help is-danger"></div>
<div class="level">
<input class="button is-primary level-left" type="submit" value="Passwort zurücksetzen" />
</div>
</form>
</div>
</section>
{% endblock %}