use argon2::{ password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, Argon2, }; use axum::{ extract::{Form, Path, State}, http::StatusCode, response::{IntoResponse, Response}, }; use julid::Julid; use serde::Deserialize; use sqlx::{query_as, Sqlite, SqlitePool}; use unicode_segmentation::UnicodeSegmentation; use super::{templates::*, Invitation}; use crate::{util::empty_string_as_none, User}; //-************************************************************************ // Error types for user creation //-************************************************************************ #[Error(desc = "Could not create user.")] #[non_exhaustive] pub struct CreateUserError(#[from] CreateUserErrorKind); impl IntoResponse for CreateUserError { fn into_response(self) -> Response { match self.0 { CreateUserErrorKind::UnknownDBError => { (StatusCode::INTERNAL_SERVER_ERROR, format!("{self}")).into_response() } CreateUserErrorKind::BadInvitation => ( StatusCode::OK, SignupErrorPage("Sorry, that invitation isn't valid.".to_string()), ) .into_response(), _ => (StatusCode::OK, format!("{self}")).into_response(), } } } #[Error] #[non_exhaustive] pub enum CreateUserErrorKind { BadInvitation, AlreadyExists, #[error(desc = "Usernames must be between 1 and 20 characters long")] BadUsername, PasswordMismatch, #[error(desc = "Password must have at least 4 and at most 50 characters")] BadPassword, #[error(desc = "Display name must be less than 100 characters long")] BadDisplayname, BadEmail, UnknownDBError, } #[derive(Debug, Default, Deserialize, PartialEq, Eq)] pub struct SignupForm { pub username: String, #[serde(default, deserialize_with = "empty_string_as_none")] pub displayname: Option, #[serde(default, deserialize_with = "empty_string_as_none")] pub email: Option, pub password: String, pub pw_verify: String, pub invitation: String, } //-************************************************************************ // User creation route handlers //-************************************************************************ /// Get Handler: displays the form to create a user #[axum::debug_handler] pub async fn get_create_user( State(_pool): State, invitation: Option>, ) -> Result { let invitation = invitation.ok_or(CreateUserErrorKind::BadInvitation)?; let invitation = Julid::from_str(&invitation.0).map_err(|_| CreateUserErrorKind::BadInvitation)?; Ok(SignupPage { invitation, ..Default::default() }) } /// Post Handler: validates form values and calls the actual, private user /// creation function #[axum::debug_handler] pub async fn post_create_user( State(pool): State, Form(signup): Form, ) -> Result { use crate::util::validate_optional_length; let username = signup.username.trim(); let password = signup.password.trim(); let verify = signup.pw_verify.trim(); let name_len = username.graphemes(true).size_hint().1.unwrap(); // we are not ascii exclusivists around here if !(1..=20).contains(&name_len) { return Err(CreateUserErrorKind::BadUsername.into()); } if password != verify { return Err(CreateUserErrorKind::PasswordMismatch.into()); } let pwlen = password.graphemes(true).size_hint().1.unwrap_or(0); let password = password.as_bytes(); if !(4..=50).contains(&pwlen) { return Err(CreateUserErrorKind::BadPassword.into()); } // clean up the optionals let displayname = validate_optional_length( &signup.displayname, 0..100, CreateUserErrorKind::BadDisplayname, )?; let email = validate_optional_length(&signup.email, 5..30, CreateUserErrorKind::BadEmail)?; let user = create_user( username, &displayname, &email, password, &pool, &signup.invitation, ) .await?; let when = user.id.created_at(); tracing::debug!("created {user:?} at {when}"); let resp = axum::response::Redirect::to(&format!("/signup_success/{}", user.id)); Ok(resp) } /// Generic handler for successful signup pub async fn get_signup_success( Path(id): Path, State(pool): State, ) -> Response { const ID_QUERY: &str = "select * from users where id = ?"; let id = id.trim(); let id = Julid::from_str(id).unwrap_or_default(); let user: User = { query_as(ID_QUERY) .bind(id) .fetch_one(&pool) .await .unwrap_or_default() }; let mut resp = SignupSuccessPage(user.clone()).into_response(); if user.username.is_empty() || id.is_alpha() { // redirect to front page if we got here without a valid user ID *resp.status_mut() = StatusCode::SEE_OTHER; resp.headers_mut().insert("Location", "/".parse().unwrap()); } resp } //-************************************************************************ // private fns //-************************************************************************ pub(crate) async fn create_user( username: &str, displayname: &Option, email: &Option, password: &[u8], pool: &SqlitePool, invitation: &str, ) -> Result { const CREATE_QUERY: &str = "insert into users (username, displayname, email, pwhash, invited_by) values ($1, $2, $3, $4, $5) returning *"; // Argon2 with default params (Argon2id v19) let argon2 = Argon2::default(); let salt = SaltString::generate(&mut OsRng); let pwhash = argon2 .hash_password(password, &salt) .unwrap() // safe to unwrap, we know the salt is valid .to_string(); let mut tx = pool.begin().await.map_err(|e| { tracing::debug!("db error: {e}"); CreateUserErrorKind::UnknownDBError })?; let invitation = Julid::from_str(invitation).map_err(|_| CreateUserErrorKind::BadInvitation)?; let invited_by = validate_invitation(invitation, &mut tx).await?; let user = sqlx::query_as(CREATE_QUERY) .bind(username) .bind(displayname) .bind(email) .bind(&pwhash) .bind(invited_by) .fetch_one(&mut *tx) .await .map_err(|e| { tracing::info!("Got error inserting new user: {e}"); match e { sqlx::Error::Database(db) => { let exit = db.code().unwrap_or_default().parse().unwrap_or(0); // https://www.sqlite.org/rescode.html codes for unique constraint violations: if exit == 2067u32 || exit == 1555 { CreateUserErrorKind::AlreadyExists } else { CreateUserErrorKind::UnknownDBError } } _ => CreateUserErrorKind::UnknownDBError, } })?; tx.commit().await.map_err(|e| { tracing::debug!("db error: {e}"); CreateUserErrorKind::UnknownDBError })?; Ok(user) } async fn validate_invitation( invitation: Julid, tx: &mut sqlx::Transaction<'_, Sqlite>, ) -> Result { let invitation: Invitation = sqlx::query_as("select * from invites where id = ?") .bind(invitation) .fetch_optional(&mut **tx) .await .map_err(|e| { tracing::debug!("db error: {e}"); CreateUserErrorKind::UnknownDBError })? .ok_or(CreateUserErrorKind::BadInvitation)?; let remaining = invitation.remaining; if remaining < 1 { return Err(CreateUserErrorKind::BadInvitation); } if let Some(ts) = invitation.expires_at { let now = chrono::Utc::now().timestamp(); if ts < now { return Err(CreateUserErrorKind::BadInvitation); } } let _ = sqlx::query("update invites set remaining = ? where id = ?") .bind(remaining - 1) .bind(invitation.id) .execute(&mut **tx) .await .map_err(|e| { tracing::debug!("db error: {e}"); CreateUserErrorKind::UnknownDBError })?; Ok(invitation.owner) } //-************************************************************************ // TESTS //-************************************************************************ #[cfg(test)] mod test { use axum::http::StatusCode; use julid::Julid; use tokio::runtime::Runtime; use crate::{ db::get_db_pool, signup::templates::{SignupPage, SignupSuccessPage}, test_utils::{massage, server_with_pool, FORM_CONTENT_TYPE, INVITE_ID_INT}, User, }; #[test] fn post_create_user() { let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { let server = server_with_pool(&pool).await; let id: Julid = INVITE_ID_INT.into(); let form = format!("username=good_user&displayname=Good+User&password=aaaa&pw_verify=aaaa&invitation={}", id); let body = massage(&form); let resp = server .post("/signup") .expect_failure() // 303 is "failure" .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; assert_eq!(StatusCode::SEE_OTHER, resp.status_code()); // get the new user from the db let user = User::try_get("good_user", &pool).await; assert!(user.is_ok()); assert!(user.unwrap().is_some()); }); } #[test] fn get_create_user() { let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { let server = server_with_pool(&pool).await; let invitation: Julid = INVITE_ID_INT.into(); let path = format!("/signup/{invitation}"); let resp = server.get(&path).await; let body = std::str::from_utf8(resp.as_bytes()).unwrap(); let expected = SignupPage { invitation, ..Default::default() } .to_string(); assert_eq!(&expected, body); }); } #[test] fn handle_signup_success() { let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { let server = server_with_pool(&pool).await; let id: Julid = INVITE_ID_INT.into(); let form = format!("username=good_user&displayname=Good+User&password=aaaa&pw_verify=aaaa&invitation={}", id); let body = massage(&form); let resp = server .post("/signup") .expect_failure() // 303 is "failure" .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; assert_eq!(StatusCode::SEE_OTHER, resp.status_code()); // get the new user from the db let user = User::try_get("good_user", &pool).await.unwrap().unwrap(); let id = user.id; let path = format!("/signup_success/{id}"); let resp = server.get(&path).expect_success().await; let body = std::str::from_utf8(resp.as_bytes()).unwrap(); let expected = SignupSuccessPage(user).to_string(); assert_eq!(&expected, body); }); } //-************************************************************************ // honestly this is basically the whole suite here //-************************************************************************ mod failure { use std::time::Duration; use axum::response::IntoResponse; use super::*; use crate::{ signup::handlers::{CreateUserError, CreateUserErrorKind}, Invitation, }; #[test] fn used_up_invite() { let lucky1 = "username=lucky1&password=aaaa&pw_verify=aaaa&invitation=0000000000000000000000001A"; let lucky2 = "username=lucky2&password=aaaa&pw_verify=aaaa&invitation=0000000000000000000000001A"; let unlucky = "username=unlucky&password=aaaa&pw_verify=aaaa&invitation=0000000000000000000000001A"; let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { let server = server_with_pool(&pool).await; let body = massage(lucky1); let _ = server .post("/signup") // 303 is "failure", but that's a successful signup .expect_failure() .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; let user = User::try_get("lucky1", &pool).await; assert!(user.is_ok() && user.unwrap().is_some()); let body = massage(lucky2); let _ = server .post("/signup") .expect_failure() .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; let user = User::try_get("lucky2", &pool).await; assert!(user.is_ok() && user.unwrap().is_some()); let body = massage(unlucky); let resp = server .post("/signup") // failure to sign up is not a failed request .expect_success() .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; let user = User::try_get("unlucky", &pool).await; assert!(user.is_ok() && user.unwrap().is_none()); let body = resp.as_bytes(); let expected: CreateUserError = CreateUserErrorKind::BadInvitation.into(); let expected = expected.into_response().into_body(); let expected = axum::body::to_bytes(expected, usize::MAX).await.unwrap(); assert_eq!(&expected, body); }); } #[test] fn expired_invite() { let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { // this function adds a user with the omega id, so the invite can be added let server = server_with_pool(&pool).await; let invite = Invitation::new(Julid::omega()) .with_expires_in(Duration::from_millis(1)) .commit(&pool) .await .unwrap(); std::thread::sleep(Duration::from_millis(500)); let username = "too slow"; let tooslow = format!("username={username}&password=aaaa&pw_verify=aaaa&invitation={invite}"); let body = massage(&tooslow); let resp = server .post("/signup") // failure to sign up is not a failed request .expect_success() .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; let user = User::try_get(username, &pool).await; assert!(user.is_ok() && user.unwrap().is_none()); let body = String::from_utf8(resp.as_bytes().to_vec()).unwrap(); let expected: CreateUserError = CreateUserErrorKind::BadInvitation.into(); let expected = expected.into_response().into_body(); let bytes = axum::body::to_bytes(expected, usize::MAX).await.unwrap(); let expected = String::from_utf8(bytes.to_vec()).unwrap(); assert_eq!(&expected, &body); }); } #[test] fn password_mismatch() { const PASSWORD_MISMATCH_FORM: &str = "username=bad_user&displayname=Bad+User&password=aaaa&pw_verify=bbbb&invitation=0000000000000000000000001A"; let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { let server = server_with_pool(&pool).await; let body = massage(PASSWORD_MISMATCH_FORM); let resp = server .post("/signup") // failure to sign up is not failure to submit the request .expect_success() .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; // no user in db let user = User::try_get("bad_user", &pool).await; assert!(user.is_ok() && user.unwrap().is_none()); let body = std::str::from_utf8(resp.as_bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::PasswordMismatch).to_string(); assert_eq!(&expected, body); }); } #[test] fn password_short() { const PASSWORD_SHORT_FORM: &str = "username=bad_user&displayname=Bad+User&password=a&pw_verify=a&invitation=0000000000000000000000001A"; let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { let server = server_with_pool(&pool).await; let body = massage(PASSWORD_SHORT_FORM); let resp = server .post("/signup") // failure to sign up is not failure to submit the request .expect_success() .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; // no user in db let user = User::try_get("bad_user", &pool).await; assert!(user.is_ok() && user.unwrap().is_none()); let body = std::str::from_utf8(resp.as_bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string(); assert_eq!(&expected, body); }); } #[test] fn password_long() { const PASSWORD_LONG_FORM: &str = "username=bad_user&displayname=Bad+User&password=sphinx+of+black+qwartz+judge+my+vow+etc+etc+yadd+yadda&pw_verify=sphinx+of+black+qwartz+judge+my+vow+etc+etc+yadd+yadda&invitation=0000000000000000000000001A"; let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { let server = server_with_pool(&pool).await; let body = massage(PASSWORD_LONG_FORM); let resp = server .post("/signup") // failure to sign up is not failure to submit the request .expect_success() .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; // no user in db let user = User::try_get("bad_user", &pool).await; assert!(user.is_ok() && user.unwrap().is_none()); let body = std::str::from_utf8(resp.as_bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string(); assert_eq!(&expected, body); }); } #[test] fn multibyte_password_too_short() { let pw = "🤡"; // min length is 4 distinct graphemes; this is one grapheme that is four bytes, // so it's not valid assert_eq!(pw.len(), 4); let pool = get_db_pool(); let rt = Runtime::new().unwrap(); let invitation: Julid = INVITE_ID_INT.into(); rt.block_on(async { let server = server_with_pool(&pool).await; let form = format!("username=bad_user&displayname=Test+User&password={pw}&pw_verify={pw}&invitation={invitation}"); let body = massage(&form); let resp = server .post("/signup") // failure to sign up is not failure to submit the request .expect_success() .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; // no user in db let user = User::try_get("bad_user", &pool).await; assert!(user.is_ok() && user.unwrap().is_none()); let body = std::str::from_utf8(resp.as_bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string(); assert_eq!(&expected, body); }); } #[test] fn username_short() { const USERNAME_SHORT_FORM: &str = "username=&displayname=Bad+User&password=aaaa&pw_verify=aaaa&invitation=0000000000000000000000001A"; let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { let server = server_with_pool(&pool).await; let body = massage(USERNAME_SHORT_FORM); let resp = server .post("/signup") // failure to sign up is not failure to submit the request .expect_success() .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; // no user in db let user = User::try_get("", &pool).await; assert!(user.is_ok() && user.unwrap().is_none()); let body = std::str::from_utf8(resp.as_bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::BadUsername).to_string(); assert_eq!(&expected, body); }); } #[test] fn username_long() { const USERNAME_LONG_FORM: &str = "username=bad_user12345678901234567890&displayname=Bad+User&password=aaaa&pw_verify=aaaa&invitation=0000000000000000000000001A"; let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { let server = server_with_pool(&pool).await; let body = massage(USERNAME_LONG_FORM); let resp = server .post("/signup") // failure to sign up is not failure to submit the request .expect_success() .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; // no user in db let user = User::try_get("bad_user12345678901234567890", &pool).await; assert!(user.is_ok() && user.unwrap().is_none()); let body = std::str::from_utf8(resp.as_bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::BadUsername).to_string(); assert_eq!(&expected, body); }); } #[test] fn username_duplicate() { let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { let server = server_with_pool(&pool).await; let id: Julid = INVITE_ID_INT.into(); let form = format!("username=good_user&displayname=Good+User&password=aaaa&pw_verify=aaaa&invitation={}", id); let body = massage(&form); //let body = massage(GOOD_FORM); let _resp = server .post("/signup") .expect_failure() // 303 is "failure" .bytes(body.clone()) .content_type(FORM_CONTENT_TYPE) .await; // get the new user from the db let user = User::try_get("good_user", &pool).await; assert!(user.unwrap().is_some()); // now try again let resp = server .post("/signup") .expect_success() .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; assert_eq!(resp.status_code(), StatusCode::OK); let expected = CreateUserError(CreateUserErrorKind::AlreadyExists).to_string(); let body = std::str::from_utf8(resp.as_bytes()).unwrap(); assert_eq!(&expected, body); }); } #[test] fn displayname_long() { const DISPLAYNAME_LONG_FORM: &str = "username=bad_user&displayname=Since+time+immemorial%2C+display+names+have+been+subject+to+a+number+of+conventions%2C+restrictions%2C+usages%2C+and+even+incentives.+Have+we+finally+gone+too+far%3F+In+this+essay%2C+&password=aaaa&pw_verify=aaaa&invitation=0000000000000000000000001A"; let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { let server = server_with_pool(&pool).await; let body = massage(DISPLAYNAME_LONG_FORM); let resp = server .post("/signup") // failure to sign up is not failure to submit the request .expect_success() .bytes(body) .content_type(FORM_CONTENT_TYPE) .await; // no user in db let user = User::try_get("bad_user", &pool).await; assert!(user.is_ok() && user.unwrap().is_none()); let body = std::str::from_utf8(resp.as_bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::BadDisplayname).to_string(); assert_eq!(&expected, body); }); } #[test] fn handle_signup_success() { let pool = get_db_pool(); let rt = Runtime::new().unwrap(); rt.block_on(async { let server = server_with_pool(&pool).await; let path = "/signup_success/nope"; let resp = server.get(path).expect_failure().await; assert_eq!(resp.status_code(), StatusCode::SEE_OTHER); }); } } }