confirm accounts on signup
This commit is contained in:
parent
a365e3cd3e
commit
b1bf7cb581
5 changed files with 175 additions and 16 deletions
16
migrations/20250724181953_add-signup-confirmations.sql
Normal file
16
migrations/20250724181953_add-signup-confirmations.sql
Normal 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;
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
8
templates/signup-confirm.html
Normal file
8
templates/signup-confirm.html
Normal 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>
|
5
templates/signup-confirmation.html
Normal file
5
templates/signup-confirmation.html
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<title>Signup</title>
|
||||||
|
<p>A confirmation email has been sent to: {{email}}</p>
|
||||||
|
</html>
|
|
@ -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?;
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue