[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。


<<:  当Expection发生时, 如何显示完整的CallStack和位置 (Traceback应用)

>>:  Day18:[排序演算法]Selection Sort - 选择排序法

[SwiftUI] 如何统计数字重复的次数

如果我有一个数字的阵列变数[2, 1, 2, 3, 5, 6, 8, 9],想要计算(0~9)各个数...

【LeetCode】Binary Search

参考 LeetCode Binary Search Summary 二分搜索法小结 的 python...

工具制作:xml处理工具

本来是想要实现config工具的,然而比较好用的配置文件的格式是xml,於是就先做一个xml的工具;...

State 和生命周期(上)(Day5)

在讲到生命周期之前要确认理解前面做过的两件事: setInterval 去更新画面的例子 记得有 c...

No Time To Die在线看

No Time To Die在线看 《007:无暇赴死》是007系列电影的第25部,由凯瑞·福永执导...