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"
|
||||
serde_json = "1.0.114"
|
||||
pico-args = "0.5.0"
|
||||
rand = "0.8.5"
|
||||
rand = { version = "0.8.5", features = ["getrandom"] }
|
||||
async-trait = "0.1.79"
|
||||
|
@ -87,4 +87,14 @@ CREATE UNLOGGED TABLE session
|
||||
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();
|
||||
|
||||
// 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 response = HttpResponse::Found()
|
||||
|
@ -32,6 +32,8 @@ pub fn init(cfg: &mut ServiceConfig) {
|
||||
cfg.service(user::get_logout::get);
|
||||
cfg.service(user::get_login::get);
|
||||
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::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_login;
|
||||
pub mod post_login;
|
||||
pub mod get_reset;
|
||||
pub mod post_reset;
|
||||
|
@ -57,7 +57,7 @@ pub async fn patch(
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
} else {
|
||||
|
@ -83,7 +83,7 @@ pub async fn post_edit(
|
||||
};
|
||||
|
||||
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(),
|
||||
Err(err) => println!("{}", err)
|
||||
}
|
||||
|
@ -17,20 +17,20 @@ async fn post(
|
||||
request: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
) -> impl Responder {
|
||||
if let Ok(result) = User::read_for_login(pool.get_ref(), &form.email).await {
|
||||
if let Some(user) = result {
|
||||
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 {
|
||||
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()
|
||||
.insert_header(("HX-LOCATION", "/"))
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return HttpResponse::BadRequest().body("E-Mail oder Passwort falsch.");
|
||||
}
|
||||
|
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 user;
|
||||
mod vehicle;
|
||||
mod password_reset;
|
||||
|
||||
pub use area::Area;
|
||||
pub use availabillity::Availabillity;
|
||||
@ -16,3 +17,4 @@ pub use location::Location;
|
||||
pub use role::Role;
|
||||
pub use user::User;
|
||||
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)
|
||||
}
|
||||
|
||||
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!(
|
||||
r#"
|
||||
SELECT id,
|
||||
@ -112,11 +112,10 @@ impl User {
|
||||
"#,
|
||||
email,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let result = match record {
|
||||
Some(record) => Some(User {
|
||||
let result = User {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
email: record.email,
|
||||
@ -129,8 +128,6 @@ impl User {
|
||||
locked: record.locked,
|
||||
last_login: record.lastlogin,
|
||||
receive_notifications: record.receivenotifications,
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
@ -273,6 +270,8 @@ impl User {
|
||||
id: i32,
|
||||
email: Option<&str>,
|
||||
name: Option<&str>,
|
||||
password: Option<&str>,
|
||||
salt: Option<&str>,
|
||||
role: Option<Role>,
|
||||
function: Option<Function>,
|
||||
area_id: Option<i32>,
|
||||
@ -291,6 +290,16 @@ impl User {
|
||||
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 {
|
||||
separated.push("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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</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