前面我們已經學會 Ktor Authentication 機制,而且也整合了 Database 及 Redis,今天我們把這些東西都串連起來,實作支援 Multi-Project 架構的 Session Authentication。
Session Authentication 的流程如下
Ktor 雖然有提供 Authentication Plugin 驗證請求,還有 Sessions Plugin 存取 Session 資料,但是這2個 Plugin 不像 Spring 框架的 Spring Session 與 Spring Security 完美整合,所以需要自己寫程式碼處理細節,但也因為如此我才能進行客製化調整。以下是我的實作目標
RedisPlugin 實作細節可參考 [Day 25] 實作 Redis Plugin 整合 Redis Coroutine Client
install(RedisFeature)
Ktor 的 Sessions Plugin 只定義存取 Session 資料的流程,必須自行實作 SessionStorage
及 SessionSerializer
,所以我實作了 SessionAuth Plugin,根據 Ktor 設定檔的 storageType
建立對應的 SessionStorage,目前支援 Redis。我使用 RedisPlugin 的 RedisClient
初始化 RedisSessionStorage
如果有設定 redisKeyExpiredNotification = true,那麼當 session 過期時,RedisKeyspaceNotificationListener
可以接收來自 Redis 的通知,RedisKeyspaceNotificationListener
的詳細實作可以參考 [Day 27] 實作 Redis PubSub Keyspace Notification 訂閱 Session Key Expired 事件通知。
接下來實作 LoginService
當使用者登入/登出時,透過 SessionStorage 建立/刪除 Session 資料,同時註冊 LoginLog
的 LogWriter
至LogMessageDispatcher
,記錄登入/登出時的 log。Logging 機制的詳細實作可參考 [Day 20] 實作 Ktor Logging 機制
sessionAuth {
storageType = "Redis" # Redis
redisKeyExpiredNotification = true
session {
expireDuration = 1d
extendDuration = 15m
}
logging {
enabled = true
destination = "AwsKinesis" # File(default), Database, AwsKinesis
}
}
install(SessionAuthPlugin)
override fun install(pipeline: Application, configure: Configuration.() -> Unit): SessionAuthPlugin {
// ========== SessionStorage ==========
val sessionStorage = when (sessionAuthConfig.storageType) {
SessionStorageType.Redis -> {
val redisClient = pipeline.get<RedisClient>()
val redisKeyspaceNotificationListener = if (sessionAuthConfig.redisKeyExpiredNotification == true) {
pipeline.get<RedisKeyspaceNotificationListener>()
} else null
val logWriter = pipeline.get<LogWriter>()
RedisSessionStorage(sessionAuthConfig.session, redisClient, redisKeyspaceNotificationListener, logWriter)
}
}
pipeline.koin {
modules(
module(createdAtStart = true) {
single<MySessionStorage> { sessionStorage }
}
)
}
// 設定 Ktor Sessions Plugin 的 SessionStorage 及 SessionSerializer
pipeline.install(Sessions) {
header<UserPrincipal>(AuthConst.SESSION_ID_HEADER_NAME, sessionStorage) {
serializer = object : SessionSerializer<UserPrincipal> {
override fun deserialize(text: String): UserPrincipal =
json.decodeFromString(UserSession.Value.serializer(), text).principal()
override fun serialize(session: UserPrincipal): String =
json.encodeToString(UserSession.Value.serializer(), session.session!!.value)
}
}
}
// ========== LoginService ==========
val loginLogWriter = when (sessionAuthConfig.logging.destination) {
LogDestination.File -> pipeline.get<FileLogWriter>()
LogDestination.Database -> LoginLogDBWriter()
LogDestination.AwsKinesis -> pipeline.get<AwsKinesisLogWriter>()
}
val logMessageDispatcher = pipeline.get<LogMessageDispatcher>()
logMessageDispatcher.register(LoginLog.LOG_TYPE, loginLogWriter)
val dbAsyncTaskCoroutineActor = pipeline.get<DBAsyncTaskCoroutineActor>()
val logWriter = pipeline.get<LogWriter>()
pipeline.koin {
modules(
module(createdAtStart = true) {
single { LoginService(sessionStorage, dbAsyncTaskCoroutineActor, logWriter) }
}
)
}
}
由於每個子專案的 Session 設定值不同,所以我在初始化子專案的時候,才設定 Ktor Authentication Plugin 的 SessionAuthenticationProvider
,SessionAuthenticationProvider
會利用 Sessions Plugin 的 SessionStorage 讀取 Session 資料來驗證請求。
club {
auth {
android {
apiKey = ${CLUB_AUTH_ANDROID_API_KEY}
session {
expireDuration = 1d
extendDuration = 15m
}
}
iOS {
apiKey = ${CLUB_AUTH_IOS_API_KEY}
session {
expireDuration = 1d
extendDuration = 15m
}
}
}
}
install(Authentication)
// Club 子專案
fun Application.clubMain() {
authentication {
// 設定 SessionAuthenticationProvider
session(
ClubAuth.userAuthProviderName,
//實作 validate 及 challenge function
UserSessionAuthValidator(projectConfig.auth.getUserAuthConfigs(), get()).configureFunction
)
}
}
Ktor 的 SessionAuthenticationProvider
需要我們實作 validate
及 challenge
2個 function。我在 validate
根據 header 夾帶的 API Key 判斷使用者發出請求的來源是否與 Session 記錄的來源相同,也就是檢查例如 App 端的 sessionId 不能拿到 Web 端使用。至於 challenge
是負責回應驗證錯誤的訊息。
class UserSessionAuthValidator(private val authConfigs: List<UserSessionAuthConfig>, private val sessionStorage: MySessionStorage) {
val configureFunction: SessionAuthenticationProvider.Configuration<UserPrincipal>.() -> Unit = {
validate { principal ->
val apiKey = request.header(AuthConst.API_KEY_HEADER_NAME)
val authConfig = authConfigs.firstOrNull { it.principalSource == principal.source }
if (authConfig != null) {
if (authConfig.apiKey != null && authConfig.apiKey != apiKey) {
attributes.put(ATTRIBUTE_KEY_AUTH_ERROR_CODE, InfraResponseCode.AUTH_BAD_KEY)
null
} else {
attributes.put(PrincipalSource.ATTRIBUTE_KEY, principal.source)
sessionStorage.extendExpireTime(principal.session!!)
principal
}
} else {
attributes.put(ATTRIBUTE_KEY_AUTH_ERROR_CODE, InfraResponseCode.AUTH_BAD_SOURCE)
null
}
}
challenge {
val errorCode = call.attributes.getOrNull(ATTRIBUTE_KEY_AUTH_ERROR_CODE)
if (errorCode != null) {
call.respond(CodeResponseDTO(errorCode))
} else {
if (call.request.path().endsWith("/logout"))
call.respond(CodeResponseDTO.OK)
else
call.respond(CodeResponseDTO(InfraResponseCode.AUTH_SESSION_NOT_FOUND))
}
}
}
}
例如在 Club 子專案,只要把使用者變更自己的密碼 API 放在 authorize(ClubAuth.User)
裡面,就可以使用 SessionAuthenticationProvider 驗證請求。更多 API Authentication 及 Autorization 實作細節可參考先前的文章
authorize(ClubAuth.User) {
put<UpdateUserPasswordForm, Unit>("/myPassword", ClubOpenApi.UpdateMyPassword) { form ->
val userId = call.principal<UserPrincipal>()!!.userId
clubUserService.updatePassword(userId, form)
call.respond(CodeResponseDTO.OK)
}
}
最後是實作 Login 與 Logout API。Club 的 Login API 放在 authorize(ClubAuth.Public)
裡面,所以不會驗證是否已登入。然後呼叫 ClubLoginService 的 login 方法,從資料庫查詢使用者資料,驗證使用者有效狀態及密碼,最後再呼叫底層共用的 LoginService 透過 SessionStorage 建立 Session 資料及 LogWriter 寫入 LoginLog。至於 Logout API 也是一樣的作法。
fun Routing.clubLogin() {
val clubLoginService by inject<ClubLoginService>()
route(ClubConst.urlRootPath) {
authorize(ClubAuth.Public) {
post<AppLoginForm, AppLoginResponse>("/login", ClubOpenApi.Login) { form ->
// 其餘省略...
val userPrincipal = clubLoginService.login(form)
}
}
authorize(ClubAuth.User) {
postEmptyBody("/logout", ClubOpenApi.Logout) {
val form = LogoutForm()
clubLoginService.logout(form, call.principal()!!)
call.respond(CodeResponseDTO.OK)
}
}
}
}