diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae4cc395..ce022e67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,12 +32,14 @@ jobs: sudo service postgresql restart sudo -u postgres createuser --superuser --login $USER createdb $USER + createdb rwf_users psql postgres://$USER@127.0.0.1:5432/$USER -c "SELECT 1" > /dev/null - uses: actions/checkout@v4 - uses: actions-rs/toolchain@v1 with: toolchain: stable override: true + - uses: Swatinem/rust-cache@v2 - name: Install test dependencies run: cargo install cargo-nextest cargo-expand - name: Run tests diff --git a/Cargo.lock b/Cargo.lock index 040a450e..40a404dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,18 @@ version = "1.0.91" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c042108f3ed77fd83760a5fd79b53be043192bb3b9dba91d8c574c0ada7850c8" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -255,6 +267,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "basic-toml" version = "0.1.9" @@ -322,6 +340,15 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1401,6 +1428,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -1887,10 +1925,11 @@ checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" [[package]] name = "rwf" -version = "0.2.0" +version = "0.2.1" dependencies = [ "aes", "aes-gcm-siv", + "argon2", "async-trait", "base64 0.22.1", "bindgen 0.65.1", @@ -1900,6 +1939,7 @@ dependencies = [ "notify", "once_cell", "parking_lot 0.12.3", + "password-hash", "pyo3", "rand 0.8.5", "rayon", @@ -1952,7 +1992,7 @@ dependencies = [ [[package]] name = "rwf-macros" -version = "0.1.14" +version = "0.2.1" dependencies = [ "macrotest", "pluralizer", @@ -2638,6 +2678,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "users" +version = "0.1.0" +dependencies = [ + "argon2", + "rwf", + "time", +] + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index d1f3b0ab..0fc48ae4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,6 @@ members = [ "examples/request-tracking", "examples/engine", "rwf-admin", - "examples/files", + "examples/files", "examples/users", ] exclude = ["examples/rails", "rwf-ruby", "examples/django", "rwf-fuzz"] diff --git a/docs/docs/controllers/sessions.md b/docs/docs/controllers/sessions.md index f13c0dc7..b54886eb 100644 --- a/docs/docs/controllers/sessions.md +++ b/docs/docs/controllers/sessions.md @@ -32,11 +32,7 @@ async fn handle(&self, request: &Request) -> Result { All [controllers](index.md) can check for the presence of a valid session: ```rust -let session = request.session(); - -let valid = session - .map(|session| !session.expired()) - .unwrap_or(false); +let valid = !request.session().expired(); ``` Unless the session cookie is set and has been encrypted using the correct algorithm and secret key, calling [`session`](https://docs.rs/rwf/latest/rwf/http/request/struct.Request.html#method.session) will return `None`. diff --git a/docs/docs/controllers/websockets.md b/docs/docs/controllers/websockets.md index 2d1ab64c..412e794b 100644 --- a/docs/docs/controllers/websockets.md +++ b/docs/docs/controllers/websockets.md @@ -95,11 +95,10 @@ to send a [`Message`](https://docs.rs/rwf/latest/rwf/http/websocket/enum.Message All WebSocket clients have a unique [session](sessions.md) identifier. Sending a message to a client only requires that you know their session ID, which you can obtain from the [`Request`](request.md), for example: ```rust -if let Some(session_id) = request.session_id() { - let client = Comms::websocket(&session_id); +let session_id = request.session_id(); +let websocket = Comms::websocket(&session_id); - client.send("hey there")?; -} +websocket.send("hey there")?; ``` WebSocket messages can be delivered to any client from anywhere in the application, including [controllers](index.md) and [background jobs](../background-jobs/index.md). diff --git a/docs/docs/security/hashing.md b/docs/docs/security/hashing.md new file mode 100644 index 00000000..f301154a --- /dev/null +++ b/docs/docs/security/hashing.md @@ -0,0 +1,57 @@ +# Password hashing + +Password hashing is a technique for storing and validating user passwords without exposing what those passwords are. All modern web apps must use this technique +to store credentials, and Rwf comes with built-in support for hashing using [Argon2](https://en.wikipedia.org/wiki/Argon2). + +## Generate hashes + +A password hash can be generated using the `rwf::crypto::hash` function, for example: + +```rust +use rwf::crypto::hash; + +let hashed = hash("secret_password".as_bytes()).unwrap(); +``` + +The hash generated by this function is a Rust string; it can be saved in a database. Since Argon2 is cryptographically secure, strong passwords are reasonably protected against brute force attacks in case the hashes are leaked. + +!!! note + While hashes are hard to brute force, it's still inadvisable to allow hashes + to be easily accessible to anyone. Make every effort to protect your production database + against unauthorized access. + +## Validate passwords + +Hashes are used to check that some information the application has seen previously matches what the it's seeing now. For example, when one of your users wants to log into the application, +they will provide the application with a login and a password. The password can be validated against an existing hash, and if the two match, it's safe to assume that the password is correct. + +Passwords can be validated using the `rwf::crypto::hash_validate` function, for example: + +```rust +use rwf::crypto::hash_validate; + +let matches = hash_validate( + "secret_password".as_bytes(), + &hashed, +).unwrap(); +``` + +## Using with Tokio + +You'll note that both `hash` and `hash_validate` functions are slow. In fact, it can take upwards a second to generate or validate a hash. This is done on purpose, to make hashes hard to brute force. +To avoid blocking the Tokio runtime and slowing down your application, make sure to use both functions inside blocking tasks: + +```rust +use tokio::task::spawn_blocking; + +let hashed = spawn_blocking(move || { + hash("secret_password".as_bytes()) +}) +.await +.unwrap() +.unwrap(); +``` + +## Learn more + +- [examples/users](https://github.com/levkk/rwf/tree/main/examples/users) diff --git a/docs/docs/user-guides/.pages b/docs/docs/user-guides/.pages index 622b20e6..89521218 100644 --- a/docs/docs/user-guides/.pages +++ b/docs/docs/user-guides/.pages @@ -1,5 +1,7 @@ nav: - 'index.md' + - 'build-your-app' - 'hot-reload.md' + - '...' - 'deploy-to-prod.md' - 'admin.md' diff --git a/docs/docs/user-guides/build-your-app/add-users.md b/docs/docs/user-guides/build-your-app/add-users.md new file mode 100644 index 00000000..4568ddb9 --- /dev/null +++ b/docs/docs/user-guides/build-your-app/add-users.md @@ -0,0 +1,78 @@ +# Add users + +!!! note + This guide is a work-in-progress. + +Unless you're building simple demo applications or static informational websites, your web app will need a way for your users to sign up and personalize their experience. There are many ways to accomplish this, and your implementation should be specific to your use case. For example, many web apps allow users to sign up using an OAuth2 provider like [Google](https://developers.google.com/identity/protocols/oauth2) or [GitHub](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app). + +In this guide, we'll cover the most popular and simple way to create user accounts: using a username and a password. + +## Username and password + +Allowing your users to create accounts in your application using a username and password is pretty universal. Implementing this system requires using all 3 components of the MVC framework: creating a database model to store usernames and password hashes, controllers to process signup and login requests, and views to serve signup and login forms. + +Rwf supports all three components natively. + +### Create the model + +To create a model in Rwf, you need to define the schema in the database and define the model in Rust code. The two should match as closely as possible. + +#### Create the schema + +Starting with the data model, let's create a simple `"users"` table in your database. This table will store usernames, password hashes, and other metadata about our users, like when their accounts were created. + +Creating a table with Rwf should be done by writing a [migration](../../models/migrations.md). This makes sure changes to the database schema are documented and deterministic. To create a migration, use the Rwf CLI: + +=== "Command" + ``` + rwf-cli migrate add -n users + ``` +=== "Output" + ``` + Created "migrations/1733265254409864495_users.up.sql" + Created "migrations/1733265254409864495_users.down.sql" + ``` + +The migration is empty, so let's create the table by adding it to the `users.up.sql` file: + +```postgresql +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR NOT NULL UNIQUE, + password_hash VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +As mentioned above, our table will store usernames, password hashes, and metadata. The `id` column is the primary key of this table, allowing us to identify our users using a unique number. + +!!! note + Rwf models by default expect the presence of the `id` column, and use it as the primary key. + This is configurable on a per-model basis, and models can be created without a primary key, + but this will prevent them from being updated or deleted by the ORM. + +Once the schema is ready, create the table in the database by applying the migration: + +=== "Command" + ``` + rwf-cli migrate run + ``` +=== "Output" + ``` + applying migration "1733265254409864495_users" + migration "1733265254409864495_users" applied + ``` + +#### Define the Rust model + +With the schema ready to go, we need to create a Rust struct which we'll use in code to reference the model records. The Rust struct should have the same fields as the columns in our table, and their data types should match as well: + +```rust +#[derive(Clone, macros::Model)] +pub struct User { + id: Option, + username: String, + password_hash: String, + created_at: OffsetDateTime, +} +``` diff --git a/docs/docs/user-guides/build-your-app/index.md b/docs/docs/user-guides/build-your-app/index.md new file mode 100644 index 00000000..39dd9a50 --- /dev/null +++ b/docs/docs/user-guides/build-your-app/index.md @@ -0,0 +1,21 @@ +# Build with Rwf + +Rwf has a lot of features and mixing them together can create powerful and efficient web apps. This guide will demonstrate how +to build a generic application from scratch using Rwf as your web framework. + +!!! note + This guide is a work in progress. Please check back soon for more updates. [Contributions](https://github.com/levkk/rwf/tree/main/docs/docs/user-guides/build-your-app) are welcome! + +## Getting started + +If you'd like to build an application with this guide, make sure make sure to install the Rwf CLI first: + +``` +cargo install rwf-cli +``` + +Once the CLI is installed, make sure to follow the [instructions](../../index.md) on creating a new Rwf application. + +## Chapters + +1. [Add users](add-users.md) diff --git a/docs/docs/views/turbo/streams.md b/docs/docs/views/turbo/streams.md index 1db295e7..f60b5117 100644 --- a/docs/docs/views/turbo/streams.md +++ b/docs/docs/views/turbo/streams.md @@ -61,21 +61,16 @@ let session_id = request.session_id(); Once you have the ID, you can send an update directly to that user: ```rust -use rwf::prelude::*; - -// Not all requests will have a session. -if let Some(session_id) = session_id { - // Create the update. - let update = TurboStream::new(r#" -
-

Hi Alice!

-

Hello Bob!

-
- "#).action("replace").target("messages"); - - // Send it via a WebSocket connection. - Comms::websocket(&session_id).send(update)?; -} +// Create the update. +let update = TurboStream::new(r#" +
+

Hi Alice!

+

Hello Bob!

+
+"#).action("replace").target("messages"); + +// Send it via a WebSocket connection. +Comms::websocket(&session_id).send(update)?; ``` If you need to send updates to the client from somewhere else besides a controller, e.g. from a [background job](../../background-jobs/index.md), pass the session identifier to that code as an argument. The session identifier is unique and unlikely to change. diff --git a/examples/auth/src/main.rs b/examples/auth/src/main.rs index 58f27d65..478b5ac1 100644 --- a/examples/auth/src/main.rs +++ b/examples/auth/src/main.rs @@ -59,7 +59,7 @@ impl Controller for ProtectedAreaController { } async fn handle(&self, request: &Request) -> Result { - let session = request.session().unwrap(); + let session = request.session(); let welcome = format!("

Welcome, user {:?}

", session.session_id); Ok(Response::new().html(welcome)) } diff --git a/examples/orm/src/main.rs b/examples/orm/src/main.rs index 7db85e61..f55ebc19 100644 --- a/examples/orm/src/main.rs +++ b/examples/orm/src/main.rs @@ -292,7 +292,7 @@ async fn main() -> Result<(), Error> { let query_plan = User::all() .filter_lte("created_at", OffsetDateTime::now_utc()) .limit(25) - .explain(&mut conn) + .explain(Pool::pool()) .await?; println!("{}", query_plan); diff --git a/examples/turbo/src/controllers/signup/middleware.rs b/examples/turbo/src/controllers/signup/middleware.rs index 4a8b4023..ac59aa43 100644 --- a/examples/turbo/src/controllers/signup/middleware.rs +++ b/examples/turbo/src/controllers/signup/middleware.rs @@ -7,10 +7,8 @@ pub struct LoggedInCheck; #[rwf::async_trait] impl Middleware for LoggedInCheck { async fn handle_request(&self, request: Request) -> Result { - if let Some(session) = request.session() { - if session.authenticated() { - return Ok(Outcome::Stop(request, Response::new().redirect("/chat"))); - } + if request.session().authenticated() { + return Ok(Outcome::Stop(request, Response::new().redirect("/chat"))); } Ok(Outcome::Forward(request)) diff --git a/examples/users/Cargo.toml b/examples/users/Cargo.toml new file mode 100644 index 00000000..c36eec7b --- /dev/null +++ b/examples/users/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "users" +version = "0.1.0" +edition = "2021" + +[dependencies] +rwf = { path = "../../rwf" } +time = "0.3" +argon2 = "0.5" diff --git a/examples/users/migrations/1733265254409864495_users.down.sql b/examples/users/migrations/1733265254409864495_users.down.sql new file mode 100644 index 00000000..cc1f647d --- /dev/null +++ b/examples/users/migrations/1733265254409864495_users.down.sql @@ -0,0 +1 @@ +DROP TABLE users; diff --git a/examples/users/migrations/1733265254409864495_users.up.sql b/examples/users/migrations/1733265254409864495_users.up.sql new file mode 100644 index 00000000..4e4f4b29 --- /dev/null +++ b/examples/users/migrations/1733265254409864495_users.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR NOT NULL UNIQUE, + password VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW () +); diff --git a/examples/users/rwf.toml b/examples/users/rwf.toml new file mode 100644 index 00000000..299aa839 --- /dev/null +++ b/examples/users/rwf.toml @@ -0,0 +1,6 @@ +[general] +secret_key = "9Sk2t2G40QC3QrdVr6e6RzYAKJLGTFjpDiRrmA7eGQk=" +log_queries = true + +[database] +name = "rwf_users" diff --git a/examples/users/src/controllers.rs b/examples/users/src/controllers.rs new file mode 100644 index 00000000..d864b705 --- /dev/null +++ b/examples/users/src/controllers.rs @@ -0,0 +1,63 @@ +use crate::models::*; +use rwf::prelude::*; + +#[derive(macros::Form)] +struct SignupForm { + email: String, + password: String, +} + +#[derive(Default, macros::PageController)] +pub struct Signup; + +#[async_trait] +impl PageController for Signup { + async fn get(&self, request: &Request) -> Result { + let user = request.user::(Pool::pool()).await?; + + if let Some(_) = user { + return Ok(Response::new().redirect("/profile")); + } + + render!(request, "templates/signup.html") + } + + async fn post(&self, request: &Request) -> Result { + let form = request.form::()?; + let user = User::signup(&form.email, &form.password).await?; + + match user { + UserLogin::Ok(user) => Ok(request.login_user(&user)?.redirect("/profile")), + _ => render!(request, "templates/signup.html", "error" => true, 400), + } + } +} + +#[controller] +pub async fn login(request: &Request) -> Result { + let form = request.form::()?; + + let user = User::login(&form.email, &form.password).await?; + + if let UserLogin::Ok(_) = user { + Ok(Response::new().redirect("/profile")) + } else { + render!( + request, + "templates/signup.html", + "login" => true, + "error" => true, + 400 + ) + } +} + +#[controller] +pub async fn profile(request: &Request) -> Result { + let user = { + let mut conn = Pool::connection().await?; + request.user_required::(&mut conn).await? + }; + + render!(request, "templates/profile.html", "user" => user) +} diff --git a/examples/users/src/main.rs b/examples/users/src/main.rs new file mode 100644 index 00000000..332e67be --- /dev/null +++ b/examples/users/src/main.rs @@ -0,0 +1,18 @@ +use rwf::{http::Server, prelude::*}; + +mod controllers; +mod models; + +#[tokio::main] +async fn main() { + Logger::init(); + + Server::new(vec![ + route!("/signup" => controllers::Signup), + route!("/login" => controllers::login), + route!("/profile" => controllers::profile), + ]) + .launch() + .await + .unwrap(); +} diff --git a/examples/users/src/models.rs b/examples/users/src/models.rs new file mode 100644 index 00000000..166096d0 --- /dev/null +++ b/examples/users/src/models.rs @@ -0,0 +1,74 @@ +// use rwf::model::Error; +use rwf::crypto::{hash, hash_validate}; +use rwf::prelude::*; +use tokio::task::spawn_blocking; + +pub enum UserLogin { + NoSuchUser, + WrongPassword, + Ok(User), +} + +#[derive(Clone, macros::Model)] +pub struct User { + id: Option, + email: String, + password: String, + created_at: OffsetDateTime, +} + +impl User { + /// Create new user with email and password. + pub async fn signup(email: &str, password: &str) -> Result { + let hash_password = password.to_owned(); + let encrypted_password = spawn_blocking(move || hash(hash_password.as_bytes())) + .await + .unwrap()?; + + match Self::login(email, password).await? { + UserLogin::Ok(user) => return Ok(UserLogin::Ok(user)), + UserLogin::WrongPassword => return Ok(UserLogin::WrongPassword), + _ => (), + } + + let user = User::create(&[ + ("email", email.to_value()), + ("password", encrypted_password.to_value()), + ]) + .fetch(Pool::pool()) + .await?; + + Ok(UserLogin::Ok(user)) + } + + /// Login user with email and password. + /// + /// Return a user if one exists and the passwords match. + /// Return `None` otherwise. + pub async fn login(email: &str, password: &str) -> Result { + if let Some(user) = User::filter("email", email) + .fetch_optional(Pool::pool()) + .await? + { + if hash_validate(password.as_bytes(), &user.password)? { + return Ok(UserLogin::Ok(user)); + } else { + return Ok(UserLogin::WrongPassword); + } + } + + Ok(UserLogin::NoSuchUser) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[tokio::test] + async fn test_user() { + Migrations::migrate().await.unwrap(); + let _user = User::signup("test@test.com", "password2").await.unwrap(); + let _user = User::login("test@test.com", "password2").await.unwrap(); + } +} diff --git a/examples/users/templates/head.html b/examples/users/templates/head.html new file mode 100644 index 00000000..1bd850d7 --- /dev/null +++ b/examples/users/templates/head.html @@ -0,0 +1,4 @@ + + + +<%= rwf_head() %> diff --git a/examples/users/templates/profile.html b/examples/users/templates/profile.html new file mode 100644 index 00000000..305c7807 --- /dev/null +++ b/examples/users/templates/profile.html @@ -0,0 +1,25 @@ + + + + <%% "templates/head.html" %> + + +
+

Profile

+
+ +
+
+
+
+ <%= user.email %> +
+
+ +
+
+
+
+
+ + diff --git a/examples/users/templates/signup.html b/examples/users/templates/signup.html new file mode 100644 index 00000000..fe25897e --- /dev/null +++ b/examples/users/templates/signup.html @@ -0,0 +1,35 @@ + + + + + + + + +
+
+ <% if error %> +
+ Account with this email already exists, and the password is incorrect. +
+ <% end %> + <%= csrf_token() %> +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + diff --git a/rwf-macros/Cargo.toml b/rwf-macros/Cargo.toml index 70417eee..7579a88d 100644 --- a/rwf-macros/Cargo.toml +++ b/rwf-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rwf-macros" -version = "0.1.14" +version = "0.2.1" edition = "2021" license = "MIT" description = "Macros for the Rust Web Framework" diff --git a/rwf-macros/src/lib.rs b/rwf-macros/src/lib.rs index 4e1266be..784a686a 100644 --- a/rwf-macros/src/lib.rs +++ b/rwf-macros/src/lib.rs @@ -4,7 +4,7 @@ use proc_macro::TokenStream; use syn::{ parse_macro_input, punctuated::Punctuated, Attribute, Data, DeriveInput, Expr, ItemFn, Meta, - ReturnType, Token, Type, + ReturnType, Token, Type, Visibility, }; use quote::quote; @@ -672,7 +672,7 @@ fn snake_case(string: &str) -> String { /// ``` #[proc_macro_attribute] pub fn controller(_args: TokenStream, input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as ItemFn); + let mut input = parse_macro_input!(input as ItemFn); let name = &input.sig.ident; let result = match input.sig.output { @@ -715,10 +715,13 @@ pub fn controller(_args: TokenStream, input: TokenStream) -> TokenStream { } }; + let vis = input.vis.clone(); + input.vis = Visibility::Inherited; + quote! { #[derive(Default)] #[allow(non_camel_case_types)] - pub struct #name; + #vis struct #name; #[rwf::async_trait] impl rwf::controller::Controller for #name { diff --git a/rwf-macros/src/render.rs b/rwf-macros/src/render.rs index 93c8588b..36c89c6d 100644 --- a/rwf-macros/src/render.rs +++ b/rwf-macros/src/render.rs @@ -7,6 +7,7 @@ struct RenderInput { _comma_1: Option, context: Vec, code: Option, + _comma_2: Option, } struct TurboStreamInput { @@ -28,6 +29,7 @@ impl TurboStreamInput { _comma_1: self._comma_2.clone(), context: self.context.clone(), code: None, + _comma_2: None, } } } @@ -117,6 +119,7 @@ impl Parse for RenderInput { let template_name: LitStr = input.parse()?; let _comma_1: Option = input.parse()?; let mut code = None; + let mut _comma_2 = None; let context = if _comma_1.is_some() { let mut result = vec![]; @@ -124,6 +127,7 @@ impl Parse for RenderInput { if input.peek(LitInt) { let c: LitInt = input.parse().unwrap(); code = Some(c); + _comma_2 = input.parse().unwrap(); } else { let context: Result = input.parse(); @@ -147,6 +151,7 @@ impl Parse for RenderInput { _comma_1, context, code, + _comma_2, }) } } diff --git a/rwf-tests/src/main.rs b/rwf-tests/src/main.rs index 7c4d01e6..7ad34dba 100644 --- a/rwf-tests/src/main.rs +++ b/rwf-tests/src/main.rs @@ -104,11 +104,10 @@ impl RestController for BasePlayerController { type Resource = i64; async fn get(&self, request: &Request, id: &i64) -> Result { - if let Some(session) = request.session() { - session - .websocket() - .send(websocket::Message::Text("controller websocket".into()))?; - } + request + .session() + .websocket() + .send(websocket::Message::Text("controller websocket".into()))?; Ok(Response::new().html(format!("

base player controller, id: {}

", id))) } diff --git a/rwf/Cargo.toml b/rwf/Cargo.toml index 2bc521b8..2fd4ea9c 100644 --- a/rwf/Cargo.toml +++ b/rwf/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rwf" -version = "0.2.0" +version = "0.2.1" edition = "2021" license = "MIT" description = "Framework for building web applications in the Rust programming language" @@ -32,7 +32,7 @@ parking_lot = "0.12" once_cell = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -rwf-macros = { path = "../rwf-macros", version = "0.1.14" } +rwf-macros = { path = "../rwf-macros", version = "0.2.1" } colored = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -49,6 +49,8 @@ rayon = { version = "1", optional = true } uuid = { version = "1", features = ["v4"] } notify = "7" rwf-ruby = { path = "../rwf-ruby", optional = true, version = "0.1.1" } +argon2 = { version = "0.5", features = ["password-hash"] } +password-hash = "0.5" [dev-dependencies] tempdir = "0.3" diff --git a/rwf/src/config.rs b/rwf/src/config.rs index 77fb7b15..9a53edc3 100644 --- a/rwf/src/config.rs +++ b/rwf/src/config.rs @@ -8,7 +8,7 @@ use std::env::var; use std::io::IsTerminal; use std::path::{Path, PathBuf}; use time::Duration; -use tracing::info; +use tracing::{error, info, warn}; use crate::controller::middleware::csrf::Csrf; use crate::controller::middleware::{request_tracker::RequestTracker, Middleware}; @@ -58,12 +58,15 @@ pub fn get_config() -> &'static Config { /// Rwf configuration file. Can be deserialized /// from a TOML file, although any format supported by /// `serde` is possible. -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize)] pub struct Config { /// Where the configuration file is located. #[serde(skip)] pub path: Option, + #[serde(skip)] + error: Option, + /// General settings. Most settings are here. #[serde(default = "General::default")] pub general: General, @@ -85,6 +88,7 @@ impl Default for Config { fn default() -> Self { Self { path: None, + error: None, general: General::default(), database: DatabaseConfig::default(), websocket: WebsocketConfig::default(), @@ -109,7 +113,14 @@ impl Config { for path in ["rwf.toml", "Rwf.toml", "Rum.toml"] { let path = Path::new(path); if path.is_file() { - return Self::load(path).unwrap_or_default(); + return match Self::load(path) { + Ok(config) => config, + Err(err) => { + let mut config = Config::default(); + config.error = Some(err); + config + } + }; } } @@ -154,6 +165,9 @@ impl Config { pub fn log_info(&self) { if let Some(ref path) = self.path { info!("Configuration file \"{}\" loaded", path.display()); + } else if let Some(error) = &self.error { + error!("Configuration file failed to load: {:?}", error); + warn!("Loading configuration from environment"); } else { info!("Configuration file missing, loaded from environment instead"); } diff --git a/rwf/src/controller/auth.rs b/rwf/src/controller/auth.rs index dfb6d758..072bd286 100644 --- a/rwf/src/controller/auth.rs +++ b/rwf/src/controller/auth.rs @@ -329,11 +329,7 @@ impl SessionAuth { #[async_trait] impl Authentication for SessionAuth { async fn authorize(&self, request: &Request) -> Result { - if let Some(session) = request.session() { - Ok(session.authenticated()) - } else { - Ok(false) - } + Ok(request.session().authenticated()) } async fn denied(&self, _request: &Request) -> Result { diff --git a/rwf/src/controller/error.rs b/rwf/src/controller/error.rs index 9da4509e..91f8ee41 100644 --- a/rwf/src/controller/error.rs +++ b/rwf/src/controller/error.rs @@ -31,6 +31,9 @@ pub enum Error { #[error("view error: {0}")] ViewError(#[from] crate::view::Error), + #[error("crypto error: {0}")] + CryptoError(#[from] crate::crypto::Error), + #[error("{0}")] Error(#[from] Box), diff --git a/rwf/src/controller/middleware/csrf.rs b/rwf/src/controller/middleware/csrf.rs index 3c9fb166..36d7bc69 100644 --- a/rwf/src/controller/middleware/csrf.rs +++ b/rwf/src/controller/middleware/csrf.rs @@ -61,10 +61,7 @@ impl Middleware for Csrf { } let header = request.header(CSRF_HEADER); - let session_id = match request.session_id() { - Some(session_id) => session_id.to_string(), - None => return Ok(Outcome::Stop(request, Response::csrf_error())), - }; + let session_id = request.session_id().to_string(); if let Some(header) = header { if csrf_token_validate(header, &session_id) { diff --git a/rwf/src/controller/mod.rs b/rwf/src/controller/mod.rs index 09c2d9e1..50006213 100644 --- a/rwf/src/controller/mod.rs +++ b/rwf/src/controller/mod.rs @@ -230,7 +230,7 @@ pub trait Controller: Sync + Send { .await? } Err(err) => { - error!("{}", err); + error!("{:?}", err); let response = match err { Error::HttpError(err) => match err.code() { @@ -728,11 +728,7 @@ pub trait WebsocketController: Controller { ) -> Result { use tokio::sync::broadcast::error::RecvError; - let session_id = if let Some(session) = request.session() { - session.session_id.clone() - } else { - return Err(Error::SessionMissingError); - }; + let session_id = request.session().session_id.clone(); info!( "{} {} {} connected", diff --git a/rwf/src/crypto.rs b/rwf/src/crypto.rs index 73fb0eb2..4824de22 100644 --- a/rwf/src/crypto.rs +++ b/rwf/src/crypto.rs @@ -5,6 +5,10 @@ use aes_gcm_siv::{ aead::{Aead, KeyInit}, Aes128GcmSiv, Nonce, }; +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; use base64::{engine::general_purpose, Engine as _}; use rand::{distributions::Alphanumeric, Rng}; use serde::{Deserialize, Serialize}; @@ -31,6 +35,12 @@ pub enum Error { /// Some other error happened. See contents for description. #[error("{0}")] Generic(&'static str), + + #[error("argon2 error: {0}")] + ArgonHash(password_hash::Error), + + #[error("argon2 error: {0}")] + Argon(argon2::Error), } impl From for Error { @@ -39,6 +49,18 @@ impl From for Error { } } +impl From for Error { + fn from(error: password_hash::Error) -> Self { + Self::ArgonHash(error) + } +} + +impl From for Error { + fn from(error: argon2::Error) -> Self { + Self::Argon(error) + } +} + fn nonce() -> Vec { rand::thread_rng().gen::<[u8; 96 / 8]>().to_vec() } @@ -290,6 +312,40 @@ pub fn csrf_token_validate(token: &str, session_id: &str) -> bool { } } +/// Hash some bytes with Argon2. +/// +/// # Example +/// +/// ``` +/// # use rwf::crypto::hash; +/// let hash = hash("password".as_bytes()).unwrap(); +/// ``` +pub fn hash(data: &[u8]) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2.hash_password(data, &salt)?.to_string(); + + Ok(password_hash) +} + +/// Validate that a hash matches the bytes. +/// +/// # Example +/// +/// ``` +/// # use rwf::crypto::{hash, hash_validate}; +/// let hash = hash("password".as_bytes()).unwrap(); +/// let valid = hash_validate("password".as_bytes(), &hash).unwrap(); +/// +/// assert!(valid) +/// ``` +pub fn hash_validate(data: &[u8], hash: &str) -> Result { + let parsed_hash = PasswordHash::new(hash)?; + Ok(Argon2::default() + .verify_password(data, &parsed_hash) + .is_ok()) +} + #[cfg(test)] mod test { use super::*; diff --git a/rwf/src/http/request.rs b/rwf/src/http/request.rs index e86182c1..28a4a314 100644 --- a/rwf/src/http/request.rs +++ b/rwf/src/http/request.rs @@ -11,10 +11,11 @@ use time::OffsetDateTime; use tokio::io::{AsyncRead, AsyncReadExt}; use super::{Cookies, Error, FormData, FromFormData, Head, Params, Response, ToParameter}; +use crate::prelude::ToConnectionRequest; use crate::{ config::get_config, controller::{Session, SessionId}, - model::{ConnectionGuard, Model}, + model::Model, view::ToTemplateValue, }; @@ -22,23 +23,25 @@ use crate::{ #[derive(Debug, Clone)] pub struct Request { head: Head, - session: Option, + session: Session, inner: Arc, params: Option>, received_at: OffsetDateTime, // Don't check for valid CSRF token. skip_csrf: bool, + renew_session: bool, } impl Default for Request { fn default() -> Self { Self { head: Head::default(), - session: None, + session: Session::default(), inner: Arc::new(Inner::default()), params: None, received_at: OffsetDateTime::now_utc(), skip_csrf: false, + renew_session: false, } } } @@ -97,10 +100,15 @@ impl Request { let cookies = head.cookies(); + let (session, renew_session) = match cookies.get_session()? { + Some(session) => (session, false), + None => (Session::anonymous(), true), + }; + Ok(Request { head, params: None, - session: cookies.get_session()?, + session, inner: Arc::new(Inner { body, peer, @@ -108,6 +116,7 @@ impl Request { }), received_at: OffsetDateTime::now_utc(), skip_csrf: false, + renew_session, }) } @@ -203,11 +212,12 @@ impl Request { &self.inner.cookies } - /// Get the session set on the request, if any. While all requests served - /// by Rwf should have a session (guest or authenticated), some HTTP clients - /// may not send the cookie back (e.g. cURL won't). - pub fn session(&self) -> Option<&Session> { - self.session.as_ref() + /// Get the session set on the request, if any. + /// + /// All Rwf requests will have a session. If a browser doesn't save cookies (e.g. cURL doesn't), + /// a new session will be generated for each request. + pub fn session(&self) -> &Session { + &self.session } /// Was the CSRF protection bypassed on this request? @@ -227,22 +237,16 @@ impl Request { /// /// This should uniquely identify a browser if it's a guest session, /// or a user if the user is logged in. - pub fn session_id(&self) -> Option { - self.session - .as_ref() - .map(|session| session.session_id.clone()) + pub fn session_id(&self) -> SessionId { + self.session.session_id.clone() } /// Get the authenticated user's ID. Combined with the `?` operator, /// will return `403 - Unauthorized` if not logged in. pub fn user_id(&self) -> Result { - if let Some(session_id) = self.session_id() { - match session_id { - SessionId::Authenticated(id) => Ok(id), - _ => Err(Error::Forbidden), - } - } else { - Err(Error::Forbidden) + match self.session_id() { + SessionId::Authenticated(id) => Ok(id), + _ => Err(Error::Forbidden), } } @@ -263,11 +267,12 @@ impl Request { /// let conn = Pool::connection().await?; /// let user = request.user::(&mut conn).await?; /// ``` - pub async fn user(&self, conn: &mut ConnectionGuard) -> Result, Error> { + pub async fn user( + &self, + conn: impl ToConnectionRequest<'_>, + ) -> Result, Error> { match self.session_id() { - Some(SessionId::Authenticated(user_id)) => { - Ok(Some(T::find(user_id).fetch(conn).await?)) - } + SessionId::Authenticated(user_id) => Ok(Some(T::find(user_id).fetch(conn).await?)), _ => Ok(None), } @@ -275,7 +280,10 @@ impl Request { /// Same function as [`Request::user`], except if returns a [`Result`] instead of an [`Option`]. /// If used with the `?` operator, returns `403 - Unauthorized` automatically. - pub async fn user_required(&self, conn: &mut ConnectionGuard) -> Result { + pub async fn user_required( + &self, + conn: impl ToConnectionRequest<'_>, + ) -> Result { match self.user(conn).await? { Some(user) => Ok(user), None => Err(Error::Forbidden), @@ -286,8 +294,9 @@ impl Request { /// /// This is automatically done by the HTTP server, /// if the session is available. - pub fn set_session(mut self, session: Option) -> Self { + pub(crate) fn set_session(mut self, session: Session) -> Self { self.session = session; + self.renew_session = true; self } @@ -320,10 +329,7 @@ impl Request { /// let response = request.login(1234); /// ``` pub fn login(&self, user_id: i64) -> Response { - let mut session = self - .session() - .map(|s| s.clone()) - .unwrap_or(Session::empty()); + let mut session = self.session.clone(); session.session_id = SessionId::Authenticated(user_id); Response::new().set_session(session).html("") } @@ -377,12 +383,11 @@ impl Request { /// let response = request.logout(); /// ``` pub fn logout(&self) -> Response { - let mut session = self - .session() - .map(|s| s.clone()) - .unwrap_or(Session::empty()); - session.session_id = SessionId::default(); - Response::new().set_session(session).html("") + Response::new().set_session(Session::anonymous()).html("") + } + + pub(crate) fn renew_session(&self) -> bool { + self.renew_session } } @@ -404,13 +409,7 @@ impl ToTemplateValue for Request { "query".to_string(), self.path().query().to_string().to_template_value()?, ); - hash.insert( - "session".to_string(), - match self.session() { - Some(session) => session.to_template_value()?, - None => Value::Null, - }, - ); + hash.insert("session".to_string(), self.session().to_template_value()?); Ok(Value::Hash(hash)) } } @@ -467,12 +466,11 @@ pub mod test { assert_eq!(req.peer(), &dummy_ip()); assert_eq!(req.upgrade_websocket(), false); assert_eq!(req.skip_csrf(), false); - assert_eq!(req.session(), None); + assert!(!req.session().authenticated()); assert!(req.user_id().is_err()); assert_eq!(req.body(), b"12345"); assert_eq!(req.string(), "12345".to_string()); assert!(req.form_data().is_err()); - assert!(req.session_id().is_none()); assert_eq!(req.query().len(), 1); assert_eq!(req.path().base(), "/apples"); diff --git a/rwf/src/http/response.rs b/rwf/src/http/response.rs index ef07ee1e..6e078b4a 100644 --- a/rwf/src/http/response.rs +++ b/rwf/src/http/response.rs @@ -228,28 +228,21 @@ impl Response { /// /// This makes sure a valid session cookie is set on all responses. pub fn from_request(mut self, request: &Request) -> Result { - // Set an anonymous session if none is set on the request. - if self.session.is_none() && request.session().is_none() { - self.session = Some(Session::anonymous()); - } - // Session set manually on the request already. if let Some(ref session) = self.session { self.cookies.add_session(&session)?; } else { let session = request.session(); - if let Some(session) = session { - if session.should_renew() { - let session = session - .clone() - .renew(get_config().general.session_duration()); - self.cookies.add_session(&session)?; - - // Set the session on the response, so it can be - // passed down in handle_stream. - self.session = Some(session); - } + if session.should_renew() || request.renew_session() { + let session = session + .clone() + .renew(get_config().general.session_duration()); + self.cookies.add_session(&session)?; + + // Set the session on the response, so it can be + // passed down in handle_stream. + self.session = Some(session); } } diff --git a/rwf/src/http/server.rs b/rwf/src/http/server.rs index 0201c19c..1f951db3 100644 --- a/rwf/src/http/server.rs +++ b/rwf/src/http/server.rs @@ -151,7 +151,10 @@ impl Server { // Set the session on the request before we pass it down // to the stream handler. - let request = request.set_session(response.session().clone()); + let request = match response.session().clone() { + Some(session) => request.set_session(session), + None => request, + }; let ok = response.status().ok(); // Calculate duration. diff --git a/rwf/src/model/mod.rs b/rwf/src/model/mod.rs index 7ab8860a..b6d204e1 100644 --- a/rwf/src/model/mod.rs +++ b/rwf/src/model/mod.rs @@ -4,6 +4,7 @@ use crate::colors::MaybeColorize; use crate::config::get_config; +use pool::ToConnectionRequest; use std::time::{Duration, Instant}; use tracing::{error, info}; @@ -613,8 +614,16 @@ impl Query { async fn execute_internal( &self, - client: &mut ConnectionGuard, + client: impl ToConnectionRequest<'_>, ) -> Result, Error> { + let request = client.to_connection_request()?; + let mut conn = request.get().await?; + + let client = match request.connection() { + Some(conn) => conn, + None => conn.as_mut().unwrap(), + }; + let result = match self { Query::Select(select) => { let query = self.to_sql(); @@ -684,14 +693,17 @@ impl Query { } /// Execute the query and fetch the first row from the database. - pub async fn fetch(self, conn: &mut ConnectionGuard) -> Result { + pub async fn fetch(self, conn: impl ToConnectionRequest<'_>) -> Result { match self.execute(conn).await?.first().cloned() { Some(row) => Ok(row), None => Err(Error::RecordNotFound), } } - pub async fn fetch_optional(self, conn: &mut ConnectionGuard) -> Result, Error> { + pub async fn fetch_optional( + self, + conn: impl ToConnectionRequest<'_>, + ) -> Result, Error> { match self.fetch(conn).await { Ok(row) => Ok(Some(row)), Err(Error::RecordNotFound) => Ok(None), @@ -700,14 +712,14 @@ impl Query { } /// Execute the query and fetch all rows from the database. - pub async fn fetch_all(self, conn: &mut ConnectionGuard) -> Result, Error> { + pub async fn fetch_all(self, conn: impl ToConnectionRequest<'_>) -> Result, Error> { self.execute(conn).await } /// Get the query plan from Postgres. /// /// Take the actual query, prepend `EXPLAIN` and execute. - pub async fn explain(self, conn: &mut ConnectionGuard) -> Result { + pub async fn explain(self, conn: impl ToConnectionRequest<'_>) -> Result { let query = format!("EXPLAIN {}", self.to_sql()); let placeholders = match self { Query::Select(select) => select.placeholders, @@ -727,11 +739,11 @@ impl Query { } } - pub async fn exists(self, conn: &mut ConnectionGuard) -> Result { + pub async fn exists(self, conn: impl ToConnectionRequest<'_>) -> Result { Ok(self.count(conn).await? > 0) } - pub async fn count(self, conn: &mut ConnectionGuard) -> Result { + pub async fn count(self, conn: impl ToConnectionRequest<'_>) -> Result { let query = match self { Query::Select(select) => Query::Select(select.exists()), _ => self, @@ -749,7 +761,7 @@ impl Query { } /// Execute a query and return an optional result. - pub async fn execute(self, conn: &mut ConnectionGuard) -> Result, Error> { + pub async fn execute(self, conn: impl ToConnectionRequest<'_>) -> Result, Error> { let start = Instant::now(); let mut results = vec![]; let rows = self.execute_internal(conn).await?; diff --git a/rwf/src/model/pool/mod.rs b/rwf/src/model/pool/mod.rs index 588b4c01..0cf480d1 100644 --- a/rwf/src/model/pool/mod.rs +++ b/rwf/src/model/pool/mod.rs @@ -75,6 +75,59 @@ pub async fn start_transaction() -> Result { get_pool().transaction().await } +/// State of database connection. +/// +/// If the caller is passing in the connection pool, the connection +/// needs to be obtained from the database. If the caller already has +/// a connection, by virtue of requesting it manually or starting a transaction, +/// the pool will use it. +pub enum ConnectionRequest<'a> { + /// Connection request is already fulfilled. + Fulfilled(&'a mut ConnectionGuard), + + /// The pool still needs to obtain a connection. + Pool(Pool), +} + +impl<'a> ConnectionRequest<'a> { + pub(crate) fn connection(self) -> Option<&'a mut ConnectionGuard> { + match self { + ConnectionRequest::Fulfilled(conn) => Some(conn), + ConnectionRequest::Pool(_) => None, + } + } + + pub(crate) async fn get(&self) -> Result, Error> { + match self { + ConnectionRequest::Fulfilled(_) => Ok(None), + ConnectionRequest::Pool(pool) => Ok(Some(pool.get().await?)), + } + } +} + +/// Convert an object to a connection request, fulfilled or otherwise. +pub trait ToConnectionRequest<'a> { + fn to_connection_request(self) -> Result, Error>; +} + +impl<'a> ToConnectionRequest<'a> for &'a mut ConnectionGuard { + fn to_connection_request(self) -> Result, Error> { + Ok(ConnectionRequest::Fulfilled(self)) + } +} + +impl<'a> ToConnectionRequest<'a> for &'a mut Transaction { + fn to_connection_request(self) -> Result, Error> { + Ok(ConnectionRequest::Fulfilled(self.deref_mut())) + } +} + +impl<'a> ToConnectionRequest<'a> for Pool { + fn to_connection_request(self) -> Result, Error> { + Ok(ConnectionRequest::Pool(self)) + } +} + /// Smart pointer that automatically checks in the connection /// back into the pool when the connection is dropped. pub struct ConnectionGuard { diff --git a/rwf/src/prelude.rs b/rwf/src/prelude.rs index 22bb9d9a..20d6da37 100644 --- a/rwf/src/prelude.rs +++ b/rwf/src/prelude.rs @@ -15,7 +15,7 @@ pub use crate::controller::{ pub use crate::http::{Cookie, CookieBuilder, Message, Method, Request, Response, ToMessage}; pub use crate::job::{queue_async, queue_delay, Job}; pub use crate::logging::Logger; -pub use crate::model::{Migrations, Model, Pool, Scope, ToSql, ToValue}; +pub use crate::model::{pool::ToConnectionRequest, Migrations, Model, Pool, Scope, ToSql, ToValue}; pub use crate::view::{Template, ToTemplateValue, TurboStream}; /// A macro to easily implement async traits methods. diff --git a/rwf/src/view/template/lexer/value.rs b/rwf/src/view/template/lexer/value.rs index 11f7b7bd..01079620 100644 --- a/rwf/src/view/template/lexer/value.rs +++ b/rwf/src/view/template/lexer/value.rs @@ -660,6 +660,15 @@ impl ToTemplateValue for T { } } +impl ToTemplateValue for Option { + fn to_template_value(&self) -> Result { + match self { + Some(model) => model.to_template_value(), + None => Ok(Value::Null), + } + } +} + impl ToTemplateValue for Vec { fn to_template_value(&self) -> Result { let mut list = vec![];