DEV Community

Cover image for Pure GraphQL OAuth
Afonso Barracha
Afonso Barracha

Posted on • Updated on

Pure GraphQL OAuth

Disclosure: The material in this Tutorial has not been reviewed, endorsed, or approved of by the Rust Foundation. For more information on the Rust Foundation Trademark Policy, click here.

Before starting, this is my first article in Rust, I try my best to follow best practices, but unlike TypeScript, that I have been using for 3 years at this point. I have only learnt Rust 1 year ago, so my skills are a bit rusty pun intended.

Hence, if you are a seasoned Rust developer and see any mistake, or have some suggestions leave them in the comments and I will try my best to update this tutorial.

TLDR.: For those who do not have 45 minutes to read the article, this is an OAuth service made only with Graphql, the repo with all the code can be found here.

Intro

In this acticle I will make a tutorial on how to create a pure GraphQL OAuth2.0 microservice.

Technologies

This tutorial will be written fully in safe Rust.

Tech Stack

  • Backend-Framework: Actix-Web, a powerful, pragmatic, and extremely fast web framework for Rust;
  • GraphQL Adaptor: Async-GraphQL, a GraphQL server-side library implemented in Rust. It is fully compatible with the GraphQL specification and most of its extensions, and offers type safety and high performance.
  • ORM: SeaORM, a relational ORM to help you build web services in Rust.

Architecture

We will use a 3-layer architecture on the microservice:

  • Data Layer: this is where we will interact with our entities in the database, this logic will be mostly abstracted by SeaORM;
  • Service Layer: most of our business logic will reside here, and will be the core of our service;
  • Resolver Layer: this is the layer that will be exposed to the public, hence it is where we will map our services' logic to their respective queries and mutations.

Project Set-up

To set up a new project with cargo, run the following command:

$ cargo new graphql-oauth
Enter fullscreen mode Exit fullscreen mode

On the Cargo.toml add the following packages:

[package]
name = "graphql-local-oauth"
version = "0.1.0"
edition = "2021"
publish = false
license = "Apache-2.0"
authors = ["John Doe <john.doe@gmail.com>"]

[dependencies]
actix-web = "^4"
chrono = "^0.4"
serde = "^1"
sea-orm = { version = "^0.10", features = ["sqlx-postgres", "runtime-actix-native-tls"] }
redis = { version = "^0.22", features = ["tokio-comp", "tokio-native-tls-comp"] }
async-graphql = { version = "^5", features = ["dataloader"] }
async-graphql-actix-web = "^5"
regex = "^1"
tokio = { version = "^1", features = ["macros", "rt-multi-thread"] }
lettre = { version = "^0.10", features = [
    "builder",
    "tokio1-native-tls",
] }
jsonwebtoken = "^8"
argon2 = { version = "^0.4", features = ["std"] }
bcrypt = "^0.13"
rand = { version = "^0.8", features = ["std_rng"] }
uuid = { version = "^1", features = [
    "v4",
    "v5",
    "fast-rng",
    "macro-diagnostics",
] }
dotenvy = "^0.15"
unicode-segmentation = "^1"
Enter fullscreen mode Exit fullscreen mode

Data Layer

Before starting developing the data layer I highly recommend reading the SeaORM docs.

SeaORM takes advantage of Cargo Workspaces so start by creating a new library called entities, so on your root project folder run the following command:

$ cargo new entities --lib
Enter fullscreen mode Exit fullscreen mode

Open the entities Cargo.toml and add the following packages:

[package]
name = "entities"
version = "0.1.0"
edition = "2021"
publish = false
authors = ["John Doe <john.doe@gmail.com>"]

[lib]
name = "entities"
path = "src/lib.rs"

[dependencies]
serde = { version = "^1", features = ["derive"] }
chrono = "^0.4"

[dependencies.sea-orm]
version = "^0.10"
features = ["sqlx-postgres", "runtime-actix-native-tls"]
Enter fullscreen mode Exit fullscreen mode

On the src folder start by creating a user model called user.rs and add it to the lib.rs file:

use chrono::Utc;
use sea_orm::{entity::prelude::*, ActiveValue};
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    #[sea_orm(column_type = "String(Some(250))", unique)]
    pub email: String,
    #[sea_orm(column_type = "String(Some(50))")]
    pub first_name: String,
    #[sea_orm(column_type = "String(Some(50))")]
    pub last_name: String,
    #[sea_orm(default_value = false)]
    pub confirmed: bool,
    #[sea_orm(default_value = false)]
    pub two_factor_enabled: bool,
    #[sea_orm(default_value = 1)]
    pub version: i16,
    #[sea_orm(column_type = "Text")]
    pub password: String,
    pub created_at: DateTime,
    pub updated_at: DateTime,
}

impl Model {
    pub fn get_full_name(&self) -> String {
        format!("{} {}", self.first_name, self.last_name)
    }
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {
    fn before_save(mut self, insert: bool) -> Result<Self, DbErr> {
        let current_time = Utc::now().naive_utc();
        self.updated_at = ActiveValue::Set(current_time);
        if insert {
            self.created_at = ActiveValue::Set(current_time);
        }
        Ok(self)
    }
}
Enter fullscreen mode Exit fullscreen mode

To add the user entity to the database, we need to create a migration file, start by installing the SeaORM cli:

$ cargo install sea-orm-cli
Enter fullscreen mode Exit fullscreen mode

After installing the cli run the following command to create the migration workspace:

$ sea-orm-cli migrate init -d migrations
Enter fullscreen mode Exit fullscreen mode

Update the Cargo.toml as follows:

[package]
name = "migrations"
version = "0.1.0"
edition = "2021"
publish = false
authors = ["John Doe <john.doe@gmail.com>"]

[lib]
name = "migrations"
path = "src/lib.rs"

[dependencies]
entities = { path = "../entities" }
async-std = { version = "^1", features = ["attributes", "tokio1"] }

[dependencies.sea-orm-migration]
version = "^0.10"
features = [ "runtime-actix-native-tls", "sqlx-postgres" ]
Enter fullscreen mode Exit fullscreen mode

Now change the name of the m20220101_000001_create_table.rs to today's date and add the name of the table m20221211_000001_create_users_table.rs. There add the following code:

use entities::user;
use sea_orm_migration::prelude::*;

pub struct Migration;

impl MigrationName for Migration {
    fn name(&self) -> &str {
        "m20221211_000001_create_users_table"
    }
}

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(user::Entity)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(user::Column::Id)
                            .integer()
                            .not_null()
                            .auto_increment()
                            .primary_key(),
                    )
                    .col(
                        ColumnDef::new(user::Column::Email)
                            .string()
                            .string_len(250)
                            .unique_key()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(user::Column::FirstName)
                            .string()
                            .string_len(50)
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(user::Column::LastName)
                            .string()
                            .string_len(50)
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(user::Column::Confirmed)
                            .boolean()
                            .default(false)
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(user::Column::TwoFactorEnabled)
                            .boolean()
                            .default(false)
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(user::Column::Version)
                            .small_integer()
                            .default(1)
                            .not_null(),
                    )
                    .col(ColumnDef::new(user::Column::Password).text().not_null())
                    .col(
                        ColumnDef::new(user::Column::CreatedAt)
                            .timestamp()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(user::Column::UpdatedAt)
                            .timestamp()
                            .not_null(),
                    )
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(user::Entity).to_owned())
            .await
    }
}

Enter fullscreen mode Exit fullscreen mode

Optionally add a new file called m20221211_000002_create_id_version_index.rs and add the following index in there:

use entities::user;
use sea_orm_migration::prelude::*;
pub struct Migration;

impl MigrationName for Migration {
    fn name(&self) -> &str {
        "m20221211_create_version_index"
    }
}

const IDX_NAME: &str = "user_id_version_idx";

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_index(
                Index::create()
                    .name(IDX_NAME)
                    .unique()
                    .table(user::Entity)
                    .col(user::Column::Id)
                    .col(user::Column::Version)
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_index(Index::drop().name(IDX_NAME).table(user::Entity).to_owned())
            .await
    }
}
Enter fullscreen mode Exit fullscreen mode

On the lib.rs file add the new two migrations:

pub use sea_orm_migration::prelude::*;

mod m20221113_000001_create_users_table;
mod m20221211_000002_create_id_version_index;

pub struct Migrator;

#[async_trait::async_trait]
impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            Box::new(m20221113_000001_create_users_table::Migration),
            Box::new(m20221211_000002_create_id_version_index::Migration),
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally on the main Cargo.toml file add the new workspaces:

# ...

[workspace]
members = [".", "entities", "migrations"]

[dependencies]
entities = { path = "entities" }
migrations = { path = "migrations" }
# ...
Enter fullscreen mode Exit fullscreen mode

Configuration

We need some configuration structs that will be added to our context data, mainly:

  • Database: a database struct with a connection to PostgreSQL;
  • Cache: a Cache struct with a connection to Redis;
  • Mailer: a Mailer struct with our SMTP protocol set up;
  • JWT: a Jwt struct with the secrets and times for our Json Web Tokens.

Lets start by adding a lib.rs file to our main src folder, then create a folder called config with a mod.rs file and the following files:

  • db.rs;
  • cache.rs;
  • jwt.rs;
  • mailer.rs.

So your mod.rs should look something like this:

pub mod cache;
pub mod db;
pub mod jwt;
pub mod mailer;

pub use cache::*;
pub use db::*;
pub use jwt::*;
pub use mailer::*;
Enter fullscreen mode Exit fullscreen mode

Database

We need the following to have access to a database connection:

use sea_orm::DatabaseConnection;

#[derive(Clone)]
pub struct Database {
    connection: DatabaseConnection,
}

impl Database {
    pub async fn new() -> Self {
        let con_str = std::env::var("DATABASE_URL").unwrap();
        let connection = sea_orm::Database::connect(con_str)
            .await
            .expect("Could not connect to database");

        Database { connection }
    }

    pub fn get_connection(&self) -> &DatabaseConnection {
        &self.connection
    }
}
Enter fullscreen mode Exit fullscreen mode

Cache

On the cache side we want to do the same as the database, to get a redis connection:

use redis::{aio::Connection, Client};
use std::env;

#[derive(Clone)]
pub struct Cache {
    client: Client,
}

impl Cache {
    pub fn new() -> Self {
        let url = env::var("REDIS_URL").unwrap();
        let client = Client::open(url).unwrap();

        Self { client }
    }

    pub async fn get_connection(&self) -> Result<Connection, String> {
        let con = self.client.get_tokio_connection().await;

        match con {
            Ok(con) => Ok(con),
            Err(err) => Err(err.to_string()),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Mailer

The mailer will be a SMTP provider and we will use lettre for this, however unlike the other connections this one will be private, and we will only expose the email functions we want to run:

use lettre::{
    transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message,
    Tokio1Executor,
};

#[derive(Debug, Clone)]
pub struct Mailer {
    email: String,
    front_end_url: String,
    mailer: AsyncSmtpTransport<Tokio1Executor>,
}

impl Mailer {
    pub fn new() -> Self {
        let host = env::var("EMAIL_HOST").unwrap();
        let email = env::var("EMAIL_USER").unwrap();
        let password = env::var("EMAIL_PASSWORD").unwrap();
        let port = env::var("EMAIL_PORT").unwrap().parse::<u16>().unwrap();
        let front_end_url = env::var("FRONT_END_URL").unwrap();
        let mailer = AsyncSmtpTransport::<Tokio1Executor>::relay(&host)
            .unwrap()
            .port(port)
            .credentials(Credentials::new(email.to_owned(), password))
            .build();

        Self {
            email,
            front_end_url,
            mailer,
        }
    }

    async fn send_email(&self, to: String, subject: String, body: String) -> Result<()> {
        let message = Message::builder()
            .from(self.email.parse().unwrap())
            .to(to.parse().unwrap())
            .subject(subject)
            .body(body);

        if let Ok(msg) = message {
            match self.mailer.send(msg).await {
                Err(_) => Err(Error::from("Error sending the email")),
                _ => Ok(()),
            }
        } else {
            Err(Error::from("Invalid subject or body"))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

From the mailer we need to be able to send three types of email:

  • Confirmation Email:
// ...

impl Mailer {
    // ...
    pub async fn send_confirmation_email(
        &self,
        email: &str,
        full_name: &str,
        jwt: &str,
    ) -> Result<()> {
        let link = format!("{}/confirmation/{}", self.front_end_url, &jwt);

        self.send_email(
            email.to_owned(),
            format!("Email confirmation, {}", full_name),
            format!(
                r#"
            <body>
              <p>Hello {},</p>
              <br />
              <p>Welcome to Your Company,</p>
              <p>
                Click
                <b>
                  <a href='{}' target='_blank'>here</a>
                </b>
                to activate your acount or go to this link:
                {}
              </p>
              <p><small>This link will expire in an hour.</small></p>
              <br />
              <p>Best regards,</p>
              <p>Your Company Team</p>
            </body>
          "#,
                full_name, &link, &link,
            ),
        )
        .await
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Access Email for 2F-Auth:
// ...

impl Mailer {
    // ...

    pub async fn send_access_email(&self, email: &str, full_name: &str, code: &str) -> Result<()> {
        self.send_email(
            email.to_owned(),
            format!("Your access code, {}", full_name),
            format!(
                r#"
                <body>
                    <p>Hello {},</p>
                    <br />
                    <p>Welcome to Your Company,</p>
                    <p>
                        Your access code is
                        <b>{}</b>
                    </p>
                    <p><small>This code will expire in 15 minutes.</small></p>
                    <br />
                    <p>Best regards,</p>
                    <p>Your Company Team</p>
                </body> 
            "#,
                full_name, code
            ),
        )
        .await
    }

}
Enter fullscreen mode Exit fullscreen mode
  • Password Rest Email:
// ...

impl Mailer {
    // ...


    pub async fn send_password_reset_email(
        &self,
        email: &str,
        full_name: &str,
        token: &str,
    ) -> Result<()> {
        let link = format!("{}/confirmation/{}", self.front_end_url, &token);

        self.send_email(
            email.to_owned(),
            format!("Email confirmation, {}", full_name),
            format!(
                r#"
                <body>
                    <p>Hello {},</p>
                    <br />
                    <p>Your password reset link:
                    <b><a href='{}' target='_blank'>here</a></b></p>
                    <p>Or go to this link: {}</p>
                    <p><small>This link will expire in 30 minutes.</small></p>
                    <br />
                    <p>Best regards,</p>
                    <p>Your Company Team</p>
                </body>
                "#,
                &full_name, &link, &link,
            ),
        )
        .await
    }
}
Enter fullscreen mode Exit fullscreen mode

JWT

JWT are composed of a secret or key and an expiration time (lifespan of the token):

#[derive(Clone)]
pub struct SingleJwt {
    pub secret: String,
    pub exp: i64,
}
Enter fullscreen mode Exit fullscreen mode

Note that since we are creating a microservice other services should be able to verify the access token, we do that by using the RS256 algorithm that accepts a public and private key:

#[derive(Clone)]
pub struct AccessJwt {
    pub private_key: String,
    pub public_key: String,
    pub exp: i64,
}
Enter fullscreen mode Exit fullscreen mode

To generate the public and private keys you can use this link.

The auth module need the JWTs for 4 operations:

  • Access: we use a 5 minute access token the authenticate the user;
  • Refresh: saved on a http-only cookie, this token has a lifespan of 7 days and has the sole porpous of refreshing the access token;
  • Confirmation: this token will be sent on an email when the user signs up to confirm the account;
  • Reset: for resetting lost passwords given an email.

I add an ID to add as the issuer so, putting it all together will give us something like this:

// ...

#[derive(Clone)]
pub struct Jwt {
    pub access: AccessJwt,
    pub reset: SingleJwt,
    pub confirmation: SingleJwt,
    pub refresh: SingleJwt,
    pub refresh_cookie: String,
    pub api_id: String,
}


impl Jwt {
    pub fn new() -> Self {
        let private_key = fs::read_to_string(Path::new("./keys/private.key")).unwrap();
        let public_key = fs::read_to_string(Path::new("./keys/public.key")).unwrap();
        let access_time = env::var("ACCESS_TIME").unwrap().parse::<i64>().unwrap();
        let reset_secret = env::var("RESET_SECRET").unwrap();
        let reset_time = env::var("RESET_TIME").unwrap().parse::<i64>().unwrap();
        let confirmation_secret = env::var("CONFIRMATION_SECRET").unwrap();
        let confirmation_time = env::var("CONFIRMATION_TIME")
            .unwrap()
            .parse::<i64>()
            .unwrap();
        let refresh_secret = env::var("REFRESH_SECRET").unwrap();
        let refresh_time = env::var("REFRESH_TIME").unwrap().parse::<i64>().unwrap();

        Self {
            access: AccessJwt {
                private_key,
                public_key,
                exp: access_time,
            },
            reset: SingleJwt {
                secret: reset_secret,
                exp: reset_time,
            },
            confirmation: SingleJwt {
                secret: confirmation_secret,
                exp: confirmation_time,
            },
            refresh: SingleJwt {
                secret: refresh_secret,
                exp: refresh_time,
            },
            refresh_cookie: env::var("REFRESH_COOKIE").unwrap(),
            api_id: env::var("API_ID").unwrap(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Modules

Module Structure

Personally I like to divide the modules in 6 parts:

  1. Models: the GraphQL Object Type models;
  2. DTOs: the GraphQL Input Types models;
  3. Guards: async-graphql query/mutation guards;
  4. Helpers: utility functions;
  5. Service: the module business logic;
  6. Resolver: the module queries and mutations.
├─── models
│      some_object.rs
│      mod.rs
├─── dtos
│      some_input.rs
│      mod.rs
├─── guards
│      some_guard.rs
│      mod.rs
├─── helpers
│      util_fn.rs
│      mod.rs
│ service.rs
│ resolver.rs
│ mod.rs
Enter fullscreen mode Exit fullscreen mode

Based on this structure do not forget to add every new file that you create to the mod.rs file, for example for the auth service helpers you would have the following mod.rs:

pub mod create_auth_tokens;
pub mod generate_two_factor_code;
pub mod jwt_operations;
pub mod password_hashing;
pub mod send_confirmation_email;

pub use create_auth_tokens::*;
pub use generate_two_factor_code::*;
pub use jwt_operations::*;
pub use password_hashing::*;
pub use send_confirmation_email::*;
Enter fullscreen mode Exit fullscreen mode

Service modules

This microservice will be divided into 3 modules:

  • Common: where most the common logic between modules resides;
  • Users: where users logic not related to authentication resides;
  • Auth: our main module where all the authentication logic resides.

Common Module

Models

Common Module has one GraphQL Object that is is common to all modules, the Message object:

use async_graphql::SimpleObject;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(SimpleObject, Serialize, Deserialize)]
pub struct Message {
    pub id: String,
    pub message: String,
}

impl Message {
    pub fn new(message: &str) -> Self {
        let id = Uuid::new_v4().to_string();

        Self {
            id,
            message: message.to_string(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Helpers

This module will have 3 helpers:

  • Password Validator;
  • Regexes;
  • Get Access User.

Password Validator:

Unlike other languages Rust only allows linear Regexes, so we will start by creating a custom password validator.

#[derive(Default)]
struct PasswordValidity {
    has_lowercase: bool,
    has_uppercase: bool,
    has_number: bool,
    has_symbol: bool,
}

impl PasswordValidity {
    fn new() -> Self {
        Self::default()
    }
}

pub fn password_validator(password: &str) -> bool {
    let mut validity = PasswordValidity::new();

    for char in password.chars() {
        if char.is_lowercase() {
            validity.has_lowercase = true;
        } else if char.is_uppercase() {
            validity.has_uppercase = true;
        } else if char.is_numeric() {
            validity.has_number = true;
        } else {
            validity.has_symbol = true;
        }
    }

    let mut passed: u16 = 0;

    if validity.has_number {
        passed += 1;
    }
    if validity.has_lowercase {
        passed += 1;
    }
    if validity.has_uppercase {
        passed += 1;
    }
    if validity.has_symbol {
        passed += 1;
    }

    return passed * 100 / 4 == 100;
}
Enter fullscreen mode Exit fullscreen mode

Regexes:

Still, we will need some linear time regexes:

  • Email Regex: to check if the user provided a valid email;
  • Name Regex: to check if names are made with letters, numbers, spaces and dots;
  • JWT Regex: to see if tokens are valid jwts;
  • Spacing Regexes: to format names with too many spaces.

Email Regex:

use regex::{Regex, RegexBuilder};

pub fn email_regex() -> Regex {
    Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]{2,}$").unwrap()
}
Enter fullscreen mode Exit fullscreen mode

Name Regex:

// ...

pub fn name_regex() -> Regex {
    RegexBuilder::new(r"(^[\p{L}0-9'\.\s]*$)")
        .unicode(true)
        .build()
        .unwrap()
}
Enter fullscreen mode Exit fullscreen mode

JWT Regex:

// ...

pub fn jwt_regex() -> Regex {
    Regex::new(r"^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$").unwrap()
}
Enter fullscreen mode Exit fullscreen mode

Spacing Regexes:

// ...

pub fn new_line_regex() -> Regex {
    Regex::new(r"\n").unwrap()
}

pub fn multi_spaces_regex() -> Regex {
    Regex::new(r"\s\s+").unwrap()
}
Enter fullscreen mode Exit fullscreen mode

Get Access User:

get_access_user is the helper we will use the most, it takes the context and returns an AccessToken struct that we will create latter on:

use async_graphql::{Context, Error, Result};

use crate::{
    auth::helpers::{decode_access_token, AccessToken},
    config::Jwt,
    gql_set_up::AuthTokens,
};

pub fn get_access_user(ctx: &Context<'_>) -> Result<AccessToken> {
    let tokens = ctx.data::<AuthTokens>()?;
    let access_token = tokens
        .access_token
        .as_ref()
        .ok_or(Error::new("Unauthorized"))?;
    let jwt = ctx.data::<Jwt>()?;

    match decode_access_token(access_token, &jwt.access.public_key) {
        Ok(user) => Ok(user),
        Err(_) => Err(Error::new("Unauthorized")),
    }
}
Enter fullscreen mode Exit fullscreen mode

Service

The service will be mostly composed of validation functionality and error handling, with the exception of a function for formatting names.

Since it is only a single function we will start by the name formatting function:

use unicode_segmentation::UnicodeSegmentation;

use super::helpers::{
    multi_spaces_regex, new_line_regex,
    password_validator::password_validator,
    regexes::{email_regex, jwt_regex, name_regex},
};

pub fn format_name(name: &str) -> String {
    let mut title = name.trim().to_lowercase();
    title = new_line_regex().replace_all(&title, " ").to_string();
    title = multi_spaces_regex().replace_all(&title, " ").to_string();
    let mut c = title.chars();

    match c.next() {
        None => title,
        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
    }
}
Enter fullscreen mode Exit fullscreen mode

Validators:

  • Validate Email;
  • Validate Name;
  • Validate Passwords;
  • Validate JWT.

Validate Email:

// ...

pub fn validate_email(email: &str) -> Result<(), String> {
    let len = email.graphemes(true).count();

    if len < 5 {
        return Err("Email needs to be at least 5 characters long".to_string());
    }

    if len > 200 {
        return Err("Email needs to be at most 200 characters long".to_string());
    }

    if !email_regex().is_match(email) {
        return Err("Invalid email".to_string());
    }

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Validate Name:

// ...

pub fn validate_name(name: &str) -> Result<(), String> {
    let len = name.graphemes(true).count();

    if len < 3 || len > 50 {
        return Err("Name needs to be between 3 and 50 characters.".to_string());
    }

    if !name_regex().is_match(name) {
        return Err("Invalid name".to_string());
    }

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Validate Passwords:

// ...

pub fn validate_passwords(password1: &str, password2: &str) -> Result<(), String> {
    if password1.is_empty() {
        return Err("Password is required".to_string());
    }

    if password2.is_empty() {
        return Err("Confirmation Password is required".to_string());
    }

    if password1 != password2 {
        return Err("Passwords do not match".to_string());
    }

    let len = password1.graphemes(true).count();

    if len < 8 || len > 40 {
        return Err("Password needs to be between 8 and 40 characters.".to_string());
    }

    if !password_validator(password1) {
        return Err("Password needs to have at least one lowercase letter, one uppercase letter, one number and one symbol.".to_string());
    }

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Validate JWT:

// ...

pub fn validate_jwt(jwt: &str) -> Result<(), String> {
    let len = jwt.chars().count();

    if len < 20 || len > 500 {
        return Err("JWT needs to be between 20 and 500 characters.".to_string());
    }

    if !jwt_regex().is_match(jwt) {
        return Err("Invalid JWT".to_string());
    }

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Error Handling:

We will use string vectors for error validation:

// ...

fn create_error_vec(validations: &[Result<(), String>]) -> Vec<&str> {
    let mut errors = Vec::<&str>::new();

    for error in validations {
        if let Err(e) = error {
            errors.push(e);
        }
    }

    errors
}

pub fn error_handler(validations: &[Result<(), String>]) -> Result<(), String> {
    let errors = create_error_vec(validations);
    if errors.is_empty() {
        Ok(())
    } else {
        Err(errors.join("\n"))
    }
}
Enter fullscreen mode Exit fullscreen mode

Resolver

The Common Module will only have the health_check query:

use async_graphql::{Object, Result};

#[derive(Default)]
pub struct CommonQuery;

#[Object]
impl CommonQuery {
    async fn health_check(&self) -> Result<String> {
        Ok("Ok".to_string())
    }
}
Enter fullscreen mode Exit fullscreen mode

Users Module

Models

This module has only one GraphQL Object, the user object:

use async_graphql::{ComplexObject, SimpleObject, ID};
use entities::user::Model;
use sea_orm::entity::prelude::DateTime;
use serde::{Deserialize, Serialize};

#[derive(SimpleObject, Serialize, Deserialize, Clone)]
#[graphql(complex)]
pub struct User {
    pub id: ID,
    pub email: String,
    pub first_name: String,
    pub last_name: String,
    #[graphql(skip)]
    pub created_at: DateTime,
    #[graphql(skip)]
    pub updated_at: DateTime,
}

impl From<Model> for User {
    fn from(model: Model) -> Self {
        Self {
            id: ID(model.id.to_string()),
            email: model.email,
            first_name: model.first_name,
            last_name: model.last_name,
            created_at: model.created_at,
            updated_at: model.updated_at,
        }
    }
}

#[ComplexObject]
impl User {
    async fn create_timestamp(&self) -> i64 {
        self.created_at.timestamp()
    }

    async fn updated_timestamp(&self) -> i64 {
        self.updated_at.timestamp()
    }
}
Enter fullscreen mode Exit fullscreen mode

Since there is no return type for DateTime we need to use a complex object and transform it to a Unix Timestamp.

Services

This service only has two operations, finding a user by its ID and deleting a user account.

User By Id:

use async_graphql::{Context, Error, Result};
use sea_orm::{EntityTrait, ModelTrait};

use entities::user::{Entity, Model};

use crate::{
    auth::{helpers::verify_password, service::logout},
    common::{helpers::get_access_user, models::Message},
    config::Database,
};

pub async fn user_by_id(db: &Database, id: i32) -> Result<Model> {
    Entity::find_by_id(id)
        .one(db.get_connection())
        .await?
        .ok_or(Error::new("User not found"))
}
Enter fullscreen mode Exit fullscreen mode

Delete Account:

// ...

pub async fn delete_account(ctx: &Context<'_>, password: String) -> Result<Message> {
    let user = get_access_user(ctx)?;
    let db = ctx.data::<Database>()?;
    let user = user_by_id(db, user.id).await?;
    verify_password(&password, &user.password)?;
    let res = user.delete(db.get_connection()).await?;

    if res.rows_affected == 0 {
        return Err(Error::new("Failed to delete account"));
    }

    logout(ctx)?;
    Ok(Message::new("Account deleted successfully"))
}
Enter fullscreen mode Exit fullscreen mode

The logout functions will be made in the auth service.

Resolver

Queries:

Users module will have two queries:

  • me: to query the current user;
  • find_user_by_id: so other microservices can query the users with Apollo Federation.

Me:

use async_graphql::{dataloader::DataLoader, Context, Error, Object, Result};

use super::{
    models::User,
    service::{delete_account, user_by_id},
};
use crate::{
    auth::guards::AuthGuard,
    common::{helpers::get_access_user, models::Message},
    config::Database,
    loaders::{users_loader::UserId, SeaOrmLoader},
};

#[derive(Default)]
pub struct UsersQuery;

#[Object]
impl UsersQuery {
    #[graphql(guard = "AuthGuard")]
    async fn me(&self, ctx: &Context<'_>) -> Result<User> {
        let user = get_access_user(ctx)?;
        let db = ctx.data::<Database>()?;
        let user = user_by_id(db, user.id).await?;
        Ok(User::from(user))
    }

    //...
}
Enter fullscreen mode Exit fullscreen mode

Find User By ID:

// ...

#[Object]
impl UsersQuery {
    //...

    #[graphql(entity)]
    async fn find_user_by_id(
        &self,
        ctx: &Context<'_>,
        #[graphql(validator(minimum = 1))] id: i32,
    ) -> Result<User> {
        ctx.data::<DataLoader<SeaOrmLoader>>()?
            .load_one(UserId(id))
            .await?
            .ok_or(Error::from("Not found"))
    }
}
Enter fullscreen mode Exit fullscreen mode

For relation with other services, as recomended by Apollo, we use a dataloder to load our relations.

Dataloaders

Dataloaders is not really a module, but more like a common util that all services can have, still it has its own folder loaders, inside the mod.rs file.

Start by creating a generic SeaORM Dataloader:

use crate::config::Database;

pub struct SeaOrmLoader {
    db: Database,
}

impl SeaOrmLoader {
    pub fn new(db: &Database) -> Self {
        Self { db: db.clone() }
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, start creating a new file called users_loaders.rs:

use async_graphql::{Error, Result};
use entities::user::{Column, Entity};
use std::collections::HashMap;

use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};

use crate::users::models::User;

#[derive(Hash, Eq, PartialEq, Clone, Debug)]
pub struct UserId(pub i32);

pub async fn load_users(
    connection: &DatabaseConnection,
    keys: &[UserId],
) -> Result<HashMap<UserId, User>> {
    let mut users_hash: HashMap<UserId, User> = HashMap::new();
    let users = Entity::find()
        .filter(Column::Id.is_in(keys.iter().map(|k| k.0).collect::<Vec<i32>>()))
        .all(connection)
        .await?;

    if users.len() != keys.len() {
        return Err(Error::from("User not found"));
    }

    for user in users {
        users_hash.insert(UserId(user.id), User::from(user));
    }

    Ok(users_hash)
}
Enter fullscreen mode Exit fullscreen mode

Finally import this file to the mod.rs and add it as Loader<T> trait to the SeaOrmLoader:

use std::collections::HashMap;

use async_graphql::dataloader::*;
use async_graphql::*;

pub mod users_loader;

// ...

#[async_trait::async_trait]
impl Loader<UserId> for SeaOrmLoader {
    type Value = User;
    type Error = async_graphql::Error;

    async fn load(&self, keys: &[UserId]) -> Result<HashMap<UserId, Self::Value>, Self::Error> {
        load_users(self.db.get_connection(), keys).await
    }
}
Enter fullscreen mode Exit fullscreen mode

Auth Module

Models

Auth module has 2 main GraphQL Objects:

  • AuthType: an object with the User Object and the access token;
  • LoginType: an union of an AuthType and a Message for 2F-Auth.

AuthType:

use async_graphql::SimpleObject;
use serde::{Deserialize, Serialize};

use crate::users::models::User;

#[derive(SimpleObject, Serialize, Deserialize)]
pub struct AuthType {
    pub user: User,
    pub access_token: String,
}

impl AuthType {
    pub fn new(access_token: String, user: User) -> Self {
        Self { user, access_token }
    }
}
Enter fullscreen mode Exit fullscreen mode

LoginType:

use async_graphql::Union;

use crate::common::models::Message;

use super::AuthType;

#[derive(Union)]
pub enum LoginType {
    Message(Message),
    Auth(AuthType),
}
Enter fullscreen mode Exit fullscreen mode

Guards

Since this is a simple authentication service, there is only an auth guard:

use async_graphql::{async_trait, Error, Guard, Result};

use crate::gql_set_up::AuthTokens;

pub struct AuthGuard;

#[async_trait::async_trait]
impl Guard for AuthGuard {
    async fn check(&self, ctx: &async_graphql::Context<'_>) -> Result<()> {
        let tokens = ctx.data::<AuthTokens>()?;

        if tokens.access_token.is_none() {
            return Err(Error::new("Unauthorized"));
        }

        Ok(())
    }
}
Enter fullscreen mode Exit fullscreen mode

DTOs

Unlike the other module, since the auth module is composed solely of mutations it has an Input Object for most mutations:

  • Register Input: for user sign up;
  • Login Input: for user sign in;
  • Confirm Login Input: for confirming the user login if 2F-Auth is enabled;
  • Change Password Input: for changing the user current password;
  • Reset Password Input: for reseting a forgotten password;
  • Change Email Input: for changing the user current email.

Register Input:

use async_graphql::{CustomValidator, InputObject};

use crate::common::service::{error_handler, validate_email, validate_name, validate_passwords};

#[derive(InputObject)]
pub struct RegisterInput {
    pub email: String,
    pub first_name: String,
    pub last_name: String,
    pub password1: String,
    pub password2: String,
}

pub struct RegisterValidator;

impl CustomValidator<RegisterInput> for RegisterValidator {
    fn check(&self, value: &RegisterInput) -> Result<(), String> {
        let validations = [
            validate_email(&value.email),
            validate_name(&value.first_name),
            validate_name(&value.last_name),
            validate_passwords(&value.password1, &value.password2),
        ];
        error_handler(&validations)
    }
}
Enter fullscreen mode Exit fullscreen mode

Login Input:

use async_graphql::InputObject;

#[derive(InputObject)]
pub struct LoginInput {
    #[graphql(validator(email, min_length = 5, max_length = 200))]
    pub email: String,
    #[graphql(validator(min_length = 1))]
    pub password: String,
}
Enter fullscreen mode Exit fullscreen mode

Confirm Login Input:

use async_graphql::InputObject;

#[derive(InputObject)]
pub struct ConfirmLoginInput {
    #[graphql(validator(email, min_length = 5, max_length = 200))]
    pub email: String,
    #[graphql(validator(min_length = 6, max_length = 6, regex = r"^[0-9]+$"))]
    pub code: String,
}
Enter fullscreen mode Exit fullscreen mode

Change Password Input:

use async_graphql::{CustomValidator, InputObject};

use crate::common::service::{error_handler, validate_passwords};

#[derive(InputObject)]
pub struct ChangePasswordInput {
    pub old_password: String,
    pub password1: String,
    pub password2: String,
}

pub struct ChangePasswordValidator;

impl CustomValidator<ChangePasswordInput> for ChangePasswordValidator {
    fn check(&self, value: &ChangePasswordInput) -> Result<(), String> {
        let validations = [validate_passwords(&value.password1, &value.password2)];
        error_handler(&validations)
    }
}
Enter fullscreen mode Exit fullscreen mode

Reset Password Input:

use async_graphql::{CustomValidator, InputObject};

use crate::common::service::{error_handler, validate_jwt, validate_passwords};

#[derive(InputObject)]
pub struct ResetPasswordInput {
    pub token: String,
    pub password1: String,
    pub password2: String,
}

pub struct ResetPasswordValidator;

impl CustomValidator<ResetPasswordInput> for ResetPasswordValidator {
    fn check(&self, value: &ResetPasswordInput) -> Result<(), String> {
        let validations = [
            validate_jwt(&value.token),
            validate_passwords(&value.password1, &value.password2),
        ];
        error_handler(&validations)
    }
}
Enter fullscreen mode Exit fullscreen mode

Change Email Input:

use async_graphql::InputObject;

#[derive(InputObject)]
pub struct ChangeEmailInput {
    #[graphql(validator(email, min_length = 5, max_length = 200))]
    pub new_email: String,
    #[graphql(validator(min_length = 1))]
    pub password: String,
}
Enter fullscreen mode Exit fullscreen mode

Helpers

The auth service will have 5 helpers:

  • JWT Operations;
  • Password Hashing;
  • Auth tokens creation;
  • Generating 2F-Auth codes;
  • Sending confirmation emails.

JWT Operations:

There will be two type of JWT operations, one for the tokens that are sent by email (apart from the refresh token) and one for the access token.

Generating Email Tokens:

use async_graphql::{Error, Result};
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};

use entities::user::Model;

#[derive(Debug, Serialize, Deserialize)]
pub struct EmailToken {
    pub id: i32,
    pub version: i16,
}

impl From<&Model> for EmailToken {
    fn from(model: &Model) -> Self {
        Self {
            id: model.id.to_owned(),
            version: model.version.to_owned(),
        }
    }
}

#[derive(Debug, Serialize, Deserialize)]
struct Claims {
    iss: String,
    sub: String,
    iat: i64,
    exp: i64,
    user: EmailToken,
}

impl Claims {
    fn create_token(
        user: &Model,
        secret: &str,
        exp: i64,
        iss: String,
        sub: String,
    ) -> Result<String> {
        let now = Utc::now();
        let claims = Claims {
            sub,
            iss,
            iat: now.timestamp(),
            exp: (now + Duration::seconds(exp)).timestamp(),
            user: EmailToken::from(user),
        };

        if let Ok(token) = encode(
            &Header::default(),
            &claims,
            &EncodingKey::from_secret(secret.as_bytes()),
        ) {
            Ok(token)
        } else {
            Err(Error::new("Could not create token"))
        }
    }

    fn decode_token(secret: &str, token: &str) -> Result<EmailToken> {
        let claims = decode::<Claims>(
            token,
            &DecodingKey::from_secret(secret.as_bytes()),
            &Validation::default(),
        );

        match claims {
            Ok(s) => Ok(s.claims.user),
            Err(_) => Err(Error::from("Invalid token")),
        }
    }
}

pub enum TokenType {
    Reset,
    Confirmation,
    Refresh,
}

pub fn create_token(
    token_type: TokenType,
    user: &Model,
    secret: &str,
    exp: i64,
    iss: &str,
) -> Result<String> {
    let sub = match token_type {
        TokenType::Reset => "reset".to_owned(),
        TokenType::Confirmation => "confirmation".to_owned(),
        TokenType::Refresh => "refresh".to_owned(),
    };

    Claims::create_token(user, secret, exp, iss.to_owned(), sub)
}

pub fn decode_token(token: &str, secret: &str) -> Result<EmailToken> {
    Claims::decode_token(secret, token)
}
Enter fullscreen mode Exit fullscreen mode

Generating Access Tokens:

// ...

#[derive(Debug, Serialize, Deserialize)]
pub struct AccessToken {
    pub id: i32,
}

impl From<&Model> for AccessToken {
    fn from(model: &Model) -> Self {
        Self {
            id: model.id.to_owned(),
        }
    }
}

#[derive(Debug, Serialize, Deserialize)]
struct AccessClaims {
    iss: String,
    sub: String,
    iat: i64,
    exp: i64,
    user: AccessToken,
}

impl AccessClaims {
    fn create_token(user: &Model, private_key: &str, exp: i64, iss: String) -> Result<String> {
        let now = Utc::now();
        let claims = AccessClaims {
            iss,
            sub: "access".to_owned(),
            iat: now.timestamp(),
            exp: (now + Duration::seconds(exp)).timestamp(),
            user: AccessToken::from(user),
        };
        let header = Header::new(Algorithm::RS256);
        let enconding_key = match EncodingKey::from_rsa_pem(private_key.as_bytes()) {
            Ok(key) => key,
            Err(_) => return Err(Error::from("Could not create token")),
        };

        if let Ok(token) = encode(&header, &claims, &enconding_key) {
            Ok(token)
        } else {
            Err(Error::new("Could not create token"))
        }
    }

    fn decode_token(public_key: &str, token: &str) -> Result<AccessToken> {
        let decoding_key = match DecodingKey::from_rsa_pem(public_key.as_bytes()) {
            Ok(key) => key,
            Err(_) => return Err(Error::from("Could not decode token")),
        };

        let claims =
            decode::<AccessClaims>(token, &decoding_key, &Validation::new(Algorithm::RS256));

        match claims {
            Ok(s) => Ok(s.claims.user),
            Err(_) => Err(Error::from("Invalid token")),
        }
    }
}

pub fn create_access_token(user: &Model, private_key: &str, exp: i64, iss: &str) -> Result<String> {
    AccessClaims::create_token(user, private_key, exp, iss.to_owned())
}

pub fn decode_access_token(token: &str, public_key: &str) -> Result<AccessToken> {
    AccessClaims::decode_token(public_key, token)
}
Enter fullscreen mode Exit fullscreen mode

Password Hashing:

use argon2::{
    password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
    Argon2, PasswordHash, PasswordVerifier,
};

pub fn hash_password(password: &str) -> Result<String, String> {
    let salt = SaltString::generate(&mut OsRng);
    let hash = Argon2::default().hash_password(password.as_bytes(), &salt);

    if hash.is_err() {
        return Err("Could not hash password, please try again".to_owned());
    }

    Ok(hash.unwrap().to_string())
}

pub fn verify_password(password: &str, str_hash: &str) -> Result<(), String> {
    let hash = PasswordHash::new(&str_hash).map_err(|e| e.to_string())?;
    Ok(Argon2::default()
        .verify_password(password.as_bytes(), &hash)
        .map_err(|_| "Invalid credentials".to_owned())?)
}
Enter fullscreen mode Exit fullscreen mode

Generate Two Factor Code:

use async_graphql::{Error, Result};
use bcrypt::{hash, verify};
use rand::Rng;

fn generate_code() -> String {
    const NUMERIC_SET: &[u8] = b"0123456789";
    const CODE_LEN: usize = 6;
    let mut rng = rand::thread_rng();
    (0..CODE_LEN)
        .map(|_| {
            let idx = rng.gen_range(0..NUMERIC_SET.len());
            NUMERIC_SET[idx] as char
        })
        .collect::<String>()
}

pub fn generate_two_factor_code() -> Result<(String, String)> {
    let code = generate_code();

    if let Ok(hash) = hash(&code, 5) {
        return Ok((code, hash));
    }

    Err(Error::new("Error generating two factor code"))
}

pub fn verify_two_factor_code(code: &str, hashed_code: &str) -> Result<()> {
    if let Ok(is_valid) = verify(code, hashed_code) {
        if is_valid {
            return Ok(());
        } else {
            return Err(Error::new("Invalid two factor code"));
        }
    }

    Err(Error::new("Error verifying two factor code"))
}
Enter fullscreen mode Exit fullscreen mode

Create Auth Tokens:

use crate::{config::Jwt, gql_set_up::Environment};

use super::{create_access_token, create_token, TokenType};
use actix_web::{cookie::time::Duration, cookie::Cookie, http::header::SET_COOKIE};
use async_graphql::{Context, Result};
use entities::user::Model;

pub fn create_auth_tokens(ctx: &Context<'_>, jwt: &Jwt, user: &Model) -> Result<String> {
    let refresh_token = create_token(
        TokenType::Refresh,
        user,
        &jwt.refresh.secret,
        jwt.refresh.exp,
        &jwt.api_id,
    )?;

    ctx.insert_http_header(
        SET_COOKIE,
        Cookie::build(jwt.refresh_cookie.to_owned(), refresh_token)
            .path("/api/graphql")
            .max_age(Duration::seconds(jwt.refresh.exp))
            .http_only(true)
            .secure(match ctx.data::<Environment>()? {
                Environment::Development => false,
                Environment::Production => true,
            })
            .finish()
            .to_string(),
    );
    create_access_token(user, &jwt.access.private_key, jwt.access.exp, &jwt.api_id)
}
Enter fullscreen mode Exit fullscreen mode

Send Confirmation Email:

use crate::{
    config::{Jwt, Mailer},
    gql_set_up::Environment,
};

use super::{create_token, TokenType};
use async_graphql::{Context, Result};
use entities::user::Model;

pub async fn send_confirmation_email(
    ctx: &Context<'_>,
    jwt: &Jwt,
    user: &Model,
) -> Result<Option<String>> {
    let confirmation_token = create_token(
        TokenType::Confirmation,
        user,
        &jwt.confirmation.secret,
        jwt.confirmation.exp,
        &jwt.api_id,
    )?;

    match ctx.data::<Environment>()? {
        Environment::Development => return Ok(Some(confirmation_token)),
        Environment::Production => {
            ctx.data::<Mailer>()?
                .send_confirmation_email(&user.email, &user.get_full_name(), &confirmation_token)
                .await?;
            return Ok(None);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Services

As the main module, the auth service will have the bulk of our business logic, with 10 main functions:

Register User:

use actix_web::{
    cookie::{time::Duration, Cookie},
    http::header::SET_COOKIE,
};
use async_graphql::{Context, Error, Result};
use generate_two_factor_code::verify_two_factor_code;
use redis::AsyncCommands;
use sea_orm::{
    ActiveModelTrait, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, Set,
};

use entities::user;

use crate::{
    common::{helpers::get_access_user::get_access_user, models::Message, service::format_name},
    config::{Cache, Database, Jwt, Mailer},
    gql_set_up::{AuthTokens, Environment},
    users::models::User,
};

use super::{
    dtos::{
        ChangeEmailInput, ChangePasswordInput, ConfirmLoginInput, LoginInput, RegisterInput,
        ResetPasswordInput,
    },
    helpers::{
        create_auth_tokens, create_token, decode_token, generate_two_factor_code, hash_password,
        send_confirmation_email, verify_password, TokenType,
    },
    models::{AuthType, LoginType},
};

/**
Register User (GraphQL Mutation)

Takes a validated register input and creates a new user, then sends a confirmation email.
 */
pub async fn register_user(ctx: &Context<'_>, input: RegisterInput) -> Result<Message> {
    let db = ctx.data::<Database>()?;
    let email = input.email.to_lowercase();
    let email_count = user::Entity::find()
        .filter(user::Column::Email.eq(email.to_owned()))
        .count(db.get_connection())
        .await?;

    if email_count > 0 {
        return Err(Error::from("Email already exists"));
    }

    let first_name = format_name(&input.first_name);
    let last_name = format_name(&input.last_name);
    let password_hash = hash_password(&input.password1)?;
    let user = user::ActiveModel {
        email: Set(email),
        first_name: Set(first_name),
        last_name: Set(last_name),
        password: Set(password_hash),
        ..Default::default()
    };
    let user = user.insert(db.get_connection()).await?;
    let jwt = ctx.data::<Jwt>()?;

    if let Some(code) = send_confirmation_email(ctx, &jwt, &user).await? {
        return Ok(Message::new(&code));
    }

    Ok(Message::new("User registered successfully"))
}
Enter fullscreen mode Exit fullscreen mode

Confirm User:

// ...

/**
Confirm User (GraphQL Mutation)

Takes the confirmation JWT and confirms the user.
 */
pub async fn confirm_user(ctx: &Context<'_>, token: String) -> Result<AuthType> {
    let jwt = ctx.data::<Jwt>()?;
    let user = decode_token(&token, &jwt.confirmation.secret)?;
    let db = ctx.data::<Database>()?;
    let user = user::Entity::find_by_id(user.id)
        .one(db.get_connection())
        .await?
        .ok_or(Error::from("User not found"))?;

    if user.confirmed {
        return Err(Error::from("User already confirmed"));
    }

    let mut user: user::ActiveModel = user.into();
    user.confirmed = Set(true);
    let user = user.update(db.get_connection()).await?;

    Ok(AuthType::new(
        create_auth_tokens(ctx, jwt, &user)?,
        User::from(user),
    ))
}
Enter fullscreen mode Exit fullscreen mode

Login User:

// ...

/**
Login User (GraphQL Mutation)

Takes a validated login input and if the user has two factor active sends a new login code to his email.
If not, creates a new auth tokens, saves the refresh-token in a http-only cookie and sends the access token
back to the front-end.
 */
pub async fn login_user(ctx: &Context<'_>, input: LoginInput) -> Result<LoginType> {
    let db = ctx.data::<Database>()?;
    let user = user::Entity::find()
        .filter(user::Column::Email.eq(input.email.to_lowercase()))
        .one(db.get_connection())
        .await?
        .ok_or(Error::from("Invalid credentials"))?;

    verify_password(&input.password, &user.password)?;
    let jwt = ctx.data::<Jwt>()?;

    if !user.confirmed {
        send_confirmation_email(ctx, &jwt, &user).await?;
        return Err(Error::from("User not confirmed"));
    }
    if user.two_factor_enabled {
        let (code, hash) = generate_two_factor_code()?;
        let mut cache_connection = ctx.data::<Cache>()?.get_connection().await?;
        cache_connection
            .set_ex(format!("2F_{}", user.id.to_string()), hash, 900)
            .await?;

        match ctx.data::<Environment>()? {
            Environment::Development => return Ok(LoginType::Message(Message::new(&code))),
            Environment::Production => {
                ctx.data::<Mailer>()?
                    .send_access_email(&user.email, &user.get_full_name(), &code)
                    .await?;
                return Ok(LoginType::Message(Message::new(
                    "Login code sent to your email",
                )));
            }
        }
    }

    Ok(LoginType::Auth(AuthType::new(
        create_auth_tokens(ctx, jwt, &user)?,
        User::from(user),
    )))
}
Enter fullscreen mode Exit fullscreen mode

Confirm Login:

// ...

/**
 Confirm Login (GraphQL Mutation)

 Takes the login code and if it matches the one in the cache, creates a new auth tokens and
 sends the access token to the front-end.
*/
pub async fn confirm_login(ctx: &Context<'_>, input: ConfirmLoginInput) -> Result<AuthType> {
    let db = ctx.data::<Database>()?;
    let user = user::Entity::find()
        .filter(user::Column::Email.eq(input.email.to_lowercase()))
        .one(db.get_connection())
        .await?
        .ok_or(Error::from("Invalid credentials"))?;

    let mut cache_con = ctx.data::<Cache>()?.get_connection().await?;
    let code: Option<String> = cache_con.get(format!("2F_{}", user.id.to_string())).await?;

    if let Some(hashed_code) = code {
        verify_two_factor_code(&input.code, &hashed_code)?;
    } else {
        return Err(Error::from("Code has expired"));
    }

    let jwt = ctx.data::<Jwt>()?;
    Ok(AuthType::new(
        create_auth_tokens(ctx, jwt, &user)?,
        User::from(user),
    ))
}
Enter fullscreen mode Exit fullscreen mode

Change Password:

// ...

/**
Change Password (GraphQL Mutation)

Takes a current password and a new password input and if the current password is valid, updates the user's password.
On updating the password, the user version is incremented so all old logins are logged out.
 */
pub async fn change_password(ctx: &Context<'_>, input: ChangePasswordInput) -> Result<AuthType> {
    let user = get_access_user(ctx)?;
    let db = ctx.data::<Database>()?;
    let user = user::Entity::find_by_id(user.id)
        .one(db.get_connection())
        .await?
        .ok_or(Error::from("User not found"))?;
    verify_password(&input.old_password, &user.password)?;
    let new_version = user.version + 1;
    let mut user: user::ActiveModel = user.into();
    user.password = Set(hash_password(&input.password1)?);
    user.version = Set(new_version);
    let user = user.update(db.get_connection()).await?;
    let jwt = ctx.data::<Jwt>()?;

    Ok(AuthType::new(
        create_auth_tokens(ctx, jwt, &user)?,
        User::from(user),
    ))
}
Enter fullscreen mode Exit fullscreen mode

Reset Password Email:

/**
Reset Password Email (GraphQL Mutation)

Sends a reset password email to a given email if a user is associated with that email.
 */
pub async fn reset_password_email(ctx: &Context<'_>, email: String) -> Result<Message> {
    let db = ctx.data::<Database>()?;
    let user = match user::Entity::find()
        .filter(user::Column::Email.eq(email.to_lowercase()))
        .one(db.get_connection())
        .await?
    {
        Some(user) => user,
        None => return Ok(Message::new("Reset password email sent")),
    };

    let jwt = ctx.data::<Jwt>()?;
    let token = create_token(
        TokenType::Reset,
        &user,
        &jwt.reset.secret,
        jwt.reset.exp,
        &jwt.api_id,
    )?;

    match ctx.data::<Environment>()? {
        Environment::Development => return Ok(Message::new(&token)),
        Environment::Production => {
            ctx.data::<Mailer>()?
                .send_password_reset_email(&user.email, &user.get_full_name(), &token)
                .await?;

            return Ok(Message::new("Reset password email sent"));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Reset Password:

// ...

/**
Reset Password (GraphQL Mutation)

Takes a reset password token and a new password input and if the token is valid, updates the user's password.
 */
pub async fn reset_password(ctx: &Context<'_>, input: ResetPasswordInput) -> Result<Message> {
    let jwt = ctx.data::<Jwt>()?;
    let user = decode_token(&input.token, &jwt.reset.secret)?;
    let db = ctx.data::<Database>()?;
    let user = user::Entity::find()
        .filter(
            Condition::all()
                .add(user::Column::Id.eq(user.id))
                .add(user::Column::Version.eq(user.version)),
        )
        .one(db.get_connection())
        .await?
        .ok_or(Error::from("Token is invalid"))?;

    if input.password1 != input.password2 {
        return Err(Error::from("Passwords do not match"));
    }

    let new_version = user.version + 1;
    let mut user: user::ActiveModel = user.into();
    user.password = Set(hash_password(&input.password1)?);
    user.version = Set(new_version);
    user.update(db.get_connection()).await?;

    Ok(Message::new("Password reset successfully"))
}
Enter fullscreen mode Exit fullscreen mode

Change Email:

// ...

/**
Change Email (GraphQL Mutation)

Takes a current password and a new email input and if the current password is valid, updates the user's email.
On updating the email, the user version is incremented so all old logins are logged out.
 */
pub async fn change_email(ctx: &Context<'_>, input: ChangeEmailInput) -> Result<AuthType> {
    let email = input.new_email.to_lowercase();
    let db = ctx.data::<Database>()?;
    let user_count = user::Entity::find()
        .filter(user::Column::Email.eq(email.to_owned()))
        .count(db.get_connection())
        .await?;

    if user_count > 0 {
        return Err(Error::from("Email already in use"));
    }

    let user = get_access_user(ctx)?;
    let user = user::Entity::find_by_id(user.id)
        .one(db.get_connection())
        .await?
        .ok_or(Error::from("User not found"))?;
    let new_version = user.version + 1;
    let mut user: user::ActiveModel = user.into();
    user.email = Set(email);
    user.version = Set(new_version);
    let user = user.update(db.get_connection()).await?;
    let jwt = ctx.data::<Jwt>()?;

    Ok(AuthType::new(
        create_auth_tokens(ctx, jwt, &user)?,
        User::from(user),
    ))
}
Enter fullscreen mode Exit fullscreen mode

Log Out:

// ...

fn remove_refresh_cookie(ctx: &Context<'_>, jwt: &Jwt) {
    let mut cookie = Cookie::build(jwt.refresh_cookie.to_owned(), "".to_owned())
        .path("/api/graphql")
        .max_age(Duration::seconds(jwt.refresh.exp))
        .http_only(true)
        .finish();
    cookie.make_removal();
    ctx.insert_http_header(SET_COOKIE, cookie.to_string());
}

/**
Log Out (GraphQL Mutation)

Invalidates the refresh token, so the user becomes log out.
 */
pub fn logout(ctx: &Context<'_>) -> Result<Message> {
    let jwt = ctx.data::<Jwt>()?;
    remove_refresh_cookie(ctx, jwt);
    Ok(Message::new("Logged out successfully"))
}
Enter fullscreen mode Exit fullscreen mode

Refresh Access:

// ...

/**
Refresh Access (GraphQL Mutation)

Takes a refresh token and if the token is valid, returns a new access token inside an AuthType.
 */
pub async fn refresh_access(ctx: &Context<'_>) -> Result<AuthType> {
    let jwt = ctx.data::<Jwt>()?;
    let tokens = ctx.data::<AuthTokens>()?;
    let refresh_token = tokens
        .refresh_token
        .as_ref()
        .ok_or(Error::from("Unauthorized"))?;
    let auth_user = decode_token(refresh_token, &jwt.refresh.secret)?;
    let db = ctx.data::<Database>()?;
    let user = user::Entity::find()
        .filter(
            Condition::all()
                .add(user::Column::Id.eq(auth_user.id))
                .add(user::Column::Version.eq(auth_user.version)),
        )
        .one(db.get_connection())
        .await?;

    if let Some(user) = user {
        Ok(AuthType::new(
            create_auth_tokens(ctx, jwt, &user)?,
            User::from(user),
        ))
    } else {
        remove_refresh_cookie(ctx, jwt);
        Err(Error::from("Unauthorized"))
    }
}
Enter fullscreen mode Exit fullscreen mode

Resolver

The auth module will only have mutations and they will just return the service functions:

use async_graphql::{Context, Object, Result};

use crate::common::models::Message;

use super::{
    dtos::{
        ChangeEmailInput, ChangePasswordInput, ChangePasswordValidator, ConfirmLoginInput,
        LoginInput, RegisterInput, RegisterValidator, ResetPasswordInput, ResetPasswordValidator,
    },
    guards::AuthGuard,
    models::{AuthType, LoginType},
    service::{
        change_email, change_password, confirm_login, confirm_user, login_user, logout,
        refresh_access, register_user, reset_password, reset_password_email,
    },
};

#[derive(Default)]
pub struct AuthMutation;

#[Object]
impl AuthMutation {
    async fn register(
        &self,
        ctx: &Context<'_>,
        #[graphql(validator(custom = "RegisterValidator"))] input: RegisterInput,
    ) -> Result<Message> {
        register_user(ctx, input).await
    }

    async fn confirm_account(
        &self,
        ctx: &Context<'_>,
        #[graphql(validator(
            regex = r"^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$",
            min_length = 20
        ))]
        token: String,
    ) -> Result<AuthType> {
        confirm_user(ctx, token).await
    }

    async fn login(&self, ctx: &Context<'_>, input: LoginInput) -> Result<LoginType> {
        login_user(ctx, input).await
    }

    async fn confirm_login(&self, ctx: &Context<'_>, input: ConfirmLoginInput) -> Result<AuthType> {
        confirm_login(ctx, input).await
    }

    async fn reset_password_email(
        &self,
        ctx: &Context<'_>,
        #[graphql(validator(email, min_length = 5, max_length = 200))] email: String,
    ) -> Result<Message> {
        reset_password_email(ctx, email).await
    }

    async fn reset_password(
        &self,
        ctx: &Context<'_>,
        #[graphql(validator(custom = "ResetPasswordValidator"))] input: ResetPasswordInput,
    ) -> Result<Message> {
        reset_password(ctx, input).await
    }

    #[graphql(guard = "AuthGuard")]
    async fn change_password(
        &self,
        ctx: &Context<'_>,
        #[graphql(validator(custom = "ChangePasswordValidator"))] input: ChangePasswordInput,
    ) -> Result<AuthType> {
        change_password(ctx, input).await
    }

    #[graphql(guard = "AuthGuard")]
    async fn change_email(&self, ctx: &Context<'_>, input: ChangeEmailInput) -> Result<AuthType> {
        change_email(ctx, input).await
    }

    #[graphql(guard = "AuthGuard")]
    async fn logout(&self, ctx: &Context<'_>) -> Result<Message> {
        logout(ctx)
    }

    async fn refresh_access(&self, ctx: &Context<'_>) -> Result<AuthType> {
        refresh_access(ctx).await
    }
}
Enter fullscreen mode Exit fullscreen mode

GraphQL Set Up

Start by creating a Environment enum for the types of envs we can have, production and development:

use actix_web::{cookie::Cookie, http::header::HeaderMap, web, HttpRequest, HttpResponse, Result};
use async_graphql::{
    dataloader::DataLoader,
    http::{playground_source, GraphQLPlaygroundConfig},
    EmptySubscription, MergedObject, Schema,
};
use async_graphql_actix_web::{GraphQLRequest, GraphQLResponse};

use crate::{
    auth::resolver::AuthMutation,
    common::resolver::CommonQuery,
    config::{Cache, Database, Jwt, Mailer},
    loaders::SeaOrmLoader,
    users::resolver::{UsersMutation, UsersQuery},
};

#[derive(Clone)]
pub enum Environment {
    Development,
    Production,
}
Enter fullscreen mode Exit fullscreen mode

To create the schema we need the Mutation and Query Roots:

// ...

#[derive(MergedObject, Default)]
pub struct MutationRoot(UsersMutation, AuthMutation);

#[derive(MergedObject, Default)]
pub struct QueryRoot(CommonQuery, UsersQuery);
Enter fullscreen mode Exit fullscreen mode

To have access to the JWTs on our queries and mutatation we need to pass the authentication JWTs to the schema, therefore lets add 2 helper functions:

  • Get Access Token from Headers:
// ...

fn get_access_token_from_headers(headers: &HeaderMap) -> Option<String> {
    let auth_header = match headers.get("Authorization") {
        Some(ah) => ah,
        None => return None,
    };
    let auth_header = match auth_header.to_str() {
        Ok(ah) => ah,
        Err(_) => return None,
    };

    if auth_header.is_empty() || !auth_header.starts_with("Bearer ") {
        return None;
    }

    let token = match auth_header.split_whitespace().last() {
        Some(t) => t,
        None => return None,
    };

    if token.is_empty() {
        return None;
    }

    Some(token.to_string())
}
Enter fullscreen mode Exit fullscreen mode
  • Get Refresh Token from Cookie:
// ...

fn get_refresh_token_from_cookie(cookie: Option<Cookie>) -> Option<String> {
    if let Some(cookie) = cookie {
        if cookie.value().is_empty() {
            return None;
        }

        Some(cookie.value().to_string())
    } else {
        None
    }
}
Enter fullscreen mode Exit fullscreen mode

You have already seen the AuthToken struct on the auth helpers, here is were we create it:

// ...

pub struct AuthTokens {
    pub access_token: Option<String>,
    pub refresh_token: Option<String>,
}

impl AuthTokens {
    pub fn new(request: &HttpRequest) -> Self {
        Self {
            access_token: get_access_token_from_headers(request.headers()),
            refresh_token: get_refresh_token_from_cookie(request.cookie("refresh_token")),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This struct needs to be added to the GraphQL Index Route:

pub async fn gql_index(
    schema: web::Data<Schema<QueryRoot, MutationRoot, EmptySubscription>>,
    req: HttpRequest,
    gql_req: GraphQLRequest,
) -> GraphQLResponse {
    schema
        .execute(gql_req.into_inner().data(AuthTokens::new(&req)))
        .await
        .into()
}
Enter fullscreen mode Exit fullscreen mode

To finalize just create a function for building the GraphQL Schema:

pub fn build_schema(
    cache: &Cache,
    db: &Database,
    jwt: &Jwt,
    mailer: &Mailer,
    environment: &str,
) -> Schema<QueryRoot, MutationRoot, EmptySubscription> {
    Schema::build(
        QueryRoot::default(),
        MutationRoot::default(),
        EmptySubscription,
    )
    .data(DataLoader::new(SeaOrmLoader::new(db), tokio::task::spawn))
    .data(cache.to_owned())
    .data(db.to_owned())
    .data(jwt.to_owned())
    .data(mailer.to_owned())
    .data(match environment {
        "production" => Environment::Production,
        _ => Environment::Development,
    })
    .enable_federation()
    .finish()
}
Enter fullscreen mode Exit fullscreen mode

Optionally you can add the GraphQL Playground as well:

pub async fn gql_index_playground() -> Result<HttpResponse> {
    let source = playground_source(GraphQLPlaygroundConfig::new("/api/graphql"));
    Ok(HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(source))
}
Enter fullscreen mode Exit fullscreen mode

App Set Up

Create an app.rs file add ad the configure_app functions with the schema, GraphQL index route and optinally the GraphQL Playground route:

use actix_web::{guard, web};

use crate::{
    config::{Cache, Database, Jwt, Mailer},
    gql_set_up::{build_schema, gql_index, gql_index_playground},
};

pub fn configure_app(
    cache: &Cache,
    db: &Database,
    jwt: &Jwt,
    mailer: &Mailer,
    environment: &str,
) -> impl Fn(&mut web::ServiceConfig) {
    let schema = build_schema(cache, db, jwt, mailer, environment);
    move |cfg: &mut web::ServiceConfig| {
        cfg.app_data(web::Data::new(schema.clone()))
            .service(
                web::resource("/api/graphql")
                    .guard(guard::Post())
                    .to(gql_index),
            )
            .service(
                web::resource("/api/graphql")
                    .guard(guard::Get())
                    .to(gql_index_playground),
            );
    }
}
Enter fullscreen mode Exit fullscreen mode

Wrap Up

Now to start using our microservice add the Actix-Web HttpServer to our main function:

use actix_web::{App, HttpServer};
use dotenvy::dotenv;
use std::env;

use graphql_local_oauth::{
    app::configure_app,
    config::{Cache, Database, Jwt, Mailer},
};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    let cache = Cache::new();
    let db = Database::new().await;
    let jwt = Jwt::new();
    let mailer = Mailer::new();
    let port = env::var("PORT").unwrap().parse::<u16>().unwrap();
    let env_type = env::var("ENV_TYPE").unwrap();
    let env_copy = env_type.clone();

    HttpServer::new(move || {
        App::new().configure(configure_app(&cache, &db, &jwt, &mailer, &env_type))
    })
    .bind((
        match env_copy.as_str() {
            "production" => "0.0.0.0",
            _ => "127.0.0.1",
        },
        port,
    ))?
    .run()
    .await
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

A complete version of this code can be found in this repository.

About the Author

Hey there my name is Afonso Barracha, I am a Econometrician made back-end developer that has a passion for GraphQL.

I try to do post once a week here on Dev about Back-End APIs and related topics, though I have been away do to personal reasons the past few weeks.

If you do not want to lose any of my posts follow me here on dev, or on LinkedIn.

Top comments (0)