Basic email verification done
Still need to add forms to confirmation page to allow resending and add rate limiting.
This commit is contained in:
parent
f938a6b46b
commit
cdc8eb9b02
@ -17,7 +17,7 @@ ansi-to-html = "0.1"
|
||||
anyhow = "1"
|
||||
argon2 = "0.5"
|
||||
async-redis-session = "0.2"
|
||||
axum = { version = "0.6", features = ["form", "headers", "multipart"] }
|
||||
axum = { version = "0.6", features = ["form", "headers", "multipart", "query"] }
|
||||
# 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"] }
|
||||
bytes = "1.4"
|
||||
|
@ -56,6 +56,7 @@ builds
|
||||
RUST_LOG=crawlnicle=debug,cli=debug,lib=debug,tower_http=debug,sqlx=debug
|
||||
HOST=127.0.0.1
|
||||
PORT=3000
|
||||
PUBLIC_URL=http://localhost:3000
|
||||
DATABASE_URL=postgresql://crawlnicle:<password>@localhost/crawlnicle
|
||||
DATABASE_MAX_CONNECTIONS=5
|
||||
REDIS_URL=redis://localhost
|
||||
@ -65,6 +66,7 @@ builds
|
||||
SMTP_SERVER=smtp.gmail.com
|
||||
SMTP_USER=user
|
||||
SMTP_PASSWORD=password
|
||||
EMAIL_FROM="crawlnicle <no-reply@mail.crawlnicle.com>"
|
||||
```
|
||||
|
||||
1. Run `just migrate` (or `sqlx migrate run`) which will run all the database
|
||||
|
@ -3,8 +3,9 @@
|
||||
* ONLY RUN IN DEVELOPMENT!
|
||||
*/
|
||||
drop table _sqlx_migrations cascade;
|
||||
drop collation case_insensitive;
|
||||
drop table entry cascade;
|
||||
drop table feed cascade;
|
||||
drop table users cascade;
|
||||
drop table user_email_verification_token cascade;
|
||||
drop type feed_type;
|
||||
drop collation case_insensitive;
|
||||
|
@ -73,6 +73,7 @@ create table if not exists "users" (
|
||||
user_id uuid primary key default uuid_generate_v1mc(),
|
||||
password_hash text not null,
|
||||
email text not null collate case_insensitive,
|
||||
email_verified boolean not null default false,
|
||||
name text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz,
|
||||
@ -80,3 +81,12 @@ create table if not exists "users" (
|
||||
);
|
||||
create unique index on "users" (email);
|
||||
select trigger_updated_at('"users"');
|
||||
|
||||
create table if not exists "user_email_verification_token" (
|
||||
token_id uuid primary key default uuid_generate_v4(),
|
||||
user_id uuid not null references "users" (user_id) on delete cascade,
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz
|
||||
);
|
||||
select trigger_updated_at('"user_email_verification_token"');
|
||||
|
@ -1,4 +1,6 @@
|
||||
use clap::Parser;
|
||||
use lettre::message::Mailbox;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Parser, Clone, Debug)]
|
||||
pub struct Config {
|
||||
@ -13,6 +15,8 @@ pub struct Config {
|
||||
#[clap(long, env)]
|
||||
pub port: u16,
|
||||
#[clap(long, env)]
|
||||
pub public_url: Url,
|
||||
#[clap(long, env)]
|
||||
pub title: String,
|
||||
#[clap(long, env)]
|
||||
pub max_mem_log_size: usize,
|
||||
@ -24,4 +28,6 @@ pub struct Config {
|
||||
pub smtp_user: String,
|
||||
#[clap(long, env)]
|
||||
pub smtp_password: String,
|
||||
#[clap(long, env)]
|
||||
pub email_from: Mailbox,
|
||||
}
|
||||
|
75
src/handlers/confirm_email.rs
Normal file
75
src/handlers/confirm_email.rs
Normal file
@ -0,0 +1,75 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::Response;
|
||||
use axum::TypedHeader;
|
||||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::htmx::HXTarget;
|
||||
use crate::models::user::User;
|
||||
use crate::models::user_email_verification_token::UserEmailVerificationToken;
|
||||
use crate::partials::layout::Layout;
|
||||
use crate::uuid::Base62Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConfirmEmailQuery {
|
||||
pub token_id: Base62Uuid,
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
State(pool): State<PgPool>,
|
||||
hx_target: Option<TypedHeader<HXTarget>>,
|
||||
layout: Layout,
|
||||
query: Query<ConfirmEmailQuery>,
|
||||
) -> Result<Response> {
|
||||
let token = match UserEmailVerificationToken::get(&pool, query.token_id.as_uuid()).await {
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
if let Error::NotFoundUuid(_, _) = err {
|
||||
return Ok(layout
|
||||
.with_subtitle("confirm email")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Email verification token not found" }
|
||||
p { "Form with email input and button to resend goes here"}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if token.expired() {
|
||||
Ok(layout
|
||||
.with_subtitle("confirm email")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Email verification token is expired" }
|
||||
p { "Form with button to resend goes here"}
|
||||
}
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
User::verify_email(&pool, token.user_id).await?;
|
||||
UserEmailVerificationToken::delete(&pool, token.token_id).await?;
|
||||
Ok(layout
|
||||
.with_subtitle("confirm email")
|
||||
.targeted(hx_target)
|
||||
.render(html! {
|
||||
div class="center-horizontal" {
|
||||
header class="center-text" {
|
||||
h2 { "Your email is now confirmed!" }
|
||||
p {
|
||||
"Thanks for verifying your email address."
|
||||
a href="/" { "Return home" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
pub mod api;
|
||||
pub mod confirm_email;
|
||||
pub mod entries;
|
||||
pub mod entry;
|
||||
pub mod home;
|
||||
|
@ -1,19 +1,30 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::TypedHeader;
|
||||
use axum::{extract::State, Form};
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::message::{Mailbox, Message};
|
||||
use chrono::Utc;
|
||||
use lettre::message::{Mailbox, Message, MultiPart};
|
||||
use lettre::{SmtpTransport, Transport};
|
||||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
use serde_with::{serde_as, NoneAsEmptyString};
|
||||
use sqlx::PgPool;
|
||||
use tracing::error;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::htmx::{HXRedirect, HXTarget};
|
||||
use crate::models::user::{AuthContext, CreateUser, User};
|
||||
use crate::models::user_email_verification_token::{
|
||||
CreateUserEmailVerificationToken, UserEmailVerificationToken,
|
||||
};
|
||||
use crate::partials::layout::Layout;
|
||||
use crate::partials::register_form::{register_form, RegisterFormProps};
|
||||
use crate::uuid::Base62Uuid;
|
||||
|
||||
// TODO: put in config
|
||||
const USER_EMAIL_VERIFICATION_TOKEN_EXPIRATION: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Debug, Deserialize)]
|
||||
@ -39,9 +50,72 @@ pub async fn get(hx_target: Option<TypedHeader<HXTarget>>, layout: Layout) -> Re
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn send_confirmation_email(pool: PgPool, mailer: SmtpTransport, config: Config, user: User) {
|
||||
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 UserEmailVerificationToken::create(
|
||||
&pool,
|
||||
CreateUserEmailVerificationToken {
|
||||
user_id: user.user_id,
|
||||
expires_at: Utc::now() + USER_EMAIL_VERIFICATION_TOKEN_EXPIRATION,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
error!("failed to create user email verification token: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let confirm_link = format!(
|
||||
"{}/confirm-email?token_id={}",
|
||||
config.public_url,
|
||||
Base62Uuid::from(token.token_id)
|
||||
);
|
||||
let email = match Message::builder()
|
||||
.from(config.email_from.clone())
|
||||
.to(mailbox)
|
||||
.subject("Welcome to crawlnicle, please confirm your email address")
|
||||
.multipart(MultiPart::alternative_plain_html(
|
||||
format!("Welcome to crawlnicle!\n\nPlease confirm your email address\n\nClick here to confirm your email address: {}", confirm_link),
|
||||
html! {
|
||||
h1 { "Welcome to crawlnicle!" }
|
||||
h2 { "Please confirm your email address" }
|
||||
p {
|
||||
a href=(confirm_link) { "Click here to confirm your email address" }
|
||||
}
|
||||
}.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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn post(
|
||||
State(pool): State<PgPool>,
|
||||
State(mailer): State<SmtpTransport>,
|
||||
State(config): State<Config>,
|
||||
mut auth: AuthContext,
|
||||
Form(register): Form<Register>,
|
||||
) -> Result<Response> {
|
||||
@ -69,8 +143,6 @@ pub async fn post(
|
||||
Err(err) => {
|
||||
if let Error::InvalidEntity(validation_errors) = err {
|
||||
let field_errors = validation_errors.field_errors();
|
||||
dbg!(&validation_errors);
|
||||
dbg!(&field_errors);
|
||||
return Ok(register_form(RegisterFormProps {
|
||||
email: Some(register.email),
|
||||
name: register.name,
|
||||
@ -116,26 +188,7 @@ pub async fn post(
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: don't 500 error on email send failure, render form with error message instead
|
||||
let mailbox = Mailbox::new(
|
||||
user.name.clone(),
|
||||
user.email.parse().map_err(|_| Error::InternalServerError)?,
|
||||
);
|
||||
let email = Message::builder()
|
||||
// TODO: make from address configurable and store in config already parsed
|
||||
.from("crawlnicle <accounts@mail.crawlnicle.com>".parse().unwrap())
|
||||
.to(mailbox)
|
||||
.subject("Welcome to crawlnicle, please confirm your email address")
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
// TODO: fill in email body, use maud to create HTML body
|
||||
.body(String::from("TODO"))
|
||||
.map_err(|_| Error::InternalServerError)?;
|
||||
|
||||
// TODO: do email sending in a background async task
|
||||
// TODO: notify the user that email has been sent somehow
|
||||
mailer
|
||||
.send(&email)
|
||||
.map_err(|_| Error::InternalServerError)?;
|
||||
send_confirmation_email(pool, mailer, config, user.clone());
|
||||
|
||||
auth.login(&user)
|
||||
.await
|
||||
|
@ -127,6 +127,7 @@ async fn main() -> Result<()> {
|
||||
.route("/logout", get(handlers::logout::get))
|
||||
.route("/register", get(handlers::register::get))
|
||||
.route("/register", post(handlers::register::post))
|
||||
.route("/confirm-email", get(handlers::confirm_email::get))
|
||||
.nest_service("/static", ServeDir::new("static"))
|
||||
.with_state(AppState {
|
||||
pool,
|
||||
|
@ -1,3 +1,4 @@
|
||||
pub mod entry;
|
||||
pub mod feed;
|
||||
pub mod user;
|
||||
pub mod user_email_verification_token;
|
||||
|
@ -12,6 +12,7 @@ use crate::error::{Error, Result};
|
||||
pub struct User {
|
||||
pub user_id: Uuid,
|
||||
pub email: String,
|
||||
pub email_verified: bool,
|
||||
pub password_hash: String,
|
||||
pub name: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
@ -95,6 +96,7 @@ impl User {
|
||||
) returning
|
||||
user_id,
|
||||
email,
|
||||
email_verified,
|
||||
password_hash,
|
||||
name,
|
||||
created_at,
|
||||
@ -108,6 +110,26 @@ impl User {
|
||||
.fetch_one(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn verify_email(pool: &PgPool, user_id: Uuid) -> Result<User> {
|
||||
sqlx::query_as!(
|
||||
User,
|
||||
r#"update users set
|
||||
email_verified = true
|
||||
where user_id = $1
|
||||
returning *
|
||||
"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if let sqlx::error::Error::RowNotFound = error {
|
||||
return Error::NotFoundUuid("user", user_id);
|
||||
}
|
||||
Error::Sqlx(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub type AuthContext = axum_login::extractors::AuthContext<Uuid, User, PostgresStore<User>>;
|
||||
|
78
src/models/user_email_verification_token.rs
Normal file
78
src/models/user_email_verification_token.rs
Normal file
@ -0,0 +1,78 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserEmailVerificationToken {
|
||||
pub token_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct CreateUserEmailVerificationToken {
|
||||
pub user_id: Uuid,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl UserEmailVerificationToken {
|
||||
pub fn expired(&self) -> bool {
|
||||
Utc::now() > self.expires_at
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, token_id: Uuid) -> Result<UserEmailVerificationToken> {
|
||||
sqlx::query_as!(
|
||||
UserEmailVerificationToken,
|
||||
r#"select
|
||||
*
|
||||
from user_email_verification_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_email_verification_token", token_id);
|
||||
}
|
||||
Error::Sqlx(error)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
payload: CreateUserEmailVerificationToken,
|
||||
) -> Result<UserEmailVerificationToken> {
|
||||
Ok(sqlx::query_as!(
|
||||
UserEmailVerificationToken,
|
||||
r#"insert into user_email_verification_token (
|
||||
user_id, expires_at
|
||||
) values (
|
||||
$1, $2
|
||||
) returning *"#,
|
||||
payload.user_id,
|
||||
payload.expires_at
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
pool: &PgPool,
|
||||
token_id: Uuid,
|
||||
) -> Result<()> {
|
||||
sqlx::query!(
|
||||
r#"delete from user_email_verification_token
|
||||
where token_id = $1"#,
|
||||
token_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -7,7 +7,7 @@ use axum::{
|
||||
async_trait,
|
||||
extract::{FromRef, FromRequestParts, State},
|
||||
http::request::Parts,
|
||||
response::{Html, IntoResponse, Response},
|
||||
response::{IntoResponse, Response},
|
||||
TypedHeader,
|
||||
};
|
||||
use axum_login::{extractors::AuthContext, SqlxStore};
|
||||
|
Loading…
Reference in New Issue
Block a user