diff --git a/Cargo.lock b/Cargo.lock index 5d364f7..fc97902 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,6 +225,19 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bcrypt" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d1c9c15093eb224f0baa400f38fcd713fc1391a6f1c389d886beef146d60a3" +dependencies = [ + "base64", + "blowfish", + "getrandom", + "subtle", + "zeroize", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -249,6 +262,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bstr" version = "1.7.0" @@ -307,6 +330,16 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.4.6" @@ -857,6 +890,7 @@ name = "imgboard" version = "0.1.0" dependencies = [ "axum", + "bcrypt", "chrono", "clap", "dotenvy", @@ -888,6 +922,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "itertools" version = "0.11.0" diff --git a/Cargo.toml b/Cargo.toml index 1c11ed2..3da95c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,8 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -axum = { version = "0.6.20", features = ["tracing", "macros", "headers"] } +axum = { version = "0.6.20", features = ["tracing", "macros", "headers", "form"] } +bcrypt = "0.15.0" chrono = { version = "0.4.31", features = ["serde"] } clap = { version = "4.4.6", features = ["derive", "env"] } dotenvy = "0.15.7" diff --git a/src/main.rs b/src/main.rs index e595101..fb82c47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,9 +20,6 @@ use crate::config::Config; use crate::state::AppState; use crate::templates::handlebars; -use crate::pages::index::index; -use crate::pages::user::{get_user, get_user_new}; - #[tokio::main] async fn main() -> Result<()> { dotenvy::dotenv().ok(); @@ -52,9 +49,9 @@ async fn main() -> Result<()> { debug!("Building router"); let app = Router::new() - .route("/", get(index)) - .route("/users/:id", get(get_user)) - .route("/users/new", get(get_user_new)) + .route("/", get(pages::index)) + .route("/users/:id", get(pages::get_user)) + .route("/users/new", get(pages::get_user_new).post(pages::post_user_new)) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/src/model/user.rs b/src/model/user.rs index 1853682..5086570 100644 --- a/src/model/user.rs +++ b/src/model/user.rs @@ -1,10 +1,11 @@ use chrono::{DateTime, Utc}; use serde::{Serialize, Serializer}; -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct User { pub id: i32, pub username: String, + #[serde(skip_serializing)] pub password: String, pub role: Role, @@ -12,7 +13,7 @@ pub struct User { pub last_active: DateTime, } -#[derive(sqlx::Type, Debug)] +#[derive(sqlx::Type, Debug, Clone, PartialEq, Eq)] #[repr(i32)] pub enum Role { Viewer = 1, @@ -37,12 +38,27 @@ impl Serialize for Role { impl From for Role { fn from(value: i32) -> Self { + // From is only used for database mapping, so panicking on invalid values is okay match value { 1 => Self::Viewer, 2 => Self::Editor, 3 => Self::Manager, 4 => Self::Admin, - _ => unreachable!("Role should never be outside 1-4") + _ => unreachable!("Role should never be outside 1-4"), + } + } +} + +impl TryFrom<&str> for Role { + type Error = eyre::Error; + + fn try_from(value: &str) -> Result { + match value { + "viewer" => Ok(Self::Viewer), + "editor" => Ok(Self::Editor), + "manager" => Ok(Self::Manager), + "admin" => Ok(Self::Admin), + _ => Err(eyre::eyre!("Invalid role string value")), } } } diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 7835bce..906852d 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,2 +1,5 @@ -pub mod index; -pub mod user; +mod index; +mod user; + +pub use index::index; +pub use user::{get_user, get_user_new, post_user_new}; diff --git a/src/pages/user/mod.rs b/src/pages/user/mod.rs index 2370b73..4ff147d 100644 --- a/src/pages/user/mod.rs +++ b/src/pages/user/mod.rs @@ -1,11 +1,14 @@ use std::{collections::BTreeMap, sync::Arc}; use axum::{ + body::BoxBody, extract::{Path, State}, - response::IntoResponse, + response::{IntoResponse, Response}, + Form, }; +use serde::Deserialize; use sqlx::{query, query_as}; -use tracing::error; +use tracing::{error, info}; use crate::{ extractor::ExtractUserToken, @@ -25,47 +28,55 @@ pub async fn get_user( Ok(user) => user, Err(why) => { error!("Getting user: {}", why); - return Page::new( - state - .hbs - .render::("error.hbs", &Default::default()), - ); + return Page::new(state.hbs.render( + "error.hbs", + &PageData { + message: Some("Internal server error".into()), + ..Default::default() + }, + )); } }; - Page::new( - state - .hbs - .render("error.hbs", &PageData { user: Some(user) }), - ) + Page::new(state.hbs.render( + "error.hbs", + &PageData { + user: Some(user), + ..Default::default() + }, + )) } pub async fn get_user_new( State(state): State>, ExtractUserToken(user): ExtractUserToken, ) -> impl IntoResponse { - if user.is_some() - && match user.as_ref().unwrap().role { - Role::Viewer => false, - Role::Editor => false, - Role::Manager => false, - Role::Admin => true, + // If there's already an authenticated user, check if they're an admin + // Admins can create new users, others can't + if let Some(user) = user { + tracing::debug!("User is authenticated"); + + if user.role == Role::Admin { + tracing::debug!("Authenticated user is admin"); + + return Page::new( + state + .hbs + .render::("new_user.hbs", &Default::default()), + ); } - { - // Admins can create accounts - // Handlebars expects *some* value when rendering a template, so just pass an empty BTreeMap - let map = BTreeMap::<&str, &str>::new(); - return Page::new(state.hbs.render("new_user.hbs", &map)); - } else if user.is_some() { - // No permissions to create account + + tracing::debug!("Authenticated user is not an admin"); return Page::new( state .hbs .render::("error.hbs", &Default::default()), ); } - // Else, let the user create a new account if no accounts exist yet + tracing::debug!("There is no authenticated user"); + + // Else, let the user create a new account, but only if no accounts exist yet let count = match query!(r#"SELECT COUNT(id) FROM users"#) .fetch_one(&state.pool) .await @@ -81,8 +92,145 @@ pub async fn get_user_new( } }; + tracing::debug!("Current user count: {}", count); + let mut map = BTreeMap::new(); map.insert("count", count); Page::new(state.hbs.render("new_user.hbs", &map)) } + +#[derive(Deserialize)] +pub struct UserNew { + pub username: String, + pub password: String, + pub password2: String, +} + +pub async fn post_user_new( + State(state): State>, + ExtractUserToken(existing_user): ExtractUserToken, + Form(form_data): Form, +) -> Response { + if existing_user.is_none() { + let count = match query!(r#"SELECT COUNT(id) FROM users"#) + .fetch_one(&state.pool) + .await + { + Ok(r) => r.count.unwrap_or(0), + Err(why) => { + error!("Getting user count: {}", why); + + return Page::new(state.hbs.render( + "error.hbs", + &PageData { + message: Some("Internal server error".into()), + ..Default::default() + }, + )) + .into_response(); + } + }; + + if count != 0 { + info!(""); + + return Page::new(state.hbs.render( + "error.hbs", + &PageData { + message: Some("You need to be logged in to create new users".into()), + ..Default::default() + }, + )) + .into_response(); + } + } else if let Some(user) = existing_user.clone() { + if user.role != Role::Admin { + info!( + user_id = user.id, + "User tried to access POST /users/new despite not being admin" + ); + + return Page::new(state.hbs.render( + "error.hbs", + &PageData { + message: Some("Only admins can create new users".into()), + ..Default::default() + }, + )) + .into_response(); + } + } + + if form_data.password != form_data.password2 { + return Page::new(state.hbs.render( + "new_user.hbs", + &PageData { + message: Some("Passwords don't match".into()), + ..Default::default() + }, + )) + .into_response(); + } + + let hashed_password = match bcrypt::hash(form_data.password, 12) { + Ok(hash) => hash, + Err(why) => { + error!("Hashing password: {}", why); + + return Page::new(state.hbs.render( + "error.hbs", + &PageData { + message: Some("Internal server error".into()), + ..Default::default() + }, + )) + .into_response(); + } + }; + + let user = match query_as!( + User, + r#"INSERT INTO users (username, password) VALUES ($1, $2) RETURNING *"#, + form_data.username, + hashed_password, + ) + .fetch_one(&state.pool) + .await + { + Ok(u) => u, + Err(why) => { + error!("Creating user: {}", why); + + return Page::new(state.hbs.render( + "error.hbs", + &PageData { + message: Some("Internal server error".into()), + ..Default::default() + }, + )) + .into_response(); + } + }; + + if existing_user.is_some() { + // Render template with new user info (ID, username) + + return Page::new(state.hbs.render( + "new_user_confirm.hbs", + &PageData { + user: existing_user, + ..Default::default() + }, + )) + .into_response(); + } + + Page::new(state.hbs.render( + "new_user.hbs", + &PageData { + ..Default::default() + }, + )) + .into_response() +} diff --git a/src/state.rs b/src/state.rs index c85870b..ee7c0ca 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use axum::{extract::{FromRef, FromRequestParts}, async_trait, http::request::Parts}; use sqlx::{postgres::Postgres, Pool}; @@ -19,3 +21,9 @@ where Ok(Self::from_ref(state)) } } + +impl FromRef> for AppState { + fn from_ref(input: &Arc) -> Self { + (**input).clone() + } +} diff --git a/src/templates.rs b/src/templates.rs index 34a944c..44dbb1c 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -54,4 +54,7 @@ impl IntoResponse for Page { #[derive(Debug, Default, Serialize)] pub struct PageData { pub user: Option, + + // An optional flash message. Not all templates will handle this. + pub message: Option, } diff --git a/templates/error.hbs b/templates/error.hbs index 83575f2..30a337b 100644 --- a/templates/error.hbs +++ b/templates/error.hbs @@ -1,5 +1,5 @@ {{#*inline "page"}}

Error

-

An internal error occurred. Sorry :(

+

Error: {{message}}

{{/inline}} {{> root.hbs}} diff --git a/templates/new_user.hbs b/templates/new_user.hbs index ab37de8..225c452 100644 --- a/templates/new_user.hbs +++ b/templates/new_user.hbs @@ -1,5 +1,10 @@ {{#*inline "page"}}

New user

+{{#if message}} +
+ {{message}} +
+{{/if}}
diff --git a/templates/new_user_confirm.hbs b/templates/new_user_confirm.hbs new file mode 100644 index 0000000..33ee67b --- /dev/null +++ b/templates/new_user_confirm.hbs @@ -0,0 +1,13 @@ +{{#*inline "page"}} +

New user created!

+{{#if message}} +
+ {{message}} +
+{{/if}} +
    +
  • ID: {{new_user.id}}
  • +
  • Username: {{new_user.username}}
  • +
+{{/inline}} +{{> root.hbs}}