To improve my understanding of Rust, I'm going to be creating an authentication service that can be used via a browser or an API. This is complex enough to be useful while being simple enough for me not to get lost. I also want to extend the concepts covered here (which only deals with the API part).

Disclaimer: I've been learning Rust for all of 2 months now so the code may not be idiomatic or elegant...but it works, which is the most important thing, after all. Also, some of what I'll write might not be accurate since I'm still getting the hang of the Rust way of doing things.

Our service will allow people to register, verify their email addresses, sign in and sign out.

Wait a minute! Where's password recovery, 2-factor authentication, social login and other features of a production-grade self-respecting authentication service, you ask? Well, hold your horses. We're only trying to improve our familiarity with Rust. We're not trying to build the next SaaS unicorn.

I'll be assuming you've already set up your system for creating Rust apps. If you haven't, see how to here. Our exploration is broken down into distinct steps to make it easier for you to follow along:

Creating the app

We'll be using Cargo, which is to Rust what NPM is to Javascript, to help us along.

$ cargo new auth_service
$ cd auth_service

The first command creates the app while the second changes to the app directory.

Next, we'll install cargo-watch to recompile and run our app when a file changes:

$ cargo install cargo-watch

Run the command below to start the app. You should see the world-famous "Hello, world!" output.

$ cargo-watch -x run

Add the following dependencies in Cargo.toml file:

[dependencies]
actix-files = "0.2.1"
actix-rt = "1.0"
actix-session = "0.3"
actix-web = "2.0"
argonautica = "0.2.0"
chrono = { version = "0.4.11", features = ["serde"] }
derive_more = "0.99.5"
diesel = { version = "1.4.4", features = ["postgres", "uuidv07", "r2d2", "chrono"] }
dotenv = "0.15.0"
env_logger = "0.7.1"
lettre = { git = "https://github.com/lettre/lettre" }
native-tls = "0.2.4"
r2d2 = "0.8.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "0.8", features = ["serde", "v4"] }
yarte = { version = "0.7", features = ["with-actix-web"]  }

[build-dependencies]
yarte = { version = "0.7", features = ["with-actix-web"]  }

We'll be using:

  • PostgreSQL as our database.
  • diesel as our ORM.
  • actix-web as our webserver.
  • actix-files to serve static files (we'll use it to serve our CSS file).
  • actix-session for...do I really need to explain this one?
  • yarte for compiling HTML templates (currently the fastest of the bunch).
  • serde for JSON serialization (which I always call Sade for some reason).
  • r2d2 for (database) connection pooling.
  • dotenv for reading variables from .env files.
  • lettre for sending e-mails.
  • argonautica for password hashing.

Create a .env file in the project directory with the following keys in KEY=value format:

  • DATABASE_URL which will contain the value postgres://<user>:<password>@<host>/auth_service.
  • DOMAIN. I use 0.0.0.0.
  • PORT. I use 3000.
  • HOST. I use http://localhost:3000.
  • SECRET_KEY.
  • SMTP_SENDER_NAME, SMTP_USERNAME, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT. I use Mailtrap to test email in development. You can use any SMTP provider you choose.

Follow the instructions here to install diesel_cli.

Create database and start the webserver

Create a directory for the Tailwind CSS file we'll be using for the templates:

$ mkdir -p templates/assets/css

Then put the downloaded CSS file there. Note that this is not the recommended way of using Tailwind, but it's sufficient for this example.

I'm a fan of doing things in one place and using it everywhere else. Let's create a file src/vars.rs that will supply our environment variables to the rest of the app. Paste this code in:

use dotenv::dotenv;

use std::env::var;


pub fn database_url() -> String {
  dotenv().ok();

  var("DATABASE_URL").expect("DATABASE_URL is not set")
}

pub fn secret_key() -> String {
  dotenv().ok();

  var("SECRET_KEY").unwrap_or_else(|_| "0123".repeat(8))
}

pub fn domain() -> String {
  dotenv().ok();

  var("DOMAIN").unwrap_or_else(|_| "localhost".to_string())
}

pub fn port() -> u16 {
  dotenv().ok();

  var("PORT").expect("PORT is not set").parse::<u16>().ok().expect("PORT should be an integer")
}

pub fn domain_url() -> String {
  dotenv().ok();

  var("DOMAIN_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
}

pub fn smtp_username() -> String {
  dotenv().ok();

  var("SMTP_USERNAME").expect("SMTP_USERNAME is not set")
}

pub fn smtp_password() -> String {
  dotenv().ok();

  var("SMTP_PASSWORD").expect("SMTP_PASSWORD is not set")
}

pub fn smtp_host() -> String {
  dotenv().ok();

  var("SMTP_HOST").expect("SMTP_HOST is not set")
}

pub fn smtp_port() -> u16 {
  dotenv().ok();

  var("SMTP_PORT").expect("SMTP_PORT is not set").parse::<u16>().ok().expect("SMTP_PORT should be an integer")
}

#[allow(dead_code)]
pub fn smtp_sender_name() -> String {
  dotenv().ok();

  var("SMTP_SENDER_NAME").expect("SMTP_SENDER_NAME is not set")
}

To be able to use anywhere in our app, we have to mod vars.rs; before the main() in src/main.rs.

Create a file src/models.rs and paste in:

use diesel::{r2d2::ConnectionManager, PgConnection};

// type alias to reduce verbosity
pub type Pool = r2d2::Pool<ConnectionManager<PgConnection>>;

Then, create the database (if it doesn't exist) and setup migrations by running:

$ diesel setup

Add actix-cors = "0.2.0" to our dependencies, then start the webserver by modifying src/main.rs to:

#[macro_use]
extern crate diesel;
extern crate serde_json;
extern crate lettre;
extern crate native_tls;


mod models;
mod vars;


#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    use actix_cors::Cors;
    use actix_files::Files;
    use actix_session::CookieSession;
    use actix_web::{middleware, web, App, HttpServer, http::header};
    use diesel::{
        prelude::*, 
        r2d2::{self, ConnectionManager}
    };

    std::env::set_var("RUST_LOG", "actix_web=info,actix_server=info");
    env_logger::init();

    // create a database connection pool
    let manager = ConnectionManager::<PgConnection>::new(vars::database_url());
    let pool: models::Pool = r2d2::Pool::builder()
        .build(manager)
        .expect("Failed to create a database connection pool.");

    // Start http server
    HttpServer::new(move || {
        App::new()
            .data(pool.clone())
            // enable logger
            .wrap(middleware::Logger::default())
            // Enable sessions
            .wrap(
                CookieSession::signed(&[0; 32])
                    .domain(vars::domain_url().as_str())
                    .name("auth")
                    .secure(false))
            .wrap(
                Cors::new()
                    .allowed_origin("*")
                    .allowed_methods(vec!["GET", "POST", "DELETE"])
                    .max_age(3600)
                    .finish())
            .service(Files::new("/assets", "./templates/assets"))
    })
    .bind(format!("{}:{}", vars::domain(), vars::port()))?
    .run()
    .await
}

Wow, we went from 0 to 100 real quick. Let's go over what we just did. We declare some external dependencies so we can use it in our app. Don't ask me why, that's how it is. Currently, Rust doesn't need that for every external crate but some crates haven't gotten the memo.  We add an attribute to the extern statement for Diesel to enable the use of macros. Diesel comes with a lot of macros and is probably not very unusable without them, so we have to turn it on.

We add another attribute above main() to mark it as our main function for actix-web. You might also notice the function header changed from the simple fn main()... to what we have above. Actix-web is an async framework, so our main has to be asynchronous. We'll be seeing more async functions as we go along. In lines 14-21, we import a bunch of stuff we'll use. We create a connection pool in lines 27-30. Lines 33-56 configure and start our server. Let's look inside: we create an app (L34), store our pool so it can be available to our handlers (L35), configure session support (39-43) and CORS (44-49), serve our static resources from our templates/assets directory (50) and bind to the domain and port declared in our .env files (52).

Adding our models

We'll create two models: User to hold our user records after registration and Confirmation which holds our user information during the registration process.

$ diesel migration generate users
$ diesel migration generate confirmations

The directories migrations/TIMESTAMP_users and migrations/TIMESTAMP_invitations contains the up and down migration SQL files for our user and invitation tables respectively.

Let's fill out the four files:

--migrations/TIMESTAMP_users/up.sql
CREATE TABLE users (
  id UUID NOT NULL PRIMARY KEY,
  email VARCHAR(50) NOT NULL UNIQUE,
  hash VARCHAR(150) NOT NULL,
  created_at TIMESTAMP NOT NULL
);

--migrations/TIMESTAMP_users/down.sql
DROP TABLE users;

--migrations/TIMESTAMP_confirmations/up.sql
CREATE TABLE confirmations (
  id UUID NOT NULL PRIMARY KEY,
  email VARCHAR(50) NOT NULL UNIQUE,
  expires_at TIMESTAMP NOT NULL
);

--migrations/TIMESTAMP_confirmations/down.sql
DROP TABLE confirmations;

To create the tables in the database (and also create a src/schema.rs file), run:

$ diesel migration run

Like we did for var.rs and models.rs, add mod schema.rs to our main.rs file.

Update src/models.rs to:

// src/models.rs
use diesel::{r2d2::ConnectionManager, PgConnection};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use super::schema::*;

// type alias to reduce verbosity
pub type Pool = r2d2::Pool<ConnectionManager<PgConnection>>;

#[derive(Debug, Serialize, Deserialize, Queryable, Insertable)]
#[table_name = "confirmations"]
pub struct Confirmation {
    pub id: Uuid,
    pub email: String,
    pub expires_at: chrono::NaiveDateTime,
}

#[derive(Debug, Serialize, Deserialize, Queryable, Insertable)]
#[table_name = "users"]
pub struct User {
    pub id: Uuid,
    pub email: String,
    pub hash: String,
    pub created_at: chrono::NaiveDateTime,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SessionUser {
    pub id: Uuid,
    pub email: String,
}

// any type that implements Into<String> can be used to create a Confirmation
impl<T> From<T> for Confirmation where
T: Into<String> {
     fn from(email: T) -> Self {
        Confirmation {
            id: Uuid::new_v4(),
            email: email.into(),
            expires_at: chrono::Local::now().naive_local() + chrono::Duration::hours(24),
        }
    }
}

impl From<User> for SessionUser {
    fn from(User { email, id, .. }: User) -> Self {
        SessionUser { email, id }
    }
}

impl User {
    pub fn from<S: Into<String>, T: Into<String>>(email: S, pwd: T) -> Self {
        User {
            id: Uuid::new_v4(),
            email: email.into(),
            hash: pwd.into(),
            created_at: chrono::Local::now().naive_local(),
        }
    }
}

Confirmation and User are structs for our database models, SessionUser is for use within the app and the impl blocks are for transforming one to the other.

The table_name attributes should be self-explanatory. The derive traits on line 10 "pairs" our struct with various traits: Debug to enable printing to the console, Serialize and Deserialize to enable conversion to and from JSON, Queryable and Insertable to enable storing into and retrieving from the database.

Add an error model

We need a way to express the different errors that could arise in our app. This way, we can tailor our response to the specific error.

Create src/errors.rs and add mod errors; in main.rs. Paste the following code into src/errors.rs:

// src/errors.rs
use actix_web::{HttpResponse, ResponseError};
use derive_more::Display;
use diesel::result::{DatabaseErrorKind, Error as DBError};
use std::convert::From;
use uuid::Error as UuidError;

#[derive(Debug, Display)]
pub enum AuthError {
    #[display(fmt = "DuplicateValue: {}", _0)]
    DuplicateValue(String),

    #[display(fmt = "BadId")]
    BadId,

    #[display(fmt = "GenericError: {}", _0)]
    GenericError(String),
}


impl ResponseError for AuthError {
    fn error_response(&self) -> HttpResponse {
        match self { 
            AuthError::BadId => HttpResponse::BadRequest().json("Invalid ID"),

            AuthError::DuplicateValue(ref message) => HttpResponse::BadRequest().json(message),

            AuthError::GenericError(ref message) => HttpResponse::BadRequest().json(message),
        }
    }
}


impl From<UuidError> for AuthError {
    fn from(_: UuidError) -> AuthError {
        AuthError::BadId
    }
}

impl From<DBError> for AuthError {
    fn from(error: DBError) -> AuthError {
        // We only care about UniqueViolations
        match error {
            DBError::DatabaseError(kind, info) => {
                let message = info.details().unwrap_or_else(|| info.message()).to_string();

                match kind {
                    DatabaseErrorKind::UniqueViolation => AuthError::DuplicateValue(message),
                    _ => AuthError::GenericError(message)
                }                
            }
            _ => AuthError::GenericError(String::from("Some database error occured")),
        }
    }
}

We declare an enumeration of our errors AuthError which will grow over time as potential error conditions multiply. At L20 we convert our errors into a HTTP response. At L33 we convert any UuidError into a BadId. At L39 we handle database errors. Other than a UniqueViolation, we return a GenericError for any other error.

Process flow

The registration flow we'll implement is:

  1. The user sends a request with a new email address.
  2. We then send a confirmation link to the email address.
  3. The user then creates a password using the link to complete the registration process.

To sign in, the user sends their email address and password combination. If the combination is valid, we create a session for the user and send them to their home page (web) or return a session cookie (API).

For signing out, we just delete the user's session.

Now we've defined what our service will do, let's look at the first step which is sending a confirmation link to the user.