null

Авторизация Oauth2 EDX: Реализация

О чем эта статья?

В предыдущий раз мы разобрали как сделать авторизацию клиентского приложения в EDX Maple по протоколу Oauth2. Сегодня мы разберем как это сделать на практике с помощью Retrofit. Мы будем использовать Kotlin и Spring Framework.

Реализация

Для начала вам понадобятся зависимости для Spring, OkHttp и Retrofit. Подробно я их приводить не буду, только перечислю для нашего клиента Http:

com.squareup.retrofit2:retrofit
com.squareup.retrofit2:converter-jackson
com.squareup.okhttp3:logging-interceptor
com.squareup.okhttp3:okhttp

Далее мы сконфигурируем Retrofit как Bean, чтобы можно было его переиспользовать в различных сервисах. Однако, для начала нам нужно написать Interceptor, который будет перехватывать любой запрос и вставлять в него Bearer заголовок. Получать он его будет отправив запрос на эндпоинт /oauth2/access_token, про который я рассказывал в прошлой статье. Уже на этом этапе у вас может возникнуть диссонанс, как же мы отправим запрос, если у нас еще нет Retrofit клиента? Для этого мы будем использовать отдельный простой клиент, единственной целью которого будет отправка запроса для получения токена авторизации. Код для этого клиента приведен ниже:

private const val GRANT_TYPE = "client_credentials"

@Service
class AuthorizationService(
    @Value("\${edx.client.id}")
    private val edxClientId: String,
    @Value("\${edx.client.secret}")
    private val edxClientSecret: String,
    @Value("\${edx.api.url}")
    private val edxApiUrl: String,
) {

    private val authEdxApi = Retrofit.Builder()
        .baseUrl(edxApiUrl)
        .addConverterFactory(JacksonConverterFactory.create())
        .build()
        .create(AuthorizationAPI::class.java)

    fun getAuthHeader(): OauthJWTResponse {
        val response = safeEdxRequest {
            authEdxApi.getNewJWT(
                jwtOauthRequest = mapOf(
                    Pair("client_id", edxClientId),
                    Pair("client_secret", edxClientSecret),
                    Pair("grant_type", GRANT_TYPE),
                ),
            ).execute()
        }
        return response.body() ?: throw InvalidDataException("Failed to update jwt")
    }
}
interface AuthorizationAPI {

    @POST("/oauth2/access_token")
    @FormUrlEncoded
    fun getNewJWT(
        @FieldMap jwtOauthRequest: Map<String, String>,
    ): Call<OauthJWTResponse>
}

Также стоит обратить внимание на safeEdxRequest, который обрабатывает непредвиденные ошибки на EDX:

fun <T> safeEdxRequest(
    request: () -> T,
): T {
    try {
        return request.invoke()
    } catch (e: Exception) {
        throw EDXUnreachableException(e)
    }
}

После создания вспомогательного клиента Retrofit мы можем приступить к написанию и конфигурации основного:

@Configuration
class EdxAuthorizedRetrofitConfig(
    @Value("\${edx.api.url}")
    private val edxApiUrl: String,
    private val authorizationService: AuthorizationService,
) {

    @Bean
    fun edxAuthorizedRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl(edxApiUrl)
            .client(getAuthInterceptorOkHttpClient())
            .addConverterFactory(JacksonConverterFactory.create())
            .build()
    }

    private fun getAuthInterceptorOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(AuthorizationInterceptor(authorizationService))
            .addInterceptor(
                HttpLoggingInterceptor().apply {
                    level = HttpLoggingInterceptor.Level.BODY
                }
            )
            .build()
    }

    private class AuthorizationInterceptor(
        private val authorizationService: AuthorizationService,
    ) : Interceptor {

        private var authHeader: String = ""

        override fun intercept(chain: Interceptor.Chain): Response {
            val request = chain.request()
                .newBuilder()
                .addHeader("Authorization", "Bearer $authHeader")
                .build()
            val response = chain.proceed(request)

            return when (response.code) {
                401, 403 -> {
                    response.close()
                    authHeader = authorizationService.getAuthHeader().accessToken
                    val authRequest = chain.request()
                        .newBuilder()
                        .addHeader("Authorization", "Bearer $authHeader")
                        .build()
                    chain.proceed(authRequest)
                }

                else -> {
                    response
                }
            }
        }
    }
}

Тем самым мы получили Http клиент, со встроенным перехватчиком запроса, который обрабатывает ответ с EDX и при получении кодов ответа 401 и 403 пытается обновить токен авторизации. 

На этом статья подходит к концу, приведенный метод обновления токена является достаточно универсальным и может применяться не только при интеграции с EDX, но и в общем со сторонними сервисами (и даже без всяких таймеров). Спасибо за внимание!