问题背景
在 Halo Pro 中启用验证码功能后,使用自定义主题时验证码不显示。这是因为:
- Halo Pro 的验证码功能是闭源的,代码打包在 JAR 中
- 开源版 Halo 没有验证码功能,无法参考
- 自定义主题需要手动适配才能正常使用验证码
环境要求
- 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.html 的 basicScriptResources 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>
关键点说明:
requestAltchaChallengeResult()是/js/altcha-utils.js提供的函数,用于获取验证码验证结果- 必须在请求头中添加
X-Altcha-Payload,否则后端会拒绝请求 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: 验证码组件不显示
检查项:
- 后台是否启用了验证码功能(控制台 -> 设置 -> 安全设置)
common.html中是否添加了 JS 加载代码- 表单中是否添加了
<div halo:captcha></div> - 浏览器控制台是否有 404 错误
Q2: 报错 "Web Crypto is not available"
原因: Altcha 使用 Web Crypto API,需要安全上下文
解决:
- 使用 HTTPS 访问
- 或使用
localhost访问 - 不要使用 IP 地址通过 HTTP 访问
Q3: JS 文件 404
版本号可能随 Halo Pro 更新变化,在浏览器中访问 /webjars/altcha/ 查看可用版本。
Q4: 发送验证码时报错
确保:
- 请求头中添加了
X-Altcha-Payload - 调用了
await requestAltchaChallengeResult()获取验证结果
globalInfo 常用字段
| 字段 | 说明 | 示例值 |
|---|---|---|
captchaProvider |
验证码提供者 | "ALTCHA" |
allowRegistration |
是否允许注册 | true |
mustVerifyEmailOnRegistration |
注册时是否需要验证邮箱 | true |
siteTitle |
网站标题 | "我的博客" |