[Day 26] 实作 Ktor Session Authentication with Redis

前面我们已经学会 Ktor Authentication 机制,而且也整合了 Database 及 Redis,今天我们把这些东西都串连起来,实作支援 Multi-Project 架构的 Session Authentication。

Session Authentication 的流程如下

  1. 使用者呼叫 Login API → 验证帐号密码 → 如果验证成功则储存 session 资料至 Redis → 回应 sessionId 给前端
  2. 使用者呼叫需要验证登入的 API (header 夹带 sessionId) → 使用 sessionId 到 Redis 取得 session 资料,如果找不到代表未登入或 session 逾期,反之找到 session 则代表通过验证
  3. 使用者呼叫 Logout API → 如果 sessionId 验证成功则删除 Redis 的 session 资料

实作目标

Ktor 虽然有提供 Authentication Plugin 验证请求,还有 Sessions Plugin 存取 Session 资料,但是这2个 Plugin 不像 Spring 框架的 Spring Session 与 Spring Security 完美整合,所以需要自己写程序码处理细节,但也因为如此我才能进行客制化调整。以下是我的实作目标

  • 支援 multi-project 架构
    • 每个子专案可以实作自己的…
      • user 及 session 资料
      • 登入验证设定,例如 session 逾期时间、展延时间
      • 外层 login, logout API
    • 每个子专案都共用以下功能程序码
      • 底层 session 登入、登出、验证
      • 储存 session 资料至 Redis,不过会依据子专案的 keyPrefix 分开放置
      • 不管登入成功或失败都要记录 Log
  • 实作 Redis PubSub Keyspace Notification 接收 Session 逾期通知
  • 密码使用 Bcrypt 加密储存

实作流程

1. 安装 Redis Plugin 初始化 RedisClient

RedisPlugin 实作细节可参考 [Day 25] 实作 Redis Plugin 整合 Redis Coroutine Client

install(RedisFeature)

2. 实作 SessionAuth Plugin 整合 Redis Plugin 建立 RedisSessionStorage

Ktor 的 Sessions Plugin 只定义存取 Session 资料的流程,必须自行实作 SessionStorageSessionSerializer,所以我实作了 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 资料,同时注册 LoginLogLogWriterLogMessageDispatcher,记录登入/登出时的 log。Logging 机制的详细实作可参考 [Day 20] 实作 Ktor Logging 机制

点我连结至 Github 完整程序码

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) }
            }
        )
    }
}

3. 子专案设定 Ktor SessionAuthenticationProvider

由於每个子专案的 Session 设定值不同,所以我在初始化子专案的时候,才设定 Ktor Authentication Plugin 的 SessionAuthenticationProviderSessionAuthenticationProvider 会利用 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 需要我们实作 validatechallenge 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))
            }
        }
    }
}

4. 子专案 API 套用 SessionAuthenticationProvider

例如在 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)
    }
}

5. 子专案实作 Login & Logout API

最後是实作 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

Day24 类别与物件--魔术方法3、trait

这里连结上一天说的内容,上一天没有举例,因为有点篇幅太多了,所以直接在今天的开头写个例子~~ <...

[Day12] Firestore

前几天总共介绍了4种不同的储存方式,今天要来介绍最後一种: Cloud FireStore。 Fir...

Day 15 Flask 回传参数

在网页中不可能只是按照设定好的 URL 去取得网页页面,在许多时候都需要带入不同的参数去取得不同的资...

[Day15] Flutter with GetX Wrap & Chip

Wrap & Chip 原生的Widget, 对於之前接触iOS的人来说一开始看到有惊讶一下...

Day 29:Google Map 自订资讯视窗

本篇文章同步发表在 HKT 线上教室 部落格,线上影音教学课程已上架至 Udemy 和 Youtu...