zero-to-axum/src/server/routes/auth/mod.rs
azdle ef3cc5a11b only confirmed users can login
Also uses a forked version of `maik` that supports getting mail bodies.
2025-07-25 12:20:02 -05:00

492 lines
12 KiB
Rust

use std::fmt;
use anyhow::Context as _;
use argon2::Argon2;
use askama::Template;
use askama_web::WebTemplate;
use axum::{
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::{debug, error, info};
use uuid::Uuid;
use crate::server::{
session::{Auth, CsrfCookie, User},
AppState,
};
use super::ErrorPage;
pub fn build() -> Router<AppState> {
Router::new()
.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)]
#[template(path = "signup.html")]
struct SignupPage {
error: Option<String>,
csrf_token: String,
}
#[tracing::instrument]
pub async fn signup_page(csrf: CsrfCookie) -> impl IntoResponse {
info!("get signup page");
let csrf_token = csrf.token().to_string();
(
csrf,
SignupPage {
error: None,
csrf_token,
},
)
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct SignupForm {
email: String,
password: String,
csrf_token: String,
}
impl fmt::Debug for SignupForm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SignupForm")
.field("email", &self.email)
.field("password", &"REDACTED")
.field("csrf-token", &self.csrf_token)
.finish()
}
}
#[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,
email_client,
conf,
..
}): State<AppState>,
csrf: CsrfCookie,
Form(form): Form<SignupForm>,
) -> Result<impl IntoResponse, SignupError> {
info!("signup attempt");
if form.csrf_token != csrf.token() {
return Err(SignupError::CsrfValidationFailed);
}
info!("hash password: {}", &form.password);
let password_hash: String = tokio::task::spawn_blocking(async move || {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
Ok::<String, anyhow::Error>(
argon2
.hash_password(form.password.as_bytes(), &salt)?
.to_string(),
)
})
.await
.context("run password hashing operation")?
.await?;
info!("hashed password: {password_hash}");
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, status)
VALUES ($1, $2, $3, 'pending_confirmation')
"#,
user_id,
form.email,
password_hash,
)
.execute(&mut *txn)
.await
.context("insert new user into database")?;
let token: String = {
let mut rng = rng();
std::iter::repeat_with(|| rng.sample(Alphanumeric))
.map(char::from)
.take(25)
.collect()
};
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}")]
Unknown(#[from] anyhow::Error),
}
impl IntoResponse for SignupError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match self {
SignupError::InvalidEmail => (StatusCode::BAD_REQUEST, "Invalid Email"),
SignupError::CsrfValidationFailed => {
(StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again")
}
SignupError::Unknown(e) => {
error!(?e, "returning INTERNAL SERVER ERROR");
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
}
};
(
status,
ErrorPage {
error: message.to_string(),
},
)
.into_response()
}
}
#[derive(Template, WebTemplate, Default)]
#[template(path = "login.html")]
struct LoginPage {
error: Option<String>,
csrf_token: String,
}
#[tracing::instrument]
pub async fn login_page(csrf: CsrfCookie) -> impl IntoResponse {
info!("get login page");
let csrf_token = csrf.token().to_string();
(
csrf,
LoginPage {
error: None,
csrf_token,
},
)
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct LoginForm {
email: String,
password: String,
csrf_token: String,
}
impl fmt::Debug for LoginForm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LoginForm")
.field("email", &self.email)
.field("password", &"REDACTED")
.finish()
}
}
#[tracing::instrument(skip(db, auth))]
pub async fn login(
State(AppState { db, .. }): State<AppState>,
mut auth: Auth,
csrf: CsrfCookie,
Form(form): Form<LoginForm>,
) -> Result<impl IntoResponse, LoginError> {
info!("login attempt");
if form.csrf_token != csrf.token() {
return Err(LoginError::CsrfValidationFailed);
}
let user = sqlx::query!(
r#"
SELECT id, password FROM users WHERE status = 'confirmed' AND email = $1 LIMIT 1;
"#,
form.email
)
.fetch_one(&db)
.await;
if matches!(user, Err(sqlx::Error::RowNotFound)) {
return Err(LoginError::UnknownUser);
}
let user = user.context("get user info from db")?;
tokio::task::spawn_blocking(async move || {
let parsed_hash =
PasswordHash::new(&user.password).context("parse stored password hash")?;
match Argon2::default().verify_password(form.password.as_bytes(), &parsed_hash) {
Ok(()) => Ok(()),
Err(password_hash::Error::Password) => Err(LoginError::InvalidPassword),
Err(e) => Err(e).context("verify password hash")?,
}
})
.await
.context("spawn password verifier task")?
.await?;
auth.user = Some(User::new(user.id));
auth.session
.cycle_id()
.await
.context("refresh session id")?;
auth.session
.insert("user", &auth.user)
.await
.context("set user data in session")?;
Ok(Redirect::to("/"))
}
#[derive(thiserror::Error, Debug)]
pub enum LoginError {
#[error("Invalid Password")]
InvalidPassword,
#[error("Unknown User")]
UnknownUser,
#[error("CSRF Validation Failed")]
CsrfValidationFailed,
#[error("Unknown Error: {0}")]
Unknown(#[from] anyhow::Error),
}
impl IntoResponse for LoginError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match self {
LoginError::InvalidPassword => (StatusCode::UNAUTHORIZED, "Invalid Password"),
LoginError::UnknownUser => (StatusCode::UNAUTHORIZED, "Unknown User"),
LoginError::CsrfValidationFailed => {
(StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again")
}
LoginError::Unknown(e) => {
error!(?e, "returning INTERNAL SERVER ERROR");
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
}
};
(
status,
ErrorPage {
error: message.to_string(),
},
)
.into_response()
}
}
#[derive(Template, WebTemplate)]
#[template(path = "logout.html")]
struct LogoutPage {
csrf_token: String,
}
#[tracing::instrument]
pub async fn logout_page(csrf: CsrfCookie) -> impl IntoResponse {
info!("get logout page");
let csrf_token = csrf.token().to_string();
(csrf, LogoutPage { csrf_token })
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct LogoutForm {
csrf_token: String,
}
pub async fn logout(
mut user: Auth,
csrf: CsrfCookie,
Form(form): Form<LogoutForm>,
) -> Result<impl IntoResponse, LogoutError> {
info!("logout attempt");
if form.csrf_token != csrf.token() {
return Err(LogoutError::CsrfValidationFailed);
}
if user.user.is_none() {
return Err(LogoutError::NotLoggedIn);
}
user.user = None;
user.session.flush().await.context("flush user session")?;
Ok(Redirect::to("/"))
}
#[derive(thiserror::Error, Debug)]
pub enum LogoutError {
#[error("Not Logged In")]
NotLoggedIn,
#[error("CSRF Validation Failed")]
CsrfValidationFailed,
#[error("Unknown Error: {0}")]
Unknown(#[from] anyhow::Error),
}
impl IntoResponse for LogoutError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match self {
LogoutError::NotLoggedIn => (StatusCode::UNAUTHORIZED, "Unknown User"),
LogoutError::CsrfValidationFailed => {
(StatusCode::BAD_REQUEST, "CSRF Validation Failed, Try Again")
}
LogoutError::Unknown(e) => {
error!(?e, "returning INTERNAL SERVER ERROR");
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
}
};
(
status,
ErrorPage {
error: message.to_string(),
},
)
.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("Invalid Token")]
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 {
let (status, message) = match self {
SignupConfirmError::InvalidToken => (StatusCode::UNAUTHORIZED, "Invalid Token"),
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")
}
};
(
status,
ErrorPage {
error: message.to_string(),
},
)
.into_response()
}
}