Kotlin Multiplatform — Registration (Server Side)

Pradyot Prakash
13 min readJun 15, 2024

--

We will be implementing Authentication into our Server. JWT approach is what we will be going with for this project.

For Project details feel free to check at

JWT?

JWT, or JSON Web Token, is a compact and secure way of transmitting information between parties as a JSON object. It’s commonly used for authentication and authorization purposes. When a user logs in, the server creates a JWT and sends it to the client. The client then includes this token in subsequent requests to verify their identity.

For more details, you can check out https://jwt.io/introduction/.

For implementing JWT authentication in Ktor, basic details can be found at https://ktor.io/docs/server-jwt.html

Let’s get started with implementation

Dependencies

Adding Authentication dependencies for implementing JWT in our server. In gradle/libs.versions.toml let’s add the below dependencies

[versions]
ktor = "2.3.10"

[libraries]
ktor-server-auth-jvm = { module = "io.ktor:ktor-server-auth-jvm", version.ref = "ktor" }
ktor-server-auth-jwt-jvm = { module = "io.ktor:ktor-server-auth-jwt-jvm", version.ref = "ktor" }

Check https://ktor.io/docs/server-auth.html for more details related to dependencies and versions.

We will also be needing MongoDB to retain the User details, so that if user logging in again then the details which was used for registration can be retrieved.

[versions]
mongodb-driver-kotlin-coroutine = "4.10.1"

[libraries]
mongodb-driver-kotlin-coroutine = { module = "org.mongodb:mongodb-driver-kotlin-coroutine", version.ref = "mongodb-driver-kotlin-coroutine" }

Check https://www.mongodb.com/developer/products/mongodb/getting-started-kotlin-driver/ for more details related to dependencies and versions.

I am using local MongoDB instance, but if you are going with the remote option then the implementation will be same. And if you are going with some other database then you can replace the MongoDB implementation with your preferred DB implementation.

For DI, we are using Kodien

[versions]
kodein-di = "7.20.1"

[libraries]
kodein-di = { module = "org.kodein.di:kodein-di", version.ref = "kodein-di" }

Check https://github.com/kosi-libs/Kodein for more details.

These are some of the basic dependencies which will be needed for Authentication implementation, there are others as well which can be checked in the code base.

MongoDB Implementation

In server/core/database let’s create our MongoDB client

object XFullStackMongoDBClient {
private val connectString = if (System.getenv(MONGODB_URI) != null) {
System.getenv(MONGODB_URI)
} else {
DEFAULT_MONGODB_URI
}
private val client = MongoClient.create(connectionString = connectString)
private val database = client.getDatabase(DATABASE_NAME)

fun getDatabase(): MongoDatabase = database
}
  • What’s System.getenv? — Since, the connectString is a secret, you don’t want others to know about this. Especially if your code can be accessed by others. So in the IDE, there is an option to set the environment variables.

Let’s see the steps to add an environment variable for your project.

Step 1:

Click on server

Step 2:

Click on Edit Configurations

Step 3:

From the configuration window, select Kotlin/Server since we are going to use the MONGODB_URI in Server module

Step 4:

Click on the Environment Variables option to add a new variable

Step 5:

Add your MONGODB_URI, you can name the variable whatever you want but make sure you use the correct one in the codebase

Now you can use System.getenv(MONGODB_URI) to get the value from the environment variable list.

You can choose any other method as well to save your secrets, this is not the only option. And feel free to share different options so that it can help others as well.

  • connectString — The instance to which you want to connect to
  • getDatabase() — This will be used in DI for injecting the DB instance.

Rest of the code is self explanatory, and more details are also available in the MongoDB documentation.

For any doubts, feel free to ask

We have created a client for our MongoDatabase, but we need to confirm if the connection is successful.

You can use the below code to check if the connection from your Ktor server to MongoDB was successful or not

    suspend fun isDatabaseAvailable(): MongoDatabase? {
return try {
val command = Document("ping", BsonInt64(1))
database.runCommand(command)
database
} catch (e: Exception) {
Logger.log(
loggerLevel = LoggerLevel.Error,
message = "Error connecting to DB. Details:\n$e"
)
null
}
}

If any error, please check the logs and try to find the issues. Any issues feel free to ask.

You can call isDatabaseAvailable() anywhere, but I usually put it in the fun Application.mainModule() extension. So that the check happens before any configuration and if any issues with connection the server can stop. But you can choose any place. And this is not compulsory to call every time, you can call just to confirm if there is no issue with connection.

Now we have our database up and running.

It’s time to add this into our DI modules so that it can be injected into the required places

object ModulesConfig {
private val databaseModule = DI.Module("DATABASE") {
bindSingleton { XFullStackMongoDBClient.getDatabase() }

...
}

...

val di = DI {
importAll(
databaseModule,
...
)
}
}

Let’s get started with the Authentication part.

In this part, we will see Registration, Login, Authenticate and get the current logged in user information (without sending the userId in the request, only from the JWT token)

But before we dive into these features, we have to implement our Authentication logic.

Create a data class in server/core/security/token named TokenConfig

data class TokenConfig(
val issuer: String,
val audience: String,
val expiresIn: Long,
val secret: String,
)
  • issuer — Who issued this token, which in our case will be the server
  • audience — If you need different type of token for different type of users
  • expiresIn — Expiration date for the JWT token, so that it cannot be used after that
  • secret — This shouldn’t be known by the user

Create a data class in server/core/security/token named TokenClaim

data class TokenClaim(
val name: String,
val value: String,
)

It is nothing a key value pair which is used to store information in the token.

Create an interface in server/core/security/token named TokenService

interface TokenService {
fun generate(
config: TokenConfig, vararg claims: TokenClaim,
): String
}

This will abstract out how our token generation logic works.

Now let’s implement our TokenService. Create a class in server/core/security/token named JwtTokenService

class JwtTokenService : TokenService {
override fun generate(config: TokenConfig, vararg claims: TokenClaim): String {
var token = JWT.create().withAudience(config.audience).withIssuer(config.issuer)
.withExpiresAt(Date(System.currentTimeMillis() + config.expiresIn))

claims.forEach { claim ->
token = token.withClaim(claim.name, claim.value)
}

return token.sign(Algorithm.HMAC256(config.secret))
}
}
  • JWT.create() — generates a token with the specified JWT settings

Check https://ktor.io/docs/server-jwt.html for more details

There are different ways as well to generate the JWT token with different algorithms, you can prefer whichever suits your use case. I am using the one from the documentation which only needs a secret to sign the token.

You have a service now to generate the token, but still we need one more layer of security before we start registering the users. And that layer is HASHING.

This is not required, it can be skip since we have a way to generate the token. But this is just a normal practice and good to have different layers of security.

Hashing will help us to store user password into our database in a cryptic format rather than a plain text. This will convert the password to a very long unreadable string. Which is a one way conversion, so it cannot be converted back to the actual string.

Hmmm.. So how user will login, if we are not storing their password?

We will hash the entered password again and use it to authenticate the user credentials, so from DB will never know what was the actual password. This is still not very secure, a DB hack can still compromise the user credentials. So we will use a SALT on top of the hashing which is a string of random characters, and it will not be easy to find the actual password.

Create a data class in server/core/security/hashing named SaltedHash

data class SaltedHash(
val hash: String,
val salt: String,
)

This will be used in another interface. Create an interface in server/core/security/hashing named HashingService

interface HashingService {
fun generateSaltedHash(value: String, saltLength: Int = SALTED_LENGTH): SaltedHash

fun verify(value: String, saltedHash: SaltedHash): Boolean
}

Create a class in server/core/security/hashing named SHA256HashingService

class SHA256HashingService : HashingService {
override fun generateSaltedHash(value: String, saltLength: Int): SaltedHash {
val salt = SecureRandom.getInstance("SHA1PRNG").generateSeed(saltLength)
val saltAsHex = Hex.encodeHexString(salt)
val hash = DigestUtils.sha256Hex("$saltAsHex$value")
return SaltedHash(
hash = hash, salt = saltAsHex
)
}

override fun verify(value: String, saltedHash: SaltedHash): Boolean {
return DigestUtils.sha256Hex(saltedHash.salt + value) == saltedHash.hash
}
}

We will be using SHA256 algorithm to do the hashing. For more details check https://en.wikipedia.org/wiki/SHA-2

  • SecureRandom — Generate secured random number of the specified length

Feel free to ask if any doubts, the hashing code is just for first hashing the password and verify it by hashing it again.

Create instances of the services we have created, which can be injected based on the requirement.

Create a DI modules file in server/config named ModulesConfig

object ModulesConfig {
...

private val securityModule = DI.Module("SECURITY") {
bindProvider<TokenService> { JwtTokenService() }
bindProvider<HashingService> { SHA256HashingService() }
bindSingleton {
TokenConfig(
issuer = Constants.Jwt.ISSUER,
audience = Constants.Jwt.AUDIENCE,
expiresIn = TOKEN_EXPIRES_IN,
secret = System.getenv(JWT_SECRET),
)
}
}

...

val di = DI {
importAll(
...
securityModule,
...
)
}
}
  • expiresIn — This you can give the expiry time, till when the token will be valid.
  • secret — Getting it from the environment variable

Now our authentication logic is done and ready to used, we can how start implementing authentication into our routes.

Add an extension security into our Application.mainModule() extenaion

fun Application.mainModule() {
configureSecurity()
...
}

Now, for the extension implementation create a file in server/config/plugins named Security

fun Application.configureSecurity() {
val tokenConfig by ModulesConfig.di.instance<TokenConfig>()

install(Authentication) {
jwt {
realm = Constants.Jwt.REALM
verifier(
JWT.require(Algorithm.HMAC256(tokenConfig.secret))
.withAudience(tokenConfig.audience)
.withIssuer(tokenConfig.issuer).build()
)
validate { credential ->
if (credential.payload.audience.contains(tokenConfig.audience)) JWTPrincipal(
credential.payload
)
else null
}
}
}
}

The code was taken from the documentation, check https://ktor.io/docs/server-jwt.html#configure-jwt for more details.

Make sure the algorithms are same at all the places, so that the authentication works correctly.

We have to create the schema for the user table which will be used to store in the MongoDB.

Create a data class in server/data/user named User

data class User(
@BsonId val id: ObjectId = ObjectId(),
val name: String,
val username: String,
val password: String,
val salt: String,
val bio: String?,
@BsonProperty(EMAIL_ADDRESS) val emailAddress: String?,
@BsonProperty(PHONE_NUMBER) val phoneNumber: String?,
@BsonProperty(PROFILE_PICTURE) val profilePicture: String?,
@BsonProperty(DATE_OF_BIRTH) val dateOfBirth: Long,
val nature: List<String>,
)
  • @BsonProperty is optional, but we are using it so that there is a consistency between the keys used in both server and client side. And it removes the work on hardcoding the keys everywhere.

Create an interface in server/data/user named UserDataSource

interface UserDataSource {
...

suspend fun insertNewUser(user: User): Boolean

suspend fun isUsernamePresent(username: String): Boolean

suspend fun isEmailPresent(email: String): Boolean

suspend fun isPhoneNumberPresent(phoneNumber: String): Boolean

...
}

This will contains the operations which we can do on the User database on MongoDB. Let’s implement these methods

Create a class in server/data/user named MongoUserDataSource

class MongoUserDataSource(
db: MongoDatabase,
) : UserDataSource {
private val usersCollection = db.getCollection<User>(USERS)

...

override suspend fun insertNewUser(user: User): Boolean {
return usersCollection.insertOne(user).wasAcknowledged()
}

override suspend fun isUsernamePresent(username: String): Boolean {
return usersCollection.find(
eq(User::username.name, username)
).toList().isNotEmpty()
}

override suspend fun isEmailPresent(email: String): Boolean {
return usersCollection.find(
eq(EMAIL_ADDRESS, email)
).toList().isNotEmpty()
}

override suspend fun isPhoneNumberPresent(phoneNumber: String): Boolean {
return usersCollection.find(
eq(PHONE_NUMBER, phoneNumber)
).toList().isNotEmpty()
}

...
}

These calls are nothing but queries to the User collection.

We need to add the data source into the DI modules so that it can be used whenever required

object ModulesConfig {
private val databaseModule = DI.Module("DATABASE") {
...

bindProvider<UserDataSource> { MongoUserDataSource(instance()) }
...
}

...

val di = DI {
importAll(
databaseModule,
...
)
}
}

Now, we have the authentication plugin and mongo db data source implemented. We will now start with adding routes for Registration first.

In Application.mainModule() add one more configuration for routing

fun Application.mainModule() {
...
configureRouting()
...
}

Create a file in server/config/plugins named Routing

fun Application.configureRouting() {
val userDataSource by ModulesConfig.di.instance<UserDataSource>()
...

val tokenService by ModulesConfig.di.instance<TokenService>()
val tokenConfig by ModulesConfig.di.instance<TokenConfig>()
val hashingService by ModulesConfig.di.instance<HashingService>()

val authenticationController by ModulesConfig.di.instance<AuthenticationController>()
...

routing {
authentication(
authenticationController = authenticationController,
hashingService = hashingService,
tokenService = tokenService,
userDataSource = userDataSource,
tokenConfig = tokenConfig,
...
)
...
}
}
  • authentication — An extension on the Routing class which will handle all the routes related to authentication
  • AuthenticationController — Will contain the business logic for all the routes, will see the code as we move forward.

We haven’t created a route for authentication/registration yet.

Create a file in server/features/authentication named Router, which will contains all the routes related to the feature. This helps in managing the routes as the project grows

fun Routing.authentication(
authenticationController: AuthenticationController,
hashingService: HashingService,
tokenService: TokenService,
userDataSource: UserDataSource,
tokenConfig: TokenConfig,
...
) {
post<AuthenticationResource.Register> {
authenticationController.registerUser(
call = this.context,
hashingService = hashingService,
userDataSource = userDataSource,
)
}

...
}

We will be using Resources plugin which helps in type safe routes. Check https://ktor.io/docs/server-resources.html for more details related to implementation and on how to use it. You can choose the basic way as well but for me this removes the hardcoded path and helps in understanding the path details by just seeing the classes.

The AuthenticationController is nothing but a parent class for all the routes which we are going to create. And each path has its own controller to divide the logics. This helps with managing the codebase and diving the work in different parts. I divide the code based on the routes, and each route will have its own controller.

Create an interface in server/features/authentication/controllers/register named RegisterController

interface RegisterController {
suspend fun registerUser(
call: ApplicationCall,
hashingService: HashingService,
userDataSource: UserDataSource,
)
}

Create a class in server/features/authentication/controllers/register named RegisterControllerImplementation

class RegisterControllerImplementation : RegisterController {
override suspend fun registerUser(
call: ApplicationCall,
hashingService: HashingService,
userDataSource: UserDataSource,
) {
delay(API_RESPONSE_DELAY)

val registerRequest = call.receive<RegisterRequest>()

UtilsMethod.Validation.isValidName(registerRequest.name)

if (UtilsMethod.Validation.isValidUserName(registerRequest.username)) {
if (userDataSource.isUsernamePresent(registerRequest.username)) {
throw InvalidParameter(
errorCode = USERNAME_ALREADY_PRESENT_ERROR_CODE,
message = Localization.USERNAME_ALREADY_EXISTS,
)
}
}

UtilsMethod.Validation.isValidPassword(registerRequest.password)

if (registerRequest.emailAddress == null && registerRequest.phoneNumber == null) {
throw InvalidParameter(
message = Localization.EMAIL_OR_PHONE_NUMBER_REQUIRED,
errorCode = EMAIL_OR_PHONE_NUMBER_REQUIRED_ERROR_CODE
)
}

registerRequest.emailAddress?.let {
if (UtilsMethod.Validation.isValidEmail(it)) {
if (userDataSource.isEmailPresent(it)) {
throw InvalidParameter(
message = Localization.EMAIL_ALREADY_EXISTS,
errorCode = EMAIL_ALREADY_PRESENT_ERROR_CODE
)
}
}
}

registerRequest.phoneNumber?.let {
if (UtilsMethod.Validation.isValidPhoneNumber(it)) {
if (userDataSource.isPhoneNumberPresent(it)) {
throw InvalidParameter(
message = Localization.PHONE_NUMBER_ALREADY_EXISTS,
errorCode = PHONE_NUMBER_ALREADY_PRESENT_ERROR_CODE
)
}
}
}

registerRequest.profilePicture?.let {
UtilsMethod.Validation.isValidLink(
it,
PROFILE_PICTURE_VALIDITY_ERROR_CODE
)
}

UtilsMethod.Validation.isValidDate(registerRequest.dateOfBirth)

registerRequest.bio?.let { UtilsMethod.Validation.isValidBio(it) }

val saltedHash = hashingService.generateSaltedHash(value = registerRequest.password)

val user = registerRequest.parseToUser(saltedHash)

val wasAcknowledged = userDataSource.insertNewUser(user)

if (!wasAcknowledged) {
throw DBWriteError()
}

call.respond(
HttpStatusCode.Created,
XFullStackResponse(
status = XFullStackResponseStatus.Success,
code = null,
message = Localization.ACCOUNT_CREATED_SUCCESSFULLY,
data = null,
)
)
}
}
  • delay(API_RESPONSE_DELAY) — This is only for making response sent to the client with a delay. Since we are working on local (server and database) the responses are received really fast. This is just for feel for the user that some operation is going on in the server side.
  • RegisterRequest — The request received from the client side which contains details related to the user.
@Serializable
data class RegisterRequest(
val name: String,
val username: String,
val password: String,
val bio: String?,
val emailAddress: String?,
val phoneNumber: String?,
val profilePicture: String?,
val dateOfBirth: Long,
)

We need to validate the requests which we received on the server side, this can be optional if it’s happening on the client side. But better to have validation both side. And throw proper error for the client to know what went wrong.

We are also checking if email address, username and phone number is not being used before registering the user. This helps in maintaining the user details properly.

  • parseToUser(saltedHash) — It’s a parser method which changes the request class to User class (used by MongoDB as a schema)
fun RegisterRequest.parseToUser(saltedHash: SaltedHash) = this.let { registerRequest ->
User(
name = registerRequest.name,
username = registerRequest.username,
password = saltedHash.hash,
salt = saltedHash.salt,
bio = registerRequest.bio,
profilePicture = registerRequest.profilePicture,
dateOfBirth = registerRequest.dateOfBirth,
emailAddress = registerRequest.emailAddress,
phoneNumber = registerRequest.phoneNumber,
nature = emptyList(),
)
}

After this we insert the user to the DB by calling the insertNewUser call of the data source. And when we get the acknowledgement we get response with the Created status code.

  • XFullStackResponse — This is nothing but the parent response class which will used for all the responses, this helps in having consistency in the responses.
@Serializable
data class XFullStackResponse<T>(
val status: XFullStackResponseStatus,
val code: String?,
val message: String?,
val data: T?,
)

With all these the user is able to register themselves but they will still not have the JWT token with them.

We also need add the controllers into the DI modules

object ModulesConfig {
...

private val controllersModule = DI.Module("CONTROLLERS") {
bindProvider<RegisterController> { RegisterControllerImplementation() }
...
}

private val featuresModule = DI.Module("FEATURES") {
bindProvider { AuthenticationController(instance(), ...) }

...
}

val di = DI {
importAll(
...
controllersModule,
featuresModule,
)
}
}

We have all the required structure to run our server and start registering our users. Let’s use Bruno to test our route

Registration flow testing on server side

As you can see in the above screenshot, we got success for the registration. Also below you can see the MongoDB user collection to see if the DB write was performed correctly

MongoDB user insertion

So, from the above screenshots it’s safe to say that our registration is working fine. But we have to also give the JWT token to the user so that they can login into our application.

Let’s check in the next article where we will be implementing our Login, Authenticate and User info routes.

If you have any questions or suggestions please provide it in the comments, will try to answer or implement it.

Follow for getting the notification when new articles are published.

Follow me on Twitter and GitHub.

--

--