DEV Community

Cover image for Implement user authentication with Node.js and Teo in 5 lines of code
Victor Teo
Victor Teo

Posted on

Implement user authentication with Node.js and Teo in 5 lines of code

Teo logo

Teo is a schema-centered web framework for Node.js, Python and Rust. It’s very concise and declarative. In this article, we’re going to use the Node.js version.

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
npm init -y
npm install typescript ts-node -D
npx tsc --init
npm install @teocloud/teo
Enter fullscreen mode Exit fullscreen mode

Password

Let’s create a simple password authentication. Paste this into a new file named 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"]!)]
Enter fullscreen mode Exit fullscreen mode

Create a dot env file .env in the same directory.

JWT_SECRET=my_top_secret
Enter fullscreen mode Exit fullscreen mode

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

  • The authentication functionalities reside in Teo’s std.identity namespace
  • @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.

npx teo serve
Enter fullscreen mode Exit fullscreen mode

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

{
  "create": {
    "email": "01@gmail.com",
    "password": "Aa123456"
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "data": {
    "id": 1,
    "email": "01@gmail.com"
  }
}
Enter fullscreen mode Exit fullscreen mode

Send this JSON input to /User/signIn.

{
  "credentials": {
    "email": "01@gmail.com",
    "password": "Aa123456"
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "data": {
    "id": 1,
    "email": "01@gmail.com"
  },
  "meta": {
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6eyJpZCI6MX0sIm1vZGVsIjpbIlVzZXIiXSwiZXhwIjoxNzQyMTI0NDk2fQ.x2DSIpdnUeJtsUOGQsHlGksr29aF-CWog6X5LILxsOc"
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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"]!)]
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "data": {
    "id": 1,
    "email": "01@gmail.com"
  },
  "meta": {
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6eyJpZCI6MX0sIm1vZGVsIjpbIlVzZXIiXSwiZXhwIjoxNzQyMTI2MDIwfQ.7e3gXp5zA_h-Yk4ClUhjIIZLL4sLXAIFpE3CDzL_gzs"
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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"]!)]
Enter fullscreen mode Exit fullscreen mode

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

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

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#
{ }
Enter fullscreen mode Exit fullscreen mode
{
  "error": {
    "type": "Unauthorized",
    "message": "token expired"
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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"]
Enter fullscreen mode Exit fullscreen mode

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

{
  "where": {
    "id": 1
  },
  "update": {
 "enabled": false
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "data": {
    "id": 1,
    "email": "01@gmail.com",
    "enabled": false
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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"]!)]
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "data": {
    "id": 2,
    "email": "02@gmail.com",
    "enabled": true,
    "thirdPartyId": "myFacebookId"
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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.

connector {
  provider: .sqlite,
  url: "sqlite:./database.sqlite"
}

server {
  bind: ("0.0.0.0", 5052)
}

entity {
  provider: .node,
  dest: "./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"]!)]
Enter fullscreen mode Exit fullscreen mode

Generate entities for writing our custom pipeline item $validateAuthCode.

npx teo generate entity
Enter fullscreen mode Exit fullscreen mode

Now let’s create main program file. Create a file named app.ts in the project directory.

import { App } from '@teocloud/teo'
import { AuthCodeWhereUniqueInput, Teo, User } from './entities'

const app = new App()

app.mainNamespace().defineValidatorPipelineItem(
    "validateAuthCode", 
    async (checkArgs: any, _, user: User, teo: Teo) => {
        const finder: AuthCodeWhereUniqueInput = {}
        if (checkArgs.ids.email) {
            finder.email = user.email!
        }
        if (checkArgs.ids.phoneNumber) {
            finder.phoneNumber = user.phoneNumber!
        }
        const authCode = await teo.authCode.findUnique({
            where: finder
        })
        if (!authCode) {
            return "auth code not found"
        }
        if (authCode.code !== checkArgs.value) {
            return "auth code is wrong"
        }
})

app.run()
Enter fullscreen mode Exit fullscreen mode

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.

npx ts-node app.ts serve
Enter fullscreen mode Exit fullscreen mode

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

{
  "create": {
    "email": "01@gmail.com",
    "password": "Aa123456"
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "data": {
    "id": 1,
    "email": "01@gmail.com"
  }
}
Enter fullscreen mode Exit fullscreen mode

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

{
  "create": {
    "email": "01@gmail.com"
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "data": {
    "id": 1,
    "email": "01@gmail.com",
    "code": "8736"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode
{
  "data": {
    "id": 1,
    "email": "01@gmail.com",
    "enabled": true
  },
  "meta": {
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6eyJpZCI6MX0sIm1vZGVsIjpbIlVzZXIiXSwiZXhwIjoxMDE3MTA2MTE5NDV9.dfO7oJ4Ka11ZfzLqjjxU2XMv83lq6qc1Ijv5WUSz5Ac"
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)