what2watch/src/signup/handlers.rs
2024-01-15 15:11:39 -08:00

732 lines
26 KiB
Rust

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<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub email: Option<String>,
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<SqlitePool>,
invitation: Option<Path<String>>,
) -> Result<impl IntoResponse, CreateUserError> {
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<SqlitePool>,
Form(signup): Form<SignupForm>,
) -> Result<impl IntoResponse, CreateUserError> {
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<String>,
State(pool): State<SqlitePool>,
) -> 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<String>,
email: &Option<String>,
password: &[u8],
pool: &SqlitePool,
invitation: &str,
) -> Result<User, CreateUserError> {
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<Julid, CreateUserErrorKind> {
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);
});
}
}
}