Introduction
Howdy guys! It's been a while here. I have been learning some rust while I was away and I will be sharing some of the things I've learned.
An authentication system is an integral part of modern applications. It's so important that almost all modern applications have some sort of it. Because of their critical nature, such systems should be secure and should follow OWAP®'s recommendations on web security and password hashing as well as storage to prevent attacks such as Preimage and Dictionary attacks (common to SHA algorithms). To demonstrate some of the recommendations, we'll be building a robust session-based authentication system in Rust and a complementary frontend application. For this article series, we'll be using Rust's actix-web and some awesome crates for the backend service. SvelteKit will be used for the frontend. It should be noted however that what we'll be building is largely framework-agnostic. As a result, you can decide to opt for axum, rocket, warp or any other rust's web framework for the backend and react, vue or any other javascript framework for the frontend. You can even use rust's yew, seed or some templating engines such as MiniJinja or tera at the frontend. It's entirely up to you. Our focus will be more on the concepts.
NOTE: We'll be utilizing the book, Zero to Production in Rust, heavily with some additional features and modifications.
Though we'll be building a session-based authentication system, it's noteworthy that with the introduction of some concepts which will be discussed in due time, you can turn it into JWT- or, more securely and appropriately, PASETO-based authentication system.
NOTE: This tutorial will be split into several short articles. At least one (1) article will be uploaded every week until the entire series is complete.
System's Requirement Specification
Throughout this tutorial series, we'll be working towards implementing these requirements:
Build a user authentication system where a user authenticates with an E-mail/Password combination. E-mail addresses must be unique and verified by sending time-limited verification emails upon registration and the verification emails must support HTML. Until verified, no user is allowed to log in. Time attacks must be addressed by sending the mails asynchronously. Password hashing must be strong and only hashed passwords should be stored in the database. Password reset functionality should be incorporated and incepted using e-mail address verifications. A protected user profile update feature should be added so that only authenticated and authorized users can access it. The user profile should include a thumbnail which should be stored in AWS S3.
😲 That was a lot, huh?! It is from this end too 😫😩. From the specification, we are bound to have some fun. We'll be moving high and charting the territory of image uploads to AWS S3, email verification, token generation and destruction, some templating and a host of others.
Technology stack
For emphasis, our tech stack comprises:
-
Backend - Some crates that will be used are:
- Actix web (v4) - Main backend web framework
- tokio v1 - An asynchronous runtime for Rust
- serde v1 - Serializing and Deserializing Rust data structures
- MiniJinja v0.32 - Templating engine
- SQLx v0.6 - Async SQL toolkit for rust
- PostgreSQL v15 - Database
- Redis - A store to facilitate the expiration of tokens, etc.
-
Frontend - Some tools that will be used are:
- SvelteKit v1 - Main frontend framework
- Typescript v5 - Language in which the frontend will be written
- Pure CSS3 and TailwindCSS v3.3 - Styles
- HTML5 - Structure
Assumption
A simple prerequisite to follow along is some familiarity with the Rust Programming language — like some understanding of structs, ownership model, borrow checker, module system, and co. — JavaScript (Typescript) and CSS. You do not need to be an expert — I ain't one in any of the technologies.
Source code
The source code for this series is hosted on github or more expressed via:
rust-auth
A full-stack secure and performant authentication system using rust's Actix web and JavaScript's SvelteKit.
This application resulted from this series of articles and it's currently live here(I have disabled the backend from running live).
Run locally
You can run the application locally by first cloning it:
~/$ git clone https://github.com/Sirneij/rust-auth.git
After that, change directory into each subdirectory: backend
and frontend
in different terminals. Then following the instructions in each subdirectory to run them.
Initial project structure
You can get this full starter template from github.
I inherited a rather, in my opinion, robust Rust web services structure from Zero to Production in Rust. I have fallen in love with the structure and will most likely be using it for most of my Rust web project irrespective of the framework of choice. This starter template is available here and how it was made will be discussed briefly. I encourage you to pick up the book, Zero to Production in Rust. It's fantastic!!! Currently, the backend
structure looks like this:
├── Cargo.lock
├── Cargo.toml
├── settings
│  ├── base.yaml
│  ├── development.yaml
│  └── production.yaml
├── src
│  ├── lib.rs
│  ├── main.rs
│  ├── routes
│  │  ├── health.rs
│  │  └── mod.rs
│  ├── settings.rs
│  ├── startup.rs
│  └── telemetry.rs
└── tests
Step 1: Create a new project and install some dependencies
Create a directory that will house the entire (both frontend and backend) application. I called mine rust-auth
. Change the directory into the newly created folder and issue the following command in your terminal:
~/rust-auth$ cargo new backend
This creates a new project called backend
with Cargo.toml
, Cargo.lock
, and src/main.rs
files created. Open it up in your editor of choice. Make your Cargo.toml
file look like this:
# Cargo.toml
[package]
name = "backend"
version = "0.1.0"
authors = ["Your name <your email>"]
edition = "2021"
[lib]
path = "src/lib.rs"
[[bin]]
path = "src/main.rs"
name = "backend"
[dependencies]
actix-web = "4"
config = { version = "0.13.3", features = ["yaml"] }
dotenv = "0.15.0"
serde = "1.0.160"
tokio = { version = "1.27.0", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.37"
tracing-subscriber = { version = "0.3.17", features = [
"fmt",
"std",
"env-filter",
"registry",
'json',
'tracing-log',
] }
We added authors
to the [package]
segment. Then we created a new segment, [lib]
, which points to the path of the project's lib.rs
file. A project can have only one lib.rs
file. Next is the binary segment, [[bin]]
. Double square brackets in .toml
files mean an array. It was used because we can have more than one binary package in a Rust project. These two new segments make it easier for us to write seamlessly integrated testing where tests are "independent" of the web framework used. Then, the [dependencies]
section. We registered the preliminary crates we will be using. config
helps with the easy transformation of .yaml
or .json
files containing some app-wide settings, like the variables in Django's settings.py
file, into rust's structs. dotenv
loads environment variables from a .env
file. serde
is rust's generic serialization/deserialization framework. tokio
is an industry-standard runtime for writing reliable, asynchronous, and slim applications with rust. At runtime or when our application is in production, we ultimately need to log requests and responses. Sometimes, our users make some complaints or our app crashes. We cannot just figure out why certain situation occurs out of thin air. We need a point of reference to debug our application. In the rust ecosystem, tracing
and its extension tracing-subscriber
is widely used for this. Telemetry
is what it's called.
Step 2: Build out the project's skeleton
Inside the src
folder, issue the following commands to create some files and folders:
~/rust-auth/backend$ touch src/lib.rs src/startup.rs src/settings.rs src/telemetry.rs
~/rust-auth/backend$ mkdir src/routes && touch src/routes/mod.rs src/routes/health.rs
For the created files and folders to be recognized, we need to turn them into modules in lib.rs
:
// src/lib.rs
pub mod routes;
pub mod settings;
pub mod startup;
pub mod telemetry;
Let's start with telemetry.rs
. Make it look like this:
// src/telemetry.rs
use tracing_subscriber::layer::SubscriberExt;
pub fn get_subscriber(debug: bool) -> impl tracing::Subscriber + Send + Sync {
let env_filter = if debug {
"trace".to_string()
} else {
"info".to_string()
};
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(env_filter));
let stdout_log = tracing_subscriber::fmt::layer().pretty();
let subscriber = tracing_subscriber::Registry::default()
.with(env_filter)
.with(stdout_log);
let json_log = if !debug {
let json_log = tracing_subscriber::fmt::layer().json();
Some(json_log)
} else {
None
};
let subscriber = subscriber.with(json_log);
subscriber
}
pub fn init_subscriber(subscriber: impl tracing::Subscriber + Send + Sync) {
tracing::subscriber::set_global_default(subscriber).expect("Failed to set subscriber");
}
We are configuring tracing_subscriber
's level and format depending on whether or not our app is in production. If debug
is true
, then we're in development mode. Else, in production. We want JSON
output in production since that is easier to parse. Then, we initialized tracing in another function based on the subscriber given.
Next, settings.rs
:
// src/settings.rs
/// Global settings for exposing all preconfigured variables
#[derive(serde::Deserialize, Clone)]
pub struct Settings {
pub application: ApplicationSettings,
pub debug: bool,
}
/// Application's specific settings to expose `port`,
/// `host`, `protocol`, and possible URL of the application
/// during and after development
#[derive(serde::Deserialize, Clone)]
pub struct ApplicationSettings {
pub port: u16,
pub host: String,
pub base_url: String,
pub protocol: String,
}
/// The possible runtime environment for our application.
pub enum Environment {
Development,
Production,
}
impl Environment {
pub fn as_str(&self) -> &'static str {
match self {
Environment::Development => "development",
Environment::Production => "production",
}
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"development" => Ok(Self::Development),
"production" => Ok(Self::Production),
other => Err(format!(
"{} is not a supported environment. Use either `development` or `production`.",
other
)),
}
}
}
/// Multipurpose function that helps detect the current environment the application
/// is running using the `APP_ENVIRONMENT` environment variable.
/// After detection, it loads the appropriate .yaml file
/// then it loads the environment variable that overrides whatever is set in the .yaml file.
/// For this to work, you the environment variable MUST be in uppercase and starts with `APP`,
/// a `_` separator then the category of settings,
/// followed by `__` separator, and then the variable, e.g.
/// `APP__APPLICATION_PORT=5001` for `port` to be set as `5001`
pub fn get_settings() -> Result<Settings, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
let settings_directory = base_path.join("settings");
// Detect the running environment.
// Default to `development` if unspecified.
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "development".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT.");
let environment_filename = format!("{}.yaml", environment.as_str());
let settings = config::Config::builder()
.add_source(config::File::from(settings_directory.join("base.yaml")))
.add_source(config::File::from(
settings_directory.join(environment_filename),
))
// Add in settings from environment variables (with a prefix of APP and '__' as separator)
// E.g. `APP_APPLICATION__PORT=5001 would set `Settings.application.port`
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__"),
)
.build()?;
settings.try_deserialize::<Settings>()
}
We have a couple of structs and an enum. These structs map directly to the .yaml
files we will create soon. The bulk of the work in this settings.rs
file is done in the get_settings
function. Anytime we need some settings variables, we will get them by calling this function. Looking inward, we first try to get the path of the directory where our .yaml
files are located. Then we detect whether or not we are in development. By default, we assume that the app is in development. To change it to production, you must set APP_ENVIRONMENT=production
in your .env
file or any other way you set your environment variables. Since development and production environments share some variables — we store those in base.yaml
— we use our config
crate to first load those common configurations before loading environment-specific ones. This is because we are likely to override those common configurations on a per-environment basis. There are some configurations in the .yaml
files that we may want to change their values using environment variables. Tokens, passwords and secret_key are some examples. We want the ones set via environment variables to take precedence. For example, if we set debug: true
in base.yaml
but in production, we want debug: false
. We can just do APP_DEBUG=true
in our .env
file and this will override the one in base.yaml
. Notice the prefix, APP_
. It's required for such to be recognized as the setting's variable. You are at liberty to change the prefix as well. We learn more about these nuances as we progress. Now, let's create the settings/
directory at the root of the project. We will also create base.yaml
, development.yaml
and production.yaml
in it:
~/rust-auth/backend$ mkdir settings && touch settings/base.yaml settings/development.yaml settings/production.yaml
For now, make settings/base.yaml
looks like this:
# settings/base.yaml
application:
port: 5000
settings/development.yaml
:
# settings/development.yaml
application:
protocol: http
host: 127.0.0.1
base_url: "http://127.0.0.1"
debug: true
And, settings/production.yaml
:
# settings/production.yaml
application:
protocol: https
host: 0.0.0.0
base_url: ""
debug: false
Next is src/startup.rs
:
// src/startup.rs
pub struct Application {
port: u16,
server: actix_web::dev::Server,
}
impl Application {
pub async fn build(settings: crate::settings::Settings) -> Result<Self, std::io::Error> {
let address = format!(
"{}:{}",
settings.application.host, settings.application.port
);
let listener = std::net::TcpListener::bind(&address)?;
let port = listener.local_addr().unwrap().port();
let server = run(listener).await?;
Ok(Self { port, server })
}
pub fn port(&self) -> u16 {
self.port
}
pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {
self.server.await
}
}
async fn run(listener: std::net::TcpListener) -> Result<actix_web::dev::Server, std::io::Error> {
let server = actix_web::HttpServer::new(move || {
actix_web::App::new().service(crate::routes::health_check)
})
.listen(listener)?
.run();
Ok(server)
}
It starts up our entire application and is done in the run_until_stopped
method of the Application
struct. The motive for writing this way is for easy testing. This is NOT the only way to start actix-web
server. The normal practice is way shorter, at least for a start. But this is entirely an optional design decision.
Let's enter into src/main.rs
:
// src/main.rs
#[tokio::main]
async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok();
let settings = backend::settings::get_settings().expect("Failed to read settings.");
let subscriber = backend::telemetry::get_subscriber(settings.clone().debug);
backend::telemetry::init_subscriber(subscriber);
let application = backend::startup::Application::build(settings).await?;
tracing::event!(target: "backend", tracing::Level::INFO, "Listening on http://127.0.0.1:{}/", application.port());
application.run_until_stopped().await?;
Ok(())
}
src/main.rs
is the entry point for Rust's applications. We opt for #[tokio::main]
runtime. You can use #[actix_web::main]
instead. Then, we brought dotenv
into the game to help load all environment variables in our .env
file. We then get our settings as written in src/settings.rs
. Telemetry is then initialized. Our entire app was then built and subsequently run but before it was run, we let the developer know the port where our app runs using tracing::event
macro. We could get the port here because we made it available in our src/startup.rs
. With this, we may not touch this file, src/main.rs
, again throughout this series. Our point-of-contact will be src/startup.rs
.
If you try to run our skeletal app at this point, it will not compile yet. Let's fix that.
Navigate to src/routes/health.rs
and make it look like this:
// src/routes/health.rs
#[tracing::instrument]
#[actix_web::get("/health-check/")]
pub async fn health_check() -> actix_web::HttpResponse {
tracing::event!(target: "backend", tracing::Level::DEBUG, "Accessing health-check endpoint.");
actix_web::HttpResponse::Ok().json("Application is safe and healthy.")
}
We have a simple endpoint to check whether or not is online. You can see how easy it is to write an API endpoint in actix-web. Apart from the instrumentations, you can wire up a "fully functional" GET request endpoint with just 3 lines of code!!!
Looking into the endpoint, we used #[tracing::instrument]
to help keep logs of all requests in this function. That's instrumentation. We then use #[actix_web::get("/health-check/")]
to signal that only GET
requests are allowed on /health-check/
. Any other methods will be rejected. One of the reasons for using actix-web is its native support for asynchronous functions coupled with the fact that it's extremely fast. We made our function async and we expect the function to return an HTTP response, actix_web::HttpResponse
. There are other ways to achieve this but I favour this method due to its brevity. We then return a message, Application is safe and healthy.
, in JSON format to the user using HTTP Ok status, 200. There are other HTTP Response methods available in actix-web and we'll encounter some.
Next, we need to make this method available. Open up src/routes/mod.rs
:
// src/routes/mod.rs
mod health;
pub use health::health_check;
This makes it publicly accessible. We then registered it as a service in src/startup.rs
using crate::routes::health_check
:
// src/startup.rs
...
async fn run(listener: std::net::TcpListener) -> Result<actix_web::dev::Server, std::io::Error> {
let server = actix_web::HttpServer::new(move || {
actix_web::App::new().service(crate::routes::health_check)
})
.listen(listener)?
.run();
Ok(server)
}
That's it for the first article in the series!! See y'all in the next one.
Outro
Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, healthcare, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn and Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!
Top comments (0)