Implement authentication

Authentication is vital to modern server apps. Teo simplifies the implementation of authentication. Authentication includes handling user signing in sessions, generate and validate user's API tokens.

Setup the project

To setup a project, install the dependencies and do programming language specific setups. In the first article in the series, we explained what each step does in this process. In this tutorial, we just include the command for pasting.

mkdir hello-teo-authentication
cd hello-teo-authentication
cargo install teo

Password

Let's create a simple password authentication. Paste this into a new file named schema.teo.

schema.teo
connector {
  provider: .sqlite,
  url: "sqlite:./database.sqlite"
}
 
server {
  bind: ("0.0.0.0", 5052)
}
 
@identity.tokenIssuer($identity.jwt(expired: 3600 * 24 * 365))
@identity.jwtSecret(ENV["JWT_SECRET"]!)
model User {
  @id @autoIncrement @readonly
  id: Int
  @unique @onSet($if($presents, $isEmail)) @identity.id
  email: String
  @writeonly @onSet($presents.bcrypt.salt)
  @identity.checker($get(.value).presents.bcrypt.verify($self.get(.password).presents))
  password: String
 
  include handler identity.signIn
  include handler identity.identity
}
 
middlewares [identity.identityFromJwt(secret: ENV["JWT_SECRET"]!)]

Create a dot env file in the same directory.

.env
JWT_SECRET=my_top_secret

Let's explain the newly introduced decorators and pipeline items one by one.

  • The authentication functionalities reside in Teo's std.identity namespace CONCEPT.
  • @identity.id specifies which field is used to fetch the user
  • @identity.checker specifies which field is used to validate credentials and how to validate
  • @bcrypt.salt performs a Bcrypt salting transform
  • @bcrypt.verify verifies the input value against the stored one
  • @writeonly hides the field value from the output
  • @identity.tokenIssuer specifies which type of token it generates
  • $identity.jwt performs the JWT token generation
  • identity.identityFromJwt middleware decodes the JWT token and set the identity to the request
  • identity.signIn is the template which defines the signIn handler
  • identity.identity is the template which fetches the user information from the token in the header

Let's start the server and perform a signIn request.

cargo teo serve

Send this JSON input to /User/create to create a user.

{
  "create": {
    "email": "01@gmail.com",
    "password": "Aa123456"
  }
}
Hide HTTP response
{
  "data": {
    "id": 1,
    "email": "01@gmail.com"
  }
}

Send this JSON input to /User/signIn.

{
  "credentials": {
    "email": "01@gmail.com",
    "password": "Aa123456"
  }
}
Hide HTTP response
{
  "data": {
    "id": 1,
    "email": "01@gmail.com"
  },
  "meta": {
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6eyJpZCI6MX0sIm1vZGVsIjpbIlVzZXIiXSwiZXhwIjoxNzQyMTI0NDk2fQ.x2DSIpdnUeJtsUOGQsHlGksr29aF-CWog6X5LILxsOc"
  }
}

We've create a token from this user. If you intentionally type the password wrongly, an error response is returned.

Companion validations

Practically only something like username and pasword are not enough. A lot of websites and apps integrates some third party image authentications to prevent non-human access. As a framework, Teo doesn't integrate with any service providers. Instead, it's easy to integrate any third-party service or identity with Teo.

Replace the content of schema.teo with this.

schema.teo
connector {
  provider: .sqlite,
  url: "sqlite:./database.sqlite"
}
 
server {
  bind: ("0.0.0.0", 5052)
}
 
@identity.tokenIssuer($identity.jwt(expired: 3600 * 24 * 365))
@identity.jwtSecret(ENV["JWT_SECRET"]!)
model User {
  @id @autoIncrement @readonly
  id: Int
  @unique @onSet($if($presents, $isEmail)) @identity.id
  email: String
  @writeonly @onSet($presents.bcrypt.salt)
  @identity.checker(
    $do($get(.value).presents.bcrypt.verify($self.get(.password).presents))
    .do($get(.companions).presents.get(.imageAuthToken).presents))
  password: String
  @virtual @writeonly @identity.companion
  imageAuthToken: String?
 
  include handler identity.signIn
  include handler identity.identity
}
 
middlewares [identity.identityFromJwt(secret: ENV["JWT_SECRET"]!)]

We created a @identity.companion field which is @virtual. A virtual field isn't stored into the database. Companion values are present when checking and validating against the checker value. In this simple example, we just ensure the image auth token value exists.

Restart the server and send this JSON input to /User/signIn.

{
  "credentials": {
    "email": "01@gmail.com",
    "password": "Aa123456",
    "imageAuthToken": "anytoken"
  }
}
Hide HTTP response
{
  "data": {
    "id": 1,
    "email": "01@gmail.com"
  },
  "meta": {
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6eyJpZCI6MX0sIm1vZGVsIjpbIlVzZXIiXSwiZXhwIjoxNzQyMTI2MDIwfQ.7e3gXp5zA_h-Yk4ClUhjIIZLL4sLXAIFpE3CDzL_gzs"
  }
}

Without the image auth token, this signIn request would fail.

Custom expiration interval

The token expiration interval can be dynamic instead of static. Let's try an example of frontend passed expiration interval. Replace the content of schema.teo with this.

schema.teo
connector {
  provider: .sqlite,
  url: "sqlite:./database.sqlite"
}
 
server {
  bind: ("0.0.0.0", 5052)
}
 
@identity.tokenIssuer($identity.jwt(expired: $get(.expired).presents))
@identity.jwtSecret(ENV["JWT_SECRET"]!)
model User {
  @id @autoIncrement @readonly
  id: Int
  @unique @onSet($if($presents, $isEmail)) @identity.id
  email: String
  @writeonly @onSet($presents.bcrypt.salt)
  @identity.checker(
    $do($get(.value).presents.bcrypt.verify($self.get(.password).presents))
    .do($get(.companions).presents.get(.imageAuthToken).presents))
  password: String
  @virtual @writeonly @identity.companion
  imageAuthToken: String?
  @virtual @writeonly @identity.companion
  expired: Int64?
 
  include handler identity.signIn
  include handler identity.identity
}
 
middlewares [identity.identityFromJwt(secret: ENV["JWT_SECRET"]!)]

Starts the server again and send this to /User/signIn.

{
  "credentials": {
    "email": "01@gmail.com",
    "password": "Aa123456",
    "imageAuthToken": "anytoken",
    "expired": 2
  }
}
Hide HTTP response
{
  "data": {
    "id": 1,
    "email": "01@gmail.com"
  },
  "meta": {
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6eyJpZCI6MX0sIm1vZGVsIjpbIlVzZXIiXSwiZXhwIjoxNzEwNTk4MjQ5fQ.Oz9O2rB-usonrdfzt8q75vr0biOf_C6Y3JIaf5O3MAE"
  }
}

Since this token is valid for 2 seconds, just paste the token to the header and send this empty JSON input to /User/identity.

Headers:

  • Authorization: Bearer #your token#
{ }
Hide HTTP response
{
  "error": {
    "type": "Unauthorized",
    "message": "token expired"
  }
}

Blocked account

Practically accounts can be blocked from signing in. Implement account blocking is quite easy. Just tell us what does it mean by invalid account.

Replace the content of schema.teo with this.

schema.teo
connector {
  provider: .sqlite,
  url: "sqlite:./database.sqlite"
}
 
server {
  bind: ("0.0.0.0", 5052)
}
 
@identity.tokenIssuer($identity.jwt(expired: $get(.expired).presents))
@identity.jwtSecret(ENV["JWT_SECRET"]!)
@identity.validateAccount($get(.enabled).presents.eq(true))
model User {
  @id @autoIncrement @readonly
  id: Int
  @unique @onSet($if($presents, $isEmail)) @identity.id
  email: String
  @writeonly @onSet($presents.bcrypt.salt)
  @identity.checker(
    $do($get(.value).presents.bcrypt.verify($self.get(.password).presents))
    .do($get(.companions).presents.get(.imageAuthToken).presents))
  password: String
  @virtual @writeonly @identity.companion
  imageAuthToken: String?
  @virtual @writeonly @identity.companion
  expired: Int64?
  @migration(default: true)
  enabled: Bool
 
  include handler identity.signIn
  include handler identity.identity
}
 
middlewares [identity.identityFromJwt(secret: ENV["JWT_SECRET"]!)]

Let's disable the previously created account. Send this JSON input to /User/update.

{
  "where": {
    "id": 1
  },
  "update": {
	"enabled": false
  }
}
Hide HTTP response
{
  "data": {
    "id": 1,
    "email": "01@gmail.com",
    "enabled": false
  }
}

Now the signIn handler doesn't work for him and token authentication always failed.

Third-party integration

Teo provides an easy way for developers to integrate with third party identity services such as signing in with Google, Facebook. For China developers, this may be something like signing in with WeChat.

Update the the content of schema.teo with this.

schema.teo
connector {
  provider: .sqlite,
  url: "sqlite:./database.sqlite"
}
 
server {
  bind: ("0.0.0.0", 5052)
}
 
@identity.tokenIssuer($identity.jwt(expired: $get(.expired).presents))
@identity.jwtSecret(ENV["JWT_SECRET"]!)
@identity.validateAccount(
  $message($get(.enabled).presents.eq(true), "this account is blocked"))
model User {
  @id @autoIncrement @readonly
  id: Int
  @unique @onSet($if($presents, $isEmail)) @identity.id
  email: String
  @writeonly @onSet($presents.bcrypt.salt)
  @identity.checker(
    $do($get(.value).presents.bcrypt.verify($self.get(.password).presents))
    .do($get(.companions).presents.get(.imageAuthToken).presents))
  password: String
  @virtual @writeonly @identity.companion
  imageAuthToken: String?
  @virtual @writeonly @identity.companion
  expired: Int64?
  @migration(default: true) @default(true)
  enabled: Bool
  @identity.id @unique
  thirdPartyId: String?
  @virtual @writeonly @identity.checker($get(.value).presents.valid)
  thirdPartyToken: String?
 
  include handler identity.signIn
  include handler identity.identity
}
 
middlewares [identity.identityFromJwt(secret: ENV["JWT_SECRET"]!)]

Restart the server and let's create a new account with third party account binded by sending this input to /User/create.

{
  "create": {
    "email": "02@gmail.com",
    "password": "Aa123456",
    "thirdPartyId": "myFacebookId",
    "thirdPartyToken": "myThirdPartyToken"
  }
}
Hide HTTP response
{
  "data": {
    "id": 2,
    "email": "02@gmail.com",
    "enabled": true,
    "thirdPartyId": "myFacebookId"
  }
}

Now try signing in with the third party id and token. Send this input to /User/signIn.

{
  "credentials": {
    "thirdPartyId": "myFacebookId",
    "thirdPartyToken": "myFacebookToken",
    "expired": 99999999999
  }
}
Hide HTTP response
{
  "data": {
    "id": 2,
    "email": "02@gmail.com",
    "enabled": true,
    "thirdPartyId": "myFacebookId"
  },
  "meta": {
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6eyJpZCI6Mn0sIm1vZGVsIjpbIlVzZXIiXSwiZXhwIjoxMDE3MTA2MDMwOTJ9.LcOzp8DFToXDQEBtu8jMtnQp-BndAazsTdBT2i4Mi3U"
  }
}

For the sake of simplicity, let's just use $valid to make every third party token valid. In the next section, we'll demonstrate how to create a custom pipeline item to validate user's credential input.

Phone number and auth code

Let's add some complexity by introducing phone number and auth code. Replace the content of schema.teo with this.

schema.teo
connector {
  provider: .sqlite,
  url: "sqlite:./database.sqlite"
}
 
server {
  bind: ("0.0.0.0", 5052)
}
 
entity {
  provider: .rust,
  dest: "./src/entities"
}
 
declare pipeline item validateAuthCode<T>: T -> String
 
@identity.tokenIssuer($identity.jwt(expired: $get(.expired).presents))
@identity.jwtSecret(ENV["JWT_SECRET"]!)
@identity.validateAccount(
  $message($get(.enabled).presents.eq(true), "this account is blocked"))
model User {
  @id @autoIncrement @readonly
  id: Int
  @unique @onSet($if($presents, $isEmail)) @identity.id
  @presentWithout(.phoneNumber)
  email: String?
  @writeonly @onSet($presents.bcrypt.salt)
  @identity.checker(
    $do($get(.value).presents.bcrypt.verify($self.get(.password).presents))
    .do($get(.companions).presents.get(.imageAuthToken).presents))
  password: String?
  @virtual @writeonly @identity.companion
  imageAuthToken: String?
  @virtual @writeonly @identity.companion
  expired: Int64?
  @migration(default: true) @default(true)
  enabled: Bool
  @identity.id @unique
  thirdPartyId: String?
  @virtual @writeonly @identity.checker($get(.value).presents.valid)
  thirdPartyToken: String?
  @onSet($if($presents, $regexMatch(/\\+?[0-9]+/))) @identity.id
  @presentWithout(.email) @unique
  phoneNumber: String?
  @virtual @writeonly @identity.checker($validateAuthCode)
  authCode: String?
 
  include handler identity.signIn
  include handler identity.identity
}
 
model AuthCode {
  @id @autoIncrement @readonly
  id: Int
  @presentWithout(.phoneNumber) @unique
  email: String?
  @presentWithout(.email) @unique
  phoneNumber: String?
  @onSave($randomDigits(4))
  code: String
}
 
middlewares [identity.identityFromJwt(secret: ENV["JWT_SECRET"]!)]

Generate entities for writing our custom pipeline item $validateAuthCode.

cargo teo generate entity

Now let's init a Rust project and create the main program file. Run the command and update files.

cargo init . --bin
Cargo.toml
[package]
name = "hello-teo-authentication"
version = "0.1.0"
edition = "2021"
 
[dependencies]
teo = { version = "0.3.2" }
tokio = { version = "1.36" }
src/main.rs
pub mod entities;
 
use entities::{Teo, User};
use indexmap::indexmap;
use tokio::main;
use teo::prelude::{pipeline::item::validator::Validity, App, Result, Value, Error};
 
#[main]
async fn main() -> Result<()> {
    let app = App::new()?;
    app.main_namespace().define_validator_pipeline_item("validateAuthCode", move |check_args: Value, user: User, teo: Teo| async move {
        let mut finder = Value::Dictionary(indexmap!{});
        let check_args = check_args.as_dictionary().unwrap();
        let ids = check_args.get("ids").unwrap().as_dictionary().unwrap();
        if ids.contains_key("email") {
            finder.as_dictionary_mut().unwrap().insert("email".to_owned(), ids.get("email").unwrap().clone());
        }
        if ids.contains_key("phoneNumber") {
            finder.as_dictionary_mut().unwrap().insert("phoneNumber".to_owned(), ids.get("phoneNumber").unwrap().clone());
        }
        let auth_code = teo.auth_code().find_unique(finder).await?;
        match auth_code {
            Some(auth_code) => if auth_code.code().as_str() == check_args.get("value").unwrap().as_str().unwrap() {
                Ok::<Validity, Error>(Validity::Valid)
            } else {
                Ok(Validity::Invalid("auth code is wrong".to_owned()))
            },
            None => Ok(Validity::Invalid("auth code not found".to_owned()))
        }
    });
    app.run().await
}

Notice that we changed the type of email field from String to String?. For databases other than SQLite is all ok. However, SQLite disallows altering table columns. Using SQLite is for our demo purpose as it doesn't require installation. Just delete the database.sqlite file and let's have a new database setup.

Start the server with the updated command.

cargo run -- serve

Let's recreate the previously created user. Send this to /User/create.

{
  "create": {
    "email": "01@gmail.com",
    "password": "Aa123456"
  }
}
Hide HTTP response
{
  "data": {
    "id": 1,
    "email": "01@gmail.com"
  }
}

Let's send the auth code to this user. Send this JSON input to /AuthCode/create.

{
  "create": {
    "email": "01@gmail.com"
  }
}
Hide HTTP response
{
  "data": {
    "id": 1,
    "email": "01@gmail.com",
    "code": "8736"
  }
}

In practice, mark the code field with @writeonly to hide it from the output, and set an expire time.

Now try to sign in with this auth code. Send this to /User/signIn. Remember to replace the auth code with the one that you got.

{
  "credentials": {
    "email": "01@gmail.com",
    "authCode": "8736",
    "expired": 99999999999
  }
}
Hide HTTP response
{
  "data": {
    "id": 1,
    "email": "01@gmail.com",
    "enabled": true
  },
  "meta": {
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6eyJpZCI6MX0sIm1vZGVsIjpbIlVzZXIiXSwiZXhwIjoxMDE3MTA2MTE5NDV9.dfO7oJ4Ka11ZfzLqjjxU2XMv83lq6qc1Ijv5WUSz5Ac"
  }
}

Now we can sign in with email and password, email and auth code, phone number with password, phone number with auth code. Together with the third party account bindings.

Going next

There are much more even greater things to explore with Teo. Authentication is all about how to validate user's validity and generate tokens. In the next tutorial, we'll learn how to protect APIs with permissions.