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
.
iss
means issuer, this field provides a security measure to prevent spoofing the token's original source.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:
signIn
- Authenticate an existing user, compare their hashed password and generate a JSON web token.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:
- 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.
- The UserInput is the model that we are going to send when we want to modify the data source.
- 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:
signIn
: Allows existing users to enter their email and password and returns aUserResponse
if successful.signUp
: Allows new users to enter their email and password and returns aUserResponse
if they are successful. If someone already signed up with their email, we return anull
response.signAccessToken
: Signs and returns an auth token. Includes a user's id in the auth token payload.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:
- Signs in or signs up an auth user with their email and password.
- 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.
- 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.