DEV Community

Cover image for Authentication system using rust (actix-web) and sveltekit - Backend Intro
John Owolabi Idogun
John Owolabi Idogun

Posted on • Updated on

Authentication system using rust (actix-web) and sveltekit - Backend Intro

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:

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:

GitHub logo Sirneij / rust-auth

A fullstack authentication system using rust, sveltekit, and Typescript

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
] }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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.
///
/// \`\`\`
/// APP_ENVIRONMENT = development | production.
/// \`\`\`
///
/// 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>()
}
Enter fullscreen mode Exit fullscreen mode

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 inwards, 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
Enter fullscreen mode Exit fullscreen mode

For now, make settings/base.yaml looks like this:

# settings/base.yaml
application:
  port: 5000
Enter fullscreen mode Exit fullscreen mode

settings/development.yaml:

# settings/development.yaml

application:
  protocol: http
  host: 127.0.0.1
  base_url: "http://127.0.0.1"

debug: true
Enter fullscreen mode Exit fullscreen mode

And, settings/production.yaml:

# settings/production.yaml

application:
  protocol: https
  host: 0.0.0.0
  base_url: ""

debug: false
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 a design decision which is optional.

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(())
}
Enter fullscreen mode Exit fullscreen mode

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.")
}
Enter fullscreen mode Exit fullscreen mode

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 into 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;
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

That's it for the first article in the series!! See y'all in the next one.

Outro

Enjoyed this article? Consider contacting me for a job, something worthwhile or buying a coffee ☕. You can also connect with/follow me on LinkedIn and Twitter. It isn't bad if you help share this article for wider coverage. I will appreciate it...

Top comments (0)