Ktor as a backend that can be built in a blink of an eye - part 3
This article is the third part of the three-part series that will smoothly introduce Ktor as a backend to you.
Part 1 can be found here and part 2 can be found here
Authentication – users endpoint
With all the good things we have put into our project so far, another thing is worth mentioning – how to protect some endpoints against unrestricted access. All modern frameworks got some out-of-the-box solutions, and the same is with KTOR – from the mobile developer’s point of view, the most common case is authentication with some token. We will add the JSON Web Token authentication for our game shop.
The flow here is simple: the user needs to request some public endpoint with username/password if credentials are valid, the severe will respond with the token, then the token must be included in the header. The server verifies the token when a user tries to access secured resources.
implementation("io.ktor:ktor-server-auth:$ktor_version")
implementation("io.ktor:ktor-server-auth-jwt:$ktor_version")
With the flow described above, we need to create a public login endpoint with a request and response.
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class LoginResponse(
val token: String
)
fun Route.loginRouting() {
val repo by inject<UsersRepository>()
route("/login") {
post {
…
)
}
}
The JWT plugin needs to be installed and configured.
When creating the token, we will check if provided user and password are correct (and the user exists in the database), then we will put the userId into the JWT claim (the payload), so it can be accessed while requesting secured paths, we also need to configure an expiration date. We provide some additional info such as secret, audience, issuer and realm (all of them are explained in the JWT RFC.
If a user exists, we will respond with a valid JWT token, if not, we will send a 400 Bad Request. Because JWT is ready to use, the standalone implementation, we don’t need to bother by storing the created tokens with users – the token is valid as long as the expiration period, or as long the SECRET stays unchanged – there is no way to revoke created tokens.
The configuration properties
const val SECRET = "SECRETT"
const val AUDIENCE = "http://0.0.0.0:8080"
const val ISSUER = "http://0.0.0.0:8080/login"
The /login endpoint implementation
fun Route.loginRouting() {
val repo by inject<UsersRepository>()
route("/login") {
post {
val loginRequest = call.receive<LoginRequest>()
repo.getUserByUsernameAndPassword(loginRequest.username, loginRequest.password)
?.let {
val token = JWT.create()
.withAudience(AUDIENCE)
.withIssuer(ISSUER)
.withClaim("userId", it.id)
.withExpiresAt(Date(Clock.System.now().toEpochMilliseconds() + 60000))
.sign(Algorithm.HMAC256(SECRET))
call.respond(LoginResponse(token))
} ?: call.respondText(
status = HttpStatusCode.BadRequest,
text = "Invalid login or password"
)
}
}
}
The getUserByUsernameAndPassword method is implemented in the UsersDAOFacadeImpl, and it’s pretty simple.
override suspend fun getUserByUsernameAndPassword(username: String, password: String): User? = dbQuery {
Users.select { (Users.username eq username) and (Users.password eq password) }
.limit(1)
.firstOrNull()
?.toUser()
}
Now we are able to obtain a token. The next step is to validate it with every incoming request. To do so, we need to install the authentication plugin and configure the token verifier. Let’s create another extension function that we can put in our Application.kt.
install(Authentication) {
jwt("jwt-auth") {
realm = "GameShopAccess"
verifier(
JWT
.require(Algorithm.HMAC256(SECRET))
.withAudience(AUDIENCE)
.withIssuer(ISSUER)
.build()
)
validate { jwtCredential ->
jwtCredential.payload.claims["userId"]?.asString()
?.let {
if (userRepository.existById(it)) {
JWTPrincipal(jwtCredential.payload)
} else {
null
}
}
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired!")
}
}
}
}
The first thing that is checked is the token format and signature. If it’s invalid, the challenge lambda is fired, and the user is unauthorized, with proper HTTP code. The verifier block is responsible for these checks.
The validate lambda happens after the verify block, and it can access the token payload where in the /login method, we put the userId. So the validation will be really simple – we just check if a user with a given id exists in our database – if yes, we can proceed with the request.
The last thing is to use created authentication with desired endpoints – let’s assume that creating new users should be restricted to logged-in users only. This is simple as just adding the authenticate() function on the routes we want to protect.
fun Route.usersRouting() {
val repo by inject<UsersRepository>()
authenticate("jwt-auth") {
route("/users") {
post {
…
)
}
get {
…
}
From now on, when we try to access the list of users without a valid token, we will receive an error.
### GET List of the users
GET http://0.0.0.0:8080/users
Content-Type: application/json
http://0.0.0.0:8080/users
HTTP/1.1 401 Unauthorized
Content-Length: 34
Content-Type: text/plain; charset=UTF-8
Connection: keep-alive
The token is not valid or has expired!
We can obtain a valid token by calling the login method and storing the received token for further requests.
### POST login and retrieve a token
POST http://0.0.0.0:8080/login
Content-Type: application/json
{
"username": "admin",
"password": "pass"
}
> {%
client.test("Assert token is retrieved", function() {
client.assert(response.body.token !== "")
})
client.global.set("auth_token", response.body.token);
%}
But unfortunately, calling this method will return 400 Bad Request Invalid login or password because the required user does not exist in our database. There is a simple solution for that – we need to pre-populate the database with some users (please note that before adding a user to the database when the table is created, we should first check if such a user is not already in our DB)
private object SchemaDefinition {
fun createSchema() {
transaction {
Users.insert {
it[name] = "Admin"
it[username] = "admin"
it[password] = "pass"
it[date_created] = Clock.System.now().toLocalDateTime(TimeZone.UTC).date.toString()
}
}
}
}
So from now on, we need to add a JWT token to the header in every request.
### GET List of the users
GET http://0.0.0.0:8080/users
Content-Type: application/json
Authorization: Bearer {{auth_token}}
http://0.0.0.0:8080/users
HTTP/1.1 200 OK
Content-Length: 321
Content-Type: application/json
Connection: keep-alive
[
{
"id": "c96ae8c1-e85e-4fd0-b54d-46e1f2a66983",
"name": "Admin",
"username": "admin"
}
]
Validation – users endpoint
There is one thing left until we consider our GameShop ready – data validation. In many cases, we need to check if the data we receive from clients is valid. In terms of adding new user we can add some of the following validations
- username and password can’t be empty
- username, password, and name must be at least 5 characters long
- username should be unique
As you probably expected, the KTOR got a plugin just for that, let’s install the RequestValidation.
implementation("io.ktor:ktor-server-request-validation:$ktor_version")
The KTOR plugins may be installed to the Application and the specific Routes – in our case, we will do it in the /users endpoint.
fun Route.usersRouting() {
val repo by inject<UsersRepository>()
authenticate("jwt-auth") {
route("/users") {
install(RequestValidation) {
validate<UserRequest> { request ->
when {
request.password.isBlank() -> ValidationResult.Invalid("Password is required!")
request.username.isBlank() -> ValidationResult.Invalid("Username is required!")
request.password.length < 5 -> ValidationResult.Invalid("Password is too short!")
request.name.length < 5 -> ValidationResult.Invalid("Name is too short!")
request.username.length < 5 -> ValidationResult.Invalid("Username is too short!")
repo.existByName(request.username) -> ValidationResult.Invalid("User already exists!")
else -> ValidationResult.Valid
}
}
}
post {
val request = call.receive<UserRequest>()
val user = repo.addUser(request)
requireNotNull(user)
call.respond(
status = HttpStatusCode.Created,
message = user
)
}
}
}
}
As you can see, we are able to call our database directly in the validate lambda. If the request is valid, the server will proceed with the request handling, if not, the RequestValidationException will be thrown. The application will respond with 500 HTTP codes. But this is not something we want, the errors should be handled gracefully with proper error codes and some explanation.
We can use the StatusPages to achieve such a goal, which can take care of any exception in our code. The usage and configuration are straightforward.
implementation("io.ktor:ktor-server-status-pages:$ktor_version")
fun Application.configureStatusPages() {
install(StatusPages) {
exception<Throwable> { call, cause ->
when (cause) {
is RequestValidationException ->
call.respondText(status = HttpStatusCode.BadRequest, text = cause.reasons.joinToString())
else ->
call.respondText(status = HttpStatusCode.InternalServerError, text = "500: $cause")
}
}
}
}
In case of any RequestValidationException, we will return a 400 BadRequest with a reason, any other issues will still be mapped to 500 Internal Server Error.
Let’s try to add a user with a too-short password.
### POST create a new user
POST http://0.0.0.0:8080/users
Content-Type: application/json
Authorization: Bearer {{auth_token}}
{
"name": "Test User",
"username": "tst",
"password": "password"
}
http://0.0.0.0:8080/users
HTTP/1.1 400 Bad Request
Content-Length: 22
Content-Type: text/plain; charset=UTF-8
Connection: keep-alive
The username is too short!
Conclusion
I hope this simple article was helpful and shows you how to develop a working backend for your mobile application with the same principles and tools as in your android app.
If you want to find more, you definitely should check the KTOR documentation which is full of good examples.
Honestly, it was fun writing something different than the mobile app – you should try it!