Skip to content

Commit

Permalink
Building apps with Rwf guide, request session not optional, better OR…
Browse files Browse the repository at this point in the history
…M interface, other fixes (#53)
  • Loading branch information
levkk authored Dec 5, 2024
1 parent 87e8823 commit e535dfd
Show file tree
Hide file tree
Showing 41 changed files with 710 additions and 133 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ jobs:
sudo service postgresql restart
sudo -u postgres createuser --superuser --login $USER
createdb $USER
createdb rwf_users
psql postgres://[email protected]: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
Expand Down
53 changes: 51 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
6 changes: 1 addition & 5 deletions docs/docs/controllers/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,7 @@ async fn handle(&self, request: &Request) -> Result<Response, Error> {
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`.
Expand Down
7 changes: 3 additions & 4 deletions docs/docs/controllers/websockets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
57 changes: 57 additions & 0 deletions docs/docs/security/hashing.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions docs/docs/user-guides/.pages
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
nav:
- 'index.md'
- 'build-your-app'
- 'hot-reload.md'
- '...'
- 'deploy-to-prod.md'
- 'admin.md'
78 changes: 78 additions & 0 deletions docs/docs/user-guides/build-your-app/add-users.md
Original file line number Diff line number Diff line change
@@ -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<i64>,
username: String,
password_hash: String,
created_at: OffsetDateTime,
}
```
21 changes: 21 additions & 0 deletions docs/docs/user-guides/build-your-app/index.md
Original file line number Diff line number Diff line change
@@ -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)
25 changes: 10 additions & 15 deletions docs/docs/views/turbo/streams.md
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
<div id="messages">
<p>Hi Alice!</p>
<p>Hello Bob!</p>
</div>
"#).action("replace").target("messages");

// Send it via a WebSocket connection.
Comms::websocket(&session_id).send(update)?;
}
// Create the update.
let update = TurboStream::new(r#"
<div id="messages">
<p>Hi Alice!</p>
<p>Hello Bob!</p>
</div>
"#).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.
Expand Down
2 changes: 1 addition & 1 deletion examples/auth/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ impl Controller for ProtectedAreaController {
}

async fn handle(&self, request: &Request) -> Result<Response, Error> {
let session = request.session().unwrap();
let session = request.session();
let welcome = format!("<h1>Welcome, user {:?}</h1>", session.session_id);
Ok(Response::new().html(welcome))
}
Expand Down
2 changes: 1 addition & 1 deletion examples/orm/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
6 changes: 2 additions & 4 deletions examples/turbo/src/controllers/signup/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ pub struct LoggedInCheck;
#[rwf::async_trait]
impl Middleware for LoggedInCheck {
async fn handle_request(&self, request: Request) -> Result<Outcome, Error> {
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))
Expand Down
Loading

0 comments on commit e535dfd

Please sign in to comment.