Understanding Identity, Authentication, and Authorization
Every application eventually grapples with two fundamental questions: Who is this person? And what are they allowed to do? While many tutorials focus on integrating third-party authentication providers or delve into low-level hashing algorithms, they often miss a crucial distinction. The core challenge isn't just verifying a login; it's understanding the actual human interacting with your app and managing their data and access accordingly. This distinction separates mere authentication from a comprehensive understanding of user identity and their associated privileges.
This post explores the critical differences between identity (who a user is), authentication (proving they are who they claim to be), and authorization (determining what they can access). We will build a robust user management system using Kotlin with the Ktor framework for the backend and the Exposed SQL framework for database interaction, leveraging JSON Web Tokens (JWTs) for secure session management.
Core Components: Ktor, Exposed, and JWT
Our chosen stack provides a powerful foundation for building a modern backend service. Ktor, a framework for creating asynchronous servers and clients in Kotlin, offers flexibility and performance. Exposed, an ORM (Object-Relational Mapper) for Kotlin, simplifies database interactions by providing a type-safe DSL. JWTs are the standard for securely transmitting information between parties as a JSON object, making them ideal for stateless authentication.
Setting up the Ktor Project
Begin by creating a new Ktor project. For this example, we'll use the Ktor Maven archetype or Gradle plugin. Ensure you include the necessary dependencies for Ktor's server core, Content Negotiation (for JSON), and JWT authentication. A typical setup might look like this:
implementation("io.ktor:ktor-server-core-jvm:2.3.4")
implementation("io.ktor:ktor-server-netty-jvm:2.3.4")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:2.3.4")
implementation("io.ktor:ktor-server-auth-jvm:2.3.4")
implementation("io.ktor:ktor-server-auth-jwt-jvm:2.3.4")
The Netty engine is a common choice for its performance, and the serialization and auth-jwt modules are essential for handling requests and JWTs.
Database Schema with Exposed
We need a database to store user information. Exposed allows us to define our schema in a type-safe manner. We'll create a Users table with essential fields like ID, username, and password hash.
object Users : IntIdTable("users") {
val username = varchar("username", 50).unique()
val passwordHash = varchar("password_hash")
}
This defines a table named users with an auto-incrementing integer ID, a unique username column, and a password hash column. We'll use a library like BCrypt to securely hash passwords.
Password Hashing with BCrypt
Never store plain-text passwords. BCrypt is a robust password hashing function that is resistant to brute-force attacks. We'll integrate a BCrypt library (e.g., org.mindrot.jbcrypt) into our application.
import org.mindrot.jbcrypt.BCrypt
fun hashPassword(password: String): String = BCrypt.hashpw(password, BCrypt.gensalt())
fun verifyPassword(password: String, hash: String): Boolean = BCrypt.checkpw(password, hash)
The hashPassword function generates a salt and hashes the password, while verifyPassword checks a given password against an existing hash. This is a critical step for security.
Implementing Authentication and Authorization with JWT
User Registration
The registration endpoint will receive a username and password, hash the password, and store the user in the database. It should return an error if the username already exists.
post("/register") {
val request = call.receive<UserRegistrationRequest>()
val hashedPassword = hashPassword(request.password)
transaction {
val existingUser = Users.select { Users.username eq request.username }.firstOrNull()
if (existingUser != null) {
call.respond(HttpStatusCode.Conflict, "Username already exists")
} else {
Users.insert {
it[username] = request.username
it[passwordHash] = hashedPassword
}
call.respond(HttpStatusCode.Created, "User registered successfully")
}
}
}
User Login and Token Generation
The login endpoint verifies credentials against the stored hash. If valid, it generates a JWT containing user claims (like username and user ID) and returns it to the client. This JWT will be used for subsequent authenticated requests.
post("/login") {
val request = call.receive<UserLoginRequest>()
val user = transaction { Users.select { Users.username eq request.username }.firstOrNull() }
if (user == null || !verifyPassword(request.password, user[Users.passwordHash])) {
call.respond(HttpStatusCode.Unauthorized, "Invalid credentials")
} else {
val token = JWT.create(
algorithm = Algorithm.HMAC256("your-secret-key"), // Use a strong, environment-variable based secret
payload = JWTClaims(
subject = user[Users.id].value.toString(),
audience = "your-audience",
issuer = "your-issuer",
expiresAt = Date(System.currentTimeMillis() + 60 * 60 * 1000) // 1 hour expiration
)
)
call.respond(mapOf("token" to token))
}
}
Crucially, the secret key used for signing the JWT must be kept secure and ideally loaded from environment variables, not hardcoded. The token payload can include claims like `sub` (subject, typically user ID), `iss` (issuer), `aud` (audience), and `exp` (expiration time).
Securing Endpoints with JWT Authentication
Ktor's authentication module allows us to protect routes. We configure the JWT authenticator with our secret key and the claims we expect.
install(Authentication) {
jwt {
val jwtSecret = environment.config.property("jwt.secret").getString() // Load from config
val issuer = environment.config.property("jwt.issuer").getString()
val audience = environment.config.property("jwt.audience").getString()
verifier(Algorithm.HMAC256(jwtSecret))
validate {
if (it.payload.audience.contains(audience) && it.payload.issuer == issuer) {
val userId = it.payload.subject.toIntOrNull()
if (userId != null) JWTPrincipal(it.payload) else null
} else {
null
}
}
challenge { defaultScheme, realm ->
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or expired. $defaultScheme, realm: $realm")
}
}
}
routing {
authenticate("JWT") {
get("/profile") {
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.subject?.toIntOrNull()
// Fetch user profile based on userId
call.respond(HttpStatusCode.OK, "Welcome, user $userId!")
}
}
}
Any route within the authenticate("JWT") block will require a valid JWT in the Authorization: Bearer <token> header. The validate function inspects the token's claims and returns a JWTPrincipal if valid, otherwise null, triggering the challenge.
Authorization: Role-Based Access Control (RBAC)
Beyond authentication, we need authorization. A common pattern is Role-Based Access Control (RBAC). We can extend our Users table or create a new Roles table and a join table (UserRoles) to assign roles to users.
Extending the Schema for Roles
object Roles : IntIdTable("roles") {
val name = varchar("name", 50).unique()
}
object UserRoles : Table("user_roles") {
val userId = reference("user_id", Users.id)
val roleId = reference("role_id", Roles.id)
override val primaryKey = PrimaryKey(userId, roleId)
}
When a user logs in, their roles can be fetched and added as custom claims to the JWT. Alternatively, roles can be checked on a per-request basis by querying the database.
Implementing Role Checks
To implement role-based authorization, you can create custom authorization checks or middleware. For instance, you could modify the JWT validation to include roles in the payload, or fetch roles on each protected request.
// Example: Checking roles within a protected endpoint
authenticate("JWT") {
get("/admin/dashboard") {
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.subject?.toIntOrNull()
val isAdmin = transaction {
Users.join(UserRoles, JoinType.INNER, Users.id, UserRoles.userId)
.join(Roles, JoinType.INNER, UserRoles.roleId, Roles.id)
.select { Users.id eq userId AND Roles.name eq "admin" }
.any()
}
if (isAdmin) {
call.respond(HttpStatusCode.OK, "Admin dashboard data")
} else {
call.respond(HttpStatusCode.Forbidden, "You do not have permission")
}
}
}
This approach ensures that only users with the 'admin' role can access the admin dashboard. The complexity of authorization can grow significantly with more granular permissions, potentially leading to a more sophisticated access control model.
Conclusion and Next Steps
Building a user management system with Ktor, Exposed, and JWTs provides a solid, secure foundation for your applications. This architecture allows for stateless authentication, enabling scalability. Key takeaways include the importance of distinguishing identity, authentication, and authorization; secure password handling via hashing; and robust JWT implementation for session management. For production, consider more advanced security practices like refresh tokens, proper secret management, and rate limiting.
