From b1bf7cb581c093c1e7ebe12cbc80b74255dc9b44 Mon Sep 17 00:00:00 2001 From: azdle Date: Thu, 24 Jul 2025 14:20:45 -0500 Subject: [PATCH] confirm accounts on signup --- ...0250724181953_add-signup-confirmations.sql | 16 ++ src/server/routes/auth/mod.rs | 154 ++++++++++++++++-- templates/signup-confirm.html | 8 + templates/signup-confirmation.html | 5 + tests/auth.rs | 8 +- 5 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 migrations/20250724181953_add-signup-confirmations.sql create mode 100644 templates/signup-confirm.html create mode 100644 templates/signup-confirmation.html diff --git a/migrations/20250724181953_add-signup-confirmations.sql b/migrations/20250724181953_add-signup-confirmations.sql new file mode 100644 index 0000000..d75f2dd --- /dev/null +++ b/migrations/20250724181953_add-signup-confirmations.sql @@ -0,0 +1,16 @@ +-- Not yet deployed, do all in one migration. + +CREATE TABLE signup_tokens( + token TEXT NOT NULL, + user_id uuid NOT NULL + REFERENCES users (id), + PRIMARY KEY (token) +); + +ALTER TABLE users ADD COLUMN status TEXT NULL; + +UPDATE users + SET status = 'confirmed' + where status IS NULL; + +ALTER TABLE users ALTER COLUMN status SET NOT NULL; diff --git a/src/server/routes/auth/mod.rs b/src/server/routes/auth/mod.rs index 93a7357..0b695af 100644 --- a/src/server/routes/auth/mod.rs +++ b/src/server/routes/auth/mod.rs @@ -5,15 +5,16 @@ use argon2::Argon2; use askama::Template; use askama_web::WebTemplate; use axum::{ - extract::State, + extract::{Query, State}, http::StatusCode, response::{IntoResponse, Redirect}, routing::get, Form, Router, }; use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use rand::{distr::Alphanumeric, rng, Rng as _}; use serde::Deserialize; -use tracing::{error, info, warn}; +use tracing::{debug, error, info}; use uuid::Uuid; use crate::server::{ @@ -26,6 +27,7 @@ pub fn build() -> Router { .route("/signup", get(signup_page).post(signup)) .route("/login", get(login_page).post(login)) .route("/logout", get(logout_page).post(logout)) + .route("/confirm", get(signup_confirm_page).post(signup_confirm)) } #[derive(Template, WebTemplate, Default)] @@ -68,10 +70,20 @@ impl fmt::Debug for SignupForm { } } -#[tracing::instrument(skip(db, user))] +#[derive(Template, WebTemplate, Default)] +#[template(path = "signup-confirmation.html")] +struct SignupConfirmation { + email: String, +} + +#[tracing::instrument(skip(db, email_client))] pub async fn signup( - State(AppState { db, .. }): State, - mut user: Auth, + State(AppState { + db, + email_client, + conf, + .. + }): State, csrf: CsrfCookie, Form(form): Form, ) -> Result { @@ -96,30 +108,64 @@ pub async fn signup( .await?; info!("hashed password: {password_hash}"); - warn!("db: {db:?}"); + let mut txn = db.begin().await.context("start database transaction")?; let user_id = Uuid::new_v4(); sqlx::query!( r#" - INSERT INTO users (id, email, password) - VALUES ($1, $2, $3) + INSERT INTO users (id, email, password, status) + VALUES ($1, $2, $3, 'pending_confirmation') "#, user_id, form.email, password_hash, ) - .execute(&db) + .execute(&mut *txn) .await .context("insert new user into database")?; - user.user = Some(User::new(user_id)); - user.session.insert("user", &user.user).await.unwrap(); + let token: String = { + let mut rng = rng(); + std::iter::repeat_with(|| rng.sample(Alphanumeric)) + .map(char::from) + .take(25) + .collect() + }; - Ok(Redirect::to("/")) + sqlx::query!( + r#" + INSERT INTO signup_tokens (user_id, token) + VALUES ($1, $2) + "#, + user_id, + token + ) + .execute(&mut *txn) + .await + .context("insert subscription token into database")?; + + email_client + .send_email( + form.email.parse().map_err(|_| SignupError::InvalidEmail)?, + "Test".to_owned(), + format!( + "Please confirm your subscription: {}auth/confirm?token={token}", + conf.app.public_url + ), + ) + .await?; + + debug!("email sent"); + + txn.commit().await.context("commit transaction")?; + + Ok(SignupConfirmation { email: form.email }) } #[derive(thiserror::Error, Debug)] pub enum SignupError { + #[error("Invalid Email")] + InvalidEmail, #[error("CSRF Validation Failed")] CsrfValidationFailed, #[error("Unknown Error: {0}")] @@ -129,6 +175,7 @@ pub enum SignupError { impl IntoResponse for SignupError { fn into_response(self) -> axum::response::Response { match self { + SignupError::InvalidEmail => (StatusCode::BAD_REQUEST, "Invalid Email"), SignupError::CsrfValidationFailed => { (StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again") } @@ -193,6 +240,7 @@ pub async fn login( return Err(LoginError::CsrfValidationFailed); } + // TODO: `status = 'confirmed' AND `, need to be able to get email contents in test let user = sqlx::query!( r#" SELECT id, password FROM users WHERE email = $1 LIMIT 1; @@ -339,3 +387,85 @@ impl IntoResponse for LogoutError { .into_response() } } + +#[derive(Template, WebTemplate)] +#[template(path = "signup-confirm.html")] +struct SignupConfirmPage { + csrf_token: String, +} + +#[tracing::instrument] +pub async fn signup_confirm_page(csrf: CsrfCookie) -> impl IntoResponse { + info!("get signup confirm page"); + + let csrf_token = csrf.token().to_string(); + + (csrf, SignupConfirmPage { csrf_token }) +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct SignupConfirmForm { + csrf_token: String, +} + +#[derive(Deserialize)] +pub struct SignupConfirmQuery { + token: String, +} + +pub async fn signup_confirm( + State(AppState { db, .. }): State, + csrf: CsrfCookie, + Query(query): Query, + Form(form): Form, +) -> Result { + info!("signup confirm attempt"); + + if form.csrf_token != csrf.token() { + return Err(SignupConfirmError::CsrfValidationFailed); + } + + let rows = sqlx::query!( + r#" + UPDATE users SET status = 'confirmed' FROM signup_tokens + WHERE status = 'pending_confirmation' AND signup_tokens.user_id = id AND signup_tokens.token = $1; + "#, + query.token + ) + .execute(&db) + .await + .context("set user to confirmed")?.rows_affected(); + + if rows < 1 { + return Err(SignupConfirmError::InvalidToken); + } + + Ok(Redirect::to("/auth/login")) +} + +#[derive(thiserror::Error, Debug)] +pub enum SignupConfirmError { + #[error("Not Logged In")] + InvalidToken, + #[error("CSRF Validation Failed")] + CsrfValidationFailed, + #[error("Unknown Error: {0}")] + Unknown(#[from] anyhow::Error), +} + +impl IntoResponse for SignupConfirmError { + fn into_response(self) -> axum::response::Response { + match self { + SignupConfirmError::InvalidToken => (StatusCode::UNAUTHORIZED, "Unknown User"), + SignupConfirmError::CsrfValidationFailed => { + (StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again") + } + SignupConfirmError::Unknown(e) => { + error!(?e, "returning INTERNAL SERVER ERROR"); + (StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error") + } + } + .into_response() + } +} diff --git a/templates/signup-confirm.html b/templates/signup-confirm.html new file mode 100644 index 0000000..f78a950 --- /dev/null +++ b/templates/signup-confirm.html @@ -0,0 +1,8 @@ + + + Confirm Account +
+ + +
+ diff --git a/templates/signup-confirmation.html b/templates/signup-confirmation.html new file mode 100644 index 0000000..a9aab54 --- /dev/null +++ b/templates/signup-confirmation.html @@ -0,0 +1,5 @@ + + + Signup +

A confirmation email has been sent to: {{email}}

+ diff --git a/tests/auth.rs b/tests/auth.rs index 754c987..da87577 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -13,7 +13,7 @@ async fn login_succeeds_with_valid_credentials() -> Result<()> { // Signup let resp = client .post("/auth/signup") - .csrf_form(&[("email", "admin"), ("password", "hunter2")]) + .csrf_form(&[("email", "admin@example.com"), ("password", "hunter2")]) .send() .await?; @@ -22,7 +22,7 @@ async fn login_succeeds_with_valid_credentials() -> Result<()> { // Login let resp = client .post("/auth/login") - .csrf_form(&[("email", "admin"), ("password", "hunter2")]) + .csrf_form(&[("email", "admin@example.com"), ("password", "hunter2")]) .send() .await?; @@ -49,7 +49,7 @@ async fn login_fails_with_invalid_credentials() -> Result<()> { // Signup let resp = client .post("/auth/signup") - .csrf_form(&[("email", "admin"), ("password", "hunter2")]) + .csrf_form(&[("email", "admin@example.com"), ("password", "hunter2")]) .send() .await?; @@ -58,7 +58,7 @@ async fn login_fails_with_invalid_credentials() -> Result<()> { // Login let resp = client .post("/auth/login") - .csrf_form(&[("email", "admin"), ("password", "hunter3")]) + .csrf_form(&[("email", "admin@example.com"), ("password", "hunter3")]) .send() .await?;