Megan Lee
Posted on June 5, 2024
Written by Eze Sunday✏️
Building a non-trivial web application with Rust can be fairly straightforward. However, when things become complex and require features like authentication, middleware, and more, that’s where Axum shines. Axum makes it a lot easier to build complex web API authentication systems. In this step-by-step guide, we'll build a JWT authentication API using Rust and the Axum framework. We'll cover everything from building the authentication endpoints to JWT middleware and protected routes.
Let’s jump right in.
Setting up our Rust and Axum project
Let’s start by installing Rust, Axum, and all the necessary dependencies. Run the following commands to install Rust if you don’t already have Rust installed:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
The above command requires internet to do. It’ll download and set up Rust and all the tools needed for Rust development, except for your code editor 🙂
Next, run the command below to create a new Rust project and install all the dependencies necessary for this project:
cargo new rust-auth && cd rust-auth && cargo add tokio --features full && cargo add serde@1.0.195 --features derive && cargo add chrono@0.4.34 --features serde && cargo add axum@0.7.5 jsonwebtoken@9.3.0 bcrypt@0.15.1 serde_json@1.0.95
The result should generate a directory like this:
.
├── Cargo.lock
├── Cargo.toml
└── src
└── main.rs
And the Cargo.toml
file should look like this:
[package]
name = "rust-auth"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7.5"
bcrypt = "0.15.1"
chrono = { version = "0.4.34", features = ["serde"] }
jsonwebtoken = "9.3.0"
serde = { version = "1.0.195", features = ["derive"] }
serde_json = "1.0.95"
tokio = { version = "1.37.0", features = ["full"] }
Here is a quick rundown of the dependencies we added and why we added each one:
- Axum: This is the core Axum Web framework we’ll use for this project
- Tokio: We’ll use the Rust Tokio runtime to write asynchronous functions
- Serde: We’ll use Serde for our serialization and deserialization needs
- Chrono: We’ll also need the date and time library for different things in our application. Specifically, we’ll use it to generate our API token expiry time
- JSON Web Token (jsonwebtoken): The jsonwebtoken library will help us generate and verify JSON Web Tokens
- BCrypt: Since we’ll be integrating password hashing, we’ll use BCrypt password hashing function for that
- Serde JSON: We'll eventually need to return the API response to the client via a REST API. So, we'll use the Serde JSON library to convert from other data structures to JSON and return the response
Now that we have our setup completed, let’s create the relevant endpoints for our project.
Authentication endpoints using Axum middleware
We’ll have a route for the user to login, as well as a protected route to demonstrate how to protect our endpoints using the Axum middleware system.
Tokio and Axum server setup
Before we proceed with that, let’s create the web server with Tokio and Axum in the main.rs
file. First off, here’s the basic server anatomy: For our specific project, copy the code below and replace your existing code in the main.rs
file with it:
use axum;
use tokio::net::TcpListener;
mod routes;
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:8080")
.await
.expect("Unable to connect to the server");
let app = routes::app().await;
axum::serve(listener, app)
.await
.expect("Error serving application");
println!("Listening on {}", listener.local_addr().unwrap() );
}
The above code uses Tokio’s TCP listener bound to the address 127.0.0.1:8080
and then uses Axum to serve the web app. It also imports the routes definition which is where will set our focus now.
Authentication routes
Let’s define the different routes we’ll use for our authentication. Basically, the flow will enable the user to:
- Sign in and receive a token (the
/signin
route) - Use the token to access protected endpoints (the
/protected/
route)
In that case, we’ll have two endpoints — let’s create them! Create a routes.rs
file in the src/
directory and add the following code in it:
use axum::{
middleware,
routing::{get, post},
Router,
};
use crate::{auth, services};
pub async fn app() -> Router {
Router::new()
.route("/signin", post(auth::sign_in))
.route(
"/protected/",
get(services::hello).layer(middleware::from_fn(auth::authorize)),
)
}
The code above contains the two routes definition with their handlers. Notice that there is a middleware in the /protected
endpoint (auth::authorize) — we’ll take a look at that in a minute.
We’ve imported the auth
and hello
services — that’s were we’ll implement the handlers. Let’s create them:
Authentication handlers
Create a services.rs
file and add the code below to create the hello
handler:
use crate::auth::CurrentUser;
#[derive(Serialize, Deserialize)]
struct UserResponse {
email: String,
first_name: String,
last_name: String
}
pub async fn hello(Extension(currentUser): Extension<CurrentUser>) -> impl IntoResponse {
Json(UserResponse {
email: currentUser.email,
first_name: currentUser.first_name,
last_name: currentUser.last_name
})
}
The hello
handler returns the logged in user’s profile information. When we call the protected route with the user JWT token, the server will return the user information like so: The next service is auth
. This service contains all the implementations for our JWT authentication. This is where you’ll need to pay closer attention 😁
Create the auth.rs
file in the src/
directory. Then, add the code to sign a user in with their username and password as shown below:
use axum::{
body::Body,
response::IntoResponse,
extract::{Request, Json},
http,
http::{Response, StatusCode},
middleware::Next,
};
use bcrypt::{hash, verify, DEFAULT_COST};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation};
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Serialize, Deserialize)]
// Define a structure for holding claims data used in JWT tokens
pub struct Claims {
pub exp: usize, // Expiry time of the token
pub iat: usize, // Issued at time of the token
pub email: String, // Email associated with the token
}
// Define a structure for holding sign-in data
#[derive(Deserialize)]
pub struct SignInData {
pub email: String, // Email entered during sign-in
pub password: String, // Password entered during sign-in
}
// Function to handle sign-in requests
pub async fn sign_in(
Json(user_data): Json<SignInData>, // JSON payload containing sign-in data
) -> Result<Json<String>, StatusCode> { // Return type is a JSON-wrapped string or an HTTP status code
// Attempt to retrieve user information based on the provided email
let user = match retrieve_user_by_email(&user_data.email) {
Some(user) => user, // User found, proceed with authentication
None => return Err(StatusCode::UNAUTHORIZED), // User not found, return unauthorized status
};
// Verify the password provided against the stored hash
if !verify_password(&user_data.password, &user.password_hash)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? // Handle bcrypt errors
{
return Err(StatusCode::UNAUTHORIZED); // Password verification failed, return unauthorized status
}
// Generate a JWT token for the authenticated user
let token = encode_jwt(user.email)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Handle JWT encoding errors
// Return the token as a JSON-wrapped string
Ok(Json(token))
}
#[derive(Clone)]
pub struct CurrentUser {
pub email: String,
pub first_name: String,
pub last_name: String,
pub password_hash: String
}
// Function to simulate retrieving user data from a database based on email
fn retrieve_user_by_email(email: &str) -> Option<CurrentUser> {
// For demonstration purposes, a hardcoded user is returned based on the provided email
let current_user: CurrentUser = CurrentUser {
email: "myemail@gmail.com".to_string(),
first_name: "Eze".to_string(),
last_name: "Sunday".to_string(),
password_hash: "$2b$12$Gwf0uvxH3L7JLfo0CC/NCOoijK2vQ/wbgP.LeNup8vj6gg31IiFkm".to_string()
};
Some(current_user) // Return the hardcoded user
}
Although the code is a bit long, it is heavily commented to make it easy to understand and follow along. Now, we are going to explain every part of it.
First, we created the Claims
and SignInData
data struct.
The Claims
is what we expect to be encoded in the JWT token. We want the expiry, issue date/time, and email to be in Claims
. We expect the user to send their email address and password in exchange for the JWT token:
// Define a structure for holding claims data used in JWT tokens
pub struct Claims {
pub exp: usize, // Expiry time of the token
pub iat: usize, // Issued at time of the token
pub email: String, // Email associated with the token
}
The SignInData
struct represents that data, as shown below in this extracted code from the previous code:
// Define a structure for holding sign-in data
#[derive(Deserialize)]
pub struct SignInData {
pub email: String, // Email entered during sign-in
pub password: String, // Password entered during sign-in
}
Next, we get into the sign-in function. The sign-in function accepts a JSON object as the request body and returns a JSON or StatusCode as shown below:
// Function to handle sign-in requests
pub async fn sign_in(
Json(user_data): Json<SignInData>, // JSON payload containing sign-in data
) -> Result<Json<String>, StatusCode> {
In the sign-in function, we attempt to get the users information from the database based on their email address they provided. If it does not exist, we don’t proceed with the login.
If it does exist, we want to verify the password the user sent with the hashed password in the database. For simplicity, we simulated the database user retrieval with the retrieve_user_by_email
function below:
// Function to simulate retrieving user data from a database based on email
fn retrieve_user_by_email(email: &str) -> Option<CurrentUser> {
// For demonstration purposes, a hardcoded user is returned based on the provided email
let current_user: CurrentUser = CurrentUser {
email: "myemail@gmail.com".to_string(),
first_name: "Eze".to_string(),
last_name: "Sunday".to_string(),
password_hash: "$2b$12$Gwf0uvxH3L7JLfo0CC/NCOoijK2vQ/wbgP.LeNup8vj6gg31IiFkm".to_string() // the plain password hashed to this is "okon" without the quotes.
};
Some(current_user) // Return the hardcoded user
}
We used bcrypt password hashing to generate the password for this example. The password in the above example is okon
. Also, below are the functions for hashing and verifying a password. Add them to the auth.rs
file:
pub fn verify_password(password: &str, hash: &str) -> Result<bool, bcrypt::BcryptError> {
verify(password, hash)
}
pub fn hash_password(password: &str) -> Result<String, bcrypt::BcryptError> {
let hash = hash(password, DEFAULT_COST)?;
Ok(hash)
}
Finally, we generate the JWT token and encode the user’s email into it:
let token = encode_jwt(user.email)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Handle JWT encoding errors
But the encode_jwt
function isn’t created yet, so we need to create it. Add the encode_jwt
function to the auth.rs
file:
pub fn encode_jwt(email: String) -> Result<String, StatusCode> {
let secret: String = "randomStringTypicallyFromEnv".to_string();
let now = Utc::now();
let expire: chrono::TimeDelta = Duration::hours(24);
let exp: usize = (now + expire).timestamp() as usize;
let iat: usize = now.timestamp() as usize;
let claim = Cliams { iat, exp, email };
encode(
&Header::default(),
&claim,
&EncodingKey::from_secret(secret.as_ref()),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
This function above uses the jwt
library to generate a valid login authentication token. The function accepts email addresses and you can add other information into the jwt
encoding as you need.
We’ll also need to decode the JWT when we start working on the the middleware, so, let’s create the decode_jwt
function. Include the decode_jwt
code below in the auth.rs
file:
pub fn decode_jwt(jwt_token: String) -> Result<TokenData<Cliams>, StatusCode> {
let secret = "randomStringTypicallyFromEnv".to_string();
let result: Result<TokenData<Cliams>, StatusCode> = decode(
&jwt_token,
&DecodingKey::from_secret(secret.as_ref()),
&Validation::default(),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR);
result
}
Make sure the DecodingKey
algorithm matches for both the encoding and decoding functions. The default algorithm is HS256; if you choose to use the default, you should also use the same secret for both the encode_jwt
and decode_jwt
functions. You can also use the RSA encryption algorithm if you need to. Here is an example of how you’d use the RSA encryption algorithm for the encoding:
let result = encode(&Header::new(Algorithm::RS256), &my_claims, &EncodingKey::from_rsa_pem(include_bytes!("privkey.pem"))?)?;
Here is decoding with the RSA encryption algorithm:
let result = decode::<Claims>(&jwt_token, &DecodingKey::from_rsa_components(jwk["n"], jwk["e"]), &Validation::new(Algorithm::RS256))?;
Middleware for protected routes
Now that we’ve got the sign-in function in order, let’s take a closer look at the middleware function to protect our routes. Add a new function by copying and pasting the code below into the auth.rs
file:
pub async fn authorization_middleware(mut req: Request, next: Next) -> Result<Response<Body>, AuthError> {
let auth_header = req.headers_mut().get(http::header::AUTHORIZATION);
let auth_header = match auth_header {
Some(header) => header.to_str().map_err(|_| AuthError {
message: "Empty header is not allowed".to_string(),
status_code: StatusCode::FORBIDDEN
})?,
None => return Err(AuthError {
message: "Please add the JWT token to the header".to_string(),
status_code: StatusCode::FORBIDDEN
}),
};
let mut header = auth_header.split_whitespace();
let (bearer, token) = (header.next(), header.next());
let token_data = match decode_jwt(token.unwrap().to_string()) {
Ok(data) => data,
Err(_) => return Err(AuthError {
message: "Unable to decode token".to_string(),
status_code: StatusCode::UNAUTHORIZED
}),
};
// Fetch the user details from the database
let current_user = match retrieve_user_by_email(&token_data.claims.email) {
Some(user) => user,
None => return Err(AuthError {
message: "You are not an authorized user".to_string(),
status_code: StatusCode::UNAUTHORIZED
}),
};
req.extensions_mut().insert(current_user);
Ok(next.run(req).await)
}
Let’s explain the code. The function takes a mutable Request
and Next
objects as arguments and returns a Response
or Error
result. This is a typical Axum middleware function signature. The Next
object represents the next middleware or handler in the chain that should be called after this middleware.
Now, we’ll grab the header content and attempt to extract the token that was passed to it by the client:
let auth_header = req.headers_mut().get(http::header::AUTHORIZATION);
If the token exists, we go ahead to decode the token, get the user’s email from it, and query the database to fetch the user’s profile. Then, we pass the user information to app extensions for the handler that will be using the middleware and handling the request.
Remember the protected
endpoint and the corresponding hello
handler?
.route("/protected/",get(services::hello).layer(middleware::from_fn(auth::authorize_middleware)),)
The hello
handler takes in an extension as an argument with the type CurrentUser
type and returns a type of impl IntoResponse
which is the typical return type of all Axum handlers.
pub async fn hello(Extension(currentUser): Extension<CurrentUser>) -> impl IntoResponse {
...
}
Next, let's test our implementation with Postman. If you'd love to clone the entire project and dive deep into it, or just test it out, you can clone it from GitHub by running the command below:
git clone https://github.com/ezesundayeze/axum--auth
Testing with Postman
We’ve have developed two endpoints: the login endpoint and the protected endpoint. Let’s start by running the server by running the command below:
cargo run
And then signing in with our username and password: The login returns our JWT token as expected. Next, we’ll copy the JWT token and use it to access the protected endpoint but before that, if we make the API call without the token, we’ll get an error: Add the token. Now we can access the protected API properly:
Conclusion
We’ve come a long way! I hope you enjoyed reading the walkthrough and following along (if you did follow along).
In this tutorial, we covered how to build a basic JWT authentication system from start to finish, noting all the key parts. From setting up the routes, handlers, and the middleware system, I hope this will help you bootstrap your Rust project easily. You can find the full project on GitHub.
Happy hacking!
LogRocket: Full visibility into web frontends for Rust apps
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Posted on June 5, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.