Add captcha module (#49)

* Add captcha module

* Rename siteverify -> siteVerify
This commit is contained in:
TonCherAmi 2021-08-18 17:47:33 +03:00 committed by GitHub
parent 7313b43dff
commit dfa3a75f4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 251 additions and 0 deletions

15
captcha/build.gradle.kts Normal file
View File

@ -0,0 +1,15 @@
plugins {
id("kotlin")
id("kotlin-spring")
}
dependencies {
implementation(project(":common"))
implementation(project(":common-spring-web"))
implementation(project(":logger-spring"))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-aop")
}

View File

@ -0,0 +1,9 @@
package ru.touchin.captcha.annotations
import ru.touchin.captcha.dto.enums.CaptchaScore
@Target(AnnotationTarget.FUNCTION)
annotation class Captcha(
val action: String,
val minScore: CaptchaScore = CaptchaScore.AVERAGE
)

View File

@ -0,0 +1,38 @@
package ru.touchin.captcha.aspects
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.annotation.Before
import org.springframework.stereotype.Component
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
import ru.touchin.captcha.annotations.Captcha
import ru.touchin.captcha.exceptions.CaptchaResponseMissingException
import ru.touchin.captcha.services.CaptchaService
import java.lang.IllegalStateException
@Aspect
@Component
class CaptchaSiteVerifyAspect(private val captchaService: CaptchaService) {
@Throws(Throwable::class)
@Before("@annotation(captcha)")
fun captchaSiteVerify(captcha: Captcha) {
val currentRequestAttributes = RequestContextHolder.currentRequestAttributes()
as? ServletRequestAttributes
?: throw IllegalStateException("unable to get current request attributes")
val captchaResponse = currentRequestAttributes.request.getHeader(CAPTCHA_HEADER_NAME)
?: throw CaptchaResponseMissingException()
captchaService.verify(captchaResponse)
.validateAction(captcha.action)
.validateScore(captcha.minScore.value)
}
companion object {
private const val CAPTCHA_HEADER_NAME = "X-Captcha-Response"
}
}

View File

@ -0,0 +1,24 @@
package ru.touchin.captcha.dto
import ru.touchin.captcha.exceptions.CaptchaActionMismatchException
import ru.touchin.captcha.exceptions.CaptchaScoreBelowMinimumException
data class CaptchaVerificationResult(val score: Double, val action: String) {
fun validateScore(minScore: Double): CaptchaVerificationResult {
if (score < minScore) {
throw CaptchaScoreBelowMinimumException(score)
}
return this
}
fun validateAction(actionToValidate: String): CaptchaVerificationResult {
if (action != actionToValidate) {
throw CaptchaActionMismatchException(actionToValidate)
}
return this
}
}

View File

@ -0,0 +1,9 @@
package ru.touchin.captcha.dto.enums
enum class CaptchaScore(val value: Double) {
WEAK(0.2),
AVERAGE(0.5),
STRONG(0.8)
}

View File

@ -0,0 +1,12 @@
package ru.touchin.captcha.dto.response
import com.fasterxml.jackson.annotation.JsonProperty
import java.time.ZonedDateTime
data class CaptchaSiteVerifyResponse(
val score: Double,
val action: String,
val success: Boolean,
@JsonProperty("challenge_ts")
val challengeTs: ZonedDateTime
)

View File

@ -0,0 +1,7 @@
package ru.touchin.captcha.exceptions
import ru.touchin.common.exceptions.CommonException
class CaptchaActionMismatchException(
action: String
) : CommonException("invalid captcha action $action")

View File

@ -0,0 +1,5 @@
package ru.touchin.captcha.exceptions
import ru.touchin.common.exceptions.CommonException
class CaptchaResponseMissingException : CommonException("missing captcha response header")

View File

@ -0,0 +1,7 @@
package ru.touchin.captcha.exceptions
import ru.touchin.common.exceptions.CommonException
class CaptchaScoreBelowMinimumException(
score: Double,
) : CommonException("captcha score below minimum $score")

View File

@ -0,0 +1,7 @@
package ru.touchin.captcha.exceptions
import ru.touchin.common.exceptions.CommonException
class CaptchaSiteVerifyFailureException(
description: String?
) : CommonException(description)

View File

@ -0,0 +1,7 @@
package ru.touchin.captcha.exceptions
import ru.touchin.common.exceptions.CommonException
class CaptchaUnknownActionException(
action: String
) : CommonException("unknown captcha action $action")

View File

@ -0,0 +1,13 @@
package ru.touchin.captcha.properties
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.ConstructorBinding
import java.net.URI
@ConstructorBinding
@ConfigurationProperties(prefix = "captcha")
data class CaptchaProperties(
val uri: URI,
val secret: String,
val actions: List<String>
)

View File

@ -0,0 +1,9 @@
package ru.touchin.captcha.services
import ru.touchin.captcha.dto.CaptchaVerificationResult
interface CaptchaService {
fun verify(response: String): CaptchaVerificationResult
}

View File

@ -0,0 +1,33 @@
package ru.touchin.captcha.services.impl
import org.springframework.stereotype.Service
import ru.touchin.captcha.dto.CaptchaVerificationResult
import ru.touchin.captcha.exceptions.CaptchaUnknownActionException
import ru.touchin.captcha.properties.CaptchaProperties
import ru.touchin.captcha.services.CaptchaService
import ru.touchin.captcha.webclients.CaptchaWebClient
import ru.touchin.logger.spring.annotations.AutoLogging
import ru.touchin.logger.spring.annotations.LogValue
@Service
class CaptchaServiceImpl(
private val captchaWebClient: CaptchaWebClient,
private val captchaProperties: CaptchaProperties,
) : CaptchaService {
@LogValue
@AutoLogging(tags = ["CAPTCHA", "CAPTCHA_VERIFICATION"])
override fun verify(response: String): CaptchaVerificationResult {
val siteVerifyResponse = captchaWebClient.siteVerify(response)
if (siteVerifyResponse.action !in captchaProperties.actions) {
throw CaptchaUnknownActionException(siteVerifyResponse.action)
}
return CaptchaVerificationResult(
score = siteVerifyResponse.score,
action = siteVerifyResponse.action,
)
}
}

View File

@ -0,0 +1,55 @@
package ru.touchin.captcha.webclients
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
import ru.touchin.captcha.properties.CaptchaProperties
import ru.touchin.captcha.dto.response.CaptchaSiteVerifyResponse
import ru.touchin.captcha.exceptions.CaptchaSiteVerifyFailureException
import ru.touchin.common.spring.web.webclient.BaseLogWebClient
import ru.touchin.common.spring.web.webclient.dto.RequestLogData
import ru.touchin.common.spring.web.webclient.logger.WebClientLogger
private const val SITE_VERIFY_PATH = "/recaptcha/api/siteverify"
@Component
class CaptchaWebClient(
webClientLogger: WebClientLogger,
webClientBuilder: WebClient.Builder,
private val captchaProperties: CaptchaProperties,
) : BaseLogWebClient(webClientLogger, webClientBuilder) {
override fun getWebClient(): WebClient {
return getWebClientBuilder(captchaProperties.uri.toString()).build()
}
fun siteVerify(response: String): CaptchaSiteVerifyResponse {
return getWebClient().post()
.uri {
it.path(SITE_VERIFY_PATH)
it.queryParam("response", response)
it.queryParam("secret", captchaProperties.secret)
it.build()
}
.accept(MediaType.APPLICATION_JSON)
.exchange(
clazz = CaptchaSiteVerifyResponse::class.java,
requestLogData = RequestLogData(
uri = SITE_VERIFY_PATH,
logTags = listOf("CAPTCHA_SITEVERIFY"),
method = HttpMethod.POST,
)
)
.block()!!
.also(this::validateSuccess)
}
private fun validateSuccess(captchaSiteVerifyResponse: CaptchaSiteVerifyResponse) {
if (!captchaSiteVerifyResponse.success) {
throw CaptchaSiteVerifyFailureException("Captcha siteverify request did not succeed")
}
}
}

View File

@ -31,6 +31,7 @@ include("common-measure")
include("common-measure-spring")
include("common-geo")
include("common-geo-spatial4j-spring")
include("captcha")
include("logger")
include("logger-spring")
include("logger-spring-web")