Added a new captcha(cloudflare turnstile) and its corresponding document. Cloudflare turnstile official instructions are here: https://developers.cloudflare.com/turnstile Signed-off-by: ByLCY <bylcy@bylcy.dev> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: Jason Song <i@wolfogre.com>tags/v1.19.0-rc0
@@ -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 | |||
@@ -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 |
@@ -643,7 +643,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. | |||
@@ -652,6 +652,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 |
@@ -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`) | |||
@@ -14,6 +14,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" | |||
) | |||
@@ -47,12 +48,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 | |||
@@ -73,6 +76,8 @@ 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: | |||
valid, err = turnstile.Verify(ctx, ctx.Req.Form.Get(cfTurnstileResponseField)) | |||
default: | |||
ctx.ServerError("Unknown Captcha Type", fmt.Errorf("Unknown Captcha Type: %s", setting.Service.CaptchaType)) | |||
return |
@@ -46,6 +46,8 @@ var Service = struct { | |||
RecaptchaSecret string | |||
RecaptchaSitekey string | |||
RecaptchaURL string | |||
CfTurnstileSecret string | |||
CfTurnstileSitekey string | |||
HcaptchaSecret string | |||
HcaptchaSitekey string | |||
McaptchaSecret string | |||
@@ -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/") |
@@ -61,6 +61,7 @@ const ( | |||
ReCaptcha = "recaptcha" | |||
HCaptcha = "hcaptcha" | |||
MCaptcha = "mcaptcha" | |||
CfTurnstile = "cfturnstile" | |||
) | |||
// settings |
@@ -0,0 +1,92 @@ | |||
// 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 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}, | |||
} | |||
// 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: %w", err) | |||
} | |||
defer resp.Body.Close() | |||
body, err := io.ReadAll(resp.Body) | |||
if err != nil { | |||
return false, fmt.Errorf("Failed to read CAPTCHA response: %w", err) | |||
} | |||
var jsonResponse Response | |||
if err := json.Unmarshal(body, &jsonResponse); err != nil { | |||
return false, fmt.Errorf("Failed to parse CAPTCHA response: %w", err) | |||
} | |||
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() | |||
} |
@@ -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> |
@@ -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" data-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" data-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" data-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" data-captcha-type="cf-turnstile" data-sitekey="{{.CfTurnstileSitekey}}"></div> | |||
</div> | |||
{{end}}{{end}} |
@@ -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('data-captcha-type')) { | |||
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: | |||
} | |||
} |
@@ -1,16 +0,0 @@ | |||
export async function initMcaptcha() { | |||
const mCaptchaEl = document.querySelector('.m-captcha'); | |||
if (!mCaptchaEl) return; | |||
const {default: mCaptcha} = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue'); | |||
mCaptcha.INPUT_NAME = 'm-captcha-response'; | |||
const siteKey = mCaptchaEl.getAttribute('data-sitekey'); | |||
const instanceURL = mCaptchaEl.getAttribute('data-instance-url'); | |||
mCaptcha.default({ | |||
siteKey: { | |||
instanceUrl: new URL(instanceURL), | |||
key: siteKey, | |||
} | |||
}); | |||
} |
@@ -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'; | |||
import {initRepositoryActionView} from './components/RepoActionView.vue'; | |||
// Run time-critical code as soon as possible. This is safe to do because this | |||
@@ -191,7 +191,7 @@ $(document).ready(() => { | |||
initRepositoryActionView(); | |||
initCommitStatuses(); | |||
initMcaptcha(); | |||
initCaptcha(); | |||
initUserAuthLinkAccountView(); | |||
initUserAuthOauth2(); |
@@ -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; | |||
} |