confirm accounts on signup

This commit is contained in:
azdle 2025-07-24 14:20:45 -05:00
parent a365e3cd3e
commit b1bf7cb581
5 changed files with 175 additions and 16 deletions

View file

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

View file

@ -5,15 +5,16 @@ use argon2::Argon2;
use askama::Template; use askama::Template;
use askama_web::WebTemplate; use askama_web::WebTemplate;
use axum::{ use axum::{
extract::State, extract::{Query, State},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
routing::get, routing::get,
Form, Router, Form, Router,
}; };
use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use rand::{distr::Alphanumeric, rng, Rng as _};
use serde::Deserialize; use serde::Deserialize;
use tracing::{error, info, warn}; use tracing::{debug, error, info};
use uuid::Uuid; use uuid::Uuid;
use crate::server::{ use crate::server::{
@ -26,6 +27,7 @@ pub fn build() -> Router<AppState> {
.route("/signup", get(signup_page).post(signup)) .route("/signup", get(signup_page).post(signup))
.route("/login", get(login_page).post(login)) .route("/login", get(login_page).post(login))
.route("/logout", get(logout_page).post(logout)) .route("/logout", get(logout_page).post(logout))
.route("/confirm", get(signup_confirm_page).post(signup_confirm))
} }
#[derive(Template, WebTemplate, Default)] #[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( pub async fn signup(
State(AppState { db, .. }): State<AppState>, State(AppState {
mut user: Auth, db,
email_client,
conf,
..
}): State<AppState>,
csrf: CsrfCookie, csrf: CsrfCookie,
Form(form): Form<SignupForm>, Form(form): Form<SignupForm>,
) -> Result<impl IntoResponse, SignupError> { ) -> Result<impl IntoResponse, SignupError> {
@ -96,30 +108,64 @@ pub async fn signup(
.await?; .await?;
info!("hashed password: {password_hash}"); info!("hashed password: {password_hash}");
warn!("db: {db:?}"); let mut txn = db.begin().await.context("start database transaction")?;
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
sqlx::query!( sqlx::query!(
r#" r#"
INSERT INTO users (id, email, password) INSERT INTO users (id, email, password, status)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, 'pending_confirmation')
"#, "#,
user_id, user_id,
form.email, form.email,
password_hash, password_hash,
) )
.execute(&db) .execute(&mut *txn)
.await .await
.context("insert new user into database")?; .context("insert new user into database")?;
user.user = Some(User::new(user_id)); let token: String = {
user.session.insert("user", &user.user).await.unwrap(); 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)] #[derive(thiserror::Error, Debug)]
pub enum SignupError { pub enum SignupError {
#[error("Invalid Email")]
InvalidEmail,
#[error("CSRF Validation Failed")] #[error("CSRF Validation Failed")]
CsrfValidationFailed, CsrfValidationFailed,
#[error("Unknown Error: {0}")] #[error("Unknown Error: {0}")]
@ -129,6 +175,7 @@ pub enum SignupError {
impl IntoResponse for SignupError { impl IntoResponse for SignupError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
match self { match self {
SignupError::InvalidEmail => (StatusCode::BAD_REQUEST, "Invalid Email"),
SignupError::CsrfValidationFailed => { SignupError::CsrfValidationFailed => {
(StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again") (StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again")
} }
@ -193,6 +240,7 @@ pub async fn login(
return Err(LoginError::CsrfValidationFailed); return Err(LoginError::CsrfValidationFailed);
} }
// TODO: `status = 'confirmed' AND `, need to be able to get email contents in test
let user = sqlx::query!( let user = sqlx::query!(
r#" r#"
SELECT id, password FROM users WHERE email = $1 LIMIT 1; SELECT id, password FROM users WHERE email = $1 LIMIT 1;
@ -339,3 +387,85 @@ impl IntoResponse for LogoutError {
.into_response() .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<AppState>,
csrf: CsrfCookie,
Query(query): Query<SignupConfirmQuery>,
Form(form): Form<SignupConfirmForm>,
) -> Result<impl IntoResponse, SignupConfirmError> {
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()
}
}

View file

@ -0,0 +1,8 @@
<!DOCTYPE html>
<html lang="en">
<title>Confirm Account</title>
<form method="post">
<input type=hidden name=csrf-token value="{{csrf_token}}" />
<button type="submit">Confirm My Account</button>
</form>
</html>

View file

@ -0,0 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<title>Signup</title>
<p>A confirmation email has been sent to: {{email}}</p>
</html>

View file

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