iT邦幫忙

2021 iThome 鐵人賽

DAY 13
0
Modern Web

基於 Kotlin Ktor 建構支援模組化開發的 Web 框架系列 第 13

[Day 13] 實作 API Authentication

Ktor Authentication Plugin

因為 Ktor 的開發風格是 DSL,不依賴 annotation 及 DI,所以 Ktor Authentication Plugin 的設計及使用方式與 Spring Security 有所不同。在此先說明 Ktor Authentication 的運作機制,後續再進一步實作自己的 Authentication Provider

  1. 先設定 Authentication Provider 並給予 provider name auth-basic
install(Authentication) {
    basic("auth-basic") {
        realm = "Access to the '/' path"
        validate { credentials ->
            if (credentials.name == "jetbrains" && credentials.password == "foobar") {
                UserIdPrincipal(credentials.name)
            } else {
                null
            }
        }
    }
}
  1. 使用 authenticate function 指定 provider name auth-basic,建立 scope 保護裡面所有巢狀階層的 routes。如果驗證成功,我們可以取得 principal 資料進行操作。 Principal 只是一個 marker interface,每個 authenticaton provider 需要提供對應的 principal 子類別實作。例如這個範例中的 UserIdPrincipal 只有 userId,如果使用 session authentication 則要自己實作 UserSessionPrincipal 以填入 session 資料
routing {
    authenticate("auth-basic") {
        get("/") {
            call.respondText("Hello, ${call.principal<UserIdPrincipal>()?.name}!")
        }
    }
}

Multi-Project Authentication

在多專案架構下,每個子專案可以定義自己的驗證方式,所以底層共用的 infra module 只單純負責安裝 Authentication Plugin 而已,實際設定 authentication provider 是交由各個子專案自行設定,我們可以使用 Application.authentication(block: Authentication.Configuration.() -> Unit) extension function 在 install plugin 之後再註冊 provider

子專案先從自己的專案設定檔讀取設定值,然後再傳入至 authentication provider 進行初始化動作,其中 service(), runAs() function 是註冊我自己實作的 ServiceAuthProvider 及 UserRunAsAuthProvider,至於 session() function 則是註冊 ktor SessionAuthenticationProvider,我建立 UserSessionAuthValidator 類別,實作 SessionAuthenticationProvider 的 validate()challenge() 2 個 function

// ===== infra module =====
fun Application.main() {
    install(Authentication)
}

// ===== club project =====
fun Application.clubMain() {
    val projectConfig = ProjectManager.loadConfig<ClubConfig>(ClubConst.projectId)
    
    authentication {
        service(ClubAuth.serviceAuthProviderName, projectConfig.auth.getServiceAuthConfigs())
        session(
            ClubAuth.userAuthProviderName,
            UserSessionAuthValidator(projectConfig.auth.getUserAuthConfigs(), get()).configureFunction
        )
        runAs(ClubAuth.userRunAsAuthProviderName, projectConfig.auth.getRunAsConfigs())
    }
}

// ===== os project =====
fun Application.opsMain() {
    val projectConfig = ProjectManager.loadConfig<OpsConfig>(OpsConst.projectId)
    
    authentication {
        service(OpsAuth.serviceAuthProviderName, projectConfig.auth.getServiceAuthConfigs())
        session(
            OpsAuth.userAuthProviderName,
            UserSessionAuthValidator(projectConfig.auth.getUserAuthConfigs(), get()).configureFunction
        )
    }
}

以下是對應的專案設定檔內容

// ===== club project application-club.conf =====
club {
    auth {
        android {
            apiKey = ${?CLUB_AUTH_ANDROID_API_KEY}
            runAsKey = ${?CLUB_AUTH_ANDROID_RUNAS_KEY}
            session {
                expireDuration = 1d
                extendDuration = 15m
            }
        }
        iOS {
            apiKey = ${?CLUB_AUTH_IOS_API_KEY}
            runAsKey = ${?CLUB_AUTH_IOS_RUNAS_KEY}
            session {
                expireDuration = 1d
                extendDuration = 15m
            }
        }
    }
}

// ===== ops project application-ops.conf =====
ops {
    auth {
        root {
            apiKey = ${?OPS_AUTH_ROOT_API_KEY}
            allowHosts = "127.0.0.1"
        }
        monitor {
            apiKey = ${?OPS_AUTH_MONITOR_API_KEY}
        }
        user {
            apiKey = ${?OPS_AUTH_USER_API_KEY}
            session {
                expireDuration = 1d
                extendDuration = 15m
            }
        }
    }
}

實作 Authentication Provider

在此以 ServiceAuthProvider 的實作程式碼為範例,說明如何實作自己的 Authenticaton Provider。我會給每個 API 呼叫端 Service 一個 principalSource 名稱及 apiKey,做為識別及驗證呼叫端來源之用,Service 送出的 Http Request 必須要夾帶 api key 於 X-API-KEY header,然後 provider 再封裝為 Credential 的子類別 ServiceAuthCredential 物件,最後再呼叫 authetication function 進行 api key 比對。此外,如果 ServiceAuthConfig 有設定 allowHosts 的話,會再比對來源 ip 是否在信任名單內。

另一方面,Authentication Provider 要攔截 AuthenticationPipeline.RequestAuthentication,這樣 Ktor 才會在收到 request 時進行驗證。如果驗證成功則回傳 Principal 物件,否則回傳 null,然後再執行 AuthenticationContext.challenge() function 回應驗證失敗的訊息。

更多完整的 authentication provider 實作程式碼,可點擊以下連結

data class ServiceAuthCredential(val apiKey: String, val host: String) : Credential

data class ServiceAuthConfig(
    val principalSource: PrincipalSource,
    val apiKey: String,
    val allowHosts: String? = null
)

private val ATTRIBUTE_KEY_AUTH_ERROR_CODE = AttributeKey<ResponseCode>("AuthErrorCode")

class ServiceAuthProvider(config: Configuration) : AuthenticationProvider(config) {

    private val authConfigs: List<ServiceAuthConfig> = config.authConfigs

    val authenticationFunction: AuthenticationFunction<ServiceAuthCredential> = { credential ->

        val authConfig = authConfigs.firstOrNull {
            credential.apiKey == it.apiKey
        }
        if (authConfig != null) {
            attributes.put(PrincipalSource.ATTRIBUTE_KEY, authConfig.principalSource)

            val hostAllowed = if (authConfig.allowHosts == null || authConfig.allowHosts == "*" || request.fromLocalhost()) true
            else if (authConfig.allowHosts == "*" && request.fromLocalhost()) true
            else authConfig.allowHosts.split(",").any { it == credential.host }

            if (hostAllowed) {
                ServicePrincipal(authConfig.principalSource)
            } else {
                attributes.put(ATTRIBUTE_KEY_AUTH_ERROR_CODE, InfraResponseCode.AUTH_BAD_HOST)
                null
            }
        } else {
            attributes.put(ATTRIBUTE_KEY_AUTH_ERROR_CODE, InfraResponseCode.AUTH_BAD_KEY)
            null
        }
    }

    class Configuration constructor(providerName: String, val authConfigs: List<ServiceAuthConfig>) :
        AuthenticationProvider.Configuration(providerName) {

        fun build(): ServiceAuthProvider = ServiceAuthProvider(this)
    }
}

fun Authentication.Configuration.service(providerName: String, authConfigs: List<ServiceAuthConfig>) {

    val provider = ServiceAuthProvider.Configuration(providerName, authConfigs).build()

    provider.pipeline.intercept(AuthenticationPipeline.RequestAuthentication) { context ->
        val apiKey = call.request.header(AuthConst.API_KEY_HEADER_NAME)
        val host = call.request.origin.remoteHost

        val credentials = if (apiKey != null) ServiceAuthCredential(apiKey, host) else null
        val principal = credentials?.let { (provider.authenticationFunction)(call, it) as ServicePrincipal? }

        if (principal != null) {
            context.principal(principal)
        } else {
            val cause = if (credentials == null) AuthenticationFailedCause.NoCredentials
            else AuthenticationFailedCause.InvalidCredentials

            context.challenge(providerName, cause) {
                call.respond(
                    CodeResponseDTO(
                        if (credentials == null) InfraResponseCode.AUTH_BAD_KEY
                        else call.attributes[ATTRIBUTE_KEY_AUTH_ERROR_CODE]
                    )
                )
                it.complete()
            }
        }
    }

    register(provider)
}

Ktor 本身只有實作 Authentication 機制,並沒有提供 User Role-Based Authorization 實作,明天我再更進一步說明如何在 Authentication 的基礎上,實作 Authorization。


上一篇
[Day 12] 實作 API Response 及 i18n Response Message
下一篇
[Day 14] 實作 API Role-Based Authorization
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30

尚未有邦友留言

立即登入留言