Afonso Barracha
Posted on December 16, 2022
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
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"
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
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"]
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)
}
}
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
After installing the cli run the following command to create the migration workspace:
$ sea-orm-cli migrate init -d migrations
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" ]
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
}
}
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
}
}
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),
]
}
}
Finally on the main Cargo.toml
file add the new workspaces:
# ...
[workspace]
members = [".", "entities", "migrations"]
[dependencies]
entities = { path = "entities" }
migrations = { path = "migrations" }
# ...
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::*;
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
}
}
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()),
}
}
}
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"))
}
}
}
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
}
}
- 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
}
}
- 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
}
}
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,
}
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,
}
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(),
}
}
}
Modules
Module Structure
Personally I like to divide the modules in 6 parts:
- Models: the GraphQL Object Type models;
- DTOs: the GraphQL Input Types models;
- Guards: async-graphql query/mutation guards;
- Helpers: utility functions;
- Service: the module business logic;
- 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
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::*;
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(),
}
}
}
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;
}
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()
}
Name Regex:
// ...
pub fn name_regex() -> Regex {
RegexBuilder::new(r"(^[\p{L}0-9'\.\s]*$)")
.unicode(true)
.build()
.unwrap()
}
JWT Regex:
// ...
pub fn jwt_regex() -> Regex {
Regex::new(r"^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$").unwrap()
}
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()
}
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")),
}
}
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(),
}
}
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(())
}
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(())
}
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(())
}
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(())
}
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"))
}
}
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())
}
}
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()
}
}
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"))
}
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"))
}
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))
}
//...
}
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"))
}
}
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() }
}
}
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)
}
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
}
}
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 aMessage
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 }
}
}
LoginType:
use async_graphql::Union;
use crate::common::models::Message;
use super::AuthType;
#[derive(Union)]
pub enum LoginType {
Message(Message),
Auth(AuthType),
}
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(())
}
}
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)
}
}
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,
}
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,
}
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)
}
}
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)
}
}
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,
}
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)
}
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)
}
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())?)
}
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"))
}
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)
}
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);
}
}
}
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"))
}
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),
))
}
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),
)))
}
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),
))
}
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),
))
}
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"));
}
}
}
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"))
}
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),
))
}
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"))
}
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"))
}
}
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
}
}
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,
}
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);
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())
}
- 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
}
}
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")),
}
}
}
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()
}
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()
}
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))
}
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),
);
}
}
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
}
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.
Posted on December 16, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.