Ktor를 이용하여 JWT 인증 시스템을 구현 과정에 대한 글입니다!
프로젝트 생성 방법과 DB 연동 이후에 이어지는 내용입니다.
JWT(Json Web Token)는 JSON 객체를 사용해 정보를 안전하게 전달하는 웹 표준입니다. 주로 사용자 인증에 사용되며, 세션 관리가 필요 없는 상태 비저장(stateless) 방식의 인증 메커니즘으로 많이 사용됩니다. JWT는 클라이언트와 서버 간의 인증 정보를 효율적으로 관리할 수 있어, 특히 RESTful API에서 많이 사용됩니다.
JWT에 더 자세히 알고 싶다면? 이 글을 참고해주세요.
Ktor와 PostgreSQL을 선택한 이유
먼저, Ktor 프로젝트를 생성하고 필요한 의존성을 추가합니다. 이 과정에서 JWT 인증, 데이터베이스 연동, 직렬화 기능 등을 설정합니다.
build.gradle.kts 파일은 프로젝트에 필요한 라이브러리 의존성을 관리하는 곳입니다.
Ktor, Exposed ORM, PostgreSQL, HikariCP, JWT 라이브러리 등 다양한 라이브러리를 추가했습니다!
dependencies {
implementation("org.jetbrains.exposed:exposed-core:0.41.1")
implementation("org.jetbrains.exposed:exposed-dao:0.41.1")
implementation("org.jetbrains.exposed:exposed-jdbc:0.41.1")
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
implementation("io.ktor:ktor-server-content-negotiation-jvm")
// Exposed ORM 라이브러리
implementation("org.jetbrains.exposed:exposed-core:$exposed_version")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version")
// PostgreSQL JDBC 드라이버
implementation("org.postgresql:postgresql:42.6.0")
// HikariCP (커넥션 풀)
implementation("com.zaxxer:HikariCP:5.0.1")
// 환경 변수 로드
implementation("io.github.cdimascio:dotenv-kotlin:6.4.0")
implementation("io.ktor:ktor-server-auth:$logback_version")
implementation("io.ktor:ktor-server-auth-jwt:$logback_version")
implementation("com.auth0:java-jwt:4.2.1")
implementation("com.h2database:h2:$h2_version")
implementation("io.ktor:ktor-server-openapi")
implementation("io.ktor:ktor-server-netty-jvm")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.ktor:ktor-server-config-yaml")
testImplementation("io.ktor:ktor-server-test-host-jvm")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
이 부분에서 주요 라이브러리를 간단히 소개하자면
.env
파일에서 환경 변수를 쉽게 로드할 수 있도록 도와줍니다.애플리케이션의 메인 함수와 모듈 설정은 Application.module()
을 사용해 설정합니다. 여기서는 데이터베이스, 직렬화, HTTP 설정, 보안 및 라우팅을 설정합니다.
fun main(args: Array<String>) {
EngineMain.main(args)
}
fun Application.module() {
configureDatabase() // 데이터베이스 설정
configureSerialization() // 직렬화 설정
configureHTTP() // HTTP 관련 설정
configureSecurity() // 보안 설정
configureRouting() // 라우팅 설정
}
이 함수는 각 기능별 설정 함수를 호출하여 애플리케이션 전반의 구성을 담당합니다.
데이터베이스 연결은 HikariCP
와 Exposed ORM
을 사용하여 설정합니다. 환경 변수는 dotenv
라이브러리를 통해 불러옵니다.
fun Application.configureDatabase() {
val dotenv = dotenv() // .env 파일 로드
val dbUrl = dotenv["DB_URL"] ?: "jdbc:postgresql://localhost:5432/defaultdb"
val dbUser = dotenv["DB_USER"] ?: "defaultuser"
val dbPassword = dotenv["DB_PASSWORD"] ?: "defaultpassword"
val hikariConfig = HikariConfig().apply {
jdbcUrl = dbUrl
driverClassName = "org.postgresql.Driver"
username = dbUser
password = dbPassword
maximumPoolSize = 10
}
try {
val dataSource = HikariDataSource(hikariConfig)
Database.connect(dataSource)
environment.log.info("Database connected successfully! : $dbUrl")
} catch (e: Exception) {
environment.log.error("Database connection failed: ${e.message}")
}
transaction {
SchemaUtils.drop(Users) // 데이터베이스 초기화 (개발 중에만 사용)
SchemaUtils.create(Users) // Users 테이블 생성
}
}
이 함수는 PostgreSQL 데이터베이스에 연결한 후 Users
테이블을 생성합니다. 여기서는 Exposed
ORM을 통해 스키마를 정의하고, 데이터베이스 초기화 및 테이블 생성을 처리합니다.
ENV 파일에는 이러한 형식으로 작성하면 됩니다.
DB_URL=jdbc:postgresql://localhost:5432/test
DB_USER=mic050r
DB_PASSWORD=123456
Users
테이블은 Exposed ORM
을 사용해 정의하였습니다. 이 테이블은 사용자의 기본 정보(이름, 이메일, 비밀번호)를 저장합니다.
object Users : Table() {
val id = integer("id").autoIncrement()
val name = varchar("name", 50)
val email = varchar("email", 50)
val password = varchar("password", 64)
override val primaryKey = PrimaryKey(id)
}
Exposed
ORM을 사용하면 데이터베이스 테이블과 필드를 쉽게 정의할 수 있습니다. 여기서는 id
, name
, email
, password
필드를 정의했습니다.
JWT 인증은 io.ktor.auth.jwt
패키지를 사용하여 설정됩니다. configureSecurity()
함수에서 JWT 인증을 처리하는 로직을 작성합니다.
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.auth.jwt.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.http.*
import io.ktor.server.auth.*
fun Application.configureSecurity() {
install(Authentication) {
jwt("auth-jwt") {
realm = "ktor-sample"
verifier(
JWT
.require(Algorithm.HMAC256("secret"))
.withAudience("ktor-audience")
.withIssuer("ktor-issuer")
.build()
)
validate { credential ->
if (credential.payload.getClaim("name").asString().isNotEmpty()) {
JWTPrincipal(credential.payload)
} else null
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
}
}
}
}
이 설정은 JWT 토큰을 검증하고, 유효한 경우 인증된 요청으로 처리합니다. 인증된 사용자는 보호된 라우트에 접근할 수 있습니다.
라우팅 설정에서는 사용자 인증이 필요한 userRoutes
와 로그인, 회원가입을 처리하는 authRoutes
를 정의합니다.
fun Application.configureRouting() {
routing {
authenticate("auth-jwt") {
userRoutes() // 유저 관련 라우팅 추가
}
authRoutes() // 로그인, 회원가입 라우팅 추가
}
}
이 코드에서는 authenticate("auth-jwt")
블록 안에 JWT 인증이 필요한 라우트를 정의했습니다. 인증이 필요한 경로에서는 사용자 정보 수정과 삭제를 처리하는 라우팅을 추가했습니다.
authRoutes()
함수에서는 회원가입/로그인 API를 정의합니다.
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import example.com.dto.Credentials
import example.com.dto.UserResponse
import example.com.models.User
import example.com.models.Users
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
fun Route.authRoutes() {
route("/users") {
// POST - 유저 생성
post {
val user = call.receive<User>()
transaction {
Users.insert {
it[name] = user.name
it[email] = user.email
it[password] = user.password // 비밀번호 저장
}
}
call.respond(HttpStatusCode.Created, "User added successfully")
}
// POST - 로그인
post("/login") {
val credentials = call.receive<Credentials>()
// 데이터베이스에서 유저 조회
val user = transaction {
Users.select { Users.name eq credentials.name }
.map { User(it[Users.id], it[Users.name], it[Users.email], it[Users.password]) }
.singleOrNull()
}
if (user != null && user.password == credentials.password) { // 비밀번호 검증
val token = JWT.create()
.withAudience("ktor-audience")
.withIssuer("ktor-issuer")
.withClaim("name", credentials.name)
.sign(Algorithm.HMAC256("secret"))
call.respond(hashMapOf("token" to token))
} else {
call.respond(HttpStatusCode.Unauthorized, "Invalid credentials")
}
}
// GET - 모든 유저 조회
get {
val users = transaction {
Users.selectAll().map {
UserResponse(it[Users.id], it[Users.name], it[Users.email]) // 비밀번호를 제외하고 응답
}
}
call.respond(users)
}
// GET - 특정 유저 조회
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid user ID")
return@get
}
val user = transaction {
Users.select { Users.id eq id }
.map { UserResponse(it[Users.id], it[Users.name], it[Users.email]) }
.singleOrNull()
}
if (user == null) {
call.respond(HttpStatusCode.NotFound, "User not found")
} else {
call.respond(user)
}
}
}
// GET - 모든 유저 조회
get {
val users = transaction {
Users.selectAll().map {
UserResponse(it[Users.id], it[Users.name], it[Users.email]) // 비밀번호를 제외하고 응답
}
}
call.respond(users)
}
// GET - 특정 유저 조회
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid user ID")
return@get
}
val user = transaction {
Users.select { Users.id eq id }
.map { UserResponse(it[Users.id], it[Users.name], it[Users.email]) }
.singleOrNull()
}
if (user == null) {
call.respond(HttpStatusCode.NotFound, "User not found")
} else {
call.respond(user)
}
}
}
userRoutes()
함수에서는 사용자의 정보를 수정하거나 삭제하는 API를 정의합니다.
package example.com.routes
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import example.com.dto.Credentials
import example.com.dto.UserResponse
import example.com.models.User
import example.com.models.Users
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
fun Route.userRoutes() {
route("/users") {
// PUT - 유저 정보 수정
put("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
val user = call.receive<User>()
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid user ID")
return@put
}
val updated = transaction {
Users.update({ Users.id eq id }) {
it[name] = user.name
it[email] = user.email
it[password] = user.password // 비밀번호 업데이트
}
}
if (updated == 0) {
call.respond(HttpStatusCode.NotFound, "User not found")
} else {
call.respond(HttpStatusCode.OK, "User updated successfully")
}
}
// DELETE - 유저 삭제
delete("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid user ID")
return@delete
}
val deleted = transaction {
Users.deleteWhere { Users.id eq id }
}
if (deleted == 0) {
call.respond(HttpStatusCode.NotFound, "User not found")
} else {
call.respond(HttpStatusCode.OK, "User deleted successfully")
}
}
}
}
이 라우트에서는 사용자 ID를 기준으로 정보를 수정하거나 삭제하는 API를 제공합니다.
/users
POST
{
"name": "John Doe",
"email": "john.doe@example.com",
"password": "password123"
}
201 Created
User added successfully
/auth/login
POST
{
"email": "john.doe@example.com",
"password": "password123"
}
200 OK
{
"token": "eyJhbGciOiJIUzI1NiIsInR..."
}
401 Unauthorized
/users/{id}
PUT
Bearer {JWT_TOKEN}
application/json
200 OK
User updated successfully
404 Not Found
User not found
로그인 했을 때 발급 된 token을 header에 넣어줍니다!
- Key :
Authorization
- Value :
Bearer <token>
get으로 불러왔더니 잘 바뀐것을 확인했습니다!
val kotlin_version: String by project
val logback_version: String by project
val exposed_version: String by project
val h2_version: String by project
plugins {
kotlin("jvm") version "2.0.10"
id("io.ktor.plugin") version "2.3.12"
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.10"
}
group = "example.com"
version = "0.0.1"
application {
mainClass.set("io.ktor.server.netty.EngineMain")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.jetbrains.exposed:exposed-core:0.41.1")
implementation("org.jetbrains.exposed:exposed-dao:0.41.1")
implementation("org.jetbrains.exposed:exposed-jdbc:0.41.1")
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
implementation("io.ktor:ktor-server-content-negotiation-jvm")
// Exposed ORM 라이브러리
implementation("org.jetbrains.exposed:exposed-core:$exposed_version")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposed_version")
// PostgreSQL JDBC 드라이버
implementation("org.postgresql:postgresql:42.6.0")
// HikariCP (커넥션 풀)
implementation("com.zaxxer:HikariCP:5.0.1")
// env
implementation("io.github.cdimascio:dotenv-kotlin:6.4.0")
implementation("io.ktor:ktor-server-auth:$logback_version") // Ktor 버전에 맞게 조정
implementation("io.ktor:ktor-server-auth-jwt:$logback_version")
implementation("com.auth0:java-jwt:4.2.1") // 최신 버전으로 조정
implementation("com.h2database:h2:$h2_version")
implementation("io.ktor:ktor-server-openapi")
implementation("io.ktor:ktor-server-auth-jvm")
implementation("io.ktor:ktor-server-netty-jvm")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.ktor:ktor-server-config-yaml")
testImplementation("io.ktor:ktor-server-test-host-jvm")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
package example.com
import example.com.plugins.*
import io.ktor.server.application.*
import io.ktor.server.netty.*
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import example.com.models.Users
import example.com.routes.authRoutes
import example.com.routes.userRoutes
import io.github.cdimascio.dotenv.dotenv
import io.ktor.server.auth.*
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
fun main(args: Array<String>) {
EngineMain.main(args)
}
fun Application.module() {
// 설정 함수 호출
configureDatabase() // 데이터베이스 설정
configureSerialization() // 직렬화 설정
configureHTTP() // HTTP 관련 설정
configureSecurity() // 보안 설정
configureRouting() // 라우팅 설정
}
fun Application.configureDatabase() {
val dotenv = dotenv() // .env 파일 로드
val dbUrl = dotenv["DB_URL"] ?: "jdbc:postgresql://localhost:5432/defaultdb"
val dbUser = dotenv["DB_USER"] ?: "defaultuser"
val dbPassword = dotenv["DB_PASSWORD"] ?: "defaultpassword"
val hikariConfig = HikariConfig().apply {
jdbcUrl = dbUrl
driverClassName = "org.postgresql.Driver"
username = dbUser
password = dbPassword
maximumPoolSize = 10
}
try {
val dataSource = HikariDataSource(hikariConfig)
Database.connect(dataSource)
// 연결 성공 시 로그 출력
environment.log.info("Database connected successfully! : $dbUrl")
} catch (e: Exception) {
// 연결 실패 시 오류 로그 출력
environment.log.error("Database connection failed: ${e.message}")
}
// 데이터베이스 테이블 생성
transaction {
SchemaUtils.drop(Users)
SchemaUtils.create(Users) // Users 테이블 생성
}
}
fun Application.configureRouting() {
routing {
authenticate("auth-jwt") { // jwt 인증이 필요한 라우팅
userRoutes() // 유저 관련 라우팅 추가
}
authRoutes() // 로그인, 회원가입 라우팅 추가
}
}
package example.com.dto
import kotlinx.serialization.Serializable
@Serializable
data class Credentials(val name: String, val password: String)
package example.com.dto
import kotlinx.serialization.Serializable
@Serializable
data class UserResponse(val id: Int, val name: String, val email: String)
package example.com.models
import org.jetbrains.exposed.sql.Table
import kotlinx.serialization.Serializable
// Exposed ORM 테이블 정의
object Users : Table() {
val id = integer("id").autoIncrement()
val name = varchar("name", 50)
val email = varchar("email", 50)
val password = varchar("password", 64) // 비밀번호 필드 추가
override val primaryKey = PrimaryKey(id)
}
// 데이터 클래스 정의 (직렬화를 위해 Serializable 사용)
@Serializable
data class User(val id: Int? = null, val name: String, val email: String, val password: String)
package example.com.plugins
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.auth.jwt.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.http.*
import io.ktor.server.auth.*
fun Application.configureSecurity() {
install(Authentication) {
jwt("auth-jwt") {
realm = "ktor-sample"
verifier(
JWT
.require(Algorithm.HMAC256("secret"))
.withAudience("ktor-audience")
.withIssuer("ktor-issuer")
.build()
)
validate { credential ->
if (credential.payload.getClaim("name").asString().isNotEmpty()) {
JWTPrincipal(credential.payload)
} else null
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
}
}
}
}
package example.com.routes
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import example.com.dto.Credentials
import example.com.dto.UserResponse
import example.com.models.User
import example.com.models.Users
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
fun Route.authRoutes() {
route("/users") {
// POST - 유저 생성
post {
val user = call.receive<User>()
transaction {
Users.insert {
it[name] = user.name
it[email] = user.email
it[password] = user.password // 비밀번호 저장
}
}
call.respond(HttpStatusCode.Created, "User added successfully")
}
// POST - 로그인
post("/login") {
val credentials = call.receive<Credentials>()
// 데이터베이스에서 유저 조회
val user = transaction {
Users.select { Users.name eq credentials.name }
.map { User(it[Users.id], it[Users.name], it[Users.email], it[Users.password]) }
.singleOrNull()
}
if (user != null && user.password == credentials.password) { // 비밀번호 검증
val token = JWT.create()
.withAudience("ktor-audience")
.withIssuer("ktor-issuer")
.withClaim("name", credentials.name)
.sign(Algorithm.HMAC256("secret"))
call.respond(hashMapOf("token" to token))
} else {
call.respond(HttpStatusCode.Unauthorized, "Invalid credentials")
}
}
// GET - 모든 유저 조회
get {
val users = transaction {
Users.selectAll().map {
UserResponse(it[Users.id], it[Users.name], it[Users.email]) // 비밀번호를 제외하고 응답
}
}
call.respond(users)
}
// GET - 특정 유저 조회
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid user ID")
return@get
}
val user = transaction {
Users.select { Users.id eq id }
.map { UserResponse(it[Users.id], it[Users.name], it[Users.email]) }
.singleOrNull()
}
if (user == null) {
call.respond(HttpStatusCode.NotFound, "User not found")
} else {
call.respond(user)
}
}
}
// GET - 모든 유저 조회
get {
val users = transaction {
Users.selectAll().map {
UserResponse(it[Users.id], it[Users.name], it[Users.email]) // 비밀번호를 제외하고 응답
}
}
call.respond(users)
}
// GET - 특정 유저 조회
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid user ID")
return@get
}
val user = transaction {
Users.select { Users.id eq id }
.map { UserResponse(it[Users.id], it[Users.name], it[Users.email]) }
.singleOrNull()
}
if (user == null) {
call.respond(HttpStatusCode.NotFound, "User not found")
} else {
call.respond(user)
}
}
}
package example.com.routes
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import example.com.dto.Credentials
import example.com.dto.UserResponse
import example.com.models.User
import example.com.models.Users
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
fun Route.userRoutes() {
route("/users") {
// PUT - 유저 정보 수정
put("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
val user = call.receive<User>()
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid user ID")
return@put
}
val updated = transaction {
Users.update({ Users.id eq id }) {
it[name] = user.name
it[email] = user.email
it[password] = user.password // 비밀번호 업데이트
}
}
if (updated == 0) {
call.respond(HttpStatusCode.NotFound, "User not found")
} else {
call.respond(HttpStatusCode.OK, "User updated successfully")
}
}
// DELETE - 유저 삭제
delete("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid user ID")
return@delete
}
val deleted = transaction {
Users.deleteWhere { Users.id eq id }
}
if (deleted == 0) {
call.respond(HttpStatusCode.NotFound, "User not found")
} else {
call.respond(HttpStatusCode.OK, "User deleted successfully")
}
}
}
}
DB_URL=jdbc:postgresql://localhost:5432/test
DB_USER=mic050r
DB_PASSWORD=123456
/auth/signup
엔드포인트로 POST
요청을 보내고, 새로운 사용자를 등록하기/auth/login
엔드포인트로 POST
요청을 보내 JWT 토큰을 발급받기/users/{id}
로 PUT
요청을 보내 사용자 정보를 수정하기/users/{id}
로 DELETE
요청을 보내 사용자 계정을 삭제할 수 있다.추가 학습 자료