diff --git a/captcha/build.gradle.kts b/captcha/build.gradle.kts new file mode 100644 index 0000000..e812cbb --- /dev/null +++ b/captcha/build.gradle.kts @@ -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") +} diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/annotations/Captcha.kt b/captcha/src/main/kotlin/ru/touchin/captcha/annotations/Captcha.kt new file mode 100644 index 0000000..5804221 --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/annotations/Captcha.kt @@ -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 +) diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/aspects/CaptchaSiteVerifyAspect.kt b/captcha/src/main/kotlin/ru/touchin/captcha/aspects/CaptchaSiteVerifyAspect.kt new file mode 100644 index 0000000..9c4ebe7 --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/aspects/CaptchaSiteVerifyAspect.kt @@ -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" + + } + +} diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/dto/CaptchaVerificationResult.kt b/captcha/src/main/kotlin/ru/touchin/captcha/dto/CaptchaVerificationResult.kt new file mode 100644 index 0000000..2778a91 --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/dto/CaptchaVerificationResult.kt @@ -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 + } + +} diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/dto/enums/CaptchaScore.kt b/captcha/src/main/kotlin/ru/touchin/captcha/dto/enums/CaptchaScore.kt new file mode 100644 index 0000000..d584c57 --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/dto/enums/CaptchaScore.kt @@ -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) + +} diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/dto/response/CaptchaSiteVerifyResponse.kt b/captcha/src/main/kotlin/ru/touchin/captcha/dto/response/CaptchaSiteVerifyResponse.kt new file mode 100644 index 0000000..8a756e8 --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/dto/response/CaptchaSiteVerifyResponse.kt @@ -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 +) diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaActionMismatchException.kt b/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaActionMismatchException.kt new file mode 100644 index 0000000..0730b0b --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaActionMismatchException.kt @@ -0,0 +1,7 @@ +package ru.touchin.captcha.exceptions + +import ru.touchin.common.exceptions.CommonException + +class CaptchaActionMismatchException( + action: String +) : CommonException("invalid captcha action $action") diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaResponseMissingException.kt b/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaResponseMissingException.kt new file mode 100644 index 0000000..e871d60 --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaResponseMissingException.kt @@ -0,0 +1,5 @@ +package ru.touchin.captcha.exceptions + +import ru.touchin.common.exceptions.CommonException + +class CaptchaResponseMissingException : CommonException("missing captcha response header") diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaScoreBelowMinimumException.kt b/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaScoreBelowMinimumException.kt new file mode 100644 index 0000000..240043f --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaScoreBelowMinimumException.kt @@ -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") diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaSiteVerifyFailureException.kt b/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaSiteVerifyFailureException.kt new file mode 100644 index 0000000..a4cc0db --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaSiteVerifyFailureException.kt @@ -0,0 +1,7 @@ +package ru.touchin.captcha.exceptions + +import ru.touchin.common.exceptions.CommonException + +class CaptchaSiteVerifyFailureException( + description: String? +) : CommonException(description) diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaUnknownActionException.kt b/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaUnknownActionException.kt new file mode 100644 index 0000000..efa43e4 --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/exceptions/CaptchaUnknownActionException.kt @@ -0,0 +1,7 @@ +package ru.touchin.captcha.exceptions + +import ru.touchin.common.exceptions.CommonException + +class CaptchaUnknownActionException( + action: String +) : CommonException("unknown captcha action $action") diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/properties/CaptchaProperties.kt b/captcha/src/main/kotlin/ru/touchin/captcha/properties/CaptchaProperties.kt new file mode 100644 index 0000000..4f5e4a2 --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/properties/CaptchaProperties.kt @@ -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 +) diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/services/CaptchaService.kt b/captcha/src/main/kotlin/ru/touchin/captcha/services/CaptchaService.kt new file mode 100644 index 0000000..14cff7d --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/services/CaptchaService.kt @@ -0,0 +1,9 @@ +package ru.touchin.captcha.services + +import ru.touchin.captcha.dto.CaptchaVerificationResult + +interface CaptchaService { + + fun verify(response: String): CaptchaVerificationResult + +} diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/services/impl/CaptchaServiceImpl.kt b/captcha/src/main/kotlin/ru/touchin/captcha/services/impl/CaptchaServiceImpl.kt new file mode 100644 index 0000000..ca35883 --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/services/impl/CaptchaServiceImpl.kt @@ -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, + ) + } + +} diff --git a/captcha/src/main/kotlin/ru/touchin/captcha/webclients/CaptchaWebClient.kt b/captcha/src/main/kotlin/ru/touchin/captcha/webclients/CaptchaWebClient.kt new file mode 100644 index 0000000..df512e2 --- /dev/null +++ b/captcha/src/main/kotlin/ru/touchin/captcha/webclients/CaptchaWebClient.kt @@ -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") + } + } + +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9cbb3c2..96ead46 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")