captcha

package module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 16, 2026 License: MIT Imports: 11 Imported by: 0

README

captcha

给任意 Go 服务的登录加 ALTCHA 人机校验(proof-of-work captcha)——自托管、无第三方、无跟踪、无需落库。后端框架无关(net/http,gin/chi/标准库都行),前端零依赖,前后端都能 go get 开箱

go get github.com/water-spinach/captcha@v1.0.0

要求:Go 1.22+(标准库示例用了 "GET /path" 方法前缀路由;更低版本要改成普通 path)。

它怎么工作

登录页加载 → GET 挑战端点取一道题 → 前端 widget 后台算 PoW 解出
           → 提交登录时把 base64 解答放进请求体 altcha 字段 → 服务端 Verify 校验

无状态、HMAC 签名。同一把 HMACKey 既签发挑战又校验解答。

特性开关 + 零中断灰度(重要)

HMACKey 为空 = 关闭校验Verify 恒为 true)。此时挑战端点仍照常出题(用空 key 签名,widget 一样能解),所以前端永远能正常工作。部署序:

  1. 先上服务CAPTCHA_HMAC_KEY 留空,不强制)
  2. 再上前端(widget 就位)
  3. 最后CAPTCHA_HMAC_KEY 重启 → 全量开启

全程登录零中断。⚠️ 若在前端上线前就设 key,旧前端不带 altcha 字段会被拒。

后端

完整可运行版见 examples/stdlibexamples/gin。下面是节选(省略了 import / engine 构造)。

标准库
import (
	"net/http"
	"os"
	"github.com/water-spinach/captcha"
)

capt := captcha.New(captcha.Options{HMACKey: os.Getenv("CAPTCHA_HMAC_KEY")})

mux := http.NewServeMux()
capt.Mount(mux, "GET /auth/altcha/challenge")        // 公开挑战端点

mux.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
	var req struct{ Email, Password, Altcha string }
	_ = json.NewDecoder(r.Body).Decode(&req)
	// 在查库 / 比对口令之前校验,把机器人挡在 DB 之前
	if !capt.Verify(req.Altcha) {
		http.Error(w, "人机验证失败", http.StatusBadRequest)
		return
	}
	// …你的真实账号校验…
})
gin(或任意非标准库框架)

Mount 只吃标准库 *http.ServeMux;其它框架用 gin.WrapF(capt.ChallengeHandler()) 挂端点:

capt := captcha.New(captcha.Options{HMACKey: os.Getenv("CAPTCHA_HMAC_KEY")})
r := gin.Default()

r.GET("/auth/altcha/challenge", gin.WrapF(capt.ChallengeHandler()))

r.POST("/login", func(c *gin.Context) {
	var req struct{ Email, Password, Altcha string }
	_ = c.ShouldBindJSON(&req)
	if !capt.Verify(req.Altcha) {
		c.JSON(400, gin.H{"error": "人机验证失败"})
		return
	}
	// …
})
API
方法 说明
New(Options{HMACKey, TTL, MaxNumber, SingleUse}) *Verifier 构造;零值字段走默认(TTL 30m、MaxNumber 1e6,SingleUse 关闭)
(*Verifier) Verify(payload string) bool 校验 base64 解答;关闭态恒 true;常量时间比签名、拒过期;SingleUse 开启时拒绝重复提交同一道解答
(*Verifier) ChallengeHandler() http.HandlerFunc GET 端点:出题 JSON + Cache-Control: no-store
(*Verifier) Mount(mux *http.ServeMux, path string) 便捷:把 ChallengeHandler 挂到标准库 ServeMux(非标准库用 gin.WrapF 等)
(*Verifier) Enabled() bool 是否设了 key
Assets() fs.FS / AssetsHandler() http.Handler 内嵌前端套件,供 go get 后直接挂载(见下)
Solve(challengeJSON []byte) (string, error) 解题工具,仅供写后端测试(见下)
NewGate(GateOptions{Verifier, Threshold, Window}) *Gate 自适应门:失败到阈值才要验证码(见「自适应」)
(*Gate) Pass / Required / Failed / Succeeded 自适应门的判定与失败计数回写
ClientIP(*http.Request) string 取客户端 IP,作 Gate 的 key(XFF 注意见 godoc)

CAPTCHA_HMAC_KEY 生成:openssl rand -base64 32。务必保密、各副本一致。

前端

需要两样:① ALTCHA widget 本体;② 本仓的胶水脚本 altcha-login.js + 主题 altcha-login.css

取得胶水脚本:两种方式

方式 A(推荐,前后端都 go get)——本模块用 go:embed 内嵌了 altcha-login.js/.css,挂载即可,无需拷文件:

mux.Handle("/captcha/", http.StripPrefix("/captcha/", captcha.AssetsHandler()))
// gin: r.StaticFS("/static", http.FS(captcha.Assets()))
// → /captcha/altcha-login.js, /captcha/altcha-login.css

方式 B——直接从仓库 web/altcha-login.js / altcha-login.css(必要时连同 altcha-login.d.ts)进你的静态目录。

接线
<link rel="stylesheet" href="/captcha/altcha-login.css" />

<input id="email" type="email" />
<input id="password" type="password" />
<altcha-widget id="cap"></altcha-widget>

<!-- widget 本体:钉大版本 @3,避免 @latest 跟到不兼容大版本无声回归 -->
<script src="https://cdn.jsdelivr.net/npm/altcha@3/dist/main/altcha.min.js" type="module"></script>
<script src="/captcha/altcha-login.js"></script>
<script>
  const cap = initAltchaLogin(document.getElementById('cap'),
                             { challengeUrl: '/auth/altcha/challenge' });
  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    const r = await fetch('/login', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: email.value, password: password.value, altcha: cap.getPayload() }),
    });
    if (!r.ok) cap.reset();   // 失败后取新题
  });
</script>

initAltchaLogin(el, {challengeUrl}){ getPayload(), reset() }getPayload() 返回已解出的 base64 载荷(未通过时为空串)。

离线 / 内网 / 严格 CSP:把 altcha@3 从 npm 装下来自托管,别用公共 CDN。

React

altcha-login.js 作为副作用脚本引入(注册全局 initAltchaLogin),TS 工程把 web/altcha-login.d.ts 纳入即有类型。也可直接照抄下面这段 hook(用 bundler 同步 import 'altcha'):

import { useEffect, useRef } from 'react'
import 'altcha' // npm i altcha@^3;同步注册 <altcha-widget>

export function useAltcha(challengeUrl = '/auth/altcha/challenge') {
  const ref = useRef<HTMLElement>(null)
  const payload = useRef('')
  useEffect(() => {
    const el = ref.current as (HTMLElement & { configure?: (c: any) => void }) | null
    if (!el) return
    el.configure?.({ challenge: challengeUrl, auto: 'onload', hideFooter: true, hideLogo: true })
    const onState = (e: Event) => {
      const d = (e as CustomEvent<{ state?: string; payload?: string }>).detail
      payload.current = d?.state === 'verified' && d.payload ? d.payload : ''
    }
    el.addEventListener('statechange', onState)
    return () => el.removeEventListener('statechange', onState)
  }, [challengeUrl])
  return { ref, getPayload: () => payload.current }
}
// JSX: <altcha-widget ref={ref} />;提交时带 getPayload()

⚠️ 上面 hook 只在用 bundler 同步 import 'altcha' 时时机可靠。若你的 React 也用 <script type=module> 异步加载 widget,请改用 altcha-login.jsinitAltchaLogin(它用 whenDefined+轮询处理了 Svelte 实例未就绪时 configure 静默 no-op 的竞态)。 严格 TS 项目:<altcha-widget> 不是标准 JSX 元素,需声明 JSX.IntrinsicElementsweb/altcha-login.d.ts 已含)或用 as any

主题

altcha-login.css 暴露 4 个变量,覆盖成你的设计色即可:

:root {
  --captcha-field-bg: #fafafa;
  --captcha-ink: #15161a;
  --captcha-border: rgba(20,22,26,0.12);
  --captcha-success: #15a35b;
}

CSS 里的 autofill 修复默认作用于全页所有 input。若会和你已有设计系统冲突,把选择器改成限定在登录容器内(如 .login input:-webkit-autofill)。

后端无浏览器自测

生产中由浏览器 widget 解题。要写服务端集成测试(无浏览器)时,用 Solve() 解一道挑战 → 喂给 Verify,全程不必直接碰底层库:

v := captcha.New(captcha.Options{HMACKey: "test-key"})
srv := httptest.NewServer(v.ChallengeHandler())
defer srv.Close()

resp, _ := http.Get(srv.URL)
challengeJSON, _ := io.ReadAll(resp.Body)

payload, _ := captcha.Solve(challengeJSON)   // = 浏览器 widget 会产出的 base64 载荷
if !v.Verify(payload) { t.Fatal("should pass") }

自适应:平时无感、异常才要验证(可选)

默认是「设了 key 就每次登录都验」(widget 后台静默解,用户无感)。如果你想要平时完全不弹、只在某 IP/账号登录失败多次后才要求验证码——用 Gate

capt := captcha.New(captcha.Options{HMACKey: os.Getenv("CAPTCHA_HMAC_KEY")})
gate := captcha.NewGate(captcha.GateOptions{Verifier: capt, Threshold: 3}) // 失败 3 次后才要

// 登录处(key 用 IP,或 IP+账号):
k := captcha.ClientIP(r)
if !gate.Pass(k, req.Altcha) {            // 已被标记需验证、且没过 → 拒
	writeJSON(w, 400, map[string]any{"error": "人机验证失败", "need_captcha": true})
	return
}
if !passwordOK {                          // 口令错
	gate.Failed(k)
	writeJSON(w, 401, map[string]any{"error": "邮箱或密码错误", "need_captcha": gate.Required(k)})
	return
}
gate.Succeeded(k)                         // 成功 → 清零,恢复无感
  • Pass(key,payload):未被标记 → 直接放行;已标记 → 取决于验证码是否通过。
  • Failed/Succeeded:登录结果回写(失败累加、成功清零)。
  • Required(key) / RequiredHandler(keyFn):给前端下发 need_captcha,或做登录前预检。
  • 内存计数、并发安全、过期自动清零(Window 默认 15m);要跨实例共享就自己换个 Redis 版门。

前端:平时把 <altcha-widget> 隐藏、不初始化;登录响应带 need_captcha:true(或预检 /auth/captcha/required 返回 required)时才 initAltchaLogin 并显示。完整可运行流程见 examples/adaptive

ALTCHA 上游开源库不提供这种"按风险才弹"的逻辑(只有纯 PoW 原语;风控是它另一个独立产品 Sentinel)。这层是本包补的。

跑示例

CAPTCHA_HMAC_KEY=$(openssl rand -base64 32) go run ./examples/stdlib    # 每次都验  → :8080
CAPTCHA_HMAC_KEY=$(openssl rand -base64 32) go run ./examples/adaptive  # 失败才弹  → :8082
cd examples/gin && CAPTCHA_HMAC_KEY=$(openssl rand -base64 32) go run .  # gin      → :8081

安全说明

  • 常量时间比较签名(VerifySolutionSafe),避免计时侧信道。
  • 防重放:默认只靠短过期(默认 30m)兜底,沿用 ALTCHA 原生语义;需要更强约束时开启 Options.SingleUse,同一道解答首次通过后在 TTL 内再次提交会被拒。
  • SingleUse 调用方配合:开启后,登录失败要让前端 widget 重新取题求解(例如调用 reset(),或重新配置 auto:'onload'),否则正常用户改密码重试会带着已核销的解答被拒。
  • SingleUse 存储边界:内置去重是进程内存级别;多实例部署只能做到单实例内单次失效。若必须全局单次失效,需要在调用侧接入共享存储版去重。
  • 挑战不缓存:端点带 Cache-Control: no-store
  • 密钥:单把 HMACKey,丢了只影响验证码(重发即可),但泄露可被离线伪造解答,按密钥管理对待。

依赖

直接依赖仅一个:github.com/altcha-org/altcha-lib-goChallengeHandler/NewChallenge 返回的 altcha.Challenge 即来自它;一般用不到,Solve() 已把解题封进本包)。前端 widget 本体来自 npm 包 altcha(v3.x),自托管或走 CDN。

踩坑记录(为什么这个包值得用)

  1. 属性名:altcha v3 配置键是 challenge(URL),旧版 challengeurl 被 v3 忽略 → widget 退化成 fetch 当前页拿回 HTML,报 received text/html
  2. 配置时机:altcha widget 是 Svelte 自定义元素,configure() 在内部实例就绪前静默 no-opaltcha-login.jswhenDefined+轮询直到 statechange 首次触发才停(避免重复取题)。
  3. 隐藏 footer/logohideFooter/hideLogo 是驼峰键,声明式属性吃不到,只能 configure() 下发。
  4. 同源/相对路径challengeUrl 由 widget 自己 fetch,按页面源解析——前后端分离时给绝对地址或确保同源/代理。
  5. autofill:Chrome 自动填充会把输入框刷成淡蓝,altcha-login.css 已修。

License

MIT

Documentation

Overview

Package captcha 给任意 Go HTTP 服务的登录加 ALTCHA 人机校验(proof-of-work captcha,自托管、无第三方、无跟踪),几行即可接入。

框架无关:核心只依赖 net/http,gin / chi / 标准库都能用(见 README)。

工作流程(无状态、HMAC 签名、无需落库):

登录页加载 → GET 挑战端点取一道题 → 前端 widget 后台算 PoW 解出 →
提交登录时把 base64 解答放进请求体的 altcha 字段 → 服务端调 Verify 校验。

特性开关:HMACKey 为空 = 关闭校验(Verify 恒为 true)。此时挑战端点仍照常出题 (用空 key 签名,widget 一样能解),保证「先上线代码、后开启校验」的灰度期间任何 登录都不中断——部署序:先上服务(key 空)、再上前端、最后设 key 重启即开启。

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Assets

func Assets() fs.FS

Assets 返回内嵌的前端套件(altcha-login.js / altcha-login.css),根目录即这两个文件。 这样消费者无需手动从仓库拷贝 web/——go get 本模块后直接挂载即可,做到前后端都「go get 开箱」:

mux.Handle("/captcha/", http.StripPrefix("/captcha/", http.FileServer(http.FS(captcha.Assets()))))
// → /captcha/altcha-login.js, /captcha/altcha-login.css

前端页面据此引用:

<link rel="stylesheet" href="/captcha/altcha-login.css">
<script src="/captcha/altcha-login.js"></script>

func AssetsHandler

func AssetsHandler() http.Handler

AssetsHandler 是一个直接发前端套件的 http.Handler(你自己负责 strip 前缀):

mux.Handle("/captcha/", http.StripPrefix("/captcha/", captcha.AssetsHandler()))

func ClientIP added in v1.1.0

func ClientIP(r *http.Request) string

ClientIP 从请求里取客户端 IP,作为 Gate 的 key 来源:优先 X-Forwarded-For 的首跳 (信任反代时),否则用 RemoteAddr。⚠️ 仅在你的反代会设置/覆盖 XFF 时才信任它, 否则客户端可伪造——不确定就别用 XFF、直接用 RemoteAddr 或改用账号做 key。

func Solve

func Solve(challengeJSON []byte) (string, error)

Solve 解开一道挑战(ChallengeHandler 输出的 JSON),返回与前端 widget 等价的 base64 解答载荷——可直接喂给 Verify。

仅供消费者写**后端集成测试**:生产中由浏览器 widget 解题,无需调用本函数。 有了它,消费者无需直接 import 二级依赖 altcha-lib-go、也无需手拼 payload 结构, 就能跑完整的「取题 → 解 → 校验」服务端冒烟:

chJSON := httptest 取到的 challenge 响应体
payload, _ := captcha.Solve(chJSON)
ok := v.Verify(payload) // true

Types

type Gate added in v1.1.0

type Gate struct {
	// contains filtered or unexported fields
}

Gate 是「自适应」验证码门:平时无感放行,只有某个 key(IP / 账号 / IP+账号)在窗口内 登录失败累计到阈值后,才**要求**该 key 带上通过校验的验证码。登录成功即清零。

它把 ALTCHA 从「每次都验」变成「可疑才验」——上游 altcha 库不提供这层,由本包补。 状态在内存、按 key 计数、并发安全、过期自动清理(无需后台 goroutine / Close)。

典型登录处理(key 可用 ClientIP(r),或 ip+":"+email):

if !gate.Pass(key, req.Altcha) {                 // 需要验证码且没过 → 拒
    writeJSON(w, 400, gin.H{"error": "人机验证失败", "need_captcha": true})
    return
}
if !passwordOK {                                 // 口令错
    gate.Failed(key)
    writeJSON(w, 401, gin.H{"error": "邮箱或密码错误", "need_captcha": gate.Required(key)})
    return
}
gate.Succeeded(key)                              // 成功 → 清零

前端据响应里的 need_captcha 决定是否显示 widget:平时不显示(无感),被标记后才显示。

func NewGate added in v1.1.0

func NewGate(o GateOptions) *Gate

NewGate 构造一个自适应门。Verifier 为 nil 会 panic(必填)。

func (*Gate) Failed added in v1.1.0

func (g *Gate) Failed(key string)

Failed 记一次登录失败(口令错等)。累加该 key 的计数并刷新窗口。

func (*Gate) Pass added in v1.1.0

func (g *Gate) Pass(key, payload string) bool

Pass 报告该 key 这次请求是否「过了验证码这一关」: 不需要验证码 → 直接 true;需要 → 取决于 payload 是否通过 Verify。 在查库 / 比对口令之前调用。注意:它只管验证码,口令对错由你后续判断并回写 Failed/Succeeded。

func (*Gate) Required added in v1.1.0

func (g *Gate) Required(key string) bool

Required 报告某 key 当前是否需要验证码(失败计数已达阈值且未过窗)。 用它给前端下发 need_captcha,或做登录前预检。

func (*Gate) RequiredHandler added in v1.1.0

func (g *Gate) RequiredHandler(keyFn func(*http.Request) string) http.HandlerFunc

RequiredHandler 是给前端的预检端点:GET 返回 {"required": bool}, key 由你给的 keyFn 从请求里取(通常用 ClientIP)。前端据此决定是否显示 widget。

mux.Handle("GET /auth/captcha/required", gate.RequiredHandler(func(r *http.Request) string {
    return captcha.ClientIP(r)
}))

func (*Gate) Succeeded added in v1.1.0

func (g *Gate) Succeeded(key string)

Succeeded 记一次登录成功:清零该 key(解除验证码要求)。

type GateOptions added in v1.1.0

type GateOptions struct {
	// Verifier 是底层校验器(必填)。其 HMACKey 为空(关闭态)时 Pass 仍恒放行。
	Verifier *Verifier
	// Threshold:同一 key 在窗口内失败达到此次数后开始要求验证码。0 = 默认 3。
	Threshold int
	// Window:失败计数的有效窗口;超窗自动清零(也是「冷却」时间)。0 = 默认 15m。
	Window time.Duration
}

GateOptions 配置 Gate。

type Options

type Options struct {
	// HMACKey 用于签发挑战、校验解答(同一把 key 两用)。空 = 关闭校验。
	// 生成示例:openssl rand -base64 32。务必保密、各副本一致。
	HMACKey string

	// TTL 是一道挑战的有效期。登录页加载即取题并解题,30 分钟足够覆盖正常填表,
	// 也是这里唯一的防重放手段(登录另有密码兜底,重放一道已解挑战价值很低)。
	// 0 = 用默认 30m。
	TTL time.Duration

	// MaxNumber 是 PoW 搜索空间(难度)。0 = 默认 1_000_000(现代浏览器亚秒级解出)。
	MaxNumber int64

	// SingleUse 开启「单次失效」:一道解答只接受一次,TTL 内再次提交即拒。
	// 默认 false(沿用 ALTCHA 原生语义——一道解答在 TTL 内可重复提交)。
	//
	// 为何需要:不开启时,攻击者解一次 PoW 就能用同一个 payload 在整个 TTL(默认 30m)内
	// 反复提交,把暴破成本摊薄到接近零;开启后每次提交都得是一道**没用过的**新解,
	// 把成本抬回「每次尝试都要重解一次 PoW」。
	//
	// ⚠️ 调用方配合:开启后,调用方(前端)必须在**每次登录失败后让 widget 重新取题求解**,
	// 否则正常用户的重试会带着同一个已用过的解答、被判重放而失败。配 auto:'onload' 自动重解
	// 体验最佳(用户无感)。
	SingleUse bool
}

Options 配置一个 Verifier。

type Verifier

type Verifier struct {
	// contains filtered or unexported fields
}

Verifier 签发并校验 ALTCHA 挑战。用 New 构造,并发安全。

func New

func New(o Options) *Verifier

New 按 Options 构造 Verifier(零值字段走默认)。

func (*Verifier) ChallengeHandler

func (v *Verifier) ChallengeHandler() http.HandlerFunc

ChallengeHandler 返回一个公开的 GET 处理器:每次签发一道新挑战,以 JSON 返回, 并带 Cache-Control: no-store(验证码挑战绝不能被浏览器/代理缓存)。

挂在一个无需鉴权的路径上,前端 widget 的 challenge URL 指向它。标准库:

mux.Handle("GET /auth/altcha/challenge", cap.ChallengeHandler())

gin:

r.GET("/auth/altcha/challenge", gin.WrapF(cap.ChallengeHandler()))

func (*Verifier) Enabled

func (v *Verifier) Enabled() bool

Enabled 报告是否开启了校验(即 HMACKey 是否非空)。

func (*Verifier) Mount

func (v *Verifier) Mount(mux *http.ServeMux, path string)

Mount 把 ChallengeHandler 注册到 mux 的 path 上(便捷封装)。

cap.Mount(mux, "/auth/altcha/challenge")

func (*Verifier) NewChallenge

func (v *Verifier) NewChallenge() (altcha.Challenge, error)

NewChallenge 签发一道新挑战。即便未开启校验也照常出题(用空 key 签名,widget 仍能解)。 一般用不到——多数场景用 ChallengeHandler 直接挂端点即可。

func (*Verifier) Verify

func (v *Verifier) Verify(payload string) bool

Verify 校验前端回传的 base64 解答载荷。未开启(key 为空)时恒为 true。

用常量时间比较签名(避免计时侧信道);checkExpires=true 让超过 TTL 的挑战判失败。 入参为空串 / 非法 base64 / 篡改 / 过期 一律返回 false。

在登录处理器里、查库或比对口令之前调用,可把机器人流量挡在 DB 之前:

if !cap.Verify(req.Altcha) {
    http.Error(w, "人机验证失败", http.StatusBadRequest); return
}

Directories

Path Synopsis
examples
adaptive command
自适应验证码 demo:平时无感(验证码隐藏),同一 IP 登录失败累计到阈值后才弹出要求验证。
自适应验证码 demo:平时无感(验证码隐藏),同一 IP 登录失败累计到阈值后才弹出要求验证。
stdlib command
可运行的最小示例:标准库 net/http + captcha 包,演示登录验证码端到端。
可运行的最小示例:标准库 net/http + captcha 包,演示登录验证码端到端。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL