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_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()
|
||||
}
|
||||
}
|
||||
|
|
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
|
||||
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?;
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue