Complete reset password flow

This commit is contained in:
Tyler Hallada 2023-10-13 14:07:38 +02:00
parent d5c5185351
commit 60671d5865
25 changed files with 943 additions and 46 deletions

40
Cargo.lock generated
View File

@ -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",

View File

@ -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"] }

View File

@ -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:

View File

@ -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;

View File

@ -53,5 +53,8 @@ watch-backend:
watch: watch:
./watch.sh ./watch.sh
reset:
sqlx database reset
migrate: migrate:
sqlx migrate run sqlx migrate run

View File

@ -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"');

View File

@ -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,
} }

View File

@ -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;

View 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."
}
}
}))
}

View File

@ -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,

View File

@ -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)
} }

View File

@ -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;

View 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" }
}
}
}))
}

View 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);
}
}
});
}

View File

@ -1 +1,3 @@
pub mod email_verification; pub mod email_verification;
pub mod forgot_password;
pub mod reset_password;

View 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);
}
}
});
}

View File

@ -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");

View File

@ -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
} }
} }

View File

@ -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;

View File

@ -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>>;

View 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(())
}
}

View 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" }
}
}
}

View File

@ -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" }
} }
} }
} }

View File

@ -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;

View 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) }
}
}
}
}