Ktor Auth
認証ライブラリ
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などと比較してコミュニティサイズが小さい
- デバッグ: 認証フローの詳細なデバッグが困難な場合がある
主要リンク
- Ktor Authentication公式ドキュメント
- Ktor JWT Authentication
- Ktor OAuth Documentation
- Ktor Session Authentication
- Ktor GitHub リポジトリ
- Ktor公式サイト
書き方の例
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}")
}
}
}
}