what2watch/src/signup.rs
2023-06-19 16:54:28 -07:00

487 lines
17 KiB
Rust

use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
use axum::{
extract::{Form, Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use sqlx::{query_as, SqlitePool};
use unicode_segmentation::UnicodeSegmentation;
use uuid::Uuid;
use crate::{CreateUser, CreateUserSuccess, 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,
}
//-************************************************************************
// User creation route handlers
//-************************************************************************
/// Get Handler: displays the form to create a user
pub async fn get_create_user() -> CreateUser {
CreateUser::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<CreateUser>,
) -> 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).await?;
tracing::debug!("created {user:?}");
let id = user.id.as_simple().to_string();
let location = format!("/signup_success/{id}");
let resp = axum::response::Redirect::to(&location);
Ok(resp)
}
/// Generic handler for successful signup
pub async fn handle_signup_success(
Path(id): Path<String>,
State(pool): State<SqlitePool>,
) -> Response {
let id = id.trim();
let user: User = {
let id = Uuid::try_parse(id).unwrap_or_default();
query_as(ID_QUERY)
.bind(id)
.fetch_one(&pool)
.await
.unwrap_or_default()
};
let mut resp = CreateUserSuccess(user.clone()).into_response();
if user.username.is_empty() || id.is_empty() {
// 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<String>,
email: &Option<String>,
password: &[u8],
pool: &SqlitePool,
) -> Result<User, CreateUserError> {
// 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();
// TODO: look into ULIDs for sortable identifiers that are UUID-compatible
let id = Uuid::new_v4();
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::{CreateUser, CreateUserSuccess},
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 = CreateUser::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.as_simple().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 = CreateUserSuccess(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);
}
}
}