Chapter 4: Authentication

Kotlin Multiplatform Mobile

link Authentication

Introduction

For this application, we will be building an authentication flow.

When a user logs in or signs up for our service, they will receive a JSON Web Token or "JWT." JWT's provide a security credential to access protected routes and resources.

Typically the client will send a JWT through the Authorization header with the following format:

Authorization: Bearer <token>

On the server side, we will check for a valid auth token. If it's present, the user will be allowed to access protected resources.

For example, copy the following JWT payload and paste it into jwt.io:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiI0YzkxNzM4Ny02ZTNiLTRjMzYtYWUyZC01OTYyYzUwNTRhZmQiLCJ1c2VySWQiOiI0YzkxNzM4Ny02ZTNiLTRjMzYtYWUyZC01OTYyYzUwNTRhZmQifQ.JzM0SCwLR0jKP4RUIqyErwXk2vcJacFDHP_CA-QFkl0

In the "Decoded" section, you should find the following payload data:

{
  "iss": "4c917387-6e3b-4c36-ae2d-5962c5054afd",
  "userId": "4c917387-6e3b-4c36-ae2d-5962c5054afd"
}

There are two relevant fields here - iss and userId.

  1. iss means issuer, this field provides a security measure to prevent spoofing the token's original source.
  2. userId means the current user's identifier. This field is used to verify the client has access to the requested resource.

JWT contain sensitive data so we try to limit the number of fields being passed from the client to the backend server.

For more information about the anatomy of JSON web tokens, visit jwt.io and scotch.io

Auth Fields

In order to generate JSON web tokens, we will need to implement new auth logic:

  1. signIn - Authenticate an existing user, compare their hashed password and generate a JSON web token.
  2. signUp - Authenticate a new user, store their hashed password and generate a JSON web token.

In case you are wondering, a hashed password means an obfuscated or "scrambled" password, which emits a non-human-readable value.

Alright, let's continue with creating the User and Profile models.

link User Model

In the models directory, create a file called User.kt and add the following:

// 1
data class User(override val id: String, val email: String, val hashedPass: ByteArray): Model

// 2
data class UserInput(val email: String, val password: String)

// 3
data class UserResponse(val token: String, val user: User)

Here's what the above code defines:

  1. The User data class provides the attributes for the model. Each user will be able to store a hashed password, we never store plain-text passwords.
  2. The UserInput is the model that we are going to send when we want to modify the data source.
  3. The UserResponse is the expected response when we retrieve a user, log in or sign up.

link Profile Model

Given a user is logged in, we would like to display their information and their list of desserts.

In the models directory, create a file called Profile.kt and add the following:

data class Profile(val user: User, val desserts: List<Dessert> = emptyList()) 

link User Repository

Now that we defined User and Profile models, we can initialize a new UserRepository, which will handle creating, fetching, updating and deleting users.

Whenever a user signs up, we would like to verify their email is not in use already. The getUserByEmail method does this by checking for any matching user's email.

import com.example.models.User
import com.mongodb.client.MongoClient
import com.mongodb.client.MongoCollection
import org.litote.kmongo.*

class UserRepository(client: MongoClient) : RepositoryInterface<User> {
    override lateinit var col: MongoCollection<User>

    init {
        val database = client.getDatabase("test")
        col = database.getCollection<User>("User")
    }

    fun getUserByEmail(email: String? = null): User? {
        return try {
            col.findOne(
                    User::email eq email,
            )
        } catch (t: Throwable) {
            throw Exception("Cannot get user with that email")
        }
    }
}

Now that we have set up our base UserRepository, we will need a method to create users. The signUp method will handle this. We will also need a method to authenticate users and verify their auth token.

Everything related to authentication will be handled by a new AuthService.

link AuthService

Before getting started with the new AuthService, let's discuss the four methods we will implement:

  1. signIn: Allows existing users to enter their email and password and returns a UserResponse if successful.
  2. signUp: Allows new users to enter their email and password and returns a UserResponse if they are successful. If someone already signed up with their email, we return a null response.
  3. signAccessToken: Signs and returns an auth token. Includes a user's id in the auth token payload.
  4. verifyToken: Checks an existing auth token, and returns a user model if the token is valid.

Create a file in the services directory called AuthService.kt and add the following:

import at.favre.lib.crypto.bcrypt.BCrypt
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import com.example.models.User
import com.example.models.UserInput
import com.example.models.UserResponse
import com.example.repository.UserRepository
import com.mongodb.client.MongoClient
import io.ktor.application.ApplicationCall
import org.koin.core.KoinComponent
import org.koin.core.inject
import java.nio.charset.StandardCharsets
import java.util.UUID

class AuthService: KoinComponent {
    private val client: MongoClient by inject()
    private val repo: UserRepository = UserRepository(client)
    private val secret: String = "secret"
    private val algorithm: Algorithm = Algorithm.HMAC256(secret)
    private val verifier: JWTVerifier = JWT.require(algorithm).build()

    /**
     * @param email the email of the user signing up
     * @param password the password of the user signing up
     *
     * @return UserResponse object with no null values if sign in successful
     * UserResponse object with two null values if unsuccessful
     */

    fun signIn(userInput: UserInput): UserResponse? {
        val user = repo.getUserByEmail(userInput.email) ?: error("No such user by that email")
        // hash incoming password and compare it to saved
        if (!BCrypt.verifyer()
                        .verify(
                                userInput.password.toByteArray(Charsets.UTF_8),
                                user.hashedPass
                        ).verified
        ) {
            error("Password incorrect")
        }

        val token = signAccessToken(user.id)
        return UserResponse(token, user)
    }

    /**
     * @param email the email of the user signing up
     * @param password the password of the user signing up
     *
     * @return UserResponse object with no null values if sign up successful
     * UserResponse object with two null values if unsuccessful
     */

    fun signUp(userInput: UserInput): UserResponse? {
        val hashedPassword = BCrypt.withDefaults().hash(10, userInput.password.toByteArray(StandardCharsets.UTF_8))
        val id = UUID.randomUUID().toString()
        val emailUser = repo.getUserByEmail(userInput.email)
        if (emailUser != null) { error("Email already in use") }
        val newUser = repo.add(
                User(
                        id = id,
                        email = userInput.email,
                        hashedPass = hashedPassword,
                )
        )
        val token = signAccessToken(newUser.id)
        return UserResponse(token, newUser)
    }

    /**
     * @param id the user Id associated with the access token intended to be generated
     * @return a valid and fresh access token
     */
    private fun signAccessToken(id: String): String {
        return JWT.create()
                .withIssuer("example")
                .withClaim("userId", id)
                .sign(algorithm)
    }

    fun verifyToken(call: ApplicationCall): User? {
        return try {
            val authHeader = call.request.headers["Authorization"] ?: ""
            val token = authHeader.split("Bearer ").last()
            val accessToken = verifier.verify(JWT.decode(token))
            val userId = accessToken.getClaim("userId").asString()
            return User(id = userId, email = "", hashedPass = ByteArray(0))
        } catch (e: Exception) {
            null
        }
    }

} 

In order to apply an encryption algorithm to each auth token, we utlized the Bcrypt library.

In order to generate valid JWTs, we utlized the Auth0 JWT library.

With the AuthService completed, we can create the entry points to our auth service using a GraphQL Schema.

link AuthSchema

Create a file in the graphql directory called AuthSchema.kt and add the following:

import com.apurebase.kgraphql.schema.dsl.SchemaBuilder
import com.example.models.User
import com.example.models.UserInput
import com.example.services.AuthService

fun SchemaBuilder.authSchema(authService: AuthService) {
    mutation("signIn") {
        description = "Authenticate an existing user"
        resolver { userInput: UserInput ->
            try {
                authService.signIn(userInput)
            } catch(e: Exception) {
                null
            }
        }
    }

    mutation("signUp") {
        description = "Authenticate a new user"
        resolver { userInput: UserInput ->
            try {
                authService.signUp(userInput)
            } catch(e: Exception) {
                null
            }
        }
    }

    type<User>{
        User::hashedPass.ignore()
    }
} 

The above code handles the following:

  1. Signs in or signs up an auth user with their email and password.
  2. For security purposes, omits the user's hashed password from the GraphQL schema.

We can test the new AuthSchema by installing it with our existing GraphQL server.

link Install AuthSchema

Return to src/Application.kt and insert the following:

install(GraphQL) {
    val authService = AuthService()

    playground = true
    schema {
        // ...
        authSchema(authService)
    }
}

Fire up the server and revisit the playground at localhost:8080/graphql.

Test Auth Mutations

Insert the following mutations inside the Query section:

mutation SignIn($userInput:UserInput!) {
  signIn(userInput:$userInput) {
    token
    user {
      id
      email
    }
  }
}
mutation SignUp($userInput:UserInput!) {
  signUp(userInput:$userInput) {
    token
    user {
      id
      email
    }
  }
}

For each mutation, use the following query variables:

{"userInput": {"email": "example@test.com", "password": "example"}}

If you were able to receive a UserResponse upon sign up and sign in, well done!

So far we have completed the core authentication logic. Up next, we will implement the profile service and fetch the current user's desserts.

Before proceeding, be sure to copy down your auth token's value -- we will use it for testing later in this section.

link Code Lab - JWT Expiry

Expiring Tokens

For this code lab, we would like to implement an expiration date for our auth tokens. Auth tokens that never expire are a potential security risk -- they mean our users stay logged in forever!

Of course, if we decided to implement an expiration for our auth tokens we would also want to create a refresh token method.

In this case, we will implement an auth token expiration of one week. If a user's token expires we can ask them to sign in again.

  1. Challenge: Update signAccessToken to accept a token expiry date of one week from the current date.

Once again, try to implement this on your own before taking a peak at the solution code.

Solution Code
private fun signAccessToken(id: String): String {
    val date = GregorianCalendar.getInstance().apply {
        this.time = Date()
        this.add(Calendar.MINUTE, 10080)
    }.time
    return JWT.create()
            .withIssuer("example")
            .withClaim("userId", id)
            .withExpiresAt(date)
            .sign(algorithm)
}

For testing token expiry, you can expire your tokens in a very short amount of time, like one minute.

link DessertService

Now that the bulk of auth logic is completed, we can move on to create a ProfileService.

First, we would like to fetch desserts based on the current user's userId.

Let's return to the src/repository/DessertRepository.kt and add the following:

fun getDessertsByUserId(userId: String): List<Dessert> {
    return try {
        col.find(Dessert::userId eq userId).asIterable().map { it }
    } catch (t: Throwable) {
        throw Exception("Cannot get user desserts")
    }
}

The above code handles fetching a list of desserts that belong to a specified userId.

link ProfileService

We can use the ProfileService to fetch an existing user's profile and list of desserts.

Inside the services directory, create a file called ProfileService.kt and add the following:

import com.example.models.Profile
import com.example.repository.DessertRepository
import com.example.repository.UserRepository
import com.mongodb.client.MongoClient
import org.koin.core.KoinComponent
import org.koin.core.inject

class ProfileService: KoinComponent {
    private val client: MongoClient by inject()
    private val repo: UserRepository = UserRepository(client)
    private val dessertRepo: DessertRepository = DessertRepository(client)

    fun getProfile(userId: String): Profile {
        val user = repo.getById(userId)
        val desserts = dessertRepo.getDessertsByUserId(userId)
        return Profile(user, desserts)
    }
}

We utlized both the UserRepository and DessertRepository to fetch an existing user, and create the Profile response.

link ProfileSchema

In order to test the new ProfileService, we can add it to a new ProfileSchema.

Create a file in the graphql directory and call it ProfileSchema.kt, then add the following:

import com.apurebase.kgraphql.Context
import com.apurebase.kgraphql.schema.dsl.SchemaBuilder
import com.example.models.User
import com.example.services.ProfileService

fun SchemaBuilder.profileSchema(profileService: ProfileService) {
    query("getProfile") {
        resolver { ctx: Context ->
            try {
                val userId = ctx.get<User>()?.id ?: error("Not signed in")
                profileService.getProfile(userId)
            } catch (e: Exception) {
                null
            }
        }
    }
} 

Now that we are accessing to the global GraphQL Context, we will also need to wire up the User in the global Context. Unless we do so, we will always throw an error ("Not signed in").

link Wire Up Context

Back in the src/graphql/ReviewSchema.kt and src/graphql/DessertSchema.kt files, make the following changes:

ReviewSchema

- val userId = "abc"
+ val userId = ctx.get<User>()?.id ?: error("Not signed in")

DessertSchema

- val userId = "abc"
+ val userId = ctx.get<User>()?.id ?: error("Not signed in")

In both schemas, we are no longer passing in an "abc" user ID. Instead we will access an optional User object from the global GraphQL Context.

So where exactly do we create this global user object in the GraphQL Context?

link Install Context

Back in src/Application.kt, make the following changes to include a global user in Context:

install(GraphQL) {
    val profileService = ProfileService()
    // ...

    playground = true
    context { call ->
        authService.verifyToken(call)?.let { +it }
        +log
        +call
    }

    schema {
        // ...
        profileSchema(profileService)
    }
}

The above code provides a global Context object that is included with every GraphQL request. So each time we recieve a request, we will try to verify an auth token using authService.verifyToken.

Test Profile Query

In order to test the profile query, we will re-run the server and include the following query:

query GetProfile {
  getProfile {
    user {
      id
      email
    }
  }
}

Do not forget to include the following Header params, to include your authentication token!

{"Authorization": "Bearer AUTH_TOKEN_HERE"}

If you were able to fetch the current user's profile, congratulations!

Today we were able to successfully implement a JWT authentication flow.

Up next, we will prepare the GraphQL server for deployment.

link References

format_list_bulleted
help_outline