Let’s get started
First, we will install Shuttle for creating the project (and later for deployment). If you don’t already have it you can install it withcargo install cargo-shuttle
. We will first go to a new directory for our project and create a new Axum app with shuttle init --template axum
.
You should see the following in src/main.rs
:
Templates
For generating HTML we will be using Tera, so we can go ahead and add this withcargo add tera
. We will store all our
templates
in a template directory in the project root.
We want a general layout for our site, so we create a base layout. In our base
layout, we can add specific tags that will apply to all pages such as a
Google font. With this layout all the content will
be injected in place of {% block content %}{% endblock content %}
:
/
path
include_str!
macro so
that the content is in the binary. This way we don’t have to deal with the
complexities of a filesystem at runtime. We register both templates so that the
index
page knows about base.html
.
Arc
so that extension cloning does not deep clone all the templates)
Rendering views
Now we have created our Tera instance we want it to be accessible to our get methods. To do this in Axum, we add the extension as a parameter to our function. In Axum, an Extension is a unit struct. Rather than dealing with .0 to access fields, we use destructuring in the parameter (if you thought that syntax looks weird).Serving assets
We can create apublic/styles.css
file
include_str!
to not have to worry about the filesystem
at runtime.
ServeDiris
an alternative if you have a filesystem at runtime. You can use this method for
other static assets like JavaScript and favicons.
Running
We will add our two new routes to the router (and remove the default “hello world” one) to get:shuttle run
.
Nice!
Adding users
We will start with a user’s table in SQL. (this is defined in schema.sql).id
is generated by the database using a sequence. The id
is a primary
key, which we will use to reference users. It is better to use a fixed value
field for identification rather than using something like the username
field
because you may add the ability to change usernames, which can leave things
pointing to the wrong places.
Registering our database
Before our app can use the database we have to add sqlx with some features:cargo add sqlx -F postgres runtime-tokio-native-tls
.
We will also enable the Postgres feature for Shuttle with
cargo add shuttle-service -F sqlx-postgres
.
Now back in the code we add a parameter with
#[shuttle_shared_db::Postgres] pool: Database
. The
#[shuttle_shared_db::Postgres]
annotation tells shuttle to provision a
Postgres database using the
infrastructure from code design.
Signup
For getting users into our database, we will create a post handler. In our handler, we will parse data using multipart. I wrote a simple parser for multipart that we will use here. The below example contains some error handling that we will ignore for now.Creating users and storing passwords safety
When storing passwords in a database, for security reasons we don’t want them to be in the exact format as plain text. To transform them away from the plain text format we will use a cryptographic hash functionfrom pbkdf2(cargo add pbkdf2):Using HTML forms
To invoke the endpoint with multipart we will use an HTML form.enctype
being multipart, which matches what we are parsing in the
handler. The above has a few attributes to do some client-side validation, but
in the full demo it is also handled on the server.
We create a handler for this markup in the same way as done for our index with:
signup
to the Tera instance and then add both the get and post
handlers to the router by adding it to the chain:
Sessions
Once signed up, we want to save the logged-in state. We don’t want the user to have to send their username and password for every request they make.Cookies and session tokens
Cookies help store the state between browser requests. When a response is sent down with Set-Cookie, then any subsequent requests the browser/client makes will send cookie information. We can then pull this information off of headers on requests on the server. Again, these need to be safe. We don’t want collisions/duplicates. We want it to be hard to guess. For these reasons, we will represent it as a 128-bit unsigned integer. This has 2^128 options, so a very low chance of a collision. We want to generate a “session token”. We want the tokens to be cryptographically secure. Given a session id, we don’t want users to be able to find the next one. A simple globally incremented u128 wouldn’t be secure because if I know I have session 10 then I can send requests with session 11 for the user who logged in after. With a cryptographically secure generator, there isn’t a distinguishing pattern between subsequently generated tokens. We will use the ChaCha algorithm/crate (we will add cargo add rand_core rand_chacha). We can see that it does implement the crypto marker-trait confirming it is valid for cryptographic scenarios. This is unlike Pseudo-random number generators where you can predict the next random number given a start point and the algorithm. This could be a problem if we have our token we can get the session token of the person who logged in after us really easy and thus impersonate them. To initialize the random generator we use SeedableRng::from_seed. The seed in this case is an initial state for the generator. Here we use OsRng.next_u64()which retrieves randomness from the operating system rather a seed. We will be doing something similar to the creation of the Tera instance. We must wrap it in an arc and a mutex because generating new identifiers requires mutable access. We now have the following main function:Adding sessions to signup
As well as creating a user on signup, we will create the session token for the newly signed-up user. First we have to create the sessions table, we can add the following to ourschema.sql
:
.to_string()
. We will send a response that
does two things, sets this new value and returns/redirects us back to the index
page. We will create a utility function for doing this:
Using the session token
Great so now we have a token/identifier for a session. Now we can use this as a key to get information about users. We can pull the cookie value using the following spaghetti of iterators and options:Auth middleware
In the last post, we went into detail about middleware. You can read more about it in more detail there. In our middleware, we will get a little fancy and make the user pulling lazy. This is so that requests that don’t need user data don’t have to make a database trip. Rather than adding our user straight onto the request, we split things apart. We first create an AuthState which contains the session token, the database, and a placeholder for our user (Option <User>)AuthState
which makes the database request.
Now we have the user’s token we need to get their information. We can do that
using SQL joins
Getting middleware and displaying our user info
Modifying our index Tera template, we can add an “if block” to show a status if the user is logged in.Logging in and logging out
Great we can signup and that now puts us in a session. We may want to log out and drop the session. This is very simple to do by returning a response with the cookieMax-Age
set to 0.
Deployment
This is great, we now have a site with signup and login functionality. But we have no users, our friends can’t log in on our localhost. We want it live on the interwebs. Luckily we are using Shuttle, so it is as simple as:shuttle deploy
Because of our #[shuttle_runtime::main]
annotation and out-the-box Axum
support our deployment doesn’t need any prior config, it is instantly live!
Now you can go ahead with these concepts and add functionality for listing and
deleting users.
The full demo implements these if you are looking for clues.
Thoughts building the tutorial and other ideas on where to take it
This demo includes the minimum required for authentication. Hopefully, the concepts and snippets are useful for building it into an existing site or for starting a site that needs authentication. If you were to continue, it would be as simple as more fields onto the user object or building relations with the id field on the user’s table. I will leave it out with some of my thoughts and opinions while building the site as well as things you could try extending it with. For templating Tera is great. I like how I separate the markup into external files rather than bundling it intosrc/main.rs
. Its API is easy to use and is
well documented. However, it is quite a simple system. I had a few errors where
I would rename or remove templates and because the template picker for rendering
uses a map it can panic at runtime if the template does not exist. It would be
nice if the system allowed checking that templates exist at compile time. The
data sending works on serde serialization, which is a little bit more
computation overhead than I would like. It also does not support streaming. With
streaming, we could send a chunk of HTML that doesn’t depend on database values
first, and then we can add more content when the database transaction has gone
through. If it supported streaming we could avoid the all-or-nothing pages with
white page pauses and start connections to services like Google Fonts earlier.
Let me know what your favorite templating engine is for Rust and whether it
supports those features!
For working with the database, sqlx has typed macros. I didn’t use them here but
for more complex queries you might prefer the type-checking behavior. Maybe 16
bytes for storing session tokens is a bit overkill. You also might want to try
sharding that table if you have a lot of sessions or using a key-value store
(such as Redis) might be simpler. We also didn’t implement cleaning up the
sessions table, if you were storing sessions using Redis you could use the
EXPIRE commandto automatically remove old
keys.