iT邦幫忙

2021 iThome 鐵人賽

DAY 26
0
Modern Web

基於 Kotlin Ktor 建構支援模組化開發的 Web 框架系列 第 26

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

上一篇
[Day 25] 實作 Redis Plugin 整合 Redis Coroutine Client
下一篇
[Day 27] 實作 Redis PubSub Keyspace Notification 訂閱 Session Key Expired 事件通知
系列文
基於 Kotlin Ktor 建構支援模組化開發的 Web 框架30

尚未有邦友留言

立即登入留言