Updated (March, 2022)

Updated to actix-web-4.0

Updated (October, 2020)

Updated to actix-web-3.1

Updated (September, 2020)

Updated to async/await actix-web-2.0

What?

We are going to create a web-server in rust that only deals with user registration and authentication. I will be explaining the steps in each file as we go. The complete project code is here repo . Please take all this with a pinch of salt as I’m a still a noob to rust 😉.

Flow of the event would look like this:

  • Registers with email address ➡ Receive an 📨 with a link to verify
  • Follow the link ➡ register with same email and a password
  • Login with email and password ➡ Get verified and receive auth cookie

Crates we are going to use

  • actix-web // Actix web is a simple, pragmatic and extremely fast web framework for Rust.
  • chrono // Date and time library for Rust.
  • derive_more // Convenience macros to derive traits easily
  • diesel // A safe, extensible ORM and Query Builder for PostgreSQL, SQLite, and MySQL.
  • dotenv // A dotenv implementation for Rust.
  • env_logger // A logging implementation for log which is configured via an environment variable.
  • futures // An implementation of futures and streams featuring zero allocations, composability, and iterator-like interfaces.
  • lazy_static // A macro for declaring lazily evaluated statics.
  • r2d2 // A generic connection pool.
  • rust-argon // crate for hashing passwords using the cryptographically-secure Argon2 hashing algorithm.
  • serde // A generic serialization/de-serialization framework.
  • serde_json // A JSON serialization file format.
  • sparkpost // Rust bindings for sparkpost email api v1.
  • uuid // A library to generate and parse UUIDs.
  • time // Date and time library.

I have provided a brief info about the crates in use from their official description. If you want to know more about any of these crates please click on the name to go to crates.io. Shameless plug: sparkpost is my crate please leave feedback if you like/dislike it.

Prerequisite

I will assume here that you have some knowledge of programming, preferably some rust as well. A working setup of rust is required. Checkout https://rustup.rs for easy rust setup. To know more about rust checkout The Rust Book.

We will be using diesel to create models and deal with database, queries and migrations. Please head over to http://diesel.rs/guides/getting-started/ to get started and setup diesel_cli. In this tutorial we will be using postgresql so follow the instructions to setup for postgres. You need to have a running postgres server and can create a database to follow this tutorial through. Another nice to have tool is Cargo Watch that lets you watch the file system and re-compile and re-run the app when you make any changes.

Install Curl if don’t have it already on your system for testing the api locally.

Let’s Begin

After checking your rust and cargo version and creating a new project with

# at the time of writing(updating) this tutorial my setup is
rustc --version && cargo --version
# rustc 1.59.0 (9d1b2106e 2022-02-23)
# cargo 1.59.0 (49d8809dc 2022-02-10)


cargo new simple-auth-server
# Created binary (application) `simple-auth-server` project

cd simple-auth-server
pro tip
`cargo install cargo-watch` if you haven't already. It will watch for changes re-compile by using `cargo watch -x run`

Fill in the cargo dependencies with the following, I will go through each of them as get used in the project. I am using explicit versions of the crates, as you know things get old and change.(in case you are reading this tutorial after a long time it was created). In part 1 of this tutorial we won’t be using all of them but they will all become handy in the final app.

[dependencies]
actix-identity = "0.4"
actix-web = "4.0"
chrono = { version = "0.4", features = ["serde"] }
derive_more = "0.99"
diesel = { version = "1.4", features = ["postgres", "uuidv07", "r2d2", "chrono"] }
dotenv = "0.15"
env_logger = "0.9"
futures = "0.3.8"
lazy_static = "1.4"
r2d2 = "0.8"
rust-argon2 = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sparkpost = "0.5"
uuid = { version = "0.8", features = ["serde", "v4"] }
time = "0.3"

Setup The Base APP

Create new file src/models.rs.

// models.rs
use diesel::{r2d2::ConnectionManager, PgConnection};

// type alias to use in multiple places
pub type Pool = r2d2::Pool<ConnectionManager<PgConnection>>;

Set up actix-web server like the following in src/main.rs. We are leaving the resource or route builders empty for now. We will add logic to this as we go.

// main.rs
#[macro_use]
extern crate diesel;

use actix_identity::{CookieIdentityPolicy, IdentityService};
use actix_web::{middleware, web, App, HttpServer};
use diesel::prelude::*;
use diesel::r2d2::{self, ConnectionManager};

mod models;
mod schema;

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    dotenv::dotenv().ok();
    std::env::set_var(
        "RUST_LOG",
        "simple-auth-server=debug,actix_web=info,actix_server=info",
    );
    env_logger::init();
    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");

    // create db connection pool
    let manager = ConnectionManager::<PgConnection>::new(database_url);
    let pool: models::Pool = r2d2::Pool::builder()
        .build(manager)
        .expect("Failed to create pool.");
    let domain: String = std::env::var("DOMAIN").unwrap_or_else(|_| "localhost".to_string());

    // Start http server
    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(pool.clone()))
            // enable logger
            .wrap(middleware::Logger::default())
            .wrap(IdentityService::new(
                CookieIdentityPolicy::new(utils::SECRET_KEY.as_bytes())
                    .name("auth")
                    .path("/")
                    .domain(domain.as_str())
                    .max_age(time::Duration::days(1))
                    .secure(false), // this can only be true if you have https
            ))
            // limit the maximum amount of data that server will accept
            .app_data(web::JsonConfig::default().limit(4096))
            // everything under '/api/' route
            .service(
                web::scope("/api")
                    .service(
                        web::resource("/invitation")
                            .route(web::post().to(invitation_handler::post_invitation)),
                    )
                    // .service(
                    //     web::resource("/register/{invitation_id}")
                    //         .route(web::post().to(register_handler::register_user)),
                    // )
                    // .service(
                    //     web::resource("/auth")
                    //         .route(web::post().to(auth_handler::login))
                    //         .route(web::delete().to(auth_handler::logout))
                    //         .route(web::get().to(auth_handler::get_me)),
                    // ),
            )
    })
    .bind("127.0.0.1:8080")?
    .run()
    .await
}

Assuming from the previous steps you have postgres and diesel-cli installed and working. In your terminal echo DATABASE_URL=postgres://username:password@localhost/database_name > .env replace database_name, username and password as you have setup. At this stage your server should compile and run on 127.0.0.1:8080. It doesn’t do anything useful for now. Let’s create some Models to use with diesel.

Setting up Diesel and creating our user Model

We start with creating a model for the user. We run diesel setup in the terminal. This will create our database if didn’t exist and setup a migration directory etc.

Let’s write some SQL, shall we. Create migrations by diesel migration generate users and invitation diesel migration generate invitations. Open the up.sql and down.sql files in migrations folder and add with following sql respectively.


--migrations/TIMESTAMP_users/up.sql
CREATE TABLE users (
  email VARCHAR(100) NOT NULL PRIMARY KEY,
  hash VARCHAR(122) NOT NULL, --argon hash
  created_at TIMESTAMP NOT NULL
);

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

--migrations/TIMESTAMP_invitations/up.sql
CREATE TABLE invitations (
  id UUID NOT NULL PRIMARY KEY,
  email VARCHAR(100) NOT NULL,
  expires_at TIMESTAMP NOT NULL
);

--migrations/TIMESTAMP_invitations/down.sql
DROP TABLE invitations;

Command diesel migration run will create the table in the DB and a file src/schema.rs. This is the extent I will go about diesel-cli and migrations. Please read their documentation to learn more.

At this stage we have created the tables in the db, let’s write some code to create a representation of user and invitation in rust. We also add impl blocks of From tarit (more on that below) In models.rs we add the following.

// models.rs
...
use super::schema::*;
use diesel::{r2d2::ConnectionManager, PgConnection};

// type alias to use in multiple places
pub type Pool = r2d2::Pool<ConnectionManager<PgConnection>>;

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

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

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

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

Implementing generics allows us to use &str, String or static str to generate our models. Next add mod schema; to your main.rs to use schema generated by Diesel. Infact every time you create a new file in rust project you need to add it to the main.rs or lib.rs to use it. Check your implementation is free from errors/warnings and keep an eye on cargo watch -x run command in the terminal.

Our own error response type

Before we start implementing the handlers for various routes of our application let’s start by setting up a generic error response. It is not a compulsory requirement but can be useful in the future as your app grows.

This will allow us to send http error response with a custom message. Create a errors.rs file with the following content.

Rust provides use with really powerful tools that we can use to convert one type of error to another. In this app we will be doing a few operations using different carets i.e. saving data with diesel, hashing password with bcrypt etc. These operations may return errors but we need to convert them to our custom error type. std::convert::From is a trait that allows us to convert that. Read more about the From trait here . By implementing the From trait we can use the ? operator to propagate errors of many different types that would get converted to our ServiceError type.

Our error is defined in the errors.rs, let’s implement some From traits by adding impls for From uuid and diesel errors, we will also add a Unauthorized variant to our ServiceError enum.

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

#[derive(Debug, Display)]
pub enum ServiceError {
    #[display(fmt = "Internal Server Error")]
    InternalServerError,

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

    #[display(fmt = "Unauthorized")]
    Unauthorized,
}

// impl ResponseError trait allows to convert our errors into http responses with appropriate data
impl ResponseError for ServiceError {
    fn error_response(&self) -> HttpResponse {
        match self {
            ServiceError::InternalServerError => {
                HttpResponse::InternalServerError().json("Internal Server Error, Please try later")
            }
            ServiceError::BadRequest(ref message) => HttpResponse::BadRequest().json(message),
            ServiceError::Unauthorized => HttpResponse::Unauthorized().json("Unauthorized"),
        }
    }
}

// we can return early in our handlers if UUID provided by the user is not valid
// and provide a custom message
impl From<ParseError> for ServiceError {
    fn from(_: ParseError) -> ServiceError {
        ServiceError::BadRequest("Invalid UUID".into())
    }
}

impl From<r2d2::Error> for ServiceError {
    fn from(_: r2d2::Error) -> Self {
        ServiceError::InternalServerError
    }
}

impl From<DBError> for ServiceError {
    fn from(error: DBError) -> ServiceError {
        // Right now we just care about UniqueViolation from diesel
        // But this would be helpful to easily map errors as our app grows
        match error {
            DBError::DatabaseError(kind, info) => {
                if let DatabaseErrorKind::UniqueViolation = kind {
                    let message = info.details().unwrap_or_else(|| info.message()).to_string();
                    return ServiceError::BadRequest(message);
                }
                ServiceError::InternalServerError
            }
            _ => ServiceError::InternalServerError,
        }
    }
}

Don’t forget to add mod errors; into your main.rs file to be able to use the custom error message.

Implementing Handlers

Prior to actix-web 1.0 it was required to use actor based architect to send messages accros to create an async web server. But now we can use much simpler tokio runtime with futures to implement our server. Please see the older version of this tutorial for older implimentation.

We want our server to get an email from the client and create in invitation entry in the database. In this implementation we will be sending an email to user. If you don’t have the email service setup, you could simply ignore the email feature and just use the response data from the server for the purpose of learning.

// invitation_handler.rs
use actix_web::{web, HttpResponse};
use diesel::prelude::*;
use serde::Deserialize;

use crate::email_service::send_invitation;
use crate::models::{Invitation, Pool};

#[derive(Deserialize)]
pub struct InvitationData {
    pub email: String,
}

pub async fn post_invitation(
    invitation_data: web::Json<InvitationData>,
    pool: web::Data<Pool>,
) -> Result<HttpResponse, actix_web::Error> {
    // run diesel blocking code
    web::block(move || create_invitation(invitation_data.into_inner().email, pool)).await??;

    Ok(HttpResponse::Ok().finish())
}

fn create_invitation(
    eml: String,
    pool: web::Data<Pool>,
) -> Result<(), crate::errors::ServiceError> {
    let invitation = dbg!(query(eml, pool)?);
    send_invitation(&invitation)
}

/// Diesel query
fn query(eml: String, pool: web::Data<Pool>) -> Result<Invitation, crate::errors::ServiceError> {
    use crate::schema::invitations::dsl::invitations;

    let new_invitation: Invitation = eml.into();
    let conn = &pool.get()?;

    let inserted_invitation = diesel::insert_into(invitations)
        .values(&new_invitation)
        .get_result(conn)?;

    Ok(inserted_invitation)
}

get_invitation() is our main handler function that we will pass to the server route, this is an async function that returns a Future with HttpResponse. This function is passed two parameters invitation_data which gets created by the json data sent to server and second parameter, connection Pool is provided by the actix-web server to any handler that requires it (we set this up in main.rs).

Diesel queries are not async so we need to use web::block() helper to run sync code and still return a future by the parent method. We call create_invitation() function that in turn runs the query method to create invitation. You may have noticed that we are creating an Invitation struct from as String type this only works because we earlier implemented the From tarit for Invitation struct.

Don’t forget to add or uncomment mod invitation_handler; in your main.rs file.

Now we have a handler to insert and log invitation to console. We can add the handler function to our actix-server route like following

// main.rs
// --snip
.service(
        web::resource("/invitation")
        .route(web::post().to(invitation_handler::post_invitation)),
)
// --snip

Test your server

Register your new route function with the actix app, your router service closure will look like the following.

// main.rs
// snip
// routes to invitation
    .service(
        web::resource("/invitation").route(
            web::post().to(invitation_routes::register_user),
        ),
    )
// snip

At this sage you should be able to test the http://localhost:3000/invitation route with the following curl command.

curl --request POST \
  --url http://localhost:3000/api/invitation \
  --header 'content-type: application/json' \
  --data '{"email":"test@test.com"}'
# dbg! will print something like this in your terminal where you are runnig the app
{
    "id": "67a68837-a059-43e6-a0b8-6e57e6260f0d",
    "email": "test@test.com",
    "expires_at": "2018-10-23T09:49:12.167510"
}

Using Sparkpost to send registration email (optional)

Please feel free to use any email service you like (I have no association with sparkpost apart from personal use) as long as you are able to replicate the sent email. Now that out of the way you need to add following in your .env file.

SPARKPOST_API_KEY='yourapikey'
SENDING_EMAIL_ADDRESS='register@yourdomain.com'

Api key is obtained from sparkpost account, you can create one for free as long as you have a domain name that you can control. To handle email we create a file email_service.rs and add the following.

// email_service.rs
use crate::errors::ServiceError;
use crate::models::Invitation;
use sparkpost::transmission::{
    EmailAddress, Message, Options, Recipient, Transmission, TransmissionResponse,
};

lazy_static::lazy_static! {
static ref API_KEY: String = std::env::var("SPARKPOST_API_KEY").expect("SPARKPOST_API_KEY must be set");
}

pub fn send_invitation(invitation: &Invitation) -> Result<(), ServiceError> {
    let tm = Transmission::new_eu(API_KEY.as_str());
    let sending_email =
        std::env::var("SENDING_EMAIL_ADDRESS").expect("SENDING_EMAIL_ADDRESS must be set");
    // new email message with sender name and email
    let mut email = Message::new(EmailAddress::new(sending_email, "Let's Organise"));

    let options = Options {
        open_tracking: false,
        click_tracking: false,
        transactional: true,
        sandbox: false,
        inline_css: false,
        start_time: None,
    };

    // recipient from the invitation email
    let recipient: Recipient = invitation.email.as_str().into();

    let email_body = format!(
        "Please click on the link below to complete registration. <br/>
         <a href=\"http://localhost:3000/register.html?id={}&email={}\">
         http://localhost:3030/register</a> <br>
         your Invitation expires on <strong>{}</strong>",
        invitation.id,
        invitation.email,
        invitation
            .expires_at
            .format("%I:%M %p %A, %-d %B, %C%y")
            .to_string()
    );

    // complete the email message with details
    email
        .add_recipient(recipient)
        .options(options)
        .subject("You have been invited to join Simple-Auth-Server Rust")
        .html(email_body);

    let result = tm.send(&email);

    // Note that we only print out the error response from email api
    match result {
        Ok(res) => match res {
            TransmissionResponse::ApiResponse(api_res) => {
                println!("API Response: \n {:#?}", api_res);
                Ok(())
            }
            TransmissionResponse::ApiError(errors) => {
                println!("Response Errors: \n {:#?}", &errors);
                Err(ServiceError::InternalServerError)
            }
        },
        Err(error) => {
            println!("Send Email Error: \n {:#?}", error);
            Err(ServiceError::InternalServerError)
        }
    }
}

To be able to use this service in our app we add the mod email_service; in our main.js file. Note that we do not return anything from the send_invitation function. It is up to you what you would want to do in a real app, for now we just log to the terminal. Then we can uncomment send_invitation(&invitation); in invitation_handler.rs to actually send emails on user registering.

Please note you could leave sending emails part out and just work with information from the terminal on register.

Get some help

We all need some help at times. We will need to hash the password before we store it in the DB. There was a suggestion on Reddit rust community abut what algorithm to use. argon2 was suggested here .

To keep some concerns separate we create a new file src/utils.rs and define a helper hashing and verify functions as following.

//utils.rs
use crate::errors::ServiceError;
use argon2::{self, Config};

lazy_static::lazy_static! {
    pub static ref SECRET_KEY: String = std::env::var("SECRET_KEY").unwrap_or_else(|_| "0123".repeat(8));
}

const SALT: &'static [u8] = b"supersecuresalt";

// WARNING THIS IS ONLY FOR DEMO PLEASE DO MORE RESEARCH FOR PRODUCTION USE
pub fn hash_password(password: &str) -> Result<String, ServiceError> {
    let config = Config {
        secret: SECRET_KEY.as_bytes(),
        ..Default::default()
    };
    argon2::hash_encoded(password.as_bytes(), &SALT, &config).map_err(|err| {
        dbg!(err);
        ServiceError::InternalServerError
    })
}

pub fn verify(hash: &str, password: &str) -> Result<bool, ServiceError> {
    argon2::verify_encoded_ext(hash, password.as_bytes(), SECRET_KEY.as_bytes(), &[]).map_err(
        |err| {
            dbg!(err);
            ServiceError::Unauthorized
        },
    )
}

You may have noticed that we return a Result and use map_error() to return our custom error. This is to allow using the ? operator later when we call this function (Another way to convert error is to implement From trait for the error returned by argonautica crate, instead).

Lets add a convenience method to our User struct defined in models.rs. We will also define another struct SlimUser that does not have the password field. We impl From trait to generate SlimUser from the user. It will all become clear as we get to use this in a while.

// models.rs
//... snip
#[derive(Debug, Serialize, Deserialize)]
pub struct SlimUser {
    pub email: String,
}

impl From<User> for SlimUser {
    fn from(user: User) -> Self {
        SlimUser { email: user.email }
    }
}

Don’t forget to add mod utils in your main.rs. Make sure your code compiles at this stage.

Registering User

Let’s create a handler for registering a user. We are going to create a struct UserData with some data that allows us to verify the Invitation and then create and return a user from the database. The flow of this function is very similar to the invitation handler one. Client sends data on a particular path (the id of the invitation) we verify invitation and create a hash from the plain text password and store into the database and return SlimUser as JSON back.

Create a new file src/register_handler.rs and add mod register_handler; to you main.rs.

// register_handler.rs
use actix_web::{web, HttpResponse};
use diesel::prelude::*;
use serde::Deserialize;

use crate::errors::ServiceError;
use crate::models::{Invitation, Pool, SlimUser, User};
use crate::utils::hash_password;
// UserData is used to extract data from a post request by the client
#[derive(Debug, Deserialize)]
pub struct UserData {
    pub email: String,
    pub password: String,
}

pub async fn register_user(
    invitation_id: web::Path<String>,
    user_data: web::Json<UserData>,
    pool: web::Data<Pool>,
) -> Result<HttpResponse, actix_web::Error> {
    let user = web::block(move || query(invitation_id.into_inner(), user_data.into_inner(), pool))
        .await??;

    Ok(HttpResponse::Ok().json(&user))
}

fn query(
    invitation_id: String,
    user_data: UserData,
    pool: web::Data<Pool>,
) -> Result<SlimUser, crate::errors::ServiceError> {
    use crate::schema::invitations::dsl::{email, id, invitations};
    use crate::schema::users::dsl::users;
    let invitation_id = uuid::Uuid::parse_str(&invitation_id)?;

    let conn = &pool.get()?;
    invitations
        .filter(id.eq(invitation_id))
        .filter(email.eq(&user_data.email))
        .load::<Invitation>(conn)
        .map_err(|_db_error| ServiceError::BadRequest("Invalid Invitation".into()))
        .and_then(|mut result| {
            if let Some(invitation) = result.pop() {
                // if invitation is not expired
                if invitation.expires_at > chrono::Local::now().naive_local() {
                    // try hashing the password, else return the error that will be converted to ServiceError
                    let password: String = hash_password(&user_data.password)?;
                    let user = User::from_details(invitation.email, password);
                    let inserted_user: User =
                        diesel::insert_into(users).values(&user).get_result(conn)?;
                    dbg!(&inserted_user);
                    return Ok(inserted_user.into());
                }
            }
            Err(ServiceError::BadRequest("Invalid Invitation".into()))
        })
}

Parsing url parameters

actix-web has many easy ways to extract data from a request.

One of the way is to use Path extractor.

Path provides information that can be extracted from the Request’s path. You can deserialize any variable segment from the path.

This will allow us to create a unique path for every invitation to be register as a user.

Let’s modify our register route in the main.rs file, and add a handler function.

// main.rs
//...snip
// routes to register as a user after the
    .service(
        web::resource("/register/{invitation_id}")
            .route(web::post().to_async(register_handler::register_user)),
    ),

Test your implementation

After taking care of the errors if you had any, lets give it a spin. invoking

curl --request POST \
  --url http://localhost:3000/api/invitation \
  --header 'content-type: application/json' \
  --data '{"email":"name@domain.com"}'

Should send an email to the adderss and also dbg! log something like

{
  "id": "f87910d7-0e33-4ded-a8d8-2264800d1783",
  "email": "name@domain.com",
  "expires_at": "2018-10-27T13:02:00.909757"
}

If you didn’t implemented the email prat of the tutorial, imagine that we sent an email to the user by creating a link that takes to a form for the user to fill. From there we would have our client post a request to http://localhost:3000/api/register/f87910d7-0e33-4ded-a8d8-2264800d1783. For the sake of this demo you can test your app with the following test command.

curl --request POST \
  --url http://localhost:3000/api/register/f87910d7-0e33-4ded-a8d8-2264800d1783 \
  --header 'content-type: application/json' \
  --data '{"email":"name@domain.com", "password":"password"}'

Which should return something like

{
  "email": "name@domain.com"
}

Running the command again would result with an error

"Key (email)=(name@domain.com) already exists."

Congratulations now you have a web service that can invite, verify and create a user and even send you a semi useful error message. 🎉🎉

Let’s do Auth

According to w3.org:

The general concept behind a token-based authentication system is simple. Allow users to enter their username and password in order to obtain a token which allows them to fetch a specific resource - without using their username and password. Once their token has been obtained, the user can offer the token - which offers access to a specific resource for a time period - to the remote site.

Now how you choose to exchange that token can have security implications. You will find many discussions/debates around the internet and many ways that people use. I am very wary of storing things on client side that can be accessed by the client side JavaScript. Unfortunately this approach is suggested in thousands of tutorial everywhere. Here is a good read Stop using JWT for sessions .

I am not sure here, what to suggest you as the reader, apart from don't follow online tutorials blindly and do your own research. The purpose of this tutorial is to learn about Actix-web and rust not how to prevent your server from vulnerabilities. For the sake of this tutorial we will be using http only cookies to exchange tokens.

PLEASE DO NOT USE IN PRODUCTION.

Now that is out of the way 😰, let’s see what we can do here. actix-web provides us with a neat way as middleware of handling a session cookie documented here actix_identity . To enable this functionality we already have enabled Identity middleware our main.rs file.

This gives us very convenient methods like Identity.remember(data) and Identity.forget() etc in our route functions. That in turn will set and remove the auth cookie from client.

JWT

In the previous version of this tutorial I used JWT but for simplicity sake we will just serialse user data and set an auth cookie to authenticate.

Auth Handling

You know the drill now 😉, lets create a new file src/auth_handler.rs and add mod auth_handler; to you main.rs.

//auth_handler.rs
use actix_identity::Identity;
use actix_web::{dev::Payload, web, Error, FromRequest, HttpRequest, HttpResponse};
use diesel::prelude::*;
use futures::future::{err, ok, Ready};
use serde::Deserialize;

use crate::errors::ServiceError;
use crate::models::{Pool, SlimUser, User};
use crate::utils::verify;

#[derive(Debug, Deserialize)]
pub struct AuthData {
    pub email: String,
    pub password: String,
}

// we need the same data
// simple aliasing makes the intentions clear and its more readable
pub type LoggedUser = SlimUser;

impl FromRequest for LoggedUser {
    type Error = Error;
    type Future = Ready<Result<LoggedUser, Error>>;

    fn from_request(req: &HttpRequest, pl: &mut Payload) -> Self::Future {
        if let Ok(identity) = Identity::from_request(req, pl).into_inner() {
            if let Some(user_json) = identity.identity() {
                if let Ok(user) = serde_json::from_str(&user_json) {
                    return ok(user);
                }
            }
        }
        err(ServiceError::Unauthorized.into())
    }
}

pub async fn logout(id: Identity) -> HttpResponse {
    id.forget();
    HttpResponse::Ok().finish()
}

pub async fn login(
    auth_data: web::Json<AuthData>,
    id: Identity,
    pool: web::Data<Pool>,
) -> Result<HttpResponse, actix_web::Error> {
    let user = web::block(move || query(auth_data.into_inner(), pool)).await??;

    let user_string = serde_json::to_string(&user)?;
    id.remember(user_string);
    Ok(HttpResponse::Ok().finish())
}

pub async fn get_me(logged_user: LoggedUser) -> HttpResponse {
    HttpResponse::Ok().json(logged_user)
}

/// Diesel query
fn query(auth_data: AuthData, pool: web::Data<Pool>) -> Result<SlimUser, ServiceError> {
    use crate::schema::users::dsl::{email, users};
    let conn = &pool.get()?;
    let mut items = users
        .filter(email.eq(&auth_data.email))
        .load::<User>(conn)?;

    if let Some(user) = items.pop() {
        if let Ok(matching) = verify(&user.hash, &auth_data.password) {
            if matching {
                return Ok(user.into());
            }
        }
    }
    Err(ServiceError::Unauthorized)
}

Handler above takes the AuthData struct that contains email and password sent by the client. We use the email to extract user from the db and use the verify function to match the password. If all goes well we set the cookie by calling id.remember(serialized_user) otherwise return Unauthorized error.

The whole point of having Auth is to have way to verify a request is coming from a authenticated client. Actix-web has a trait FromRequest that we can implement on any type and then use that to extract data from the request. See documentation here .

Last thing we need to do now is use login/logout function in our main.rs. Change the web::rsource("/auth") closure to following:

.// routes for authentication
.service(
    web::resource("/auth")
        .route(web::post().to(auth_handler::login))
        .route(web::delete().to(auth_handler::logout))
        .route(web::get().to(auth_handler::get_me)),
)

Test run Auth

If you have been following the tutorial, you have already created a user with email and password. Use the following curl command to test our server.

curl -i --request POST \
  --url http://localhost:3000/api/auth \
  --header 'content-type: application/json' \
  --data '{"email": "name@domain.com","password":"password"}'

## response
HTTP/1.1 200 OK
set-cookie: auth=iqsB4KUUjXUjnNRl1dVx9lKiRfH24itiNdJjTAJsU4CcaetPpaSWfrNq6IIoVR5+qKPEVTrUeg==; HttpOnly; Path=/; Domain=localhost; Max-Age=86400
content-length: 0
date: Sun, 28 Oct 2018 12:36:43 GMT

If you received a 200 response like above with a set-cookie header, Congratulations you have successfully logged in.

To test the logout we send a DELETE request to the /auth, make sure you get set-cookie header with empty data and immediate expiry date.

curl -i --request DELETE \
  --url http://localhost:3000/auth

## response
HTTP/1.1 200 OK
set-cookie: auth=; HttpOnly; Path=/; Domain=localhost; Max-Age=0; Expires=Fri, 27 Oct 2017 13:01:52 GMT
content-length: 0
date: Sat, 27 Oct 2018 13:01:52 GMT

Testing logged in user

Try the following Curl command in the terminal.

curl -i --request POST \
  --url http://localhost:3000/auth \
  --header 'content-type: application/json' \
  --data '{
        "email": "name@domain.com",
        "password":"password"
}'
# result would be something like
HTTP/1.1 200 OK
set-cookie: auth=HdS0iPKTBL/4MpTmoUKQ5H7wft5kP7OjP6vbyd05Ex5flLvAkKd+P2GchG1jpvV6p9GQtzPEcg==; HttpOnly; Path=/; Domain=localhost; Max-Age=86400
content-length: 0
date: Sun, 28 Oct 2018 19:16:12 GMT

## and then pass the cookie back for a get request
curl -i --request GET \
  --url http://localhost:3000/auth \
  --cookie auth=HdS0iPKTBL/4MpTmoUKQ5H7wft5kP7OjP6vbyd05Ex5flLvAkKd+P2GchG1jpvV6p9GQtzPEcg==
## result
HTTP/1.1 200 OK
content-length: 27
content-type: application/json
date: Sun, 28 Oct 2018 19:21:04 GMT

{"email":"name@domain.com"}

It should successfully return your email as json back. Only logged in users or requests with a valid auth cookie will pass through the routes you extract the LoggedUser.

What’s next

Please look at the repo for full code for this app. Thank you for reading this tutorial.

Get in touch with me on Mastodon if you have a question or suggestion about any of it.

If you enjoyed reading this post, you can click 👍 at the top a few times.
Please get in touch with me on https://hachyderm.io/@mygnu (Mastodon) if you have a question or suggestions.

Thank you for reading and Happy Coding!