All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.nappin.play.recaptcha.RecaptchaVerifier.scala Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2017 Chris Nappin
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.nappin.play.recaptcha

import javax.inject.{Inject, Singleton}
import play.api.Logger
import play.api.mvc.{AnyContent, Request}
import play.api.data.Form
import play.api.data.FormBinding.Implicits.formBinding
import play.api.libs.ws.WSClient

import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.duration.Duration
import java.util.concurrent.TimeUnit
import play.api.libs.json._

object RecaptchaVerifier {
    /** The artificial form field key used for captcha errors. */
    val formErrorKey = "com.nappin.play.recaptcha.error"

    /** The recaptcha challenge field name. */
    val recaptchaChallengeField = "recaptcha_challenge_field"

    /** The recaptcha (v2) response field name. */
    val recaptchaV2ResponseField = "g-recaptcha-response"
}

/**
 * Verifies whether a recaptcha response is valid, by invoking the Google Recaptcha verify web
 * service.
 *
 * @author chrisnappin, AmazingDreams
 * @constructor Creates a new instance.
 * @param settings     The Recaptcha settings
 * @param parser        The response parser to use
 * @param wsClient      The web service client to use
 */
@Singleton
class RecaptchaVerifier @Inject() (settings: RecaptchaSettings, parser: ResponseParser, wsClient: WSClient) {

    private val logger = Logger(this.getClass)

    /**
      * Get the request data, regardless of the request format (form, JSON, etc).
      *
      * Returns an empty map if an unsupported request format.
      *
      * @param request      The implicit request
      * @return A map of request parameter values, keyed by parameter name
      */
    private def getRequestPostData()(implicit request: play.api.mvc.Request[_]): Map[String, Seq[String]] = {
        request.body match {
            case body: play.api.mvc.AnyContent if body.asFormUrlEncoded.isDefined =>
                body.asFormUrlEncoded.get

            case body: play.api.mvc.AnyContent if body.asMultipartFormData.isDefined =>
                body.asMultipartFormData.get.asFormUrlEncoded

            case body: play.api.mvc.AnyContent if body.asJson.isDefined =>
                fromJson(js = body.asJson.get)

            case body: Map[_, _] =>
                body.asInstanceOf[Map[String, Seq[String]]]

            case body: play.api.mvc.MultipartFormData[_] =>
                body.asFormUrlEncoded

            case body: play.api.libs.json.JsValue =>
                fromJson(js = body)

            case _ =>
                Map.empty[String, Seq[String]]

        }
    }

    /**
      * Recursively converts JSON data.
      *
      * This code was provided by AmazingDreams and comes from the Play source code. It does not support certain
      * corner cases like repeating object names or JSON nulls.
      *
      * @param prefix       The current parameter name prefix
      * @param js           The current JSON object
      * @return A map of each request parameter value keyed by parameter name, where sub-objects have names of the form
      *         "<parent>.<child>" etc (recursing to any number of levels).
      */
    private[recaptcha] def fromJson(prefix: String = "", js: JsValue): Map[String, Seq[String]] = js match {
        case JsObject(fields) => {
            val f: Iterable[Map[String, Seq[String]]] = fields.map {
                case (key, value) => {
                    val recursiveKey = if (prefix == "") key else prefix + "." + key
                    fromJson(recursiveKey, value)
                }
            }
            f.foldLeft(Map.empty[String, Seq[String]])(_ ++ _)
        }
        case JsArray(values) => {
            values.zipWithIndex.map {
                case (value, i) => fromJson(prefix + "[" + i + "]", value)
            }.foldLeft(Map.empty[String, Seq[String]])((a, b) => a ++ b)
        }
        case JsNull => Map.empty[String, Seq[String]]
        case JsUndefined() => Map.empty[String, Seq[String]]
        case JsBoolean(value) => Map(prefix -> Seq(value.toString))
        case JsNumber(value) => Map(prefix -> Seq(value.toString))
        case JsString(value) => Map(prefix -> Seq(value.toString))
    }

    /**
     * High level API (using Play forms).
     *
     * Binds form data from the request, and if valid then verifies the recaptcha response, by
     * invoking the Google Recaptcha verify web service (API v2) in a reactive manner.
     * Possible errors include:
     * 
    *
  • Binding error (form validation, regardless of recaptcha)
  • *
  • Recaptcha response missing (end user didn't enter it)
  • *
  • Recpatcha response incorrect
  • *
  • Error invoking the recaptcha service
  • *
* * Apart from generic binding error, the recaptcha errors are populated against the artificial * form field key formErrorKey, handled by the recaptcha view template tags. * * @param form The form * @param request Implicit - The current web request * @param context Implicit - The execution context used for futures * @return A future that will be the form to use, either populated with an error or success * @throws IllegalStateException Developer errors that shouldn't happen - no recaptcha * challenge, or multiple challenges or responses found */ def bindFromRequestAndVerify[T](form: Form[T])(implicit request: Request[AnyContent], context: ExecutionContext): Future[Form[T]] = { val boundForm = form.bindFromRequest() val response = readResponse(getRequestPostData()) if (response.length < 1) { // probably an end user error logger.debug("User did not enter a captcha response in the form POST submitted") // return the missing required field, plus any other form bind errors that might have happened return Future { boundForm.withError( RecaptchaVerifier.formErrorKey, RecaptchaErrorCode.responseMissing) } } boundForm.fold( // form binding failed, so don't call recaptcha error => Future { logger.debug("Binding error") error }, // form binding succeeded, so verify the captcha response success => { val result = verifyV2(response, request.remoteAddress) result.map { r => { r.fold( // captcha incorrect, or a technical error error => boundForm.withError(RecaptchaVerifier.formErrorKey, error.code), // all success success => boundForm ) } } }) } /** * Read the response field from the POST'ed response. * * @param params The POST parameters * @return The response * @throws IllegalStateException If response missing or multiple */ private def readResponse(params: Map[String, Seq[String]]): String = { val fieldName = RecaptchaVerifier.recaptchaV2ResponseField if (!params.contains(fieldName)) { // probably a developer error val message = "No recaptcha response POSTed, check the form submitted was valid" logger.error(message) throw new IllegalStateException(message) } else if (params(fieldName).size > 1) { // probably a developer error val message = "Multiple recaptcha responses POSTed, check the form submitted was valid" logger.error(message) throw new IllegalStateException(message) } params(fieldName).head } /** * Low level API (independent of Play form and request APIs). * * Verifies whether a recaptcha response is valid, by invoking the Google Recaptcha API version * 2 verify web service in a reactive manner. * * @param response The recaptcha response, to verify * @param remoteIp The IP address of the end user * @param context Implicit - The execution context used for futures * @return A future that will be either an Error (with a code) or Success */ def verifyV2(response: String, remoteIp: String)( implicit context: ExecutionContext): Future[Either[Error, Success]] = { // create the v2 POST payload val payload = Map( "secret" -> Seq(settings.privateKey), "response" -> Seq(response), "remoteip" -> Seq(remoteIp) ) logger.info(s"Verifying v2 recaptcha ($response) for $remoteIp") val futureResponse = wsClient.url(settings.verifyUrl) .withRequestTimeout(Duration(settings.requestTimeoutMs, TimeUnit.MILLISECONDS)) .post(payload) futureResponse.map { response => { if (response.status == play.api.http.Status.OK) { parser.parseV2Response(response.json) } else { logger.error("Error calling recaptcha v2 API, HTTP response " + response.status) Left(Error(RecaptchaErrorCode.recaptchaNotReachable)) } } } recover { case ex: java.io.IOException => logger.error("Unable to call recaptcha v2 API" , ex) Left(Error(RecaptchaErrorCode.recaptchaNotReachable)) // e.g. various JSON parsing errors are possible case other: Any => logger.error("Error calling recaptcha v2 API: " + other.getMessage) Left(Error(RecaptchaErrorCode.apiError)) } } } /** * Used to hold various recaptcha error codes. Some are defined by Google Recaptcha, some are used * solely by this module. Yes, the Google Recpatcha documentation states not to rely on these, so * we deliberately keep these to a minimum. */ object RecaptchaErrorCode { /** Defined in Google Recaptcha documentation, returned by Recaptcha itself, means the captcha was incorrect. */ val captchaIncorrect = "incorrect-captcha-sol" /** Defined in Google Recaptcha documentation, used by this module, means recaptcha itself couldn't be reached. */ val recaptchaNotReachable = "recaptcha-not-reachable" /** An API error (e.g. invalid format response). */ val apiError = "recaptcha-api-error" /** The recaptcha response was missing from the request (probably an end user error). */ val responseMissing = "recaptcha-response-missing" }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy