前面我们已经学会 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)
}
}
}
}
<<: JavaScript Array | 与其他程序语言很不同的阵列(下)
>>: 【把玩Azure DevOps】Day19 CI/CD的关键:Azure DevOps Agent
这里连结上一天说的内容,上一天没有举例,因为有点篇幅太多了,所以直接在今天的开头写个例子~~ <...
前几天总共介绍了4种不同的储存方式,今天要来介绍最後一种: Cloud FireStore。 Fir...
在网页中不可能只是按照设定好的 URL 去取得网页页面,在许多时候都需要带入不同的参数去取得不同的资...
Wrap & Chip 原生的Widget, 对於之前接触iOS的人来说一开始看到有惊讶一下...
本篇文章同步发表在 HKT 线上教室 部落格,线上影音教学课程已上架至 Udemy 和 Youtu...