因為 Ktor 的開發風格是 DSL,不依賴 annotation 及 DI,所以 Ktor Authentication Plugin 的設計及使用方式與 Spring Security 有所不同。在此先說明 Ktor Authentication 的運作機制,後續再進一步實作自己的 Authentication Provider
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
}
}
}
}
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}!")
}
}
}
在多專案架構下,每個子專案可以定義自己的驗證方式,所以底層共用的 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
}
}
}
}
在此以 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。