Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new captcha: cloudflare turnstile #22369

Merged
merged 14 commits into from
Feb 5, 2023
Merged
6 changes: 5 additions & 1 deletion custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,7 @@ ROUTER = console
;; Enable this to require captcha validation for login
;REQUIRE_CAPTCHA_FOR_LOGIN = false
;;
;; Type of captcha you want to use. Options: image, recaptcha, hcaptcha, mcaptcha.
;; Type of captcha you want to use. Options: image, recaptcha, hcaptcha, mcaptcha, cfturnstile.
;CAPTCHA_TYPE = image
;;
;; Change this to use recaptcha.net or other recaptcha service
Expand All @@ -787,6 +787,10 @@ ROUTER = console
;MCAPTCHA_SECRET =
;MCAPTCHA_SITEKEY =
;;
;; Go to https://dash.cloudflare.com/?to=/:account/turnstile to sign up for a key
;CF_TURNSTILE_SITEKEY =
;CF_TURNSTILE_SECRET =
;;
;; Default value for KeepEmailPrivate
;; Each new user will get the value of this setting copied into their profile
;DEFAULT_KEEP_EMAIL_PRIVATE = false
Expand Down
4 changes: 3 additions & 1 deletion docs/content/doc/advanced/config-cheat-sheet.en-us.md
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ Certain queues have defaults that override the defaults set in `[queue]` (this o
- `REQUIRE_CAPTCHA_FOR_LOGIN`: **false**: Enable this to require captcha validation for login. You also must enable `ENABLE_CAPTCHA`.
- `REQUIRE_EXTERNAL_REGISTRATION_CAPTCHA`: **false**: Enable this to force captcha validation
even for External Accounts (i.e. GitHub, OpenID Connect, etc). You also must enable `ENABLE_CAPTCHA`.
- `CAPTCHA_TYPE`: **image**: \[image, recaptcha, hcaptcha, mcaptcha\]
- `CAPTCHA_TYPE`: **image**: \[image, recaptcha, hcaptcha, mcaptcha, cfturnstile\]
- `RECAPTCHA_SECRET`: **""**: Go to https://www.google.com/recaptcha/admin to get a secret for recaptcha.
- `RECAPTCHA_SITEKEY`: **""**: Go to https://www.google.com/recaptcha/admin to get a sitekey for recaptcha.
- `RECAPTCHA_URL`: **https://www.google.com/recaptcha/**: Set the recaptcha url - allows the use of recaptcha net.
Expand All @@ -653,6 +653,8 @@ Certain queues have defaults that override the defaults set in `[queue]` (this o
- `MCAPTCHA_SECRET`: **""**: Go to your mCaptcha instance to get a secret for mCaptcha.
- `MCAPTCHA_SITEKEY`: **""**: Go to your mCaptcha instance to get a sitekey for mCaptcha.
- `MCAPTCHA_URL` **https://demo.mcaptcha.org/**: Set the mCaptcha URL.
- `CF_TURNSTILE_SECRET` **""**: Go to https://dash.cloudflare.com/?to=/:account/turnstile to get a secret for cloudflare turnstile.
- `CF_TURNSTILE_SITEKEY` **""**: Go to https://dash.cloudflare.com/?to=/:account/turnstile to get a sitekey for cloudflare turnstile.
- `DEFAULT_KEEP_EMAIL_PRIVATE`: **false**: By default set users to keep their email address private.
- `DEFAULT_ALLOW_CREATE_ORGANIZATION`: **true**: Allow new users to create organizations by default.
- `DEFAULT_USER_IS_RESTRICTED`: **false**: Give new users restricted permissions by default
Expand Down
11 changes: 11 additions & 0 deletions docs/content/doc/advanced/config-cheat-sheet.zh-cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,17 @@ menu:
- `ENABLE_REVERSE_PROXY_AUTO_REGISTRATION`: 允许通过反向认证做自动注册。
- `ENABLE_CAPTCHA`: **false**: 注册时使用图片验证码。
- `REQUIRE_CAPTCHA_FOR_LOGIN`: **false**: 登录时需要图片验证码。需要同时开启 `ENABLE_CAPTCHA`。
- `CAPTCHA_TYPE`: **image**: \[image, recaptcha, hcaptcha, mcaptcha, cfturnstile\],人机验证类型,分别表示图片认证、 recaptcha 、 hcaptcha 、mcaptcha 、和 cloudlfare 的 turnstile。
- `RECAPTCHA_SECRET`: **""**: recaptcha 服务的密钥,可在 https://www.google.com/recaptcha/admin 获取。
- `RECAPTCHA_SITEKEY`: **""**: recaptcha 服务的网站密钥 ,可在 https://www.google.com/recaptcha/admin 获取。
- `RECAPTCHA_URL`: **https://www.google.com/recaptcha/**: 设置 recaptcha 的 url 。
- `HCAPTCHA_SECRET`: **""**: hcaptcha 服务的密钥,可在 https://www.hcaptcha.com/ 获取。
- `HCAPTCHA_SITEKEY`: **""**: hcaptcha 服务的网站密钥,可在 https://www.hcaptcha.com/ 获取。
- `MCAPTCHA_SECRET`: **""**: mCaptcha 服务的密钥。
- `MCAPTCHA_SITEKEY`: **""**: mCaptcha 服务的网站密钥。
- `MCAPTCHA_URL` **https://demo.mcaptcha.org/**: 设置 remCaptchacaptcha 的 url 。
- `CF_TURNSTILE_SECRET` **""**: cloudlfare turnstile 服务的密钥,可在 https://dash.cloudflare.com/?to=/:account/turnstile 获取。
- `CF_TURNSTILE_SITEKEY` **""**: cloudlfare turnstile 服务的网站密钥 ,可在 https://www.google.com/recaptcha/admin 获取。

### Service - Expore (`service.explore`)

Expand Down
17 changes: 14 additions & 3 deletions modules/context/captcha.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package context

import (
"fmt"
"net"
"sync"

"code.gitea.io/gitea/modules/base"
Expand All @@ -14,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/mcaptcha"
"code.gitea.io/gitea/modules/recaptcha"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/turnstile"

"gitea.com/go-chi/captcha"
)
Expand Down Expand Up @@ -47,12 +49,14 @@ func SetCaptchaData(ctx *Context) {
ctx.Data["HcaptchaSitekey"] = setting.Service.HcaptchaSitekey
ctx.Data["McaptchaSitekey"] = setting.Service.McaptchaSitekey
ctx.Data["McaptchaURL"] = setting.Service.McaptchaURL
ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
}

const (
gRecaptchaResponseField = "g-recaptcha-response"
hCaptchaResponseField = "h-captcha-response"
mCaptchaResponseField = "m-captcha-response"
gRecaptchaResponseField = "g-recaptcha-response"
hCaptchaResponseField = "h-captcha-response"
mCaptchaResponseField = "m-captcha-response"
cfTurnstileResponseField = "cf-turnstile-response"
)

// VerifyCaptcha verifies Captcha data
Expand All @@ -73,6 +77,13 @@ func VerifyCaptcha(ctx *Context, tpl base.TplName, form interface{}) {
valid, err = hcaptcha.Verify(ctx, ctx.Req.Form.Get(hCaptchaResponseField))
case setting.MCaptcha:
valid, err = mcaptcha.Verify(ctx, ctx.Req.Form.Get(mCaptchaResponseField))
case setting.CfTurnstile:
var ip string
ip, _, err = net.SplitHostPort(ctx.RemoteAddr())
if err != nil {
break
}
valid, err = turnstile.Verify(ctx, ctx.Req.Form.Get(cfTurnstileResponseField), ip)
default:
ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType))
return
Expand Down
4 changes: 4 additions & 0 deletions modules/setting/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ var Service = struct {
RecaptchaSecret string
RecaptchaSitekey string
RecaptchaURL string
CfTurnstileSecret string
CfTurnstileSitekey string
HcaptchaSecret string
HcaptchaSitekey string
McaptchaSecret string
Expand Down Expand Up @@ -137,6 +139,8 @@ func newService() {
Service.RecaptchaSecret = sec.Key("RECAPTCHA_SECRET").MustString("")
Service.RecaptchaSitekey = sec.Key("RECAPTCHA_SITEKEY").MustString("")
Service.RecaptchaURL = sec.Key("RECAPTCHA_URL").MustString("https://www.google.com/recaptcha/")
Service.CfTurnstileSecret = sec.Key("CF_TURNSTILE_SECRET").MustString("")
Service.CfTurnstileSitekey = sec.Key("CF_TURNSTILE_SITEKEY").MustString("")
Service.HcaptchaSecret = sec.Key("HCAPTCHA_SECRET").MustString("")
Service.HcaptchaSitekey = sec.Key("HCAPTCHA_SITEKEY").MustString("")
Service.McaptchaURL = sec.Key("MCAPTCHA_URL").MustString("https://demo.mcaptcha.org/")
Expand Down
1 change: 1 addition & 0 deletions modules/setting/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const (
ReCaptcha = "recaptcha"
HCaptcha = "hcaptcha"
MCaptcha = "mcaptcha"
CfTurnstile = "cfturnstile"
)

// settings
Expand Down
93 changes: 93 additions & 0 deletions modules/turnstile/turnstile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package turnstile

import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"

"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
)

// Response is the structure of JSON returned from API
type Response struct {
Success bool `json:"success"`
ChallengeTS string `json:"challenge_ts"`
Hostname string `json:"hostname"`
ErrorCodes []ErrorCode `json:"error-codes"`
Action string `json:"login"`
Cdata string `json:"cdata"`
}

// Verify calls Cloudflare Turnstile API to verify token
func Verify(ctx context.Context, response, ip string) (bool, error) {
// Cloudflare turnstile official access instruction address: https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
post := url.Values{
"secret": {setting.Service.CfTurnstileSecret},
"response": {response},
"remoteip": {ip},
Copy link
Contributor

@wxiaoguang wxiaoguang Feb 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remoteip is optional. Does Gitea guarantee that the site admin always has the correct reverse-proxy config and Gitea can get the correct user remote IP by RemoteAddr? If no, what would happen? Just a question.


Checked with hcaptcha code, hcaptcha also supports remoteip but it doesn't use it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My personal opinion is the same as this answer. Therefore, I think it may be a better choice to carry remoteip in the parameter.

One possible scenario is farming cheap labour to manually solve captchas and then submit them back with the form.


But the usage of remoteip above is just my personal guess, cloudflare’s official documentation does not explain how they use remoteip.
If you think that third parties should not be given so much information, then I will remove remoteip in a future commit.
What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I prefer to keep the same behavior as hcaptcha (no remoteip)

It might still be fine to have the remoteip for the cloudflare turnstile, then it could be better to have enough comments.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'm going to remove remoteip. If there is enough information in the future to prove that it would be better to carry this parameter, then we will discuss whether to add it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was removed in this commit 46aa739

}
// Basically a copy of http.PostForm, but with a context
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://challenges.cloudflare.com/turnstile/v0/siteverify", strings.NewReader(post.Encode()))
if err != nil {
return false, fmt.Errorf("Failed to create CAPTCHA request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, fmt.Errorf("Failed to send CAPTCHA response: %s", err)
wolfogre marked this conversation as resolved.
Show resolved Hide resolved
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return false, fmt.Errorf("Failed to read CAPTCHA response: %s", err)
wolfogre marked this conversation as resolved.
Show resolved Hide resolved
}

var jsonResponse Response
if err := json.Unmarshal(body, &jsonResponse); err != nil {
return false, fmt.Errorf("Failed to parse CAPTCHA response: %s", err)
wolfogre marked this conversation as resolved.
Show resolved Hide resolved
}

var respErr error
if len(jsonResponse.ErrorCodes) > 0 {
respErr = jsonResponse.ErrorCodes[0]
}
return jsonResponse.Success, respErr
}

// ErrorCode is a reCaptcha error
type ErrorCode string

// String fulfills the Stringer interface
func (e ErrorCode) String() string {
switch e {
case "missing-input-secret":
return "The secret parameter was not passed."
case "invalid-input-secret":
return "The secret parameter was invalid or did not exist."
case "missing-input-response":
return "The response parameter was not passed."
case "invalid-input-response":
return "The response parameter is invalid or has expired."
case "bad-request":
return "The request was rejected because it was malformed."
case "timeout-or-duplicate":
return "The response parameter has already been validated before."
case "internal-error":
return "An internal error happened while validating the response. The request can be retried."
}
return string(e)
}

// Error fulfills the error interface
func (e ErrorCode) Error() string {
return e.String()
}
7 changes: 5 additions & 2 deletions templates/base/footer.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
<!-- Third-party libraries -->
{{if .EnableCaptcha}}
{{if eq .CaptchaType "recaptcha"}}
<script src='{{URLJoin .RecaptchaURL "api.js"}}' async></script>
<script src='{{URLJoin .RecaptchaURL "api.js"}}'></script>
{{end}}
{{if eq .CaptchaType "hcaptcha"}}
<script src='https://hcaptcha.com/1/api.js' async></script>
<script src='https://hcaptcha.com/1/api.js'></script>
{{end}}
{{if eq .CaptchaType "cfturnstile"}}
<script src='https://challenges.cloudflare.com/turnstile/v0/api.js'></script>
{{end}}
{{end}}
<script src="{{AssetUrlPrefix}}/js/index.js?v={{AssetVersion}}" onerror="alert('Failed to load asset files from ' + this.src + '. Please make sure the asset files can be accessed.')"></script>
Expand Down
10 changes: 7 additions & 3 deletions templates/user/auth/captcha.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@
</div>
{{else if eq .CaptchaType "recaptcha"}}
<div class="inline field required">
<div class="g-recaptcha" data-sitekey="{{.RecaptchaSitekey}}"></div>
<div id="captcha" captcha-type="g-recaptcha" class="g-recaptcha-style" data-sitekey="{{.RecaptchaSitekey}}"></div>
</div>
{{else if eq .CaptchaType "hcaptcha"}}
<div class="inline field required">
<div class="h-captcha" data-sitekey="{{.HcaptchaSitekey}}"></div>
<div id="captcha" captcha-type="h-captcha" class="h-captcha-style" data-sitekey="{{.HcaptchaSitekey}}"></div>
</div>
{{else if eq .CaptchaType "mcaptcha"}}
<div class="inline field df ac db-small captcha-field">
<span>{{.locale.Tr "captcha"}}</span>
<div class="border-secondary w-100-small" id="mcaptcha__widget-container" style="width: 50%; height: 5em"></div>
<div class="m-captcha" data-sitekey="{{.McaptchaSitekey}}" data-instance-url="{{.McaptchaURL}}"></div>
<div id="captcha" captcha-type="m-captcha" class="m-captcha" data-sitekey="{{.McaptchaSitekey}}" data-instance-url="{{.McaptchaURL}}"></div>
</div>
{{else if eq .CaptchaType "cfturnstile"}}
<div class="inline field captcha-field tc">
<div id="captcha" captcha-type="cf-turnstile" data-sitekey="{{.CfTurnstileSitekey}}"></div>
</div>
{{end}}{{end}}
51 changes: 51 additions & 0 deletions web_src/js/features/captcha.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {isDarkTheme} from '../utils.js';

export async function initCaptcha() {
const captchaEl = document.querySelector('#captcha');
if (!captchaEl) return;

const siteKey = captchaEl.getAttribute('data-sitekey');
const isDark = isDarkTheme();

const params = {
sitekey: siteKey,
theme: isDark ? 'dark' : 'light'
};

switch (captchaEl.getAttribute('captcha-type')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-standard attributes usually need the data- prefix.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was fixed in this commit 4b3b828

case 'g-recaptcha': {
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
window.grecaptcha.render(captchaEl, params);
});
}
break;
}
case 'cf-turnstile': {
if (window.turnstile) {
window.turnstile.render(captchaEl, params);
}
break;
}
case 'h-captcha': {
if (window.hcaptcha) {
window.hcaptcha.render(captchaEl, params);
}
break;
}
case 'm-captcha': {
const {default: mCaptcha} = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
mCaptcha.INPUT_NAME = 'm-captcha-response';
const instanceURL = captchaEl.getAttribute('data-instance-url');

mCaptcha.default({
siteKey: {
instanceUrl: new URL(instanceURL),
key: siteKey,
}
});
break;
}
default:
}
}
16 changes: 0 additions & 16 deletions web_src/js/features/mcaptcha.js

This file was deleted.

4 changes: 2 additions & 2 deletions web_src/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ import {initCommonOrganization} from './features/common-organization.js';
import {initRepoWikiForm} from './features/repo-wiki.js';
import {initRepoCommentForm, initRepository} from './features/repo-legacy.js';
import {initFormattingReplacements} from './features/formatting.js';
import {initMcaptcha} from './features/mcaptcha.js';
import {initCopyContent} from './features/copycontent.js';
import {initCaptcha} from './features/captcha.js';

// Run time-critical code as soon as possible. This is safe to do because this
// script appears at the end of <body> and rendered HTML is accessible at that point.
Expand Down Expand Up @@ -189,7 +189,7 @@ $(document).ready(() => {
initRepository();

initCommitStatuses();
initMcaptcha();
initCaptcha();

initUserAuthLinkAccountView();
initUserAuthOauth2();
Expand Down
14 changes: 10 additions & 4 deletions web_src/less/_form.less
Original file line number Diff line number Diff line change
Expand Up @@ -220,18 +220,24 @@ textarea:focus,
}

@media @mediaMdAndUp {
.g-recaptcha,
.h-captcha {
.g-recaptcha-style,
.h-captcha-style {
margin: 0 auto !important;
width: 304px;
padding-left: 30px;

iframe {
border-radius: 5px !important;
width: 302px !important;
height: 76px !important;
}
}
}

@media (max-height: 575px) {
#rc-imageselect,
.g-recaptcha,
.h-captcha {
.g-recaptcha-style,
.h-captcha-style {
transform: scale(.77);
transform-origin: 0 0;
}
Expand Down