Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
April 23, 2023 02:54 pm GMT

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




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.rspub mod routes;pub mod settings;pub mod startup;pub mod telemetry;

Let's start with telemetry.rs. Make it look like this:

// src/telemetry.rsuse 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.////// \`\`\`/// 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>()}

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

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

# settings/base.yamlapplication:  port: 5000

settings/development.yaml:

# settings/development.yamlapplication:  protocol: http  host: 127.0.0.1  base_url: "http://127.0.0.1"debug: true

And, settings/production.yaml:

# settings/production.yamlapplication:  protocol: https  host: 0.0.0.0  base_url: ""debug: false

Next is src/startup.rs:

// src/startup.rspub 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 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(())}

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 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.rsmod 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? 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...


Original Link: https://dev.to/sirneij/full-stack-authentication-system-using-rust-actix-web-and-sveltekit-1cc6

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To