文章背景图

Halo 自定义主题集成 Altcha 验证码完整教程

2026-01-22
11
-
- 分钟
|

问题背景

在 Halo Pro 中启用验证码功能后,使用自定义主题时验证码不显示。这是因为:

  1. Halo Pro 的验证码功能是闭源的,代码打包在 JAR 中
  2. 开源版 Halo 没有验证码功能,无法参考
  3. 自定义主题需要手动适配才能正常使用验证码

环境要求

  • Halo Pro >= 2.22.x
  • 后台已启用验证码(控制台 -> 设置 -> 安全设置 -> 验证码)

核心发现

通过进入 Docker 容器查看 Halo Pro 内置模板,发现了关键信息:

# 查看容器内的模板文件
docker exec halo cat /application/BOOT-INF/classes/templates/gateway_fragments/login.html
docker exec halo cat /application/BOOT-INF/classes/templates/gateway_fragments/common.html

发现 1:验证码组件使用自定义 Thymeleaf 方言

<div halo:captcha class="form-item-compact"></div>

halo:captcha 是 Halo Pro 的自定义 Thymeleaf 方言,会自动:

  • 判断是否启用验证码
  • 注入 <altcha-widget> 组件
  • 处理显示/隐藏模式配置

发现 2:JS 文件需要单独加载

<th:block th:if="${#strings.equalsIgnoreCase(globalInfo.captchaProvider, 'ALTCHA')}">
    <script async defer src="/webjars/altcha/2.0.3/dist/altcha.i18n.js" type="module"></script>
    <script src="/js/altcha-lib.iife.js"></script>
    <script src="/js/altcha-utils.js"></script>
</th:block>

发现 3:关键变量

globalInfo.captchaProvider = "ALTCHA"

集成步骤

第一步:修改 common.html 加载 JS

templates/gateway_fragments/common.htmlbasicScriptResources fragment 中添加:

<th:block th:fragment="basicScriptResources">
    <script th:inline="javascript">
        const i18nResources = {
            sendVerificationCodeSuccess: "验证码发送成功",
            sendVerificationCodeFailed: "验证码发送失败",
            sendVerificationCodeSending: "发送中...",
            passwordConfirmationFailed: "两次密码不一致",
        };
    </script>

    <script src="/js/main.js"></script>
    
    <!-- ⭐ Altcha Captcha Scripts (Halo Pro) - 必须添加 -->
    <th:block th:if="${#strings.equalsIgnoreCase(globalInfo.captchaProvider, 'ALTCHA')}">
        <script async defer src="/webjars/altcha/2.0.3/dist/altcha.i18n.js" type="module"></script>
        <script src="/js/altcha-lib.iife.js"></script>
        <script src="/js/altcha-utils.js"></script>
    </th:block>
</th:block>

第二步:登录页面添加验证码

文件:templates/gateway_fragments/login.html

<form th:fragment="form" class="auth-form-terminal" name="login-form" id="login-form"
    th:action="${authProvider.spec.authenticationUrl}" th:method="${authProvider.spec.method}">
    
    <!-- 错误提示 -->
    <div class="terminal-alert" role="alert"
        th:if="${param.error != null and param.error.size() > 0}" th:with="error = ${param.error[0]}">
        <span th:if="${error == 'invalid-credential'}">用户名或密码错误</span>
        <span th:if="${error == 'rate-limit-exceeded'}">请求过于频繁,请稍后再试</span>
        <span th:if="${error == 'account-disabled'}">账号已被禁用</span>
    </div>

    <!-- 动态表单字段(用户名、密码) -->
    <div th:replace="~{__${fragmentTemplateName}__::form}"></div>

    <!-- 记住我 -->
    <div th:if="${authProvider.spec.rememberMeSupport}" class="form-item-compact">
        <input type="checkbox" id="remember-me" name="remember-me" value="true" th:checked="${rememberMe}"/>
        <label for="remember-me">记住我</label>
    </div>

    <!-- ⭐⭐⭐ 验证码组件 - 关键代码 ⭐⭐⭐ -->
    <div halo:captcha class="form-item-compact w-full mb-6"></div>

    <!-- 提交按钮 -->
    <button type="submit">登录</button>
</form>

第三步:注册页面添加验证码

文件:templates/gateway_fragments/signup.html

<form th:fragment="form" class="auth-form-terminal" name="signup-form" id="signup-form"
    th:action="@{/signup}" th:object="${form}" method="post">

    <!-- 错误提示 -->
    <div class="terminal-alert" role="alert" th:if="${error == 'invalid-email-code'}">
        邮箱验证码无效
    </div>
    <div class="terminal-alert" role="alert" th:if="${error == 'rate-limit-exceeded'}">
        请求过于频繁,请稍后再试
    </div>
    <div class="terminal-alert" role="alert" th:if="${error == 'duplicate-username'}">
        用户名已被使用
    </div>

    <!-- 用户名 -->
    <div class="form-item">
        <label for="username">用户名</label>
        <input type="text" id="username" name="username" th:field="*{username}"
            required minlength="4" maxlength="63" placeholder="4-63 个字符" />
        <p th:if="${#fields.hasErrors('username')}" th:errors="*{username}"></p>
    </div>

    <!-- 昵称 -->
    <div class="form-item">
        <label for="displayName">昵称</label>
        <input type="text" id="displayName" name="displayName" th:field="*{displayName}"
            required placeholder="显示名称" />
    </div>

    <!-- 邮箱 -->
    <div class="form-item">
        <label for="email">邮箱</label>
        <input type="email" id="email" name="email" th:field="*{email}"
            required placeholder="user@example.com" />
    </div>

    <!-- 邮箱验证码(仅当后台启用时显示) -->
    <div class="form-item" th:if="${globalInfo.mustVerifyEmailOnRegistration}">
        <label for="emailCode">邮箱验证码</label>
        <div style="display: flex; gap: 0.5rem;">
            <input type="text" id="emailCode" name="emailCode" required placeholder="6位数字" style="flex: 1;" />
            <button id="emailCodeSendButton" type="button">发送验证码</button>
        </div>
    </div>

    <!-- 密码 -->
    <div class="form-item">
        <label for="password">密码</label>
        <input type="password" id="password" name="password" required minlength="5" maxlength="257" />
    </div>

    <!-- 确认密码 -->
    <div class="form-item">
        <label for="confirmPassword">确认密码</label>
        <input type="password" id="confirmPassword" name="confirmPassword" required />
    </div>

    <!-- ⭐⭐⭐ 验证码组件 - 关键代码 ⭐⭐⭐ -->
    <div halo:captcha class="form-item-compact w-full"></div>

    <!-- 提交按钮 -->
    <button type="submit">注册</button>

    <!-- ⭐ 发送邮箱验证码的 JS(需要 Altcha 验证) -->
    <script th:if="${globalInfo.mustVerifyEmailOnRegistration}" th:inline="javascript">
        document.addEventListener("DOMContentLoaded", function () {
            const headerName = /*[[${_csrf.headerName}]]*/ "";
            const token = /*[[${_csrf.token}]]*/ "";
            
            async function sendRequest() {
                const email = document.getElementById("email").value;
                if (!email) {
                    throw new Error("请先输入邮箱地址");
                }

                // ⭐ 检查是否启用 Altcha,如果启用则获取验证结果
                const altchaEnabled = [[${#strings.equalsIgnoreCase(globalInfo.captchaProvider, 'ALTCHA')}]];
                const altchaPayload = altchaEnabled ? await requestAltchaChallengeResult() : '';

                const response = await fetch("/signup/send-email-code", {
                    method: "POST",
                    body: JSON.stringify({ email: email }),
                    headers: {
                        "Content-Type": "application/json",
                        [headerName]: token,
                        'X-Altcha-Payload': altchaPayload,  // ⭐ 必须添加这个 header
                    },
                });

                if (!response.ok) {
                    try {
                        const json = await response.json();
                        if (json.errors && json.errors.length) {
                            throw new Error(json.errors[0]);
                        }
                        if (json.detail) {
                            throw new Error(json.detail);
                        }
                    } catch (e) {
                        if (e.message) throw e;
                        throw new Error("发送失败,请稍后重试");
                    }
                }
                return response;
            }

            const emailCodeSendButton = document.getElementById("emailCodeSendButton");
            // sendVerificationCode 是 Halo 提供的函数,在 /js/main.js 中
            if (typeof sendVerificationCode === 'function') {
                sendVerificationCode(emailCodeSendButton, sendRequest);
            }
        });
    </script>
</form>

关键点说明:

  1. requestAltchaChallengeResult()/js/altcha-utils.js 提供的函数,用于获取验证码验证结果
  2. 必须在请求头中添加 X-Altcha-Payload,否则后端会拒绝请求
  3. sendVerificationCode() 是 Halo 的 /js/main.js 提供的函数,处理倒计时等逻辑

第四步:密码重置页面添加验证码

文件:templates/gateway_fragments/password_reset_email_send.html

<th:block th:fragment="form">
    <!-- 发送成功后的界面 -->
    <form th:if="${sent}" method="get" action="/login" class="auth-form-terminal">
        <div class="terminal-alert success">
            重置链接已发送到您的邮箱,请查收
        </div>
        <button type="submit">返回登录</button>
    </form>

    <!-- 发送表单 -->
    <form th:unless="${sent}" class="auth-form-terminal" th:action="@{/password-reset/email}" method="post" th:object="${form}">
        <!-- 错误提示 -->
        <div class="terminal-alert error" th:if="${param.error != null and param.error.size() > 0}">
            <span th:if="${param.error[0] == 'invalid_reset_token'}">重置链接无效或已过期</span>
        </div>
        <div class="terminal-alert error" th:if="${error == 'rate_limit_exceeded'}">
            请求过于频繁,请稍后再试
        </div>

        <!-- 邮箱输入 -->
        <div class="form-item">
            <label for="email">邮箱地址</label>
            <input type="email" id="email" name="email" required th:field="*{email}" placeholder="user@example.com" />
        </div>

        <!-- ⭐⭐⭐ 验证码组件 ⭐⭐⭐ -->
        <div halo:captcha class="form-item-compact w-full"></div>

        <!-- 提交按钮 -->
        <button type="submit">发送重置链接</button>
    </form>
</th:block>

文件:templates/gateway_fragments/password_reset_email_reset.html

<form th:fragment="form" class="auth-form-terminal"
    th:action="@{/password-reset/email/{resetToken}(resetToken=${resetToken})}"
    method="post" th:object="${form}">
    
    <!-- 错误提示 -->
    <div class="terminal-alert error" th:if="${error == 'rate_limit_exceeded'}">
        请求过于频繁,请稍后再试
    </div>

    <!-- 新密码 -->
    <div class="form-item">
        <label for="password">新密码</label>
        <input type="password" id="password" name="password" required minlength="5" maxlength="257" />
    </div>

    <!-- 确认密码 -->
    <div class="form-item">
        <label for="confirmPassword">确认密码</label>
        <input type="password" id="confirmPassword" name="confirmPassword" required />
    </div>

    <!-- ⭐⭐⭐ 验证码组件 ⭐⭐⭐ -->
    <div halo:captcha class="form-item-compact w-full"></div>

    <!-- 提交按钮 -->
    <button type="submit">重置密码</button>

    <script>
        document.addEventListener("DOMContentLoaded", function () {
            // setupPasswordConfirmation 是 Halo 的 /js/main.js 提供的函数
            if (typeof setupPasswordConfirmation === 'function') {
                setupPasswordConfirmation("password", "confirmPassword");
            }
        });
    </script>
</form>

文件结构

templates/
├── gateway_fragments/
│   ├── common.html                      # ⭐ 添加 Altcha JS 加载
│   ├── layout.html                      # 页面布局
│   ├── login.html                       # ⭐ 登录表单,添加 halo:captcha
│   ├── login_local.html                 # 本地登录字段(用户名、密码)
│   ├── signup.html                      # ⭐ 注册表单,添加 halo:captcha
│   ├── password_reset_email_send.html   # ⭐ 发送重置链接,添加 halo:captcha
│   └── password_reset_email_reset.html  # ⭐ 重置密码,添加 halo:captcha
├── login.html                           # 登录页面入口
├── signup.html                          # 注册页面入口
└── password-reset/
    └── email/
        ├── send.html                    # 发送重置链接页面入口
        └── reset.html                   # 重置密码页面入口

工作原理

┌─────────────────────────────────────────────────────────────┐
│                      页面加载流程                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. common.html 加载                                        │
│     │                                                       │
│     ├─► 检查 globalInfo.captchaProvider == 'ALTCHA'        │
│     │                                                       │
│     └─► 如果是,加载三个 JS 文件:                          │
│         • /webjars/altcha/2.0.3/dist/altcha.i18n.js        │
│         • /js/altcha-lib.iife.js                           │
│         • /js/altcha-utils.js                              │
│                                                             │
│  2. 表单模板渲染                                            │
│     │                                                       │
│     └─► halo:captcha 方言处理:                             │
│         • 检查后台验证码配置                                │
│         • 注入 <altcha-widget> 组件                        │
│         • 自动设置 challengeurl="/captcha/altcha"          │
│                                                             │
│  3. 用户提交表单                                            │
│     │                                                       │
│     └─► Altcha widget 自动将验证结果附加到表单              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

调试技巧

查看可用变量

在模板中临时添加:

<pre th:text="${globalInfo}"></pre>

查看 Halo Pro 内置模板

# 列出所有模板
docker exec halo ls -la /application/BOOT-INF/classes/templates/gateway_fragments/

# 查看具体文件
docker exec halo cat /application/BOOT-INF/classes/templates/gateway_fragments/login.html
docker exec halo cat /application/BOOT-INF/classes/templates/gateway_fragments/common.html
docker exec halo cat /application/BOOT-INF/classes/templates/gateway_fragments/signup.html

检查 JS 是否加载

在浏览器控制台输入:

// 检查 altcha-utils.js 是否加载
typeof requestAltchaChallengeResult  // 应该返回 "function"

// 检查 main.js 是否加载
typeof sendVerificationCode  // 应该返回 "function"

常见问题

Q1: 验证码组件不显示

检查项:

  1. 后台是否启用了验证码功能(控制台 -> 设置 -> 安全设置)
  2. common.html 中是否添加了 JS 加载代码
  3. 表单中是否添加了 <div halo:captcha></div>
  4. 浏览器控制台是否有 404 错误

Q2: 报错 "Web Crypto is not available"

原因: Altcha 使用 Web Crypto API,需要安全上下文

解决:

  • 使用 HTTPS 访问
  • 或使用 localhost 访问
  • 不要使用 IP 地址通过 HTTP 访问

Q3: JS 文件 404

版本号可能随 Halo Pro 更新变化,在浏览器中访问 /webjars/altcha/ 查看可用版本。

Q4: 发送验证码时报错

确保:

  1. 请求头中添加了 X-Altcha-Payload
  2. 调用了 await requestAltchaChallengeResult() 获取验证结果

globalInfo 常用字段

字段 说明 示例值
captchaProvider 验证码提供者 "ALTCHA"
allowRegistration 是否允许注册 true
mustVerifyEmailOnRegistration 注册时是否需要验证邮箱 true
siteTitle 网站标题 "我的博客"

参考资料

评论交流

文章目录