use argon2::{ password_hash::{rand_core::OsRng, PasswordHasher, SaltString}, Argon2, }; use axum::{ extract::{Form, Path, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; use sqlx::{query_as, SqlitePool}; use unicode_segmentation::UnicodeSegmentation; use crate::{util::empty_string_as_none, DbId, SignupPage, SignupSuccessPage, User}; pub(crate) const CREATE_QUERY: &str = "insert into witches (id, username, displayname, email, pwhash) values ($1, $2, $3, $4, $5)"; const ID_QUERY: &str = "select * from witches where id = $1"; //-************************************************************************ // 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() } _ => (StatusCode::OK, format!("{self}")).into_response(), } } } #[Error] #[non_exhaustive] pub enum CreateUserErrorKind { AlreadyExists, #[error(desc = "Usernames must be between 1 and 20 non-whitespace 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, } //-************************************************************************ // User creation route handlers //-************************************************************************ /// Get Handler: displays the form to create a user pub async fn get_create_user() -> SignupPage { SignupPage::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 id = DbId::new(); let user = create_user(username, &displayname, &email, password, &pool, id).await?; let now = user.id.created_at(); tracing::debug!("created {user:?} at {now:?}"); let id = user.id.as_string(); let location = format!("/signup_success/{id}"); let resp = axum::response::Redirect::to(&location); Ok(resp) } /// Generic handler for successful signup pub async fn get_signup_success( Path(id): Path, State(pool): State, ) -> Response { let id = id.trim(); let id = DbId::from_string(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_nil() { // redirect to front page if we got here without a valid witch 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, id: DbId, ) -> Result { // 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 query = sqlx::query(CREATE_QUERY) .bind(id) .bind(username) .bind(displayname) .bind(email) .bind(&pwhash); let res = query.execute(pool).await; match res { Ok(_) => { let user = User { id, username: username.to_string(), displayname: displayname.to_owned(), email: email.to_owned(), last_seen: None, pwhash, }; Ok(user) } Err(sqlx::Error::Database(db)) => { if let Some(exit) = db.code() { let exit = exit.parse().unwrap_or(0u32); // https://www.sqlite.org/rescode.html codes for unique constraint violations: if exit == 2067u32 || exit == 1555 { Err(CreateUserErrorKind::AlreadyExists.into()) } else { Err(CreateUserErrorKind::UnknownDBError.into()) } } else { Err(CreateUserErrorKind::UnknownDBError.into()) } } _ => Err(CreateUserErrorKind::UnknownDBError.into()), } } //-************************************************************************ // TESTS //-************************************************************************ #[cfg(test)] mod test { use axum::http::StatusCode; use crate::{ db::get_db_pool, templates::{SignupPage, SignupSuccessPage}, test_utils::{get_test_user, insert_user, massage, server_with_pool, FORM_CONTENT_TYPE}, User, }; const GOOD_FORM: &str = "username=test_user&displayname=Test+User&password=aaaa&pw_verify=aaaa"; #[tokio::test] async fn post_create_user() { let pool = get_db_pool().await; let server = server_with_pool(&pool).await; let body = massage(GOOD_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("test_user", &pool).await; assert!(user.is_ok()); } #[tokio::test] async fn get_create_user() { let pool = get_db_pool().await; let server = server_with_pool(&pool).await; let resp = server.get("/signup").await; let body = std::str::from_utf8(resp.bytes()).unwrap(); let expected = SignupPage::default().to_string(); assert_eq!(&expected, body); } #[tokio::test] async fn handle_signup_success() { let pool = get_db_pool().await; let server = server_with_pool(&pool).await; let user = get_test_user(); insert_user(&user, &pool).await; let id = user.id.0.to_string(); let path = format!("/signup_success/{id}"); let resp = server.get(&path).expect_success().await; let body = std::str::from_utf8(resp.bytes()).unwrap(); let expected = SignupSuccessPage(user).to_string(); assert_eq!(&expected, body); } //-************************************************************************ // honestly this is basically the whole suite here //-************************************************************************ mod failure { use super::*; use crate::signup::{CreateUserError, CreateUserErrorKind}; // various ways to fuck up signup const PASSWORD_MISMATCH_FORM: &str = "username=test_user&displayname=Test+User&password=aaaa&pw_verify=bbbb"; const PASSWORD_SHORT_FORM: &str = "username=test_user&displayname=Test+User&password=a&pw_verify=a"; const PASSWORD_LONG_FORM: &str = "username=test_user&displayname=Test+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"; const USERNAME_SHORT_FORM: &str = "username=&displayname=Test+User&password=aaaa&pw_verify=aaaa"; const USERNAME_LONG_FORM: &str = "username=test_user12345678901234567890&displayname=Test+User&password=aaaa&pw_verify=aaaa"; const DISPLAYNAME_LONG_FORM: &str = "username=test_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"; #[tokio::test] async fn password_mismatch() { let pool = get_db_pool().await; 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("test_user", &pool).await; assert!(user.is_err()); let body = std::str::from_utf8(resp.bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::PasswordMismatch).to_string(); assert_eq!(&expected, body); } #[tokio::test] async fn password_short() { let pool = get_db_pool().await; 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("test_user", &pool).await; assert!(user.is_err()); let body = std::str::from_utf8(resp.bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string(); assert_eq!(&expected, body); } #[tokio::test] async fn password_long() { let pool = get_db_pool().await; 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("test_user", &pool).await; assert!(user.is_err()); let body = std::str::from_utf8(resp.bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string(); assert_eq!(&expected, body); } #[tokio::test] async fn multibyte_password_too_short() { let pw = "🤡"; // min length is 4 assert_eq!(pw.len(), 4); let pool = get_db_pool().await; let server = server_with_pool(&pool).await; let form = format!("username=test_user&displayname=Test+User&password={pw}&pw_verify={pw}"); 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("test_user", &pool).await; assert!(user.is_err()); let body = std::str::from_utf8(resp.bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::BadPassword).to_string(); assert_eq!(&expected, body); } #[tokio::test] async fn username_short() { let pool = get_db_pool().await; 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("test_user", &pool).await; assert!(user.is_err()); let body = std::str::from_utf8(resp.bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::BadUsername).to_string(); assert_eq!(&expected, body); } #[tokio::test] async fn username_long() { let pool = get_db_pool().await; 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("test_user", &pool).await; assert!(user.is_err()); let body = std::str::from_utf8(resp.bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::BadUsername).to_string(); assert_eq!(&expected, body); } #[tokio::test] async fn username_duplicate() { let pool = get_db_pool().await; let server = server_with_pool(&pool).await; 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("test_user", &pool).await; assert!(user.is_ok()); // 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.bytes()).unwrap(); assert_eq!(&expected, body); } #[tokio::test] async fn displayname_long() { let pool = get_db_pool().await; 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("test_user", &pool).await; assert!(user.is_err()); let body = std::str::from_utf8(resp.bytes()).unwrap(); let expected = CreateUserError(CreateUserErrorKind::BadDisplayname).to_string(); assert_eq!(&expected, body); } #[tokio::test] async fn handle_signup_success() { let pool = get_db_pool().await; let server = server_with_pool(&pool).await; let path = format!("/signup_success/nope"); let resp = server.get(&path).expect_failure().await; assert_eq!(resp.status_code(), StatusCode::SEE_OTHER); } } }