Skip to content

Commit

Permalink
Major refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
wusatosi committed Mar 29, 2023
1 parent edc2186 commit 442ba8d
Show file tree
Hide file tree
Showing 10 changed files with 265 additions and 174 deletions.
4 changes: 2 additions & 2 deletions src/main/kotlin/com/wusatosi/recaptcha/RecaptchaClient.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.wusatosi.recaptcha

import com.wusatosi.recaptcha.internal.UniversalRecaptchaClientImpl
import com.wusatosi.recaptcha.internal.checkURLCompatibility
import com.wusatosi.recaptcha.internal.likelyValidRecaptchaParameter
import io.ktor.client.*
import io.ktor.client.engine.*
import io.ktor.client.engine.cio.*
Expand All @@ -20,7 +20,7 @@ interface RecaptchaClient : Closeable {
useRecaptchaDotNetEndPoint: Boolean = false,
engine: HttpClientEngine = CIO.create()
): RecaptchaClient {
if (!checkURLCompatibility(secretKey))
if (!likelyValidRecaptchaParameter(secretKey))
throw InvalidSiteKeyException

return UniversalRecaptchaClientImpl(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,47 +11,80 @@ import io.ktor.client.statement.*
import io.ktor.http.*
import java.io.IOException

private const val DEFAULT_DOMAIN = "www.google.com"
private const val ALTERNATE_DOMAIN = "www.recaptcha.net"
private const val VALIDATION_PATH = "/recaptcha/api/siteverify"

// Error code Description
// missing-input-secret The secret parameter is missing.
// invalid-input-secret The secret parameter is invalid or malformed.
// missing-input-response The response parameter is missing.
// invalid-input-response The response parameter is invalid or malformed.
// bad-request The request is invalid or malformed.
// timeout-or-duplicate Timeout... (didn't include in the v3 documentation)
internal const val INVALID_SITE_SECRET_KEY = "invalid-input-secret"
internal const val INVALID_TOKEN_KEY = "invalid-input-response"
internal const val TIMEOUT_OR_DUPLICATE_KEY = "timeout-or-duplicate"

private const val SUCCESS_ATTRIBUTE = "success"
private const val ERROR_CODES_ATTRIBUTE = "error-codes"

internal abstract class RecaptchaClientBase(
private val secretKey: String,
useRecaptchaDotNetEndPoint: Boolean,
engine: HttpClientEngine
) : RecaptchaClient {

protected val client: HttpClient = HttpClient(engine) {}
private val validateHost = if (useRecaptchaDotNetEndPoint) "www.recaptcha.net" else "www.google.com"
private val path = "/recaptcha/api/siteverify"
private val client: HttpClient = HttpClient(engine) {}
private val validateHost = if (!useRecaptchaDotNetEndPoint) DEFAULT_DOMAIN else ALTERNATE_DOMAIN

protected suspend fun transact(token: String): JsonObject {
val response =
try {
client.post {
url {
protocol = URLProtocol.HTTPS
host = validateHost
path(path)
parameters.append("secret", secretKey)
parameters.append("response", token)
}
}
} catch (io: IOException) {
throw RecaptchaIOError(io)
}
val response = executeRequest(token)
checkResponseStatus(response)
try {
val body = JsonParser.parseString(response.bodyAsText())
if (!body.isJsonObject)
throwUnexpectedJsonStructure()
return body.asJsonObject
} catch (error: JsonParseException) {
throwUnexpectedJsonStructure(error)
}
}

private fun throwUnexpectedJsonStructure(error: JsonParseException? = null): Nothing =
throw UnexpectedJsonStructure("The server did not respond with a valid Json object", error)

private fun checkResponseStatus(response: HttpResponse) {
val status = response.status
if (status.value !in 200..299)
throw UnexpectedError("Invalid respond status code: ${status.value}, ${status.description}", null)
}

var parseError: JsonParseException? = null
try {
val body = JsonParser.parseString(response.bodyAsText())
if (body.isJsonObject)
return body.asJsonObject
} catch (error: JsonParseException) {
parseError = error
private suspend fun executeRequest(token: String) = try {
client.post {
url {
protocol = URLProtocol.HTTPS
host = validateHost
path(VALIDATION_PATH)
parameters.append("secret", secretKey)
parameters.append("response", token)
}
}
throw UnexpectedJsonStructure("The server did not respond with a valid Json object", parseError)
} catch (io: IOException) {
throw RecaptchaIOError(io)
}

protected fun interpretResponseBody(body: JsonObject): Pair<Boolean, List<String>> {
val isSuccess = body[SUCCESS_ATTRIBUTE]
.expectBoolean(SUCCESS_ATTRIBUTE)
val errorCodes = body[ERROR_CODES_ATTRIBUTE]
?.let { it.expectStringArray(ERROR_CODES_ATTRIBUTE) }
?: listOf()
if (INVALID_SITE_SECRET_KEY in errorCodes)
throw InvalidSiteKeyException
return isSuccess to errorCodes
}

override fun close() = client.close()

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,11 @@ internal class RecaptchaV2ClientImpl(
) : RecaptchaClientBase(secretKey, useRecaptchaDotNetEndPoint, engine), RecaptchaV2Client {

override suspend fun verify(token: String): Boolean {
// There is no way to validate it here,
// So check if it only contains characters
// that is valid for a URL string
if (!checkURLCompatibility(token))
if (!likelyValidRecaptchaParameter(token))
return false

val obj = transact(token)
val isSuccess = obj["success"]
.expectBoolean("success")

if (!isSuccess)
obj["error-codes"]?.let {
// Check if we need to throw InvalidSiteKeyException,
// we don't care if the token is invalid.
checkSiteSecretError(it.expectStringArray("error-codes"))
}

val response = transact(token)
val (isSuccess, _) = interpretResponseBody(response)
return isSuccess
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.wusatosi.recaptcha.internal

import com.wusatosi.recaptcha.UnexpectedError
import com.wusatosi.recaptcha.v3.RecaptchaV3Client
import io.ktor.client.engine.*

private const val SCORE_ATTRIBUTE = "score"

internal class RecaptchaV3ClientImpl(
secretKey: String,
private val defaultScoreThreshold: Double,
Expand All @@ -12,29 +15,34 @@ internal class RecaptchaV3ClientImpl(

override suspend fun getVerifyScore(
token: String,
invalidate_token_score: Double,
timeout_or_duplicate_score: Double
invalidateTokenScore: Double,
timeoutOrDuplicateScore: Double
): Double {
// There is no way to validate it here,
// So check if it only contains characters
// that is valid for a URL string
if (!checkURLCompatibility(token)) return invalidate_token_score

val obj = transact(token)
if (!likelyValidRecaptchaParameter(token)) return invalidateTokenScore

val isSuccess = obj["success"]
.expectBoolean("success")

return if (!isSuccess) {
val errorCodes = obj["error-codes"].expectStringArray("error-codes")
mapErrorCodes(errorCodes, invalidate_token_score, timeout_or_duplicate_score)
} else {
obj["score"]
.expectNumber("score")
val response = transact(token)
val (isSuccess, errorCodes) = interpretResponseBody(response)
return if (isSuccess) {
response[SCORE_ATTRIBUTE]
.expectNumber(SCORE_ATTRIBUTE)
.asDouble
} else {
mapErrorCodes(errorCodes, invalidateTokenScore, timeoutOrDuplicateScore)
}
}

private fun mapErrorCodes(
errorCodes: List<String>,
invalidTokenScore: Double,
timeoutOrDuplicateTokenScore: Double
): Double {
if (INVALID_TOKEN_KEY in errorCodes)
return invalidTokenScore
if (TIMEOUT_OR_DUPLICATE_KEY in errorCodes)
return timeoutOrDuplicateTokenScore
throw UnexpectedError("unexpected error codes: $errorCodes")
}

override suspend fun verify(token: String): Boolean = getVerifyScore(token) > defaultScoreThreshold

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,16 @@ internal class UniversalRecaptchaClientImpl(
) : RecaptchaClientBase(secretKey, useRecaptchaDotNetEndPoint, engine), RecaptchaClient {

override suspend fun verify(token: String): Boolean {
if (!checkURLCompatibility(token))
if (!likelyValidRecaptchaParameter(token))
return false

val obj = transact(token)
val response = transact(token)
val (isSuccess, _) = interpretResponseBody(response)

val isSuccess = obj["success"]
.expectBoolean("success")

if (!isSuccess) {
obj["error-codes"]?.let {
checkSiteSecretError(it.expectStringArray("error-codes"))
}
return false
}

val scoreIndicate = obj["score"] ?: return isSuccess

val score = scoreIndicate
.expectNumber("score")
.asDouble
val score = response["score"]
?.expectNumber("score")
?.asDouble
?: return isSuccess

return score > defaultScoreThreshold
}
Expand Down
39 changes: 3 additions & 36 deletions src/main/kotlin/com/wusatosi/recaptcha/internal/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,15 @@ package com.wusatosi.recaptcha.internal

import com.google.gson.JsonElement
import com.google.gson.JsonPrimitive
import com.wusatosi.recaptcha.InvalidSiteKeyException
import com.wusatosi.recaptcha.UnexpectedError
import com.wusatosi.recaptcha.UnexpectedJsonStructure
import java.util.regex.Pattern

private val pattern = Pattern.compile("^[-a-zA-Z0-9+&@#/%?=~_!:,.;]*[-a-zA-Z0-9+&@#/%=~_]")
internal fun checkURLCompatibility(target: String): Boolean = pattern.matcher(target).matches()

// Error code Description
// missing-input-secret The secret parameter is missing. [x]
// invalid-input-secret The secret parameter is invalid or malformed. [1]
// missing-input-response The response parameter is missing. [x]
// invalid-input-response The response parameter is invalid or malformed. [2]
// bad-request The request is invalid or malformed. [x]
// timeout-or-duplicate Timeout... (didn't include in the v3 documentation) [3]
// There's no way to validate if a site secret/ token is valid,
// the only thing we know is that if it's not URL compatible, it's not a valid token
internal fun likelyValidRecaptchaParameter(target: String): Boolean = pattern.matcher(target).matches()

// By severity, Invalid site secret > Invalid token (input response) > Timeout or duplicate.
// there's something wrong with this client.
// We don't need to check missing-xxx, or bad-request, if we get those error codes,

private const val INVALID_SITE_SECRET = "invalid-input-secret"

internal fun checkSiteSecretError(errorCodes: List<String>) {
if (INVALID_SITE_SECRET in errorCodes)
throw InvalidSiteKeyException
}

private const val INVALID_TOKEN = "invalid-input-response"
private const val TIMEOUT_OR_DUPLICATE = "timeout-or-duplicate"

internal fun mapErrorCodes(
errorCodes: List<String>,
invalidTokenScore: Double,
timeoutOrDuplicateTokenScore: Double
): Double {
checkSiteSecretError(errorCodes)
if (INVALID_TOKEN in errorCodes)
return invalidTokenScore
if (TIMEOUT_OR_DUPLICATE in errorCodes)
return timeoutOrDuplicateTokenScore
throw UnexpectedError("unexpected error codes: $errorCodes")
}

internal fun JsonElement?.expectStringArray(attributeName: String): List<String> {
this ?: throwNull(attributeName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.wusatosi.recaptcha.v2
import com.wusatosi.recaptcha.InvalidSiteKeyException
import com.wusatosi.recaptcha.RecaptchaClient
import com.wusatosi.recaptcha.internal.RecaptchaV2ClientImpl
import com.wusatosi.recaptcha.internal.checkURLCompatibility
import com.wusatosi.recaptcha.internal.likelyValidRecaptchaParameter
import io.ktor.client.engine.*
import io.ktor.client.engine.cio.*

Expand All @@ -15,7 +15,7 @@ interface RecaptchaV2Client : RecaptchaClient {
useRecaptchaDotNetEndPoint: Boolean = false,
engine: HttpClientEngine = CIO.create()
): RecaptchaV2Client {
if (!checkURLCompatibility(siteKey))
if (!likelyValidRecaptchaParameter(siteKey))
throw InvalidSiteKeyException
return RecaptchaV2ClientImpl(
siteKey,
Expand Down
11 changes: 7 additions & 4 deletions src/main/kotlin/com/wusatosi/recaptcha/v3/RecaptchaV3Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@ package com.wusatosi.recaptcha.v3

import com.wusatosi.recaptcha.InvalidSiteKeyException
import com.wusatosi.recaptcha.RecaptchaClient
import com.wusatosi.recaptcha.RecaptchaError
import com.wusatosi.recaptcha.internal.RecaptchaV3ClientImpl
import com.wusatosi.recaptcha.internal.checkURLCompatibility
import com.wusatosi.recaptcha.internal.likelyValidRecaptchaParameter
import io.ktor.client.engine.*
import io.ktor.client.engine.cio.*
import kotlin.jvm.Throws

interface RecaptchaV3Client : RecaptchaClient {

@Throws(RecaptchaError::class)
suspend fun getVerifyScore(
token: String,
invalidate_token_score: Double = -1.0,
timeout_or_duplicate_score: Double = -2.0
invalidateTokenScore: Double = -1.0,
timeoutOrDuplicateScore: Double = -2.0
): Double

companion object {
Expand All @@ -22,7 +25,7 @@ interface RecaptchaV3Client : RecaptchaClient {
useRecaptchaDotNetEndPoint: Boolean = false,
engine: HttpClientEngine = CIO.create()
): RecaptchaV3Client {
if (!checkURLCompatibility(secretKey))
if (!likelyValidRecaptchaParameter(secretKey))
throw InvalidSiteKeyException
return RecaptchaV3ClientImpl(
secretKey,
Expand Down
Loading

0 comments on commit 442ba8d

Please sign in to comment.