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_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<AppState> {
.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<AppState>,
mut user: Auth,
State(AppState {
db,
email_client,
conf,
..
}): State<AppState>,
csrf: CsrfCookie,
Form(form): Form<SignupForm>,
) -> Result<impl IntoResponse, SignupError> {
@ -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<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
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?;