feat: password reset without email
This commit is contained in:
parent
53b50a153c
commit
d8ef8104c6
@ -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"
|
||||||
|
@ -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);
|
||||||
|
@ -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()
|
||||||
|
@ -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);
|
||||||
|
41
src/endpoints/user/get_reset.rs
Normal file
41
src/endpoints/user/get_reset.rs
Normal 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();
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
78
src/endpoints/user/post_reset.rs
Normal file
78
src/endpoints/user/post_reset.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
55
src/models/password_reset.rs
Normal file
55
src/models/password_reset.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
27
templates/user/forgot_password.html
Normal file
27
templates/user/forgot_password.html
Normal 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 %}
|
@ -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>
|
||||||
|
32
templates/user/reset_password.html
Normal file
32
templates/user/reset_password.html
Normal 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 %}
|
Loading…
x
Reference in New Issue
Block a user