Add Altcha captcha provider support
Some checks are pending
continuous-integration/drone/push Build is running

This commit is contained in:
nemunaire 2026-02-13 12:28:12 +07:00
commit e0d8526577
8 changed files with 160 additions and 7 deletions

1
go.mod
View file

@ -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
View file

@ -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=

View file

@ -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())

View 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
}

View file

@ -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":

View file

@ -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

View file

@ -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)

View file

@ -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}