Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#100 Implement Yandex OAuth Client #104

Merged
merged 10 commits into from
May 7, 2024
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ gradle :botalka:bootJar
### Start infrastructure

```bash
docker compose up --build --force-recreate
docker compose up --build --force-recreate --remove-orphans
```

### Connect to LMS Database
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package ru.vityaman.lms.botalka.app.spring.api.http.endpoint

import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RestController
import ru.vityaman.lms.botalka.app.spring.api.http.server.Auth2ViaCodeMessage
import ru.vityaman.lms.botalka.app.spring.api.http.server.apis.AuthApi
import ru.vityaman.lms.botalka.core.external.yandex.Yandex
import ru.vityaman.lms.botalka.core.external.yandex.YandexVerificationCode

@RestController
class AuthHttpApi(private val yandex: Yandex) : AuthApi {
private val log = LoggerFactory.getLogger(this::class.java)

override suspend fun authCodeYandexPost(
auth2ViaCodeMessage: Auth2ViaCodeMessage,
): ResponseEntity<Unit> {
val code = YandexVerificationCode(auth2ViaCodeMessage.code)
val token = yandex.token(code)
val user = yandex.user(token)
log.info("Got $user")
return ResponseEntity.ok(Unit)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package ru.vityaman.lms.botalka.app.spring.client.yandex

import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.stereotype.Service
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.reactive.function.BodyInserters
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
import ru.vityaman.lms.botalka.core.external.yandex.Yandex
import ru.vityaman.lms.botalka.core.external.yandex.YandexAppCredentials
import ru.vityaman.lms.botalka.core.external.yandex.YandexOAuthToken
import ru.vityaman.lms.botalka.core.external.yandex.YandexUser
import ru.vityaman.lms.botalka.core.external.yandex.YandexVerificationCode
import java.util.Base64

@Service
class SpringYandexClient(
@Value("\${external.service.yandex.oauth.url}")
oauthUrl: String,

@Value("\${external.service.yandex.login.url}")
loginUrl: String,

@Value("\${external.service.yandex.oauth.clientId}")
clientId: String,

@Value("\${external.service.yandex.oauth.clientSecret}")
clientSecret: String,
) : Yandex {
private val oauth = WebClient.create(oauthUrl)
private val login = WebClient.create(loginUrl)

private val credentials = YandexAppCredentials(
YandexAppCredentials.ClientId(clientId),
YandexAppCredentials.ClientSecret(clientSecret),
)

private val base64 = Base64.getEncoder()

override suspend fun user(token: YandexOAuthToken): YandexUser =
login.get()
.uri("/info?format=json")
.header("Authorization", "OAuth ${token.text}")
.retrieve()
.awaitBody<YandexUserInfo>()
.let {
YandexUser(
YandexUser.Id(it.id.toLong()),
YandexUser.Login(it.login),
)
}

override suspend fun token(code: YandexVerificationCode): YandexOAuthToken =
oauth.post()
.uri("/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.header("Authorization", "Basic ${credentials.base64Encoded()}")
.body(
formData(
"grant_type" to "authorization_code",
"code" to code.number,
),
)
.retrieve()
.awaitBody<YandexTokens>()
.let { YandexOAuthToken(it.accessToken) }

private fun YandexAppCredentials.base64Encoded(): String =
base64.encodeToString("${id.text}:${secret.text}".encodeToByteArray())

private fun formData(vararg pairs: Pair<String, Any>) =
BodyInserters.fromFormData(
LinkedMultiValueMap<String, String>()
.apply {
pairs.forEach { (key, value) ->
add(key, value.toString())
}
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package ru.vityaman.lms.botalka.app.spring.client.yandex

import com.fasterxml.jackson.annotation.JsonProperty

data class YandexTokens(
@field:JsonProperty("access_token") val accessToken: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package ru.vityaman.lms.botalka.app.spring.client.yandex

import com.fasterxml.jackson.annotation.JsonProperty

data class YandexUserInfo(
@field:JsonProperty("login") val login: String,
@field:JsonProperty("id") val id: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package ru.vityaman.lms.botalka.core.external.yandex

interface Yandex {
suspend fun user(token: YandexOAuthToken): YandexUser
suspend fun token(code: YandexVerificationCode): YandexOAuthToken
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package ru.vityaman.lms.botalka.core.external.yandex

data class YandexAppCredentials(
val id: ClientId,
val secret: ClientSecret,
) {
@JvmInline
value class ClientId(val text: String)

@JvmInline
value class ClientSecret(val text: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package ru.vityaman.lms.botalka.core.external.yandex

@JvmInline
value class YandexOAuthToken(val text: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package ru.vityaman.lms.botalka.core.external.yandex

data class YandexUser(
val id: Id,
val login: Login,
) {
@JvmInline
value class Id(val number: Long)

@JvmInline
value class Login(val text: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package ru.vityaman.lms.botalka.core.external.yandex

@JvmInline
value class YandexVerificationCode(val number: Int)
9 changes: 9 additions & 0 deletions botalka/src/main/resources/application-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
external:
service:
yandex:
oauth:
url: http://localhost:8080/fake/external/service/yandex/oauth
clientId: fake-yandex-client-id
clientSecret: fake-yandex-client-secret
login:
url: http://localhost:8080/fake/external/service/yandex/login
11 changes: 10 additions & 1 deletion botalka/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,13 @@ management:
enabled: true
distribution:
percentiles-histogram:
"[http.server.requests]": true
"[http.server.requests]": true
external:
service:
yandex:
oauth:
url: https://oauth.yandex.ru
clientId: ${LMS_YANDEX_CLIENT_ID}
clientSecret: ${LMS_YANDEX_CLIENT_SECRET}
login:
url: https://login.yandex.ru
21 changes: 21 additions & 0 deletions botalka/src/main/resources/static/openapi/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,20 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
/auth/code/yandex:
post:
tags: [ Auth ]
summary: Authenticate via Yandex authentication code
description: Takes an authentication code and checks authority
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Auth2ViaCode'
responses:
204:
description: OK
components:
schemas:
HomeworkId:
Expand Down Expand Up @@ -594,6 +608,13 @@ components:
- id
- user_id
- status
Auth2ViaCode:
type: object
properties:
code:
type: integer
required:
- code
GeneralError:
type: object
properties:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.AfterEach
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.ContextConfiguration
import ru.vityaman.lms.botalka.app.spring.storage.DatabaseContainerInitializer
import ru.vityaman.lms.botalka.app.spring.storage.SpringMigration
import ru.vityaman.lms.botalka.storage.jooq.JooqDatabase
import ru.vityaman.lms.botalka.storage.jooq.Lms.Companion.LMS

@ActiveProfiles(profiles = ["test"])
@SpringBootTest(
classes = [BotalkaApplication::class],
webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ru.vityaman.lms.botalka.app.spring.api.http.client

import org.springframework.context.annotation.Bean
import org.springframework.stereotype.Component
import ru.vityaman.lms.botalka.app.spring.api.http.client.apis.AuthApi
import ru.vityaman.lms.botalka.app.spring.api.http.client.apis.HomeworkApi
import ru.vityaman.lms.botalka.app.spring.api.http.client.apis.MonitoringApi
import ru.vityaman.lms.botalka.app.spring.api.http.client.apis.RatingApi
Expand All @@ -22,4 +23,7 @@ class ApiClientSet {

@Bean
fun ratingApi() = RatingApi(url)

@Bean
fun authApi() = AuthApi(url)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package ru.vityaman.lms.botalka.app.spring.api.http.endpoint

import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import ru.vityaman.lms.botalka.app.spring.BotalkaTestSuite
import ru.vityaman.lms.botalka.app.spring.api.http.client.Auth2ViaCodeMessage
import ru.vityaman.lms.botalka.app.spring.api.http.client.apis.AuthApi

class AuthApiTest(
@Autowired private val auth: AuthApi,
) : BotalkaTestSuite() {
@Test
fun yandexCodePostSuccess(): Unit = runBlocking {
auth.authCodeYandexPost(Auth2ViaCodeMessage(123_456)).awaitSingle()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package ru.vityaman.lms.botalka.app.spring.api.http.fake

import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import ru.vityaman.lms.botalka.app.spring.client.yandex.YandexTokens
import ru.vityaman.lms.botalka.app.spring.client.yandex.YandexUserInfo
import ru.vityaman.lms.botalka.core.external.yandex.YandexAppCredentials
import java.util.Base64

@RestController
@RequestMapping("/fake/external/service/yandex")
class FakeYandexApi {
@GetMapping("/login/info")
suspend fun getUserInfo(
@RequestHeader("Authorization") authorization: String,
): YandexUserInfo {
require(authorization == "OAuth fake-yandex-oauth-token")
return YandexUserInfo(
id = "666666666",
login = "fake.yandex.user",
)
}

@PostMapping(
"/oauth/token",
consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE],
)
suspend fun postTokenCode(
@RequestHeader("Authorization") authorization: String,
code: VerificationCode,
): ResponseEntity<YandexTokens> {
parseBasicAuthHeader(authorization)
return if (code.code == "123456") {
ResponseEntity.ok(
YandexTokens(accessToken = "fake-yandex-oauth-token"),
)
} else {
ResponseEntity.badRequest().build()
}
}

private fun parseBasicAuthHeader(header: String): YandexAppCredentials {
val prefix = "Basic "
require(header.startsWith(prefix)) {
"Header must start with '$prefix'"
}

val token = header
.substring(prefix.length, header.length)
.let { Base64.getDecoder().decode(it) }
.decodeToString()

val parts = token.split(":")
require(parts.size == 2) { "Expected 'id:secret' token" }

return YandexAppCredentials(
YandexAppCredentials.ClientId(parts[0]),
YandexAppCredentials.ClientSecret(parts[1]),
)
}

data class VerificationCode(
val code: String,
)
}
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ services:
LMS_POSTGRES_DB: ${LMS_POSTGRES_DB?:err}
LMS_POSTGRES_USER: ${LMS_POSTGRES_USER?:err}
LMS_POSTGRES_PASSWORD: ${LMS_POSTGRES_PASSWORD?:err}
LMS_YANDEX_CLIENT_ID: ${LMS_YANDEX_CLIENT_ID?:err}
LMS_YANDEX_CLIENT_SECRET: ${LMS_YANDEX_CLIENT_SECRET?:err}
networks:
- lms-network
depends_on:
Expand Down Expand Up @@ -69,7 +71,7 @@ services:
stdin_open: true
tty: true
restart: always
entrypoint: ["sh", "-c", "sleep 8 && ./lms/infra/fuzzing/restler/compile.sh && ./lms/infra/fuzzing/restler/fuzz.sh"]
entrypoint: [ "sh", "-c", "sleep 8 && ./lms/infra/fuzzing/restler/compile.sh && ./lms/infra/fuzzing/restler/fuzz.sh" ]
volumes:
- .:/lms
networks:
Expand Down
8 changes: 8 additions & 0 deletions infra/env/oauth.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env sh

MODE=$1
BROWSER=firefox

if [ "$MODE" = "code" ]; then
$BROWSER "https://oauth.yandex.ru/authorize?response_type=code&client_id=$LMS_YANDEX_CLIENT_ID"
fi
Loading