Add Altcha captcha provider support
Some checks are pending
continuous-integration/drone/push Build is running
Some checks are pending
continuous-integration/drone/push Build is running
This commit is contained in:
parent
0090054324
commit
e0d8526577
8 changed files with 160 additions and 7 deletions
1
go.mod
1
go.mod
|
|
@ -53,6 +53,7 @@ require (
|
|||
github.com/Shopify/goreferrer v0.0.0-20250617153402-88c1d9a79b05 // indirect
|
||||
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 // indirect
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -62,6 +62,8 @@ github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 h1:F1j7z+/DKEsYqZNoxC6wvfmai
|
|||
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2/go.mod h1:QlXr/TrICfQ/ANa76sLeQyhAJyNR9sEcfNuZBkY9jgY=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU=
|
||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0 h1:7oPti0aUS+YCep8nwt5b9g4jYfCU55ZruWESL8G9K5M=
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0/go.mod h1:I8ESLVWR9C58uvGufB/AJDPhaSU4+4Oh3DLpVtgwDAk=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
|
|
|
|||
|
|
@ -38,6 +38,17 @@ func DeclareAuthenticationRoutes(cfg *happydns.Options, baserouter, apirouter *g
|
|||
apirouter.POST("/auth", lc.Login)
|
||||
apirouter.POST("/auth/logout", lc.Logout)
|
||||
|
||||
if localChallenge, ok := dependancies.CaptchaVerifier().(happydns.CaptchaLocalChallenge); ok {
|
||||
apirouter.GET("/auth/challenge", func(c *gin.Context) {
|
||||
challenge, err := localChallenge.NewChallenge()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", challenge)
|
||||
})
|
||||
}
|
||||
|
||||
if len(cfg.OIDCClients) > 0 {
|
||||
oidcp := controller.NewOIDCProvider(cfg, dependancies.AuthenticationUsecase())
|
||||
|
||||
|
|
|
|||
94
internal/captcha/altcha.go
Normal file
94
internal/captcha/altcha.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
altcha "github.com/altcha-org/altcha-lib-go"
|
||||
)
|
||||
|
||||
var (
|
||||
altchaComplexity int64
|
||||
altchaHMACKey string
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.Int64Var(&altchaComplexity, "altcha-complexity", 100_000, "Serves as a measure to balance security against automated abuse/spam and user experience")
|
||||
flag.StringVar(&altchaHMACKey, "altcha-hmac-key", "", "Secret HMAC key for Altcha challenge signing and verification")
|
||||
}
|
||||
|
||||
type AltchaVerifier struct {
|
||||
options altcha.ChallengeOptions
|
||||
}
|
||||
|
||||
func NewAltchaVerifier() *AltchaVerifier {
|
||||
if altchaHMACKey == "" {
|
||||
b := make([]byte, 24)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
log.Fatalf("error generating Altcha HMAC key: %v", err)
|
||||
}
|
||||
altchaHMACKey = base64.URLEncoding.EncodeToString(b)[:32]
|
||||
}
|
||||
|
||||
return &AltchaVerifier{
|
||||
options: altcha.ChallengeOptions{
|
||||
HMACKey: altchaHMACKey,
|
||||
MaxNumber: altchaComplexity,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AltchaVerifier) Provider() string { return "altcha" }
|
||||
func (a *AltchaVerifier) SiteKey() string { return "" }
|
||||
|
||||
func (a *AltchaVerifier) Verify(token, _ string) error {
|
||||
ok, err := altcha.VerifySolution(token, altchaHMACKey, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("altcha verification failed: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("altcha verification failed: invalid solution")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewAltchaChallenge generates a new Altcha challenge to be served to the frontend.
|
||||
func (a *AltchaVerifier) NewChallenge() (json.RawMessage, error) {
|
||||
challenge, err := altcha.CreateChallenge(a.options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create altcha challenge: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(challenge)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal altcha challenge: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
|
@ -30,6 +30,8 @@ import (
|
|||
// Returns a no-op verifier when provider is empty.
|
||||
func NewVerifier(provider string) happydns.CaptchaVerifier {
|
||||
switch provider {
|
||||
case "altcha":
|
||||
return NewAltchaVerifier()
|
||||
case "hcaptcha":
|
||||
return &hCaptchaVerifier{}
|
||||
case "recaptchav2":
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ func declareFlags(o *happydns.Options) {
|
|||
flag.StringVar(&o.MailSMTPPassword, "mail-smtp-password", o.MailSMTPPassword, "Password associated with the given username for SMTP authentication")
|
||||
flag.BoolVar(&o.MailSMTPTLSSNoVerify, "mail-smtp-tls-no-verify", o.MailSMTPTLSSNoVerify, "Do not verify certificate validity on SMTP connection")
|
||||
|
||||
flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (hcaptcha, recaptchav2, turnstile, or empty to disable)")
|
||||
flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (altcha, hcaptcha, recaptchav2, turnstile, or empty to disable)")
|
||||
flag.IntVar(&o.CaptchaLoginThreshold, "captcha-login-threshold", 3, "Number of failed login attempts before captcha is required (0 = always require when provider configured)")
|
||||
|
||||
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@
|
|||
|
||||
package happydns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// CaptchaVerifier is implemented by all captcha providers.
|
||||
type CaptchaVerifier interface {
|
||||
// Provider returns the provider identifier ("hcaptcha", "recaptchav2", "turnstile", or "").
|
||||
|
|
@ -31,6 +35,10 @@ type CaptchaVerifier interface {
|
|||
Verify(token, remoteIP string) error
|
||||
}
|
||||
|
||||
type CaptchaLocalChallenge interface {
|
||||
NewChallenge() (json.RawMessage, error)
|
||||
}
|
||||
|
||||
type FailureTracker interface {
|
||||
RecordFailure(ip, email string)
|
||||
RecordSuccess(ip, email string)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
let { token = $bindable() }: { token: string | null } = $props();
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let altchaWidget: HTMLElement | undefined = $state();
|
||||
let widgetId: unknown = $state(undefined);
|
||||
|
||||
const provider = $derived($appConfig.captcha_provider);
|
||||
|
|
@ -37,7 +38,7 @@
|
|||
token = t;
|
||||
}
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
function loadScript(src: string, isModule = false): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector(`script[src="${src}"]`)) {
|
||||
resolve();
|
||||
|
|
@ -45,8 +46,12 @@
|
|||
}
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
if (isModule) {
|
||||
script.type = "module";
|
||||
} else {
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
}
|
||||
script.onload = () => resolve();
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
|
|
@ -54,17 +59,25 @@
|
|||
}
|
||||
|
||||
async function renderWidget() {
|
||||
if (!container || !provider || !siteKey) return;
|
||||
if (!provider) return;
|
||||
|
||||
if (provider === "hcaptcha") {
|
||||
if (provider === "altcha") {
|
||||
await loadScript(
|
||||
"https://cdn.jsdelivr.net/gh/altcha-org/altcha/dist/altcha.min.js",
|
||||
true,
|
||||
);
|
||||
} else if (provider === "hcaptcha") {
|
||||
if (!siteKey) return;
|
||||
await loadScript("https://js.hcaptcha.com/1/api.js?render=explicit");
|
||||
// @ts-ignore
|
||||
widgetId = hcaptcha.render(container, { sitekey: siteKey, callback: onToken });
|
||||
} else if (provider === "recaptchav2") {
|
||||
if (!siteKey) return;
|
||||
await loadScript("https://www.google.com/recaptcha/api.js?render=explicit");
|
||||
// @ts-ignore
|
||||
widgetId = grecaptcha.render(container, { sitekey: siteKey, callback: onToken });
|
||||
} else if (provider === "turnstile") {
|
||||
if (!siteKey) return;
|
||||
await loadScript(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
|
||||
);
|
||||
|
|
@ -75,6 +88,13 @@
|
|||
|
||||
export function reset() {
|
||||
token = null;
|
||||
if (provider === "altcha") {
|
||||
if (altchaWidget) {
|
||||
// @ts-ignore
|
||||
altchaWidget.reset?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (widgetId === undefined) return;
|
||||
|
||||
if (provider === "hcaptcha") {
|
||||
|
|
@ -94,6 +114,21 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
{#if provider}
|
||||
{#if provider === "altcha"}
|
||||
<div class="mb-3">
|
||||
<altcha-widget
|
||||
bind:this={altchaWidget}
|
||||
challengeurl="/api/auth/challenge"
|
||||
onstatechange={(ev: CustomEvent<{ payload: string; state: string }>) => {
|
||||
const { payload, state } = ev.detail;
|
||||
if (state === "verified" && payload) {
|
||||
token = payload;
|
||||
} else {
|
||||
token = null;
|
||||
}
|
||||
}}
|
||||
></altcha-widget>
|
||||
</div>
|
||||
{:else if provider}
|
||||
<div bind:this={container} class="my-2"></div>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue