Complete reset password flow
This commit is contained in:
parent
d5c5185351
commit
60671d5865
40
Cargo.lock
generated
40
Cargo.lock
generated
@ -358,6 +358,17 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-client-ip"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ef117890a418b7832678d9ea1e1c08456dd7b2fd1dadb9676cd6f0fe7eb4b21"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"forwarded-header-value",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum-core"
|
name = "axum-core"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@ -737,6 +748,7 @@ dependencies = [
|
|||||||
"argon2",
|
"argon2",
|
||||||
"async-redis-session",
|
"async-redis-session",
|
||||||
"axum",
|
"axum",
|
||||||
|
"axum-client-ip",
|
||||||
"axum-login",
|
"axum-login",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -746,6 +758,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"headers",
|
"headers",
|
||||||
"http",
|
"http",
|
||||||
|
"ipnetwork",
|
||||||
"lettre",
|
"lettre",
|
||||||
"maud",
|
"maud",
|
||||||
"notify",
|
"notify",
|
||||||
@ -1110,6 +1123,16 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "forwarded-header-value"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
||||||
|
dependencies = [
|
||||||
|
"nonempty",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fsevent-sys"
|
name = "fsevent-sys"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@ -1684,6 +1707,15 @@ version = "2.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
|
checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipnetwork"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@ -2043,6 +2075,12 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nonempty"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify"
|
name = "notify"
|
||||||
version = "6.1.1"
|
version = "6.1.1"
|
||||||
@ -3163,6 +3201,7 @@ dependencies = [
|
|||||||
"hashlink",
|
"hashlink",
|
||||||
"hex",
|
"hex",
|
||||||
"indexmap 2.0.0",
|
"indexmap 2.0.0",
|
||||||
|
"ipnetwork",
|
||||||
"log",
|
"log",
|
||||||
"memchr",
|
"memchr",
|
||||||
"native-tls",
|
"native-tls",
|
||||||
@ -3290,6 +3329,7 @@ dependencies = [
|
|||||||
"hkdf",
|
"hkdf",
|
||||||
"hmac 0.12.1",
|
"hmac 0.12.1",
|
||||||
"home",
|
"home",
|
||||||
|
"ipnetwork",
|
||||||
"itoa 1.0.9",
|
"itoa 1.0.9",
|
||||||
"log",
|
"log",
|
||||||
"md-5",
|
"md-5",
|
||||||
|
@ -18,6 +18,7 @@ anyhow = "1"
|
|||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
async-redis-session = "0.2"
|
async-redis-session = "0.2"
|
||||||
axum = { version = "0.6", features = ["form", "headers", "multipart", "query"] }
|
axum = { version = "0.6", features = ["form", "headers", "multipart", "query"] }
|
||||||
|
axum-client-ip = "0.4"
|
||||||
# waiting for new axum-login release which will support sqlx v. 0.7+
|
# waiting for new axum-login release which will support sqlx v. 0.7+
|
||||||
axum-login = { git = "https://github.com/maxcountryman/axum-login", branch = "main", features = ["postgres"] }
|
axum-login = { git = "https://github.com/maxcountryman/axum-login", branch = "main", features = ["postgres"] }
|
||||||
bytes = "1.4"
|
bytes = "1.4"
|
||||||
@ -28,6 +29,7 @@ feed-rs = "1.3"
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
headers = "0.3"
|
headers = "0.3"
|
||||||
http = "0.2.9"
|
http = "0.2.9"
|
||||||
|
ipnetwork = "0.20"
|
||||||
lettre = { version = "0.10", features = ["builder"] }
|
lettre = { version = "0.10", features = ["builder"] }
|
||||||
maud = { version = "0.25", features = ["axum"] }
|
maud = { version = "0.25", features = ["axum"] }
|
||||||
notify = "6"
|
notify = "6"
|
||||||
@ -45,6 +47,7 @@ sqlx = { version = "0.7", features = [
|
|||||||
"migrate",
|
"migrate",
|
||||||
"chrono",
|
"chrono",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"ipnetwork",
|
||||||
] }
|
] }
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
@ -38,6 +38,7 @@ builds
|
|||||||
createdb crawlnicle
|
createdb crawlnicle
|
||||||
sudo -u postgres -i psql
|
sudo -u postgres -i psql
|
||||||
postgres=# ALTER DATABASE crawlnicle OWNER TO crawlnicle;
|
postgres=# ALTER DATABASE crawlnicle OWNER TO crawlnicle;
|
||||||
|
postgres=# ALTER USER crawlnicle CREATEDB;
|
||||||
\password crawlnicle
|
\password crawlnicle
|
||||||
|
|
||||||
# Or, on Windows in PowerShell:
|
# Or, on Windows in PowerShell:
|
||||||
|
@ -273,6 +273,11 @@ header.feed-header button {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-form-grid .forgot-password {
|
||||||
|
grid-column: 2;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-form-grid span.error {
|
.auth-form-grid span.error {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
grid-column: 2 / 3;
|
grid-column: 2 / 3;
|
||||||
|
3
justfile
3
justfile
@ -53,5 +53,8 @@ watch-backend:
|
|||||||
watch:
|
watch:
|
||||||
./watch.sh
|
./watch.sh
|
||||||
|
|
||||||
|
reset:
|
||||||
|
sqlx database reset
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
sqlx migrate run
|
sqlx migrate run
|
||||||
|
@ -90,3 +90,14 @@ create table if not exists "user_email_verification_token" (
|
|||||||
updated_at timestamptz
|
updated_at timestamptz
|
||||||
);
|
);
|
||||||
select trigger_updated_at('"user_email_verification_token"');
|
select trigger_updated_at('"user_email_verification_token"');
|
||||||
|
|
||||||
|
create table if not exists "user_password_reset_token" (
|
||||||
|
token_id uuid primary key default uuid_generate_v4(),
|
||||||
|
user_id uuid not null references "users" (user_id) on delete cascade,
|
||||||
|
request_user_agent text,
|
||||||
|
request_ip inet not null,
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz
|
||||||
|
);
|
||||||
|
select trigger_updated_at('"user_password_reset_token"');
|
||||||
|
@ -1,7 +1,34 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use axum_client_ip::SecureClientIpSource;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use lettre::message::Mailbox;
|
use lettre::message::Mailbox;
|
||||||
|
use serde::Deserialize;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct IpSource(pub SecureClientIpSource);
|
||||||
|
|
||||||
|
impl FromStr for IpSource {
|
||||||
|
type Err = &'static str;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
// SourceClientIpSource doesn't implement FromStr itself, so I have to implement it on this
|
||||||
|
// wrapping newtype. See https://github.com/imbolc/axum-client-ip/issues/11
|
||||||
|
let inner = match s {
|
||||||
|
"RightmostForwarded" => SecureClientIpSource::RightmostForwarded,
|
||||||
|
"RightmostXForwardedFor" => SecureClientIpSource::RightmostXForwardedFor,
|
||||||
|
"XRealIp" => SecureClientIpSource::XRealIp,
|
||||||
|
"FlyClientIp" => SecureClientIpSource::FlyClientIp,
|
||||||
|
"TrueClientIp" => SecureClientIpSource::TrueClientIp,
|
||||||
|
"CfConnectingIp" => SecureClientIpSource::CfConnectingIp,
|
||||||
|
"ConnectInfo" => SecureClientIpSource::ConnectInfo,
|
||||||
|
_ => return Err("Unknown variant"),
|
||||||
|
};
|
||||||
|
Ok(Self(inner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Parser, Clone, Debug)]
|
#[derive(Parser, Clone, Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[clap(long, env)]
|
#[clap(long, env)]
|
||||||
@ -32,4 +59,6 @@ pub struct Config {
|
|||||||
pub email_from: Mailbox,
|
pub email_from: Mailbox,
|
||||||
#[clap(long, env)]
|
#[clap(long, env)]
|
||||||
pub session_secret: String,
|
pub session_secret: String,
|
||||||
|
#[clap(long, env)]
|
||||||
|
pub ip_source: IpSource,
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
use axum::{Form, TypedHeader};
|
use axum::{Form, TypedHeader};
|
||||||
use axum_login::SqlxStore;
|
|
||||||
use lettre::SmtpTransport;
|
use lettre::SmtpTransport;
|
||||||
use maud::html;
|
use maud::html;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
127
src/handlers/forgot_password.rs
Normal file
127
src/handlers/forgot_password.rs
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::TypedHeader;
|
||||||
|
use axum::{extract::State, Form};
|
||||||
|
use axum_client_ip::SecureClientIp;
|
||||||
|
use headers::UserAgent;
|
||||||
|
use lettre::SmtpTransport;
|
||||||
|
use maud::html;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_with::serde_as;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::htmx::HXTarget;
|
||||||
|
use crate::mailers::forgot_password::send_forgot_password_email;
|
||||||
|
use crate::models::user::AuthContext;
|
||||||
|
use crate::partials::forgot_password_form::{forgot_password_form, ForgotPasswordFormProps};
|
||||||
|
use crate::{models::user::User, partials::layout::Layout};
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ForgotPassword {
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forgot_password_page(
|
||||||
|
hx_target: Option<TypedHeader<HXTarget>>,
|
||||||
|
layout: Layout,
|
||||||
|
form_props: ForgotPasswordFormProps,
|
||||||
|
) -> Response {
|
||||||
|
layout
|
||||||
|
.with_subtitle("forgot password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Forgot Password" }
|
||||||
|
}
|
||||||
|
p class="readable-width" {
|
||||||
|
"A password reset email will be sent if the email submitted matches an account in the system and the email is verfied. If your email is not verified, " a href="/confirm-email" { "please verify your email first" } "."
|
||||||
|
}
|
||||||
|
(forgot_password_form(form_props))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(
|
||||||
|
auth: AuthContext,
|
||||||
|
hx_target: Option<TypedHeader<HXTarget>>,
|
||||||
|
layout: Layout,
|
||||||
|
) -> Result<Response> {
|
||||||
|
Ok(forgot_password_page(
|
||||||
|
hx_target,
|
||||||
|
layout,
|
||||||
|
ForgotPasswordFormProps {
|
||||||
|
email: auth.current_user.map(|u| u.email),
|
||||||
|
email_error: None,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
State(mailer): State<SmtpTransport>,
|
||||||
|
State(config): State<Config>,
|
||||||
|
SecureClientIp(ip): SecureClientIp,
|
||||||
|
hx_target: Option<TypedHeader<HXTarget>>,
|
||||||
|
user_agent: Option<TypedHeader<UserAgent>>,
|
||||||
|
layout: Layout,
|
||||||
|
Form(forgot_password): Form<ForgotPassword>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
dbg!(&ip);
|
||||||
|
dbg!(&user_agent);
|
||||||
|
dbg!(&forgot_password.email);
|
||||||
|
let user: User = match User::get_by_email(&pool, forgot_password.email.clone()).await {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(err) => {
|
||||||
|
dbg!(&err);
|
||||||
|
if let Error::NotFoundString(_, _) = err {
|
||||||
|
info!(email = forgot_password.email, "invalid email");
|
||||||
|
return Ok(layout
|
||||||
|
.with_subtitle("forgot password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Reset password email sent" }
|
||||||
|
}
|
||||||
|
p class="readable-width" {
|
||||||
|
"If the email you entered matched an existing account with a verified email, then a password reset email was sent. Please follow the link sent in the email."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if user.email_verified {
|
||||||
|
info!(user_id = %user.user_id, "user exists with verified email, sending password reset email");
|
||||||
|
send_forgot_password_email(
|
||||||
|
pool,
|
||||||
|
mailer,
|
||||||
|
config,
|
||||||
|
user,
|
||||||
|
ip.into(),
|
||||||
|
user_agent.map(|ua| ua.to_string()),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!(user_id = %user.user_id, "user exists with unverified email, skip sending password reset email");
|
||||||
|
}
|
||||||
|
Ok(layout
|
||||||
|
.with_subtitle("forgot password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Reset password email sent" }
|
||||||
|
}
|
||||||
|
p class="readable-width" {
|
||||||
|
"If the email you entered matched an existing account with a verified email, then a password reset email was sent. Please follow the link sent in the email."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
@ -57,7 +57,7 @@ pub async fn post(
|
|||||||
Ok(user) => user,
|
Ok(user) => user,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let Error::NotFoundString(_, _) = err {
|
if let Error::NotFoundString(_, _) = err {
|
||||||
info!(email = login.email, "invalid enail");
|
info!(email = login.email, "invalid email");
|
||||||
return Ok(login_page(
|
return Ok(login_page(
|
||||||
hx_target,
|
hx_target,
|
||||||
layout,
|
layout,
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
use axum::response::Redirect;
|
use crate::{models::user::AuthContext, htmx::HXRedirect};
|
||||||
|
|
||||||
use crate::models::user::AuthContext;
|
pub async fn get(mut auth: AuthContext) -> HXRedirect {
|
||||||
|
|
||||||
pub async fn get(mut auth: AuthContext) -> Redirect {
|
|
||||||
auth.logout().await;
|
auth.logout().await;
|
||||||
Redirect::to("/")
|
HXRedirect::to("/").reload(true)
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,9 @@ pub mod home;
|
|||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod feeds;
|
pub mod feeds;
|
||||||
|
pub mod forgot_password;
|
||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod logout;
|
pub mod logout;
|
||||||
pub mod register;
|
pub mod register;
|
||||||
|
pub mod reset_password;
|
||||||
|
306
src/handlers/reset_password.rs
Normal file
306
src/handlers/reset_password.rs
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
use axum::extract::Query;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::TypedHeader;
|
||||||
|
use axum::{extract::State, Form};
|
||||||
|
use axum_client_ip::SecureClientIp;
|
||||||
|
use headers::UserAgent;
|
||||||
|
use lettre::SmtpTransport;
|
||||||
|
use maud::html;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_with::serde_as;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::htmx::HXTarget;
|
||||||
|
use crate::mailers::reset_password::send_password_reset_email;
|
||||||
|
use crate::models::user::UpdateUserPassword;
|
||||||
|
use crate::models::user_password_reset_token::UserPasswordResetToken;
|
||||||
|
use crate::partials::reset_password_form::{reset_password_form, ResetPasswordFormProps};
|
||||||
|
use crate::uuid::Base62Uuid;
|
||||||
|
use crate::{models::user::User, partials::layout::Layout};
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ResetPassword {
|
||||||
|
pub token: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub password_confirmation: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ResetPasswordQuery {
|
||||||
|
pub token_id: Option<Base62Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_password_page(
|
||||||
|
hx_target: Option<TypedHeader<HXTarget>>,
|
||||||
|
layout: Layout,
|
||||||
|
form_props: ResetPasswordFormProps,
|
||||||
|
) -> Response {
|
||||||
|
layout
|
||||||
|
.with_subtitle("forgot password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Reset Password" }
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
"A password reset email will be sent if the email submitted matches an account in the system and the email is verfied. If your email is not verified, " a href="/confirm-email" { "please verify your email first" } "."
|
||||||
|
}
|
||||||
|
(reset_password_form(form_props))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
hx_target: Option<TypedHeader<HXTarget>>,
|
||||||
|
layout: Layout,
|
||||||
|
query: Query<ResetPasswordQuery>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
if let Some(token_id) = query.token_id {
|
||||||
|
info!(token_id = %token_id.as_uuid(), "get with token_id");
|
||||||
|
let token = match UserPasswordResetToken::get(&pool, token_id.as_uuid()).await {
|
||||||
|
Ok(token) => token,
|
||||||
|
Err(err) => {
|
||||||
|
if let Error::NotFoundUuid(_, _) = err {
|
||||||
|
warn!(token_id = %token_id.as_uuid(), "token not found in database");
|
||||||
|
return Ok(layout
|
||||||
|
.with_subtitle("reset password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Password reset token not found" }
|
||||||
|
}
|
||||||
|
p class="readable-width" { "The reset password link has already been used or is invalid." }
|
||||||
|
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } "." }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if token.expired() {
|
||||||
|
warn!(token_id = %token.token_id, "token expired");
|
||||||
|
Ok(layout
|
||||||
|
.with_subtitle("reset password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Password reset token is expired" }
|
||||||
|
}
|
||||||
|
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } ". The link in the email will be valid for 24 hours." }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
info!(token_id = %token.token_id, "token valid, showing reset password form");
|
||||||
|
let user = User::get(&pool, token.user_id).await?;
|
||||||
|
Ok(layout
|
||||||
|
.with_subtitle("reset password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Reset Password" }
|
||||||
|
}
|
||||||
|
(reset_password_form(ResetPasswordFormProps {
|
||||||
|
token: token.token_id,
|
||||||
|
email: user.email,
|
||||||
|
password_error: None,
|
||||||
|
general_error: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(layout
|
||||||
|
.with_subtitle("reset password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Missing password reset token" }
|
||||||
|
}
|
||||||
|
p class="readable-width" { "Passwords can only be reset by requesting a password reset email and following the unique link within the email."}
|
||||||
|
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } "." }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
State(mailer): State<SmtpTransport>,
|
||||||
|
State(config): State<Config>,
|
||||||
|
SecureClientIp(ip): SecureClientIp,
|
||||||
|
hx_target: Option<TypedHeader<HXTarget>>,
|
||||||
|
user_agent: Option<TypedHeader<UserAgent>>,
|
||||||
|
layout: Layout,
|
||||||
|
Form(reset_password): Form<ResetPassword>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
if reset_password.password != reset_password.password_confirmation {
|
||||||
|
return Ok(layout
|
||||||
|
.with_subtitle("reset password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Reset Password" }
|
||||||
|
}
|
||||||
|
(reset_password_form(ResetPasswordFormProps {
|
||||||
|
token: reset_password.token,
|
||||||
|
email: reset_password.email,
|
||||||
|
password_error: Some("passwords do not match".to_string()),
|
||||||
|
general_error: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let token = match UserPasswordResetToken::get(&pool, reset_password.token).await {
|
||||||
|
Ok(token) => token,
|
||||||
|
Err(err) => {
|
||||||
|
if let Error::NotFoundUuid(_, _) = err {
|
||||||
|
warn!(token_id = %reset_password.token, "token not found in database");
|
||||||
|
return Ok(layout
|
||||||
|
.with_subtitle("reset password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Reset Password" }
|
||||||
|
}
|
||||||
|
(reset_password_form(ResetPasswordFormProps {
|
||||||
|
token: reset_password.token,
|
||||||
|
email: reset_password.email,
|
||||||
|
password_error: None,
|
||||||
|
general_error: Some("token not found".to_string()),
|
||||||
|
}))
|
||||||
|
p class="error readable-width" { "The reset password link has already been used or is invalid." }
|
||||||
|
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } "." }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if token.expired() {
|
||||||
|
warn!(token_id = %token.token_id, "token expired");
|
||||||
|
return Ok(layout
|
||||||
|
.with_subtitle("reset password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Reset Password" }
|
||||||
|
}
|
||||||
|
(reset_password_form(ResetPasswordFormProps {
|
||||||
|
token: reset_password.token,
|
||||||
|
email: reset_password.email,
|
||||||
|
password_error: None,
|
||||||
|
general_error: Some("token expired".to_string()),
|
||||||
|
}))
|
||||||
|
p class="error readable-width" { "The reset password link has expired." }
|
||||||
|
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } ". The link in the email will be valid for 24 hours." }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let user = match User::get(&pool, token.user_id).await {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(err) => {
|
||||||
|
if let Error::NotFoundString(_, _) = err {
|
||||||
|
info!(user_id = %token.user_id, email = reset_password.email, "invalid token user_id");
|
||||||
|
return Ok(layout
|
||||||
|
.with_subtitle("reset password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Reset Password" }
|
||||||
|
}
|
||||||
|
(reset_password_form(ResetPasswordFormProps {
|
||||||
|
token: reset_password.token,
|
||||||
|
email: reset_password.email,
|
||||||
|
password_error: None,
|
||||||
|
general_error: Some("user not found".to_string()),
|
||||||
|
}))
|
||||||
|
p class="error readable-width" { "The user associated with this password reset could not be found." }
|
||||||
|
p class="readable-width" { a href="/forgot-password" { "Follow this link to request a new password reset email" } "." }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!(user_id = %user.user_id, "user exists with verified email, resetting password");
|
||||||
|
// TODO: do both in transaction
|
||||||
|
UserPasswordResetToken::delete(&pool, reset_password.token).await?;
|
||||||
|
let user = match user
|
||||||
|
.update_password(
|
||||||
|
&pool,
|
||||||
|
UpdateUserPassword {
|
||||||
|
password: reset_password.password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(err) => {
|
||||||
|
if let Error::InvalidEntity(validation_errors) = err {
|
||||||
|
let field_errors = validation_errors.field_errors();
|
||||||
|
return Ok(layout
|
||||||
|
.with_subtitle("reset password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Reset Password" }
|
||||||
|
}
|
||||||
|
(reset_password_form(ResetPasswordFormProps {
|
||||||
|
token: reset_password.token,
|
||||||
|
email: reset_password.email,
|
||||||
|
password_error: field_errors.get("password").map(|&errors| {
|
||||||
|
errors
|
||||||
|
.iter()
|
||||||
|
.filter_map(|error| error.message.clone().map(|m| m.to_string()))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
}),
|
||||||
|
general_error: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
send_password_reset_email(
|
||||||
|
mailer,
|
||||||
|
config,
|
||||||
|
user,
|
||||||
|
ip.into(),
|
||||||
|
user_agent.map(|ua| ua.to_string()),
|
||||||
|
);
|
||||||
|
Ok(layout
|
||||||
|
.with_subtitle("reset password")
|
||||||
|
.targeted(hx_target)
|
||||||
|
.render(html! {
|
||||||
|
div class="center-horizontal" {
|
||||||
|
header class="center-text" {
|
||||||
|
h2 { "Password reset!" }
|
||||||
|
}
|
||||||
|
p class="readable-width" {
|
||||||
|
"Your password has been reset. "
|
||||||
|
a href="/" { "Return home" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
105
src/mailers/forgot_password.rs
Normal file
105
src/mailers/forgot_password.rs
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use ipnetwork::IpNetwork;
|
||||||
|
use lettre::message::{Mailbox, Message, MultiPart};
|
||||||
|
use lettre::{SmtpTransport, Transport};
|
||||||
|
use maud::html;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tracing::error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::models::user::User;
|
||||||
|
use crate::models::user_password_reset_token::{CreatePasswordResetToken, UserPasswordResetToken};
|
||||||
|
use crate::uuid::Base62Uuid;
|
||||||
|
|
||||||
|
// TODO: put in config
|
||||||
|
const PASSWORD_RESET_TOKEN_EXPIRATION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
|
||||||
|
pub fn send_forgot_password_email(
|
||||||
|
pool: PgPool,
|
||||||
|
mailer: SmtpTransport,
|
||||||
|
config: Config,
|
||||||
|
user: User,
|
||||||
|
request_ip: IpNetwork,
|
||||||
|
request_user_agent: Option<String>,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let user_email_address = match user.email.parse() {
|
||||||
|
Ok(address) => address,
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to parse email address: {}", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mailbox = Mailbox::new(user.name.clone(), user_email_address);
|
||||||
|
let token = match UserPasswordResetToken::create(
|
||||||
|
&pool,
|
||||||
|
CreatePasswordResetToken {
|
||||||
|
token_id: Uuid::new_v4(), // cyptographically-secure random uuid
|
||||||
|
user_id: user.user_id,
|
||||||
|
request_ip,
|
||||||
|
request_user_agent,
|
||||||
|
expires_at: Utc::now() + PASSWORD_RESET_TOKEN_EXPIRATION,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(token) => token,
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to create user password reset token: {}", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut password_reset_link = config.public_url.clone();
|
||||||
|
password_reset_link.set_path("reset-password");
|
||||||
|
password_reset_link
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair("token_id", &Base62Uuid::from(token.token_id).to_string());
|
||||||
|
let password_reset_link = password_reset_link.as_str();
|
||||||
|
|
||||||
|
let email = match Message::builder()
|
||||||
|
.from(config.email_from.clone())
|
||||||
|
.to(mailbox)
|
||||||
|
.subject("Reset your crawlnicle account password")
|
||||||
|
.multipart(MultiPart::alternative_plain_html(
|
||||||
|
format!(
|
||||||
|
"Reset your crawlnicle account password\n\nA password reset has been requested for your crawlnicle account. If you did not request this, please ignore this email.\n\nRequest IP address: {}\nRequest user agent: {}\n\nClick here to reset your password: {}",
|
||||||
|
token.request_ip,
|
||||||
|
token.request_user_agent.clone().unwrap_or_default(),
|
||||||
|
password_reset_link
|
||||||
|
),
|
||||||
|
html! {
|
||||||
|
h1 { "Reset your crawlnicle account password" }
|
||||||
|
p {
|
||||||
|
"A password reset has been requested for your crawlnicle account. If you did not request this, please ignore this email."
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
"IP address: " (token.request_ip.to_string())
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
"user agent: " (token.request_user_agent.unwrap_or_default())
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
a href=(password_reset_link) { "Click here to reset your password" }
|
||||||
|
}
|
||||||
|
}.into_string(),
|
||||||
|
))
|
||||||
|
{
|
||||||
|
Ok(email) => email,
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to create email: {}", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: notify the user that email has been sent somehow
|
||||||
|
match mailer.send(&email) {
|
||||||
|
Ok(_) => (),
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to send email: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -1 +1,3 @@
|
|||||||
pub mod email_verification;
|
pub mod email_verification;
|
||||||
|
pub mod forgot_password;
|
||||||
|
pub mod reset_password;
|
||||||
|
65
src/mailers/reset_password.rs
Normal file
65
src/mailers/reset_password.rs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
use ipnetwork::IpNetwork;
|
||||||
|
use lettre::message::{Mailbox, Message, MultiPart};
|
||||||
|
use lettre::{SmtpTransport, Transport};
|
||||||
|
use maud::html;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::models::user::User;
|
||||||
|
|
||||||
|
pub fn send_password_reset_email(
|
||||||
|
mailer: SmtpTransport,
|
||||||
|
config: Config,
|
||||||
|
user: User,
|
||||||
|
request_ip: IpNetwork,
|
||||||
|
request_user_agent: Option<String>,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let user_email_address = match user.email.parse() {
|
||||||
|
Ok(address) => address,
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to parse email address: {}", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mailbox = Mailbox::new(user.name.clone(), user_email_address);
|
||||||
|
|
||||||
|
let email = match Message::builder()
|
||||||
|
.from(config.email_from.clone())
|
||||||
|
.to(mailbox)
|
||||||
|
.subject("Your crawlnicle account password was reset")
|
||||||
|
.multipart(MultiPart::alternative_plain_html(
|
||||||
|
format!(
|
||||||
|
"Your crawlnicle account password was reset\n\nIf you did not perform this change, then this might indicate that your account has been compromised.\n\nRequest IP address: {}\nRequest user agent: {}",
|
||||||
|
request_ip,
|
||||||
|
request_user_agent.clone().unwrap_or_default()
|
||||||
|
),
|
||||||
|
html! {
|
||||||
|
h1 { "Your crawlnicle account password was reset" }
|
||||||
|
p {
|
||||||
|
"If you did not perform this change, then this might indicate that your account has been compromised."
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
"IP address: " (request_ip.to_string())
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
"user agent: " (request_user_agent.unwrap_or_default())
|
||||||
|
}
|
||||||
|
}.into_string(),
|
||||||
|
))
|
||||||
|
{
|
||||||
|
Ok(email) => email,
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to create email: {}", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match mailer.send(&email) {
|
||||||
|
Ok(_) => (),
|
||||||
|
Err(err) => {
|
||||||
|
error!("failed to send email: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
17
src/main.rs
17
src/main.rs
@ -8,8 +8,7 @@ use axum::{
|
|||||||
Extension, Router,
|
Extension, Router,
|
||||||
};
|
};
|
||||||
use axum_login::{
|
use axum_login::{
|
||||||
axum_sessions::SessionLayer,
|
axum_sessions::SessionLayer, AuthLayer, PostgresStore, RequireAuthorizationLayer,
|
||||||
AuthLayer, PostgresStore, RequireAuthorizationLayer,
|
|
||||||
};
|
};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
@ -17,7 +16,6 @@ use dotenvy::dotenv;
|
|||||||
use lettre::transport::smtp::authentication::Credentials;
|
use lettre::transport::smtp::authentication::Credentials;
|
||||||
use lettre::SmtpTransport;
|
use lettre::SmtpTransport;
|
||||||
use notify::Watcher;
|
use notify::Watcher;
|
||||||
use rand::Rng;
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use tokio::sync::watch::channel;
|
use tokio::sync::watch::channel;
|
||||||
@ -41,7 +39,7 @@ use uuid::Uuid;
|
|||||||
async fn serve(app: Router, addr: SocketAddr) -> Result<()> {
|
async fn serve(app: Router, addr: SocketAddr) -> Result<()> {
|
||||||
debug!("listening on {}", addr);
|
debug!("listening on {}", addr);
|
||||||
axum::Server::bind(&addr)
|
axum::Server::bind(&addr)
|
||||||
.serve(app.into_make_service())
|
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -75,7 +73,7 @@ async fn main() -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let session_store = RedisSessionStore::new(config.redis_url.clone())?;
|
let session_store = RedisSessionStore::new(config.redis_url.clone())?;
|
||||||
let session_layer = SessionLayer::new(session_store, &secret).with_secure(false);
|
let session_layer = SessionLayer::new(session_store, secret).with_secure(false);
|
||||||
let user_store = PostgresStore::<User>::new(pool.clone())
|
let user_store = PostgresStore::<User>::new(pool.clone())
|
||||||
.with_query("select * from users where user_id = $1");
|
.with_query("select * from users where user_id = $1");
|
||||||
let auth_layer = AuthLayer::new(user_store, &secret);
|
let auth_layer = AuthLayer::new(user_store, &secret);
|
||||||
@ -100,6 +98,8 @@ async fn main() -> Result<()> {
|
|||||||
let _ = crawl_scheduler.bootstrap().await;
|
let _ = crawl_scheduler.bootstrap().await;
|
||||||
let importer = ImporterHandle::new(pool.clone(), crawl_scheduler.clone(), imports.clone());
|
let importer = ImporterHandle::new(pool.clone(), crawl_scheduler.clone(), imports.clone());
|
||||||
|
|
||||||
|
let ip_source_extension = config.ip_source.0.clone().into_extension();
|
||||||
|
|
||||||
let addr = format!("{}:{}", &config.host, &config.port).parse()?;
|
let addr = format!("{}:{}", &config.host, &config.port).parse()?;
|
||||||
let mut app = Router::new()
|
let mut app = Router::new()
|
||||||
.route("/protected", get(protected_handler))
|
.route("/protected", get(protected_handler))
|
||||||
@ -129,6 +129,10 @@ async fn main() -> Result<()> {
|
|||||||
.route("/register", post(handlers::register::post))
|
.route("/register", post(handlers::register::post))
|
||||||
.route("/confirm-email", get(handlers::confirm_email::get))
|
.route("/confirm-email", get(handlers::confirm_email::get))
|
||||||
.route("/confirm-email", post(handlers::confirm_email::post))
|
.route("/confirm-email", post(handlers::confirm_email::post))
|
||||||
|
.route("/forgot-password", get(handlers::forgot_password::get))
|
||||||
|
.route("/forgot-password", post(handlers::forgot_password::post))
|
||||||
|
.route("/reset-password", get(handlers::reset_password::get))
|
||||||
|
.route("/reset-password", post(handlers::reset_password::post))
|
||||||
.nest_service("/static", ServeDir::new("static"))
|
.nest_service("/static", ServeDir::new("static"))
|
||||||
.with_state(AppState {
|
.with_state(AppState {
|
||||||
pool,
|
pool,
|
||||||
@ -144,7 +148,8 @@ async fn main() -> Result<()> {
|
|||||||
})
|
})
|
||||||
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
|
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()))
|
||||||
.layer(auth_layer)
|
.layer(auth_layer)
|
||||||
.layer(session_layer);
|
.layer(session_layer)
|
||||||
|
.layer(ip_source_extension);
|
||||||
|
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
debug!("starting livereload");
|
debug!("starting livereload");
|
||||||
|
@ -108,51 +108,49 @@ impl Entry {
|
|||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
} else {
|
} else if let Some(published_before) = options.published_before {
|
||||||
if let Some(published_before) = options.published_before {
|
if let Some(id_before) = options.id_before {
|
||||||
if let Some(id_before) = options.id_before {
|
sqlx::query_as!(
|
||||||
sqlx::query_as!(
|
Entry,
|
||||||
Entry,
|
"select * from entry
|
||||||
"select * from entry
|
where deleted_at is null
|
||||||
where deleted_at is null
|
and (published_at, entry_id) < ($1, $2)
|
||||||
and (published_at, entry_id) < ($1, $2)
|
order by published_at desc, entry_id desc
|
||||||
order by published_at desc, entry_id desc
|
limit $3
|
||||||
limit $3
|
",
|
||||||
",
|
published_before,
|
||||||
published_before,
|
id_before,
|
||||||
id_before,
|
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||||
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
)
|
||||||
)
|
.fetch_all(pool)
|
||||||
.fetch_all(pool)
|
.await
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
sqlx::query_as!(
|
|
||||||
Entry,
|
|
||||||
"select * from entry
|
|
||||||
where deleted_at is null
|
|
||||||
and published_at < $1
|
|
||||||
order by published_at desc
|
|
||||||
limit $2
|
|
||||||
",
|
|
||||||
published_before,
|
|
||||||
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Entry,
|
Entry,
|
||||||
"select * from entry
|
"select * from entry
|
||||||
where deleted_at is null
|
where deleted_at is null
|
||||||
|
and published_at < $1
|
||||||
order by published_at desc
|
order by published_at desc
|
||||||
limit $1
|
limit $2
|
||||||
",
|
",
|
||||||
|
published_before,
|
||||||
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Entry,
|
||||||
|
"select * from entry
|
||||||
|
where deleted_at is null
|
||||||
|
order by published_at desc
|
||||||
|
limit $1
|
||||||
|
",
|
||||||
|
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,3 +2,4 @@ pub mod entry;
|
|||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
pub mod user_email_verification_token;
|
pub mod user_email_verification_token;
|
||||||
|
pub mod user_password_reset_token;
|
||||||
|
@ -34,6 +34,16 @@ pub struct CreateUser {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default, Validate)]
|
||||||
|
pub struct UpdateUserPassword {
|
||||||
|
#[validate(length(
|
||||||
|
min = 8,
|
||||||
|
max = 255,
|
||||||
|
message = "password must be between 8 and 255 characters long"
|
||||||
|
))]
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl AuthUser<Uuid> for User {
|
impl AuthUser<Uuid> for User {
|
||||||
fn get_id(&self) -> Uuid {
|
fn get_id(&self) -> Uuid {
|
||||||
self.user_id
|
self.user_id
|
||||||
@ -130,6 +140,33 @@ impl User {
|
|||||||
Error::Sqlx(error)
|
Error::Sqlx(error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_password(&self, pool: &PgPool, payload: UpdateUserPassword) -> Result<User> {
|
||||||
|
payload.validate()?;
|
||||||
|
let password_hash = hash_password(payload.password).await?;
|
||||||
|
|
||||||
|
Ok(sqlx::query_as!(
|
||||||
|
User,
|
||||||
|
r#"update users set
|
||||||
|
password_hash = $2
|
||||||
|
where
|
||||||
|
user_id = $1
|
||||||
|
returning
|
||||||
|
user_id,
|
||||||
|
email,
|
||||||
|
email_verified,
|
||||||
|
password_hash,
|
||||||
|
name,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
deleted_at
|
||||||
|
"#,
|
||||||
|
self.user_id,
|
||||||
|
password_hash,
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type AuthContext = axum_login::extractors::AuthContext<Uuid, User, PostgresStore<User>>;
|
pub type AuthContext = axum_login::extractors::AuthContext<Uuid, User, PostgresStore<User>>;
|
||||||
|
87
src/models/user_password_reset_token.rs
Normal file
87
src/models/user_password_reset_token.rs
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use ipnetwork::IpNetwork;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct UserPasswordResetToken {
|
||||||
|
pub token_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub request_user_agent: Option<String>,
|
||||||
|
pub request_ip: IpNetwork,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreatePasswordResetToken {
|
||||||
|
pub token_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub request_user_agent: Option<String>,
|
||||||
|
pub request_ip: IpNetwork,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserPasswordResetToken {
|
||||||
|
pub fn expired(&self) -> bool {
|
||||||
|
Utc::now() > self.expires_at
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(pool: &PgPool, token_id: Uuid) -> Result<UserPasswordResetToken> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
UserPasswordResetToken,
|
||||||
|
r#"select
|
||||||
|
*
|
||||||
|
from user_password_reset_token
|
||||||
|
where token_id = $1"#,
|
||||||
|
token_id
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
if let sqlx::error::Error::RowNotFound = error {
|
||||||
|
return Error::NotFoundUuid("user_password_reset_token", token_id);
|
||||||
|
}
|
||||||
|
Error::Sqlx(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
pool: &PgPool,
|
||||||
|
payload: CreatePasswordResetToken,
|
||||||
|
) -> Result<UserPasswordResetToken> {
|
||||||
|
Ok(sqlx::query_as!(
|
||||||
|
UserPasswordResetToken,
|
||||||
|
r#"insert into user_password_reset_token (
|
||||||
|
token_id, user_id, request_user_agent, request_ip, expires_at
|
||||||
|
) values (
|
||||||
|
$1, $2, $3, $4, $5
|
||||||
|
) returning *"#,
|
||||||
|
payload.token_id,
|
||||||
|
payload.user_id,
|
||||||
|
payload.request_user_agent,
|
||||||
|
payload.request_ip,
|
||||||
|
payload.expires_at
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(
|
||||||
|
pool: &PgPool,
|
||||||
|
token_id: Uuid,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"delete from user_password_reset_token
|
||||||
|
where token_id = $1"#,
|
||||||
|
token_id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
27
src/partials/forgot_password_form.rs
Normal file
27
src/partials/forgot_password_form.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ForgotPasswordFormProps {
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub email_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forgot_password_form(props: ForgotPasswordFormProps) -> Markup {
|
||||||
|
let ForgotPasswordFormProps { email, email_error } = props;
|
||||||
|
html! {
|
||||||
|
form action="forgot-password" method="post" class="auth-form-grid" {
|
||||||
|
label for="email" { "Email" }
|
||||||
|
input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
id="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value=(email.unwrap_or_default())
|
||||||
|
required;
|
||||||
|
@if let Some(email_error) = email_error {
|
||||||
|
span class="error" { (email_error) }
|
||||||
|
}
|
||||||
|
button type="submit" { "Send password reset email" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -31,6 +31,7 @@ pub fn login_form(props: LoginFormProps) -> Markup {
|
|||||||
@if let Some(general_error) = general_error {
|
@if let Some(general_error) = general_error {
|
||||||
span class="error" { (general_error) }
|
span class="error" { (general_error) }
|
||||||
}
|
}
|
||||||
|
a href="/forgot-password" class="forgot-password" { "Forgot password" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,9 +5,11 @@ pub mod entry_list;
|
|||||||
pub mod feed_link;
|
pub mod feed_link;
|
||||||
pub mod feed_list;
|
pub mod feed_list;
|
||||||
pub mod footer;
|
pub mod footer;
|
||||||
|
pub mod forgot_password_form;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod login_form;
|
pub mod login_form;
|
||||||
pub mod opml_import_form;
|
pub mod opml_import_form;
|
||||||
pub mod register_form;
|
pub mod register_form;
|
||||||
|
pub mod reset_password_form;
|
||||||
pub mod user_name;
|
pub mod user_name;
|
||||||
|
43
src/partials/reset_password_form.rs
Normal file
43
src/partials/reset_password_form.rs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ResetPasswordFormProps {
|
||||||
|
pub token: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub password_error: Option<String>,
|
||||||
|
pub general_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_password_form(props: ResetPasswordFormProps) -> Markup {
|
||||||
|
let ResetPasswordFormProps { token, email, password_error, general_error } = props;
|
||||||
|
html! {
|
||||||
|
form action="reset-password" method="post" class="auth-form-grid" {
|
||||||
|
input
|
||||||
|
type="text"
|
||||||
|
name="token"
|
||||||
|
id="token"
|
||||||
|
value=(token.to_string())
|
||||||
|
style="display:none;";
|
||||||
|
label for="email" { "Email" }
|
||||||
|
input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
id="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value=(email)
|
||||||
|
required;
|
||||||
|
label for="password" { "Password" }
|
||||||
|
input type="password" name="password" id="password" placeholder="Password" minlength="8" maxlength="255" required;
|
||||||
|
@if let Some(password_error) = password_error {
|
||||||
|
span class="error" { (password_error) }
|
||||||
|
}
|
||||||
|
label for="password_confirmation" { "Confirm Password" }
|
||||||
|
input type="password" name="password_confirmation" id="password_confirmation" placeholder="Confirm Password" required;
|
||||||
|
button type="submit" { "Reset password" }
|
||||||
|
@if let Some(general_error) = general_error {
|
||||||
|
span class="error" { (general_error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user