DEV Community

Michael Odhiambo
Michael Odhiambo

Posted on

Implementing Role-Based Access Control (RBAC) in Kotlin with Ktor: A Complete Guide

Role-Based Access Control (RBAC) is essential for securing modern web applications. In this article, I'll walk through how I implemented RBAC in my Kotlin Ktor application, providing a step-by-step guide that beginners can follow to implement similar security in their projects.

Understanding the RBAC Flow

Before diving into the code, let's understand the complete flow of our RBAC implementation:

  1. Authentication: User logs in and receives a JWT token
  2. Token Validation: On each request, we extract and validate the user's token
  3. User Extraction: We get the user profile from the validated token
  4. Role Checking: We verify if the user's role is permitted for the requested action
  5. Access Control: We either allow or deny access based on the role check

Let's implement each step in detail.

Step 1: Define User Roles

First, we define the possible roles in our system using a Kotlin enum:

// in com.example.routing.rbac.rbac.kt
enum class UserRole {
    ADMIN,
    USER,
    LECTURER,
    STUDENT
}
Enter fullscreen mode Exit fullscreen mode

Using an enum provides type safety and makes role management straightforward.

Step 2: Setting Up JWT Authentication

For our RBAC to work, we need to authenticate users and generate tokens:

// in com.example.backend.plugins.AuthConfig.kt
object AuthConfig {
    const val JWT_SECRET = "your-secret-key" // Use environment variables in production
    const val JWT_ISSUER = "your-application"
    const val JWT_AUDIENCE = "your-audience"
    const val ACCESS_TOKEN_EXPIRATION = 3600000L // 1 hour
    const val REFRESH_TOKEN_EXPIRATION = 2592000000L // 30 days

    fun generateAccessToken(username: String): String {
        return JWT.create()
            .withIssuer(JWT_ISSUER)
            .withAudience(JWT_AUDIENCE)
            .withSubject(username) // We store username in the token
            .withExpiresAt(Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION))
            .sign(Algorithm.HMAC256(JWT_SECRET))
    }

    // Additional token-related functions...
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Extracting User Information from JWT Tokens

This is the critical step that ties authentication to authorization. We need to:

  1. Extract the token from the request
  2. Validate the token
  3. Get the username from the token
  4. Fetch the user profile with role information

Here's how I implemented this:

// in com.example.backend.schema.auth.ExtractUser.kt
fun extractUserFromToken(call: ApplicationCall): Profile? {
    try {
        // Step 1: Extract token from Authorization header
        val authHeader = call.request.headers[HttpHeaders.Authorization]
        if (authHeader.isNullOrBlank() || !authHeader.startsWith("Bearer ")) {
            logMessage("No Authorization header found or invalid format")
            return null
        }

        // Step 2: Get the token by removing the "Bearer " prefix
        val accessToken = authHeader.removePrefix("Bearer ").trim()

        // Step 3: Extract username from token and validate it
        val username = getUsernameFromToken(accessToken)
        if (username != null) {
            // Step 4: Get the user profile with role information
            return getUserByUsername(username)
        }
        return null
    } catch (e: Exception) {
        logMessage("Error extracting user from token: ${e.message}", LogType.ERROR)
        return null
    }
}

// Helper function to extract username from token
fun getUsernameFromToken(accessToken: String): String? {
    return try {
        val jwt = JWT.require(Algorithm.HMAC256(AuthConfig.JWT_SECRET))
            .withIssuer(AuthConfig.JWT_ISSUER)
            .withAudience(AuthConfig.JWT_AUDIENCE)
            .build()
            .verify(accessToken)
        jwt.subject // This is the username we stored in the token
    } catch (e: Exception) {
        logMessage("Error extracting username from token: ${e.message}", LogType.ERROR)
        null
    }
}

// Database function to get user profile by username
fun getUserByUsername(username: String): Profile? {
    return transaction {
        // First find the user in the Users table
        val user = Users.selectAll()
            .where { Users.userName eq username }
            .singleOrNull() ?: return@transaction null

        // Then get their profile
        val userId = user[Users.userId]
        val profile = Profiles.selectAll()
            .where { Profiles.userId eq userId }
            .singleOrNull() ?: return@transaction null

        // Return a Profile object with the user's role
        Profile(
            userId = userId,
            username = user[Users.userName],
            firstName = profile[Profiles.firstName],
            lastName = profile[Profiles.lastName],
            userRole = user[Users.userRole], // This is the key field for RBAC
            email = profile[Profiles.email],
            // Other profile fields...
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Creating the RBAC Middleware

Now we create a middleware function that will protect our routes based on user roles:

// in com.example.routing.rbac.rbac.kt
suspend fun withRole(
    call: ApplicationCall,
    vararg allowedRoles: UserRole,
    handler: suspend (Profile) -> Unit
) {
    try {
        // Extract user from the token
        val user = extractUserFromToken(call)
        logMessage("Fetched user role: ${user?.userRole ?: "Unknown"} for path: ${call.request.path()}")

        // Check if user exists and has one of the allowed roles
        if (user == null || user.userRole.uppercase() !in allowedRoles.map { it.name }) {
            call.respondText("Access denied", status = HttpStatusCode.Forbidden)
            return
        }

        // If role check passes, execute the handler
        handler(user)
    } catch (e: Exception) {
        logMessage("Error in withRole for path ${call.request.path()}: ${e.message}")
        call.respondText(
            "Internal server error: ${e.message}",
            status = HttpStatusCode.InternalServerError
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Setting Up the Database Schema

Our RBAC system needs to store user roles in the database:

// in com.example.backend.schema.auth.User.kt
object Users : Table() {
    val userId = varchar("user_id", 36).primaryKey()
    val userName = varchar("username", 50).uniqueIndex()
    val passwordHash = varchar("password_hash", 255)
    val userRole = varchar("user_role", 255) // Stores the user's role
    val status = varchar("status", 20)
    val createdAt = long("created_at")
    val updatedAt = long("updated_at")
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Using RBAC in Routes

Now comes the most important part - applying RBAC to protect our routes. Here are real examples from my application:

Example 1: Basic Route Protection

// in com.example.routing.programs.ProgramRouting.kt
fun Route.programRouting() {
    route("/programs") {
        // Only ADMIN and LECTURER roles can access this endpoint
        get {
            withRole(call, UserRole.ADMIN, UserRole.LECTURER) { user ->
                val programs = getAllPrograms()
                call.respond(programs)
            }
        }

        // Only ADMIN role can create new programs
        post {
            withRole(call, UserRole.ADMIN) { user ->
                val program = call.receive<Program>()
                val newProgram = createProgram(program)
                call.respond(HttpStatusCode.Created, newProgram)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Example 2: Role-Based Content Filtering

// in com.example.routing.timetable.TimetableRouting.kt
fun Route.timetableRouting() {
    route("/timetables") {
        get("/class/{classId}") {
            withRole(call, UserRole.ADMIN, UserRole.LECTURER, UserRole.STUDENT) { user ->
                val classId = call.parameters["classId"] ?: return@withRole call.respondText(
                    "Missing classId",
                    status = HttpStatusCode.BadRequest
                )

                // Different behavior based on user role
                val timetables = when (user.userRole.uppercase()) {
                    UserRole.ADMIN.name -> getAllTimetables(classId)
                    UserRole.LECTURER.name -> getLecturerTimetables(classId, user.userId)
                    UserRole.STUDENT.name -> getStudentTimetables(classId)
                    else -> {
                        call.respond(HttpStatusCode.BadRequest, "Invalid user role")
                        return@withRole
                    }
                }

                call.respond(timetables)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Example 3: Checking Role Directly

Sometimes you might need to check the role inside the handler function for more complex logic:

// in com.example.routing.feedback.FeedbackRouting.kt
fun Route.feedbackRouting() {
    route("/feedback") {
        get {
            val user = extractUserFromToken(call) ?: 
                return@get call.respondText("Unauthorized", status = HttpStatusCode.Unauthorized)

            // Use the user's role for custom logic
            val isAdmin = user.userRole.equals("ADMIN", ignoreCase = true)
            val feedbackItems = if (isAdmin) {
                // Admins can see all feedback
                getAllFeedback()
            } else {
                // Others can only see their own feedback
                getUserFeedback(user.userId)
            }

            call.respond(feedbackItems)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Role Validation for User Management

When creating or updating users, validate the roles:

fun validateUserRole(role: String): Boolean {
    return try {
        UserRole.valueOf(role.uppercase())
        true
    } catch (e: IllegalArgumentException) {
        false
    }
}

fun createUser(userRequest: UserCreationRequest): Pair<UserResponse?, String?> {
    // Validate the role
    if (!validateUserRole(userRequest.userRole)) {
        return Pair(null, "Invalid user role")
    }

    // Proceed with user creation
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Complete RBAC Flow Example

Let's put it all together to understand the complete flow:

  1. User Authentication:
   // User logs in
   val loginResponse = login(LoginRequest("username", "password"))
   // User receives JWT token
   val token = loginResponse.tokens.accessToken
Enter fullscreen mode Exit fullscreen mode
  1. Making Authenticated Request:
   // Frontend includes the token in the Authorization header
   // Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Enter fullscreen mode Exit fullscreen mode
  1. Server Processes Request:
   // Route protected with RBAC
   get("/admin-only") {
       withRole(call, UserRole.ADMIN) { user ->
           // Only admins reach this point
           call.respond("Admin content")
       }
   }
Enter fullscreen mode Exit fullscreen mode
  1. What Happens Inside withRole:
    • Extracts token from the request
    • Validates the token and gets the username
    • Fetches the user profile with role from the database
    • Checks if the user's role is in the allowed roles list
    • Executes the handler if authorized, returns 403 Forbidden if not

Best Practices

  1. Role Hierarchy

    • Design your roles with a clear hierarchy in mind
    • In our system, ADMIN has the highest privileges, followed by LECTURER, then STUDENT
  2. Error Handling

    • Always log authentication and authorization failures
    • Return appropriate HTTP status codes (401 for authentication failures, 403 for authorization failures)
    • Don't expose sensitive error details to clients
  3. JWT Security

    • Store only necessary information in JWTs (username, not roles)
    • Keep tokens short-lived
    • Use refresh tokens for longer sessions
    • Store the JWT secret securely using environment variables
  4. Logging

    • Log all important RBAC events for auditing and debugging
    • Include the user and requested path in logs

Conclusion

Implementing RBAC in a Kotlin Ktor application involves setting up authentication with JWT, creating a middleware for role checking, and consistently applying it to your routes. The approach I've shown here is clean, maintainable, and secure.

By following this guide, you can implement a robust RBAC system in your own applications. Remember that security is an ongoing process, so continue to review and improve your implementation as your application evolves.

Resources

Top comments (0)