replace recaptcha with hcaptcha - closes #3530

pull/8606/head
Thibault Duplessis 2021-04-08 16:33:41 +02:00
parent 2c95bc03c1
commit 9c34d433f9
18 changed files with 136 additions and 143 deletions

View File

@ -104,6 +104,6 @@ Lichess as deployed on https://lichess.org/ also uses these external services:
- [detectlanguage.com](https://detectlanguage.com/)
- Fallback to [Google Fonts](https://fonts.google.com/)
- [Google Cloud Messaging](https://developers.google.com/cloud-messaging/) for mobile notifications
- [reCAPTCHA](https://www.google.com/recaptcha/)
- [hCaptcha](https://hcaptcha.com)
- [PeerJS](https://peerjs.com/) for voice chat
- [crowdin](https://crowdin.com/project/lichess) for localization

View File

@ -391,8 +391,8 @@ final class Account(
def reopenApply =
OpenBody { implicit ctx =>
implicit val req = ctx.body
env.security.recaptcha.verify() flatMap {
_.ok ?? {
env.security.hcaptcha.verify() flatMap { captcha =>
if (captcha.ok)
env.security.forms.reopen.form
.bindFromRequest()
.fold(
@ -412,7 +412,7 @@ final class Account(
}(rateLimitedFu)
}
)
}
else BadRequest(renderReopen(none, none)).fuccess
}
}

View File

@ -335,8 +335,8 @@ final class Auth(
def passwordResetApply =
OpenBody { implicit ctx =>
implicit val req = ctx.body
env.security.recaptcha.verify() flatMap {
_.ok ?? {
env.security.hcaptcha.verify() flatMap { captcha =>
if (captcha.ok)
forms.passwordReset.form
.bindFromRequest()
.fold(
@ -353,7 +353,7 @@ final class Auth(
Redirect(routes.Auth.passwordResetSent(data.realEmail.conceal)).fuccess
}
)
}
else BadRequest(renderPasswordReset(none, fail = true)).fuccess
}
}
@ -416,8 +416,8 @@ final class Auth(
def magicLinkApply =
OpenBody { implicit ctx =>
implicit val req = ctx.body
env.security.recaptcha.verify() flatMap {
_.ok ?? {
env.security.hcaptcha.verify() flatMap { captcha =>
if (captcha.ok)
forms.magicLink.form
.bindFromRequest()
.fold(
@ -437,7 +437,8 @@ final class Auth(
Redirect(routes.Auth.magicLinkSent(data.realEmail.value)).fuccess
}
)
}
else
BadRequest(renderMagicLink(none, fail = true)).fuccess
}
}

View File

@ -9,14 +9,14 @@ import controllers.routes
object reopen {
def form(form: lila.security.RecaptchaForm[_], error: Option[String] = None)(implicit
def form(form: lila.security.HcaptchaForm[_], error: Option[String] = None)(implicit
ctx: Context
) =
views.html.base.layout(
title = "Reopen your account",
moreCss = cssTag("auth"),
moreJs = views.html.base.recaptcha.script(form),
csp = defaultCsp.withRecaptcha.some
moreJs = views.html.base.hcaptcha.script(form),
csp = defaultCsp.withHcaptcha.some
) {
main(cls := "page-small box box-pad")(
h1("Reopen your account"),
@ -26,7 +26,7 @@ object reopen {
p(strong("This will only work once.")),
p("If you close your account a second time, there will be no way of recovering it."),
hr,
postForm(id := form.formId, cls := "form3", action := routes.Account.reopenApply)(
postForm(cls := "form3", action := routes.Account.reopenApply)(
error.map { err =>
p(cls := "error")(strong(err))
},
@ -35,7 +35,8 @@ object reopen {
.group(form("email"), trans.email(), help = frag("Email address associated to the account").some)(
form3.input(_, typ = "email")
),
form3.action(views.html.base.recaptcha.button(form)(form3.submit(trans.emailMeALink())))
views.html.base.hcaptcha.tag(form),
form3.action(form3.submit(trans.emailMeALink()))
)
)
}

View File

@ -7,7 +7,7 @@ import play.api.data.{ Field, Form }
import lila.api.Context
import lila.app.templating.Environment._
import lila.app.ui.ScalatagsTemplate._
import lila.security.RecaptchaForm
import lila.security.HcaptchaForm
import lila.user.User
object bits {
@ -33,25 +33,24 @@ object bits {
}
)
def passwordReset(form: RecaptchaForm[_], fail: Boolean)(implicit ctx: Context) =
def passwordReset(form: HcaptchaForm[_], fail: Boolean)(implicit ctx: Context) =
views.html.base.layout(
title = trans.passwordReset.txt(),
moreCss = cssTag("auth"),
moreJs = views.html.base.recaptcha.script(form),
csp = defaultCsp.withRecaptcha.some
moreJs = views.html.base.hcaptcha.script(form),
csp = defaultCsp.withHcaptcha.some
) {
main(cls := "auth auth-signup box box-pad")(
h1(
fail option span(cls := "is-red", dataIcon := "L"),
trans.passwordReset()
),
postForm(id := form.formId, cls := "form3", action := routes.Auth.passwordResetApply)(
form3.group(form("email"), trans.email())(form3.input(_, typ = "email")(autofocus)),
form3.action(
views.html.base.recaptcha.button(form) {
form3.submit(trans.emailMeALink())
}
)
postForm(cls := "form3", action := routes.Auth.passwordResetApply)(
form3.group(form("email"), trans.email())(
form3.input(_, typ = "email")(autofocus, required, autocomplete := "email")
),
views.html.base.hcaptcha.tag(form),
form3.action(form3.submit(trans.emailMeALink()))
)
)
}
@ -106,12 +105,12 @@ object bits {
)
}
def magicLink(form: RecaptchaForm[_], fail: Boolean)(implicit ctx: Context) =
def magicLink(form: HcaptchaForm[_], fail: Boolean)(implicit ctx: Context) =
views.html.base.layout(
title = "Log in by email",
moreCss = cssTag("auth"),
moreJs = views.html.base.recaptcha.script(form),
csp = defaultCsp.withRecaptcha.some
moreJs = views.html.base.hcaptcha.script(form),
csp = defaultCsp.withHcaptcha.some
) {
main(cls := "auth auth-signup box box-pad")(
h1(
@ -119,13 +118,12 @@ object bits {
"Log in by email"
),
p("We will send you an email containing a link to log you in."),
postForm(id := form.formId, cls := "form3", action := routes.Auth.magicLinkApply)(
postForm(cls := "form3", action := routes.Auth.magicLinkApply)(
form3.group(form("email"), trans.email())(
form3.input(_, typ = "email")(autofocus, autocomplete := "email")
form3.input(_, typ = "email")(autofocus, required, autocomplete := "email")
),
form3.action(views.html.base.recaptcha.button(form) {
form3.submit(trans.emailMeALink())
})
views.html.base.hcaptcha.tag(form),
form3.action(form3.submit(trans.emailMeALink()))
)
)
}

View File

@ -9,25 +9,21 @@ import controllers.routes
object signup {
def apply(form: lila.security.RecaptchaForm[_])(implicit ctx: Context) =
def apply(form: lila.security.HcaptchaForm[_])(implicit ctx: Context) =
views.html.base.layout(
title = trans.signUp.txt(),
moreJs = frag(
jsModule("login"),
embedJsUnsafeLoadThen("""loginSignup.signupStart()"""),
views.html.base.recaptcha.script(form),
fingerprintTag,
embedJsUnsafeLoadThen("""
lichess.loadModule('passwordComplexity').then(() =>
passwordComplexity.addPasswordChangeListener('form3-password')
)""")
views.html.base.hcaptcha.script(form),
fingerprintTag
),
moreCss = cssTag("auth"),
csp = defaultCsp.withRecaptcha.some
csp = defaultCsp.withHcaptcha.some
) {
main(cls := "auth auth-signup box box-pad")(
h1(trans.signUp()),
postForm(id := form.formId, cls := "form3", action := routes.Auth.signupPost)(
postForm(id := "signup-form", cls := "form3", action := routes.Auth.signupPost)(
auth.bits.formFields(form("username"), form("password"), form("email").some, register = true),
input(id := "signup-fp-input", name := "fp", tpe := "hidden"),
div(cls := "form-group text", dataIcon := "")(
@ -41,9 +37,8 @@ object signup {
)
),
agreement(form("agreement"), form.form.errors.exists(_.key startsWith "agreement.")),
views.html.base.recaptcha.button(form) {
button(cls := "submit button text big")(trans.signUp())
}
views.html.base.hcaptcha.tag(form),
button(cls := "submit button text big")(trans.signUp())
)
)
}

View File

@ -0,0 +1,18 @@
package views.html
package base
import lila.api.Context
import lila.app.templating.Environment._
import lila.app.ui.ScalatagsTemplate._
import lila.security.HcaptchaForm
object hcaptcha {
private val dataSitekey = attr("data-sitekey")
def script(re: HcaptchaForm[_])(implicit ctx: Context) =
re.enabled option raw("""<script src="https://hcaptcha.com/1/api.js" async defer></script>""")
def tag(form: HcaptchaForm[_]) =
div(cls := "h-captcha form-group", dataSitekey := form.config.key)
}

View File

@ -1,29 +0,0 @@
package views.html
package base
import lila.api.Context
import lila.app.templating.Environment._
import lila.app.ui.ScalatagsTemplate._
import lila.security.RecaptchaForm
object recaptcha {
private val callbackFunction = "recaptchaSubmit"
def script(re: RecaptchaForm[_])(implicit ctx: Context) =
re.enabled option frag(
raw(
"""<script src="https://www.google.com/recaptcha/api.js" async defer></script>"""
),
embedJsUnsafe(
s"""$callbackFunction=t=>document.getElementById('${re.formId}').submit()"""
)
)
def button(re: RecaptchaForm[_])(tag: Tag) =
tag(
cls := "g-recaptcha",
attr("data-sitekey") := re.config.key,
attr("data-callback") := callbackFunction
)
}

View File

@ -236,7 +236,7 @@ security {
enabled = false
url = "http://ip2proxy.lichess.ovh:1929"
}
recaptcha = ${recaptcha}
hcaptcha = ${hcaptcha}
lame_name_check = true
}
oauth {
@ -248,9 +248,9 @@ oauth {
app = oauth_client
}
}
recaptcha {
endpoint = "https://www.google.com/recaptcha/api/siteverify"
public_key = "6LcxLTUUAAAAAFwWrEgEehMJ6VXrtVOKIifTLGoW"
hcaptcha {
endpoint = "https://hcaptcha.com/siteverify"
public_key = "f91a151d-73e5-4a95-9d4e-74bfa19bec9d"
private_key = ""
enabled = false
}

View File

@ -46,10 +46,14 @@ case class ContentSecurityPolicy(
def withGoogleForm = copy(frameSrc = "https://docs.google.com" :: frameSrc)
def withRecaptcha =
private val hCaptchaDomains = List("https://hcaptcha.com", "https://*.hcaptcha.com")
def withHcaptcha =
copy(
scriptSrc = "https://www.google.com" :: scriptSrc,
frameSrc = "https://www.google.com" :: frameSrc
scriptSrc = hCaptchaDomains ::: scriptSrc,
frameSrc = hCaptchaDomains ::: frameSrc,
styleSrc = hCaptchaDomains ::: styleSrc,
connectSrc = hCaptchaDomains ::: connectSrc
)
def withPeer = copy(connectSrc = "wss://0.peerjs.com" :: connectSrc)

View File

@ -315,9 +315,9 @@ object mon {
}
def usersAlikeTime(field: String) = timer("security.usersAlike.time").withTag("field", field)
def usersAlikeFound(field: String) = histogram("security.usersAlike.found").withTag("field", field)
object recaptcha {
object hCaptcha {
def hit(client: String, result: String) =
counter("recaptcha.hit").withTags(Map("client" -> client, "result" -> result))
counter("hcaptcha.hit").withTags(Map("client" -> client, "result" -> result))
}
}
object tv {

View File

@ -36,10 +36,10 @@ final class Env(
private val config = appConfig.get[SecurityConfig]("security")(SecurityConfig.loader)
private def recaptchaPublicConfig = config.recaptcha.public
private def hcaptchaPublicConfig = config.hcaptcha.public
def recaptcha[A](formId: String, form: play.api.data.Form[A]) =
RecaptchaForm(form, formId, config.recaptcha.public)
def hcaptcha[A](form: play.api.data.Form[A]) =
HcaptchaForm(form, config.hcaptcha.public)
lazy val firewall = new Firewall(
coll = db(config.collection.firewall),
@ -48,9 +48,9 @@ final class Env(
lazy val flood = wire[Flood]
lazy val recaptcha: Recaptcha =
if (config.recaptcha.enabled) wire[RecaptchaGoogle]
else RecaptchaSkip
lazy val hcaptcha: Hcaptcha =
if (config.hcaptcha.enabled) wire[HcaptchaReal]
else HcaptchaSkip
lazy val forms = wire[SecurityForm]

View File

@ -13,15 +13,15 @@ import play.api.mvc.RequestHeader
import lila.common.config._
import lila.common.HTTPRequest
trait Recaptcha {
trait Hcaptcha {
def verify(response: String, req: RequestHeader): Fu[Recaptcha.Result]
def verify(response: String, req: RequestHeader): Fu[Hcaptcha.Result]
def verify()(implicit req: play.api.mvc.Request[_], formBinding: FormBinding): Fu[Recaptcha.Result] =
verify(~Recaptcha.form.bindFromRequest().value.flatten, req)
def verify()(implicit req: play.api.mvc.Request[_], formBinding: FormBinding): Fu[Hcaptcha.Result] =
verify(~Hcaptcha.form.bindFromRequest().value.flatten, req)
}
object Recaptcha {
object Hcaptcha {
sealed abstract class Result(val ok: Boolean)
object Result {
@ -30,7 +30,7 @@ object Recaptcha {
case object Fail extends Result(false)
}
val field = "g-recaptcha-response" -> optional(nonEmptyText)
val field = "h-captcha-response" -> optional(nonEmptyText)
val form = Form(single(field))
private[security] case class Config(
@ -39,30 +39,27 @@ object Recaptcha {
@ConfigName("private_key") privateKey: Secret,
enabled: Boolean
) {
def public = RecaptchaPublicConfig(publicKey, enabled)
def public = HcaptchaPublicConfig(publicKey, enabled)
}
implicit private[security] val configLoader = AutoConfig.loader[Config]
}
object RecaptchaSkip extends Recaptcha {
object HcaptchaSkip extends Hcaptcha {
def verify(response: String, req: RequestHeader) = fuccess(Recaptcha.Result.Valid)
def verify(response: String, req: RequestHeader) = fuccess(Hcaptcha.Result.Valid)
}
final class RecaptchaGoogle(
final class HcaptchaReal(
ws: StandaloneWSClient,
netDomain: NetDomain,
config: Recaptcha.Config
config: Hcaptcha.Config
)(implicit ec: scala.concurrent.ExecutionContext)
extends Recaptcha {
extends Hcaptcha {
import Recaptcha.Result
import Hcaptcha.Result
private case class GoodResponse(
success: Boolean,
hostname: String
)
private case class GoodResponse(success: Boolean, hostname: String)
implicit private val goodReader = Json.reads[GoodResponse]
private case class BadResponse(
@ -80,34 +77,35 @@ final class RecaptchaGoogle(
Map(
"secret" -> config.privateKey.value,
"response" -> response,
"remoteip" -> HTTPRequest.ipAddress(req).value
"remoteip" -> HTTPRequest.ipAddress(req).value,
"sitekey" -> config.publicKey
)
) map {
case res if res.status == 200 =>
res.body[JsValue].validate[GoodResponse] match {
case JsSuccess(res, _) =>
lila.mon.security.recaptcha.hit(client, "success").increment()
lila.mon.security.hCaptcha.hit(client, "success").increment()
if (res.success && res.hostname == netDomain.value) Result.Valid
else Result.Fail
case JsError(err) =>
res.body[JsValue].validate[BadResponse].asOpt match {
case Some(err) if err.missingInput =>
logger.info(s"recaptcha missing ${HTTPRequest printClient req}")
lila.mon.security.recaptcha.hit(client, "missing").increment()
logger.info(s"hcaptcha missing ${HTTPRequest printClient req}")
lila.mon.security.hCaptcha.hit(client, "missing").increment()
if (HTTPRequest.apiVersion(req).isDefined) Result.Pass else Result.Fail
case Some(err) =>
lila.mon.security.recaptcha.hit(client, err.toString).increment()
lila.mon.security.hCaptcha.hit(client, err.toString).increment()
Result.Fail
case _ =>
lila.mon.security.recaptcha.hit(client, "error").increment()
logger.info(s"recaptcha $err ${res.body}")
lila.mon.security.hCaptcha.hit(client, "error").increment()
logger.info(s"hcaptcha $err ${res.body}")
Result.Fail
}
}
case res =>
lila.mon.security.recaptcha.hit(client, res.status.toString).increment()
logger.info(s"recaptcha ${res.status} ${res.body}")
lila.mon.security.hCaptcha.hit(client, res.status.toString).increment()
logger.info(s"hcaptcha ${res.status} ${res.body}")
Result.Fail
}
}
}.thenPp
}

View File

@ -21,7 +21,7 @@ final private class SecurityConfig(
@ConfigName("disposable_email") val disposableEmail: DisposableEmail,
@ConfigName("dns_api") val dnsApi: DnsApi,
@ConfigName("check_mail_api") val checkMail: CheckMail,
val recaptcha: Recaptcha.Config,
val hcaptcha: Hcaptcha.Config,
val mailer: Mailer.Config,
@ConfigName("ip2proxy") val ip2Proxy: Ip2Proxy,
@ConfigName("lame_name_check") val lameNameCheck: LameNameCheck

View File

@ -14,7 +14,7 @@ final class SecurityForm(
authenticator: lila.user.Authenticator,
emailValidator: EmailAddressValidator,
lameNameCheck: LameNameCheck,
recaptchaPublicConfig: RecaptchaPublicConfig
hcaptchaPublicConfig: HcaptchaPublicConfig
)(implicit ec: scala.concurrent.ExecutionContext) {
import SecurityForm._
@ -77,7 +77,7 @@ final class SecurityForm(
val emailField = withAcceptableDns(acceptableUniqueEmail(none))
val website = RecaptchaForm(
val website = HcaptchaForm(
Form(
mapping(
"username" -> username,
@ -87,8 +87,7 @@ final class SecurityForm(
"fp" -> optional(nonEmptyText)
)(SignupData.apply)(_ => None)
),
"signup-form",
recaptchaPublicConfig
hcaptchaPublicConfig
)
val mobile = Form(
@ -100,14 +99,13 @@ final class SecurityForm(
)
}
val passwordReset = RecaptchaForm(
val passwordReset = HcaptchaForm(
Form(
mapping(
"email" -> sendableEmail // allow unacceptable emails for BC
)(PasswordReset.apply)(_ => None)
),
"password-reset-form",
recaptchaPublicConfig
hcaptchaPublicConfig
)
val newPassword = Form(
@ -130,14 +128,13 @@ final class SecurityForm(
)
)
val magicLink = RecaptchaForm(
val magicLink = HcaptchaForm(
Form(
mapping(
"email" -> sendableEmail // allow unacceptable emails for BC
)(MagicLink.apply)(_ => None)
),
"magic-link-form",
recaptchaPublicConfig
hcaptchaPublicConfig
)
def changeEmail(u: User, old: Option[EmailAddress]) =
@ -207,15 +204,14 @@ final class SecurityForm(
def toggleKid = passwordProtected _
val reopen = RecaptchaForm(
val reopen = HcaptchaForm(
Form(
mapping(
"username" -> LilaForm.cleanNonEmptyText,
"email" -> sendableEmail // allow unacceptable emails for BC
)(Reopen.apply)(_ => None)
),
"reopen-form",
recaptchaPublicConfig
hcaptchaPublicConfig
)
private def passwordMapping(candidate: User.LoginCandidate) =

View File

@ -18,7 +18,7 @@ final class Signup(
forms: SecurityForm,
emailAddressValidator: EmailAddressValidator,
emailConfirm: EmailConfirm,
recaptcha: Recaptcha,
hcaptcha: Hcaptcha,
authenticator: lila.user.Authenticator,
userRepo: lila.user.UserRepo,
slack: lila.irc.SlackApi,
@ -59,16 +59,16 @@ final class Signup(
def website(
blind: Boolean
)(implicit req: Request[_], lang: Lang, formBinding: FormBinding): Fu[Signup.Result] =
recaptcha.verify().flatMap {
case Recaptcha.Result.Fail => fuccess(Signup.MissingCaptcha)
case Recaptcha.Result.Pass if !blind => fuccess(Signup.MissingCaptcha)
case recaptchaResult =>
hcaptcha.verify().flatMap {
case Hcaptcha.Result.Fail => fuccess(Signup.MissingCaptcha)
case Hcaptcha.Result.Pass if !blind => fuccess(Signup.MissingCaptcha)
case hcaptchaResult =>
forms.signup.website.form
.bindFromRequest()
.fold[Fu[Signup.Result]](
err => fuccess(Signup.Bad(err tap signupErrLog)),
data =>
signupRateLimit(data.username, if (recaptchaResult == Recaptcha.Result.Valid) 1 else 3) {
signupRateLimit(data.username, if (hcaptchaResult == Hcaptcha.Result.Valid) 1 else 3) {
MustConfirmEmail(data.fingerPrint) flatMap { mustConfirm =>
lila.mon.user.register.count(none)
lila.mon.user.register.mustConfirmEmail(mustConfirm.toString).increment()

View File

@ -36,12 +36,12 @@ case class LocatedSession(session: UserSession, location: Option[Location])
case class IpAndFp(ip: IpAddress, fp: Option[String], user: User.ID)
case class RecaptchaPublicConfig(key: String, enabled: Boolean)
case class HcaptchaPublicConfig(key: String, enabled: Boolean)
case class RecaptchaForm[A](form: Form[A], formId: String, config: RecaptchaPublicConfig) {
case class HcaptchaForm[A](form: Form[A], config: HcaptchaPublicConfig) {
def enabled = config.enabled
def apply(key: String) = form(key)
def withForm[B](form: Form[B]) = RecaptchaForm(form, formId, config)
def withForm[B](form: Form[B]) = HcaptchaForm(form, config)
}
case class LameNameCheck(value: Boolean) extends AnyVal

View File

@ -60,12 +60,23 @@ export function signupStart() {
xhr.json(xhr.url('/player/autocomplete', { term: name, exists: 1 })).then(res => $exists.toggle(res));
}, 300);
$form.on('submit', () =>
$form.find('button.submit').prop('disabled', true).removeAttr('data-icon').addClass('frameless').html(spinnerHtml)
);
$form.on('submit', () => {
if ($form.find('[name="h-captcha-response"]').val())
$form
.find('button.submit')
.prop('disabled', true)
.removeAttr('data-icon')
.addClass('frameless')
.html(spinnerHtml);
else return false;
});
window.signupSubmit = () => {
const form = document.getElementById('signup-form') as HTMLFormElement;
if (form.reportValidity()) form.submit();
};
lichess
.loadModule('passwordComplexity')
.then(() => window['passwordComplexity'].addPasswordChangeListener('form3-password'));
}