Ktor Auth

認証ライブラリKotlinKtorJWTOAuthセッション管理REST API

認証ライブラリ

Ktor Auth

概要

Ktor Authは、KtorフレームワークのKotlinサーバーアプリケーション用統合認証ソリューションで、JWT、OAuth、セッション認証など複数の認証方式を統一されたAPIで提供します。

詳細

Ktor Authは、JetBrains社が開発したKtorウェブフレームワークの公式認証プラグインです。マルチプラットフォーム対応のKotlinサーバーアプリケーションにおいて、企業グレードの認証機能を簡単に実装できます。主要な認証方式として、JWT(JSON Web Token)認証、OAuth 2.0 / OpenID Connect、Basic認証、Digest認証、Form-based認証、セッション認証、Bearer Token認証をサポートしています。特にJWT認証では、RS256やHS256などの暗号化アルゴリズムをサポートし、トークンの自動検証やカスタムクレーム処理が可能です。OAuth実装では、Google、Facebook、GitHubなどの主要プロバイダーとの統合が容易で、PKCE(Proof Key for Code Exchange)もサポートしています。セッション管理機能では、サーバーサイドセッション、クライアントサイドセッション(暗号化Cookie)、分散セッション(Redis等)に対応し、セッションの有効期限管理やセキュリティ設定も包括的にサポートしています。

メリット・デメリット

メリット

  • 統合型プラグイン: 複数の認証方式を統一されたAPIで管理
  • Kotlinネイティブ: Kotlin言語の機能を最大限活用した型安全な実装
  • マルチプラットフォーム: JVM、Android、ネイティブプラットフォーム対応
  • プラグイン型アーキテクチャ: 必要な認証方式のみを選択して軽量化
  • 非同期サポート: Kotlin Coroutinesによる高パフォーマンス処理
  • 柔軟な設定: カスタム認証ロジックの実装が容易
  • セキュリティ準拠: 最新のセキュリティ標準に準拠した実装

デメリット

  • Ktor依存: Ktorフレームワーク専用で他のフレームワークでは利用不可
  • 学習コスト: Kotlin Coroutinesとktor固有の概念の理解が必要
  • ドキュメント: 他のメジャーな認証ライブラリと比較してドキュメントが限定的
  • エコシステム: Spring Securityなどと比較してコミュニティサイズが小さい
  • デバッグ: 認証フローの詳細なデバッグが困難な場合がある

主要リンク

書き方の例

Hello World(基本的なJWT認証)

import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm

fun Application.configureAuthentication() {
    install(Authentication) {
        jwt("auth-jwt") {
            realm = "ktor sample app"
            verifier(
                JWT
                    .require(Algorithm.HMAC256("secret"))
                    .withAudience("jwt-audience")
                    .withIssuer("jwt-issuer")
                    .build()
            )
            validate { credential ->
                if (credential.payload.getClaim("username").asString() != "") {
                    JWTPrincipal(credential.payload)
                } else {
                    null
                }
            }
        }
    }
}

fun Application.configureRouting() {
    routing {
        authenticate("auth-jwt") {
            get("/hello") {
                val principal = call.principal<JWTPrincipal>()
                val username = principal!!.payload.getClaim("username").asString()
                call.respondText("Hello $username!")
            }
        }
    }
}

OAuth 2.0統合認証

import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import io.ktor.http.*

data class UserSession(val name: String, val count: Int)

fun Application.configureOAuth() {
    install(Authentication) {
        oauth("auth-oauth-google") {
            urlProvider = { "http://localhost:8080/callback" }
            providerLookup = {
                OAuthServerSettings.OAuth2ServerSettings(
                    name = "google",
                    authorizeUrl = "https://accounts.google.com/o/oauth2/auth",
                    accessTokenUrl = "https://oauth2.googleapis.com/token",
                    requestMethod = HttpMethod.Post,
                    clientId = System.getenv("GOOGLE_CLIENT_ID"),
                    clientSecret = System.getenv("GOOGLE_CLIENT_SECRET"),
                    defaultScopes = listOf("openid", "profile", "email")
                )
            }
            client = HttpClient(Apache)
        }
    }
    
    install(Sessions) {
        cookie<UserSession>("user_session") {
            cookie.path = "/"
            cookie.maxAgeInSeconds = 60 * 60 * 24 * 30 // 30 days
        }
    }
}

fun Application.configureOAuthRouting() {
    routing {
        authenticate("auth-oauth-google") {
            get("/login") {
                // Redirects to OAuth provider
            }
            
            get("/callback") {
                val principal: OAuthAccessTokenResponse.OAuth2? = call.principal()
                call.sessions.set(UserSession(principal?.state ?: "", 1))
                call.respondRedirect("/hello")
            }
        }
        
        get("/hello") {
            val userSession = call.sessions.get<UserSession>()
            if (userSession != null) {
                val countText = if (userSession.count == 1) "first" else "${userSession.count}th"
                call.respondText("Hello ${userSession.name}, this is your $countText visit.")
            } else {
                call.respondRedirect("/login")
            }
        }
    }
}

セッション認証システム

import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import io.ktor.http.*
import kotlinx.serialization.Serializable

@Serializable
data class UserSession(
    val userId: String,
    val username: String,
    val roles: List<String>,
    val loginTime: Long,
    val lastActivity: Long
)

@Serializable
data class LoginRequest(val username: String, val password: String)

// ユーザーデータベース(実際の実装ではDBを使用)
data class User(
    val id: String,
    val username: String,
    val passwordHash: String,
    val roles: List<String>
)

val users = mapOf(
    "admin" to User("1", "admin", "hashed_admin_password", listOf("admin", "user")),
    "user" to User("2", "user", "hashed_user_password", listOf("user"))
)

fun Application.configureSessionAuth() {
    install(Sessions) {
        cookie<UserSession>("user_session") {
            cookie.path = "/"
            cookie.maxAgeInSeconds = 60 * 60 * 8 // 8 hours
            cookie.httpOnly = true
            cookie.secure = true
            cookie.extensions["SameSite"] = "Strict"
        }
    }
    
    install(Authentication) {
        session<UserSession>("auth-session") {
            validate { session ->
                // セッションの有効性チェック
                val now = System.currentTimeMillis()
                val sessionAge = now - session.loginTime
                val inactivityTime = now - session.lastActivity
                
                when {
                    sessionAge > 8 * 60 * 60 * 1000 -> null // 8時間でセッション無効
                    inactivityTime > 30 * 60 * 1000 -> null // 30分非アクティブで無効
                    else -> {
                        // アクティビティ更新
                        val updatedSession = session.copy(lastActivity = now)
                        updatedSession
                    }
                }
            }
            challenge {
                call.respondRedirect("/login")
            }
        }
    }
}

fun Application.configureSessionRouting() {
    routing {
        post("/login") {
            val loginRequest = call.receive<LoginRequest>()
            val user = users[loginRequest.username]
            
            if (user != null && validatePassword(loginRequest.password, user.passwordHash)) {
                val currentTime = System.currentTimeMillis()
                val session = UserSession(
                    userId = user.id,
                    username = user.username,
                    roles = user.roles,
                    loginTime = currentTime,
                    lastActivity = currentTime
                )
                call.sessions.set(session)
                call.respond(HttpStatusCode.OK, "Login successful")
            } else {
                call.respond(HttpStatusCode.Unauthorized, "Invalid credentials")
            }
        }
        
        post("/logout") {
            call.sessions.clear<UserSession>()
            call.respond(HttpStatusCode.OK, "Logout successful")
        }
        
        authenticate("auth-session") {
            get("/profile") {
                val session = call.principal<UserSession>()
                call.respond(session!!)
            }
            
            get("/admin") {
                val session = call.principal<UserSession>()
                if ("admin" in session!!.roles) {
                    call.respondText("Admin area")
                } else {
                    call.respond(HttpStatusCode.Forbidden, "Admin access required")
                }
            }
        }
    }
}

fun validatePassword(password: String, hash: String): Boolean {
    // 実際の実装ではbcryptなどを使用
    return "hashed_${password}_password" == hash
}

JWT認証とリフレッシュトークン

import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import kotlinx.serialization.Serializable
import java.util.*

@Serializable
data class TokenResponse(
    val accessToken: String,
    val refreshToken: String,
    val expiresIn: Long
)

@Serializable
data class RefreshRequest(val refreshToken: String)

object JWTConfig {
    const val ISSUER = "http://localhost:8080/"
    const val AUDIENCE = "jwt-audience"
    const val REALM = "ktor sample app"
    private const val SECRET = "your-super-secret-key"
    private const val ACCESS_TOKEN_VALIDITY = 15 * 60 * 1000L // 15 minutes
    private const val REFRESH_TOKEN_VALIDITY = 7 * 24 * 60 * 60 * 1000L // 7 days
    
    val algorithm = Algorithm.HMAC256(SECRET)
    
    fun generateAccessToken(userId: String, username: String, roles: List<String>): String {
        return JWT.create()
            .withAudience(AUDIENCE)
            .withIssuer(ISSUER)
            .withClaim("userId", userId)
            .withClaim("username", username)
            .withClaim("roles", roles)
            .withExpiresAt(Date(System.currentTimeMillis() + ACCESS_TOKEN_VALIDITY))
            .sign(algorithm)
    }
    
    fun generateRefreshToken(userId: String): String {
        return JWT.create()
            .withAudience(AUDIENCE)
            .withIssuer(ISSUER)
            .withClaim("userId", userId)
            .withClaim("type", "refresh")
            .withExpiresAt(Date(System.currentTimeMillis() + REFRESH_TOKEN_VALIDITY))
            .sign(algorithm)
    }
}

fun Application.configureJWTAuth() {
    install(Authentication) {
        jwt("auth-jwt") {
            realm = JWTConfig.REALM
            verifier(
                JWT.require(JWTConfig.algorithm)
                    .withAudience(JWTConfig.AUDIENCE)
                    .withIssuer(JWTConfig.ISSUER)
                    .build()
            )
            validate { credential ->
                val userId = credential.payload.getClaim("userId").asString()
                val username = credential.payload.getClaim("username").asString()
                if (userId != null && username != null) {
                    JWTPrincipal(credential.payload)
                } else {
                    null
                }
            }
            challenge { defaultScheme, realm ->
                call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
            }
        }
        
        jwt("refresh-jwt") {
            realm = JWTConfig.REALM
            verifier(
                JWT.require(JWTConfig.algorithm)
                    .withAudience(JWTConfig.AUDIENCE)
                    .withIssuer(JWTConfig.ISSUER)
                    .withClaim("type", "refresh")
                    .build()
            )
            validate { credential ->
                val userId = credential.payload.getClaim("userId").asString()
                val type = credential.payload.getClaim("type").asString()
                if (userId != null && type == "refresh") {
                    JWTPrincipal(credential.payload)
                } else {
                    null
                }
            }
        }
    }
}

fun Application.configureJWTRouting() {
    routing {
        post("/auth/login") {
            val loginRequest = call.receive<LoginRequest>()
            val user = users[loginRequest.username]
            
            if (user != null && validatePassword(loginRequest.password, user.passwordHash)) {
                val accessToken = JWTConfig.generateAccessToken(user.id, user.username, user.roles)
                val refreshToken = JWTConfig.generateRefreshToken(user.id)
                
                call.respond(
                    TokenResponse(
                        accessToken = accessToken,
                        refreshToken = refreshToken,
                        expiresIn = 15 * 60 // 15 minutes
                    )
                )
            } else {
                call.respond(HttpStatusCode.Unauthorized, "Invalid credentials")
            }
        }
        
        authenticate("refresh-jwt") {
            post("/auth/refresh") {
                val principal = call.principal<JWTPrincipal>()
                val userId = principal!!.payload.getClaim("userId").asString()
                
                // ユーザー情報を取得(実際の実装ではDBから)
                val user = users.values.find { it.id == userId }
                if (user != null) {
                    val newAccessToken = JWTConfig.generateAccessToken(user.id, user.username, user.roles)
                    val newRefreshToken = JWTConfig.generateRefreshToken(user.id)
                    
                    call.respond(
                        TokenResponse(
                            accessToken = newAccessToken,
                            refreshToken = newRefreshToken,
                            expiresIn = 15 * 60
                        )
                    )
                } else {
                    call.respond(HttpStatusCode.Unauthorized, "User not found")
                }
            }
        }
        
        authenticate("auth-jwt") {
            get("/api/protected") {
                val principal = call.principal<JWTPrincipal>()
                val username = principal!!.payload.getClaim("username").asString()
                val roles = principal.payload.getClaim("roles").asList(String::class.java)
                
                call.respond(mapOf(
                    "message" to "Protected resource accessed",
                    "username" to username,
                    "roles" to roles
                ))
            }
        }
    }
}

複数認証プロバイダーの統合

import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*

fun Application.configureMultiAuth() {
    install(Authentication) {
        // Basic認証
        basic("auth-basic") {
            realm = "Basic Auth"
            validate { credentials ->
                if (credentials.name == "admin" && credentials.password == "admin") {
                    UserIdPrincipal(credentials.name)
                } else {
                    null
                }
            }
        }
        
        // Bearer Token認証
        bearer("auth-bearer") {
            realm = "Bearer Auth"
            authenticate { tokenCredential ->
                // APIキーの検証
                if (tokenCredential.token == "your-api-key") {
                    UserIdPrincipal("api-user")
                } else {
                    null
                }
            }
        }
        
        // Form-based認証
        form("auth-form") {
            userParamName = "user"
            passwordParamName = "password"
            challenge {
                // カスタムログインページにリダイレクト
                call.respondRedirect("/login")
            }
            validate { credentials ->
                if (credentials.name == "form-user" && credentials.password == "form-pass") {
                    UserIdPrincipal(credentials.name)
                } else {
                    null
                }
            }
        }
    }
}

fun Application.configureMultiAuthRouting() {
    routing {
        // 複数の認証方式を許可
        authenticate("auth-jwt", "auth-basic", optional = false) {
            get("/api/flexible") {
                val principal = call.principal<UserIdPrincipal>() ?: call.principal<JWTPrincipal>()
                call.respondText("Access granted via multiple auth methods")
            }
        }
        
        // Basic認証専用エンドポイント
        authenticate("auth-basic") {
            get("/admin/basic") {
                call.respondText("Admin area with Basic Auth")
            }
        }
        
        // Bearer Token認証専用エンドポイント
        authenticate("auth-bearer") {
            get("/api/external") {
                call.respondText("External API access")
            }
        }
        
        // オプショナル認証(認証ありとなしの両方をサポート)
        authenticate("auth-jwt", optional = true) {
            get("/public-or-private") {
                val principal = call.principal<JWTPrincipal>()
                if (principal != null) {
                    val username = principal.payload.getClaim("username").asString()
                    call.respondText("Welcome back, $username!")
                } else {
                    call.respondText("Public access")
                }
            }
        }
        
        // 認証不要のエンドポイント
        get("/public") {
            call.respondText("Public endpoint")
        }
    }
}

カスタム認証プロバイダー

import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.util.*

// カスタム認証設定
class CustomAuthenticationConfig : AuthenticationProvider.Config("custom") {
    var userProvider: ((String) -> User?)? = null
    var headerName: String = "X-API-Key"
    var rateLimitProvider: ((String) -> Boolean)? = null
}

// カスタム認証プロバイダー
class CustomAuthenticationProvider(config: CustomAuthenticationConfig) : AuthenticationProvider(config) {
    val userProvider = config.userProvider
    val headerName = config.headerName
    val rateLimitProvider = config.rateLimitProvider

    override suspend fun onAuthenticate(context: AuthenticationContext) {
        val token = context.call.request.headers[headerName]
        
        if (token == null) {
            context.challenge("CustomAuth", AuthenticationFailedCause.NoCredentials) {
                it.respond(HttpStatusCode.Unauthorized, "Missing $headerName header")
                it.finish()
            }
            return
        }
        
        // レート制限チェック
        if (rateLimitProvider?.invoke(token) == false) {
            context.challenge("CustomAuth", AuthenticationFailedCause.InvalidCredentials) {
                it.respond(HttpStatusCode.TooManyRequests, "Rate limit exceeded")
                it.finish()
            }
            return
        }
        
        val user = userProvider?.invoke(token)
        if (user != null) {
            context.principal(UserIdPrincipal(user.username))
        } else {
            context.challenge("CustomAuth", AuthenticationFailedCause.InvalidCredentials) {
                it.respond(HttpStatusCode.Unauthorized, "Invalid API key")
                it.finish()
            }
        }
    }
}

// 拡張関数でカスタム認証を簡単に設定
fun AuthenticationConfig.custom(
    name: String? = null,
    configure: CustomAuthenticationConfig.() -> Unit
) {
    val provider = CustomAuthenticationProvider(CustomAuthenticationConfig().apply(configure))
    register(provider)
}

fun Application.configureCustomAuth() {
    install(Authentication) {
        custom("custom-api-key") {
            headerName = "X-API-Key"
            userProvider = { apiKey ->
                // API キーからユーザーを検索
                when (apiKey) {
                    "secret-key-1" -> User("1", "service-a", "", listOf("service"))
                    "secret-key-2" -> User("2", "service-b", "", listOf("service"))
                    else -> null
                }
            }
            rateLimitProvider = { apiKey ->
                // 簡単なレート制限(実際の実装ではRedisなどを使用)
                true
            }
        }
    }
}

fun Application.configureCustomAuthRouting() {
    routing {
        authenticate("custom-api-key") {
            get("/api/services") {
                val principal = call.principal<UserIdPrincipal>()
                call.respondText("Service API accessed by: ${principal?.name}")
            }
        }
    }
}