Basic email verification done

Still need to add forms to confirmation page to allow resending and add
rate limiting.
This commit is contained in:
Tyler Hallada 2023-09-28 23:53:46 -04:00
parent f938a6b46b
commit cdc8eb9b02
13 changed files with 277 additions and 27 deletions

View File

@ -17,7 +17,7 @@ ansi-to-html = "0.1"
anyhow = "1" 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"] } axum = { version = "0.6", features = ["form", "headers", "multipart", "query"] }
# 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"

View File

@ -56,6 +56,7 @@ builds
RUST_LOG=crawlnicle=debug,cli=debug,lib=debug,tower_http=debug,sqlx=debug RUST_LOG=crawlnicle=debug,cli=debug,lib=debug,tower_http=debug,sqlx=debug
HOST=127.0.0.1 HOST=127.0.0.1
PORT=3000 PORT=3000
PUBLIC_URL=http://localhost:3000
DATABASE_URL=postgresql://crawlnicle:<password>@localhost/crawlnicle DATABASE_URL=postgresql://crawlnicle:<password>@localhost/crawlnicle
DATABASE_MAX_CONNECTIONS=5 DATABASE_MAX_CONNECTIONS=5
REDIS_URL=redis://localhost REDIS_URL=redis://localhost
@ -65,6 +66,7 @@ builds
SMTP_SERVER=smtp.gmail.com SMTP_SERVER=smtp.gmail.com
SMTP_USER=user SMTP_USER=user
SMTP_PASSWORD=password 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 1. Run `just migrate` (or `sqlx migrate run`) which will run all the database

View File

@ -3,8 +3,9 @@
* ONLY RUN IN DEVELOPMENT! * ONLY RUN IN DEVELOPMENT!
*/ */
drop table _sqlx_migrations cascade; drop table _sqlx_migrations cascade;
drop collation case_insensitive;
drop table entry cascade; drop table entry cascade;
drop table feed cascade; drop table feed cascade;
drop table users cascade; drop table users cascade;
drop table user_email_verification_token cascade;
drop type feed_type; drop type feed_type;
drop collation case_insensitive;

View File

@ -73,6 +73,7 @@ create table if not exists "users" (
user_id uuid primary key default uuid_generate_v1mc(), user_id uuid primary key default uuid_generate_v1mc(),
password_hash text not null, password_hash text not null,
email text not null collate case_insensitive, email text not null collate case_insensitive,
email_verified boolean not null default false,
name text, name text,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
updated_at timestamptz, updated_at timestamptz,
@ -80,3 +81,12 @@ create table if not exists "users" (
); );
create unique index on "users" (email); create unique index on "users" (email);
select trigger_updated_at('"users"'); 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"');

View File

@ -1,4 +1,6 @@
use clap::Parser; use clap::Parser;
use lettre::message::Mailbox;
use url::Url;
#[derive(Parser, Clone, Debug)] #[derive(Parser, Clone, Debug)]
pub struct Config { pub struct Config {
@ -13,6 +15,8 @@ pub struct Config {
#[clap(long, env)] #[clap(long, env)]
pub port: u16, pub port: u16,
#[clap(long, env)] #[clap(long, env)]
pub public_url: Url,
#[clap(long, env)]
pub title: String, pub title: String,
#[clap(long, env)] #[clap(long, env)]
pub max_mem_log_size: usize, pub max_mem_log_size: usize,
@ -24,4 +28,6 @@ pub struct Config {
pub smtp_user: String, pub smtp_user: String,
#[clap(long, env)] #[clap(long, env)]
pub smtp_password: String, pub smtp_password: String,
#[clap(long, env)]
pub email_from: Mailbox,
} }

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

View File

@ -1,4 +1,5 @@
pub mod api; pub mod api;
pub mod confirm_email;
pub mod entries; pub mod entries;
pub mod entry; pub mod entry;
pub mod home; pub mod home;

View File

@ -1,19 +1,30 @@
use std::time::Duration;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::TypedHeader; use axum::TypedHeader;
use axum::{extract::State, Form}; use axum::{extract::State, Form};
use lettre::message::header::ContentType; use chrono::Utc;
use lettre::message::{Mailbox, Message}; use lettre::message::{Mailbox, Message, MultiPart};
use lettre::{SmtpTransport, Transport}; use lettre::{SmtpTransport, Transport};
use maud::html; use maud::html;
use serde::Deserialize; use serde::Deserialize;
use serde_with::{serde_as, NoneAsEmptyString}; use serde_with::{serde_as, NoneAsEmptyString};
use sqlx::PgPool; use sqlx::PgPool;
use tracing::error;
use crate::config::Config;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::htmx::{HXRedirect, HXTarget}; use crate::htmx::{HXRedirect, HXTarget};
use crate::models::user::{AuthContext, CreateUser, User}; use crate::models::user::{AuthContext, CreateUser, User};
use crate::models::user_email_verification_token::{
CreateUserEmailVerificationToken, UserEmailVerificationToken,
};
use crate::partials::layout::Layout; use crate::partials::layout::Layout;
use crate::partials::register_form::{register_form, RegisterFormProps}; 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] #[serde_as]
#[derive(Debug, Deserialize)] #[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( pub async fn post(
State(pool): State<PgPool>, State(pool): State<PgPool>,
State(mailer): State<SmtpTransport>, State(mailer): State<SmtpTransport>,
State(config): State<Config>,
mut auth: AuthContext, mut auth: AuthContext,
Form(register): Form<Register>, Form(register): Form<Register>,
) -> Result<Response> { ) -> Result<Response> {
@ -69,8 +143,6 @@ pub async fn post(
Err(err) => { Err(err) => {
if let Error::InvalidEntity(validation_errors) = err { if let Error::InvalidEntity(validation_errors) = err {
let field_errors = validation_errors.field_errors(); let field_errors = validation_errors.field_errors();
dbg!(&validation_errors);
dbg!(&field_errors);
return Ok(register_form(RegisterFormProps { return Ok(register_form(RegisterFormProps {
email: Some(register.email), email: Some(register.email),
name: register.name, 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 send_confirmation_email(pool, mailer, config, user.clone());
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)?;
auth.login(&user) auth.login(&user)
.await .await

View File

@ -127,6 +127,7 @@ async fn main() -> Result<()> {
.route("/logout", get(handlers::logout::get)) .route("/logout", get(handlers::logout::get))
.route("/register", get(handlers::register::get)) .route("/register", get(handlers::register::get))
.route("/register", post(handlers::register::post)) .route("/register", post(handlers::register::post))
.route("/confirm-email", get(handlers::confirm_email::get))
.nest_service("/static", ServeDir::new("static")) .nest_service("/static", ServeDir::new("static"))
.with_state(AppState { .with_state(AppState {
pool, pool,

View File

@ -1,3 +1,4 @@
pub mod entry; pub mod entry;
pub mod feed; pub mod feed;
pub mod user; pub mod user;
pub mod user_email_verification_token;

View File

@ -12,6 +12,7 @@ use crate::error::{Error, Result};
pub struct User { pub struct User {
pub user_id: Uuid, pub user_id: Uuid,
pub email: String, pub email: String,
pub email_verified: bool,
pub password_hash: String, pub password_hash: String,
pub name: Option<String>, pub name: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
@ -95,6 +96,7 @@ impl User {
) returning ) returning
user_id, user_id,
email, email,
email_verified,
password_hash, password_hash,
name, name,
created_at, created_at,
@ -108,6 +110,26 @@ impl User {
.fetch_one(pool) .fetch_one(pool)
.await?) .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>>; pub type AuthContext = axum_login::extractors::AuthContext<Uuid, User, PostgresStore<User>>;

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

View File

@ -7,7 +7,7 @@ use axum::{
async_trait, async_trait,
extract::{FromRef, FromRequestParts, State}, extract::{FromRef, FromRequestParts, State},
http::request::Parts, http::request::Parts,
response::{Html, IntoResponse, Response}, response::{IntoResponse, Response},
TypedHeader, TypedHeader,
}; };
use axum_login::{extractors::AuthContext, SqlxStore}; use axum_login::{extractors::AuthContext, SqlxStore};