实名认证与两步验证
本章覆盖 HyperFRP Panel 后端的实名认证与两步验证(2FA)接口,共 7 个,全部位于 /api/users/ 路由组下且全部要求 JWT Bearer 认证:实名认证 4 个(人工审核提交、二要素核验、自动二要素核验、实名状态查询),两步验证 3 个(开启、确认、关闭)。
公共行为:所有接口通过 Authorization: Bearer <token> 请求头携带 JWT,Bearer 令牌的获取方式见 认证指南;全部路由经过全站 WAF 防护,并显式禁止缓存(响应头 Cache-Control: no-store, no-cache, must-revalidate, private)。所有响应均为统一包络 {"code": 0, "message": "...", "data": ...};200 成功响应的外层 message 固定为「成功」,业务文案位于 data.message。与具体接口无关的通用错误(未认证 401、账户封禁 403 等)见 通用约定。
隐私口径:身份证号明文只进不出——明文仅随提交请求进入服务端,落库前经 RSA-4096-OAEP-SHA256 加密;任何接口的响应中,姓名与身份证号都只出现掩码形态(maskedName / maskedCardNo,如 张*、110101********1234),不存在回显明文的字段;服务端审计日志仅记录姓名与结果状态,不记录身份证号。
实名认证
实名认证支持两条路径:二要素核验(POST /api/users/profile/verify 与 POST /api/users/profile/verify/face)调用外部云市场「身份证二要素核验」服务实时比对姓名与身份证号,匹配即立刻生效;人工审核(POST /api/users/profile/verify/manual)上传身份证照片进入 pending 状态,由管理员审核。核验通过后账号自动迁移至「正式用户」用户组(若该组存在且用户尚不在其中)。
提交受尝试次数限制:服务端按配置的尝试上限(未配置时兜底 5 次)计数,已实名、有审核中申请或次数用尽时拒绝提交。状态取值:not_submitted(未提交)/ pending(人工审核中)/ approved(已通过)/ rejected(已驳回)。
POST /api/users/profile/verify/manual
以人工审核方式提交实名认证:上传身份证照片并填写姓名、身份证号,进入 pending 状态等待管理员审核。
鉴权:JWT Bearer · 缓存:NoCache
请求体
multipart/form-data:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
realName | string(表单字段) | 是 | 真实姓名,2~20 个字符 |
idCardNumber | string(表单字段) | 是 | 18 位身份证号;前 17 位为数字,末位为数字或 X/x,且校验位须通过 MOD 11-2 校验 |
proof | file | 是 | 身份证照片,仅允许 jpg / jpeg / png / gif / webp,单文件 ≤ 10MiB;multipart 部件的 Content-Type 须与扩展名匹配(如 image/png),且文件头魔数须为真实图片格式 |
整个 multipart 请求体在请求体边界中间件中登记的硬上限为 11MiB(单文件 10MiB),超限由中间件直接返回 413。
请求示例
curl -X POST https://api.hyperfrp.com/api/users/profile/verify/manual \
-H "Authorization: Bearer <token>" \
-F "realName=张三" \
-F "idCardNumber=110101199001011237" \
-F "proof=@/path/to/idcard.png;type=image/png"const form = new FormData();
form.append('realName', '张三');
form.append('idCardNumber', '110101199001011237');
form.append('proof', fileInput.files[0]); // 待上传的文件:/path/to/idcard.png
const res = await fetch('https://api.hyperfrp.com/api/users/profile/verify/manual', {
method: 'POST',
headers: { 'Authorization': 'Bearer <token>' }, // 不手动设 Content-Type,由浏览器自动写入 boundary
body: form
});
const data = await res.json();
console.log(data);import requests
res = requests.post(
'https://api.hyperfrp.com/api/users/profile/verify/manual',
headers={'Authorization': 'Bearer <token>'},
files={'proof': open('/path/to/idcard.png', 'rb')},
)
data = res.json()
print(data)package main
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
)
func main() {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
file, _ := os.Open('/path/to/idcard.png')
defer file.Close()
part, _ := writer.CreateFormFile('proof', filepath.Base('/path/to/idcard.png'))
io.Copy(part, file)
writer.Close()
req, _ := http.NewRequest("POST", "https://api.hyperfrp.com/api/users/profile/verify/manual", &buf)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer <token>")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var result map[string]any
json.NewDecoder(res.Body).Decode(&result)
fmt.Println(result)
}响应字段
| 字段 | 类型 | 说明 |
|---|---|---|
message | string | 固定为 人工认证申请已提交,请等待审核 |
status | string | 固定为 pending |
响应示例
{
"code": 0,
"message": "成功",
"data": {
"message": "人工认证申请已提交,请等待审核",
"status": "pending"
}
}错误场景
| 场景 | HTTP | 说明 |
|---|---|---|
| multipart 表单解析失败 | 400 | 解析实名认证表单失败 |
| 请求体读取超时 | 408 | 读取上传请求超时 |
| 请求体或单文件超过登记上限 | 413 | 由请求体边界中间件统一返回(该路由单文件 10MiB / 请求体 11MiB);文件本身不超 11MiB 但大于 10MiB 时为 文件大小不能超过10MiB |
realName 缺失或长度不在 2~20 | 400 | 请填写真实的姓名 |
idCardNumber 缺失或长度非 18 | 400 | 请输入有效的18位身份证号码 |
idCardNumber 校验位不符合 MOD 11-2 | 400 | 请输入有效的身份证号码 |
缺少 proof 文件 | 400 | 请上传身份证照片 |
| 文件扩展名 / Content-Type / 魔数不符合图片要求 | 400 | 只允许上传有效的图片文件 |
| 已完成实名认证 | 400 | 您已完成实名认证 |
| 有正在审核中的申请 | 400 | 您有正在审核中的认证申请 |
| 尝试次数已用尽 | 400 | 您的认证尝试次数已用尽,请联系管理员 |
| 服务端创建目录 / 保存文件 / 落库失败 | 500 | 创建上传目录失败 / 保存文件失败 / 提交人工认证失败: ... |
注意事项
- 人工提交不消耗尝试次数;尝试次数只在管理员驳回时 +1。
- 照片保存为
uploads/verifications/{userId}-{毫秒时间戳}{原扩展名},登记的proofUrl仅供管理端审核列表使用;提交后若服务端落库失败,已上传的照片文件会被自动删除。 - 提交后身份证号即以 RSA-4096-OAEP-SHA256 加密落库、姓名明文入库供审核;
is_verified在管理员审核通过前保持 false,此期间重复提交会被您有正在审核中的认证申请拦截。
POST /api/users/profile/verify/face
调用外部「身份证二要素核验」服务实时比对姓名与身份证号:匹配即实名成功(approved),不匹配或服务异常即拒绝(rejected)。路由名中的 face 为历史命名,实际核验的是姓名 + 身份证号二要素,不涉及人脸照片。
鉴权:JWT Bearer · 缓存:NoCache
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
realName | string | 是 | 真实姓名,2~20 个字符 |
idCardNumber | string | 是 | 18 位身份证号,校验位须通过 MOD 11-2 校验 |
请求示例
curl -X POST https://api.hyperfrp.com/api/users/profile/verify/face \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"realName":"张三","idCardNumber":"110101199001011237"}'const res = await fetch('https://api.hyperfrp.com/api/users/profile/verify/face', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer <token>',
},
body: JSON.stringify({"realName":"张三","idCardNumber":"110101199001011237"})
});
const data = await res.json();
console.log(data);import requests
res = requests.post(
'https://api.hyperfrp.com/api/users/profile/verify/face',
headers={'Authorization': 'Bearer <token>'},
json={
'realName': '张三',
'idCardNumber': '110101199001011237'
},
)
data = res.json()
print(data)package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
var payload bytes.Buffer
json.NewEncoder(&payload).Encode(map[string]any{
"realName": "张三",
"idCardNumber": "110101199001011237",
})
req, _ := http.NewRequest("POST", "https://api.hyperfrp.com/api/users/profile/verify/face", &payload)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer <token>")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var result map[string]any
json.NewDecoder(res.Body).Decode(&result)
fmt.Println(result)
}响应字段
| 字段 | 类型 | 说明 |
|---|---|---|
message | string | 核验结果文案:实名认证通过、姓名与身份证号不匹配,请确认信息后重试、实名认证服务暂未开启,请联系管理员 等 |
status | string | approved(匹配,实名成功)或 rejected(不匹配 / 服务不可用等) |
maskedName | string | 仅 status=approved 时返回;脱敏姓名如 张*,优先取外部服务脱敏返回值,缺失时由服务端按「保留首字、其余以 * 补齐」生成 |
maskedCardNo | string | 仅 status=approved 时返回;脱敏身份证号如 110101********1234(优先取外部服务返回值,缺失时由服务端按「前 6 位 + 8 个 * + 末 4 位」生成) |
响应示例
核验通过(approved):
{
"code": 0,
"message": "成功",
"data": {
"message": "实名认证通过",
"status": "approved",
"maskedName": "张*",
"maskedCardNo": "110101********1234"
}
}核验不通过(rejected,HTTP 仍为 200):
{
"code": 0,
"message": "成功",
"data": {
"message": "姓名与身份证号不匹配,请确认信息后重试",
"status": "rejected"
}
}错误场景
| 场景 | HTTP | 说明 |
|---|---|---|
JSON 绑定失败(缺 realName / idCardNumber 或长度不符) | 400 | 请填写姓名和身份证号码 |
idCardNumber 校验位不符合 MOD 11-2 | 400 | 请输入有效的18位身份证号码 |
| 已完成实名认证 | 400 | 您已完成实名认证 |
| 有正在审核中的申请 | 400 | 您有正在审核中的认证申请 |
| 尝试次数已用尽 | 400 | 您的认证尝试次数已用尽,请联系管理员 |
注意事项
- 每次调用无论核验成功与否都会消耗 1 次尝试次数。
rejected不是 HTTP 错误:不匹配、服务未开启、服务暂时不可用、认证中心库中无此身份证记录(可能为现役军人 / 户口迁移未同步 / 姓名变更未同步 / 未更换二代身份证等原因)、网关响应异常等一律返回 HTTP 200 +status=rejected,具体原因看message。approved时服务端完成落库:身份证号 RSA 加密存储、is_verified=true、状态置为approved、脱敏结果存入认证信息字段,并自动迁移「正式用户」用户组。- 外部核验服务由服务端统一配置与鉴权,对客户端完全透明;接口只透出核验结果,不暴露第三方服务的任何信息。
POST /api/users/profile/verify
自动二要素核验。与 POST /api/users/profile/verify/face 调用同一个外部核验服务与同一套落库逻辑,差异仅在响应字段名与尝试次数的扣减时机:
| 差异点 | verify/face | verify(本接口) |
|---|---|---|
| 响应状态字段名 | status | realNameVerificationStatus |
| 尝试次数扣减 | 成功与失败都 +1 | 仅核验不通过时 +1(含服务未开启等失败),通过不扣减 |
| 尝试次数预检位置 | service 内部 | handler 预检(错误消息相同) |
鉴权:JWT Bearer · 缓存:NoCache
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
realName | string | 是 | 真实姓名,2~20 个字符 |
idCardNumber | string | 是 | 18 位身份证号,校验位须通过 MOD 11-2 校验 |
请求示例
curl -X POST https://api.hyperfrp.com/api/users/profile/verify \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"realName":"张三","idCardNumber":"110101199001011237"}'const res = await fetch('https://api.hyperfrp.com/api/users/profile/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer <token>',
},
body: JSON.stringify({"realName":"张三","idCardNumber":"110101199001011237"})
});
const data = await res.json();
console.log(data);import requests
res = requests.post(
'https://api.hyperfrp.com/api/users/profile/verify',
headers={'Authorization': 'Bearer <token>'},
json={
'realName': '张三',
'idCardNumber': '110101199001011237'
},
)
data = res.json()
print(data)package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
var payload bytes.Buffer
json.NewEncoder(&payload).Encode(map[string]any{
"realName": "张三",
"idCardNumber": "110101199001011237",
})
req, _ := http.NewRequest("POST", "https://api.hyperfrp.com/api/users/profile/verify", &payload)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer <token>")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var result map[string]any
json.NewDecoder(res.Body).Decode(&result)
fmt.Println(result)
}响应字段
| 字段 | 类型 | 说明 |
|---|---|---|
message | string | 核验结果文案,取值同 verify/face |
realNameVerificationStatus | string | approved(匹配,实名成功)或 rejected(不匹配 / 服务不可用等) |
maskedName | string | 仅 approved 时返回,规则同 verify/face |
maskedCardNo | string | 仅 approved 时返回,规则同 verify/face |
响应示例
{
"code": 0,
"message": "成功",
"data": {
"message": "实名认证通过",
"realNameVerificationStatus": "approved",
"maskedName": "张*",
"maskedCardNo": "110101********1234"
}
}错误场景
| 场景 | HTTP | 说明 |
|---|---|---|
JSON 绑定失败(缺 realName / idCardNumber 或长度不符) | 400 | 请填写完整的实名认证信息 |
idCardNumber 校验位不符合 MOD 11-2 | 400 | 请输入有效的18位身份证号码 |
| 已完成实名认证 / 有审核中申请 / 次数用尽 | 400 | 消息同 verify/face |
| 用户不存在或落库失败 | 500 | 用户不存在 / 保存认证结果失败: ... |
注意事项
- 服务未开启或外部服务异常导致的核验失败同样消耗 1 次尝试次数(失败路径固定 +1)。
rejected同样返回 HTTP 200,原因看message;approved的落库与用户组迁移行为同verify/face。
GET /api/users/profile/verification-status
查询当前用户的实名认证状态与脱敏结果。
鉴权:JWT Bearer · 缓存:NoCache
请求示例
curl -X GET https://api.hyperfrp.com/api/users/profile/verification-status \
-H "Authorization: Bearer <token>"const res = await fetch('https://api.hyperfrp.com/api/users/profile/verification-status', {
headers: {
'Authorization': 'Bearer <token>',
},
});
const data = await res.json();
console.log(data);import requests
res = requests.get(
'https://api.hyperfrp.com/api/users/profile/verification-status',
headers={'Authorization': 'Bearer <token>'},
)
data = res.json()
print(data)package main
import (
"fmt"
"net/http"
)
func main() {
req, _ := http.NewRequest("GET", "https://api.hyperfrp.com/api/users/profile/verification-status", nil)
req.Header.Set("Authorization", "Bearer <token>")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var result map[string]any
json.NewDecoder(res.Body).Decode(&result)
fmt.Println(result)
}响应字段
| 字段 | 类型 | 说明 |
|---|---|---|
realNameVerificationStatus | string | 实名状态:not_submitted / pending / approved / rejected |
isRealNameVerified | boolean | 是否已实名 |
realName | string | 脱敏姓名;仅 approved 时返回,其余状态为空串 |
realNameVerificationMessage | string | 认证结果信息:自动核验通过时为脱敏结果的 JSON 字符串(如 {"name":"张*","cardNo":"110101********1234"}),人工审核时为管理员审核留言 |
realNameVerificationTime | string / null | 最近一次实名操作时间(RFC 3339),无则为 null |
realNameVerificationAttempts | number | 已消耗的提交 / 核验尝试次数 |
realNameVerificationRemaining | number | 剩余尝试次数(直接按服务端配置值相减,未配置时可能为 0 或负值) |
maxAttempts | number | 服务端配置的尝试上限(未配置时为 0;实际提交校验兜底为 5 次) |
canRetry | boolean | rejected 状态下次数未用尽时为 true,其余状态固定 false |
remainingDays | number | 固定为 0(预留字段) |
maskedName | string | 脱敏姓名;approved 时优先从存储的脱敏结果读取,缺失时按姓名生成,其余状态为空串 |
maskedCardNo | string | 脱敏身份证号;规则同 maskedName |
响应示例
{
"code": 0,
"message": "成功",
"data": {
"realNameVerificationStatus": "approved",
"isRealNameVerified": true,
"realName": "张*",
"realNameVerificationMessage": "{\"name\":\"张*\",\"cardNo\":\"110101********1234\"}",
"realNameVerificationTime": "2026-09-19T10:30:00+08:00",
"realNameVerificationAttempts": 1,
"realNameVerificationRemaining": 4,
"maxAttempts": 5,
"canRetry": false,
"remainingDays": 0,
"maskedName": "张*",
"maskedCardNo": "110101********1234"
}
}错误场景
| 场景 | HTTP | 说明 |
|---|---|---|
| 用户不存在 | 500 | 用户不存在 |
注意事项
- 该接口带有服务端自愈副作用:
approved用户若尚未迁移「正式用户」组会自动补迁;存在加密身份证数据但状态异常的历史数据会被自动修正为approved。查询本身不会改动正常用户的实名信息。 - 身份证号任何形态下都不返回明文,只返回掩码。
两步验证(2FA)
基于 TOTP(算法 SHA1、6 位动态码、30 秒周期)的两步验证,兼容 Google Authenticator、Microsoft Authenticator、1Password 等标准验证器。开启流程:先 enable 获取密钥与 otpauth URI,验证器扫码或手动录入后 confirm 提交一枚动态码完成开启并一次性领取恢复码;disable 关闭需再次提交动态码。密钥与恢复码以 AES 加密存储于服务端 data/2fa/{userId}.enc。
confirm 与 disable 只接受 TOTP 动态码;恢复码不能用于这两个接口,只能在登录的两步验证环节使用。确认与关闭成功都会使 auth_version 递增,该账号所有已签发 JWT 立即失效,需要重新登录。
POST /api/users/2fa/enable
生成新的 TOTP 密钥与 5 枚恢复码并暂存,返回密钥与 otpauth:// URI 供验证器绑定。
鉴权:JWT Bearer · 缓存:NoCache
无请求体。
请求示例
curl -X POST https://api.hyperfrp.com/api/users/2fa/enable \
-H "Authorization: Bearer <token>"const res = await fetch('https://api.hyperfrp.com/api/users/2fa/enable', {
method: 'POST',
headers: {
'Authorization': 'Bearer <token>',
},
});
const data = await res.json();
console.log(data);import requests
res = requests.post(
'https://api.hyperfrp.com/api/users/2fa/enable',
headers={'Authorization': 'Bearer <token>'},
)
data = res.json()
print(data)package main
import (
"fmt"
"net/http"
)
func main() {
req, _ := http.NewRequest("POST", "https://api.hyperfrp.com/api/users/2fa/enable", nil)
req.Header.Set("Authorization", "Bearer <token>")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var result map[string]any
json.NewDecoder(res.Body).Decode(&result)
fmt.Println(result)
}响应字段
| 字段 | 类型 | 说明 |
|---|---|---|
secret | string | Base32(RFC 4648,无填充)编码的 TOTP 密钥:20 字节随机数编码为 32 字符,用于验证器手动录入 |
uri | string | otpauth:// 标准 URI,即二维码内容,格式为 otpauth://totp/HyperZoneFRP:{username}?algorithm=SHA1&digits=6&issuer=HyperZoneFRP&period=30&secret={secret},label 为 HyperZoneFRP:{username} |
响应示例
{
"code": 0,
"message": "成功",
"data": {
"secret": "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP",
"uri": "otpauth://totp/HyperZoneFRP:hyperzone?algorithm=SHA1&digits=6&issuer=HyperZoneFRP&period=30&secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
}
}错误场景
| 场景 | HTTP | 说明 |
|---|---|---|
| 密钥生成失败或密钥数据写盘失败 | 500 | 启用两步验证失败 |
注意事项
- 服务端不校验当前是否已开启 2FA:已开启时再次调用不报错,而是生成一套全新的
secret与 5 枚新恢复码并覆盖原数据——旧密钥与旧恢复码立即作废,而two_factor_enabled仍为 true,必须用新secret重新 confirm,否则旧验证器将无法通过登录校验。 - 本调用仅暂存密钥,
two_factor_enabled不会置为 true,登录行为不变;confirm 成功后才真正生效。 - 5 枚恢复码在本步生成并加密存盘,但不在本响应中返回,confirm 时一次性返回。
- 二维码内容即
uri字段,由客户端自行渲染;也可将secret手动录入验证器(SHA1、6 位、30 秒)。
POST /api/users/2fa/confirm
提交一枚 TOTP 动态码确认开启两步验证,成功后一次性返回 5 枚恢复码。
鉴权:JWT Bearer · 缓存:NoCache
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
code | string | 是 | 验证器当前显示的 6 位 TOTP 动态码(允许前后各 1 个 30 秒窗口的时钟偏移);恢复码不可用 |
请求示例
curl -X POST https://api.hyperfrp.com/api/users/2fa/confirm \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"code":"492031"}'const res = await fetch('https://api.hyperfrp.com/api/users/2fa/confirm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer <token>',
},
body: JSON.stringify({"code":"492031"})
});
const data = await res.json();
console.log(data);import requests
res = requests.post(
'https://api.hyperfrp.com/api/users/2fa/confirm',
headers={'Authorization': 'Bearer <token>'},
json={
'code': '492031'
},
)
data = res.json()
print(data)package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
var payload bytes.Buffer
json.NewEncoder(&payload).Encode(map[string]any{
"code": "492031",
})
req, _ := http.NewRequest("POST", "https://api.hyperfrp.com/api/users/2fa/confirm", &payload)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer <token>")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var result map[string]any
json.NewDecoder(res.Body).Decode(&result)
fmt.Println(result)
}响应字段
| 字段 | 类型 | 说明 |
|---|---|---|
message | string | 固定为 两步验证已启用 |
recoveryCodes | string[] | 5 枚一次性恢复码,每枚 8 字符(字符集 23456789ABCDEFGHJKMNPQRSTUVWXYZ,不含易混淆的 0/1/I/L/O);仅在确认成功的本响应中返回这一次 |
响应示例
{
"code": 0,
"message": "成功",
"data": {
"message": "两步验证已启用",
"recoveryCodes": ["K7M2P4QX", "R9T3W6YB", "C4D7F2HN", "J5M8P3TR", "W2X6Y9AB"]
}
}错误场景
| 场景 | HTTP | 说明 |
|---|---|---|
缺少 code | 400 | 请输入验证码 |
| 未先调用 enable(服务端无该用户的密钥数据)或数据损坏 | 400 | 服务端原始错误文本 |
| 验证码错误(非当前时间窗内的有效 TOTP 码) | 400 | 验证码错误 |
| 数据库更新失败 | 400 | 该接口将服务层错误统一按 400 返回,文本为原始错误信息 |
注意事项
- 确认成功后
two_factor_enabled=true且auth_version递增:该账号所有已签发 JWT 立即失效(认证中间件校验auth_version一致性),所有已登录会话需重新登录,此后登录进入两步验证环节。 recoveryCodes仅此一次返回;服务端以加密形态存储副本但列表不再展示,请立即妥善保存。丢失后只能先关闭 2FA 再重新开启换取新码。- 关闭并重新开启会生成全新的一组恢复码,旧恢复码一律作废。
POST /api/users/2fa/disable
关闭两步验证:提交一枚 TOTP 动态码校验通过后关闭开关、删除服务端密钥数据(未用完的恢复码一并销毁),此后登录不再要求 2FA。
鉴权:JWT Bearer · 缓存:NoCache
请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
code | string | 是 | 验证器当前显示的 6 位 TOTP 动态码;恢复码不可用 |
请求示例
curl -X POST https://api.hyperfrp.com/api/users/2fa/disable \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"code":"718294"}'const res = await fetch('https://api.hyperfrp.com/api/users/2fa/disable', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer <token>',
},
body: JSON.stringify({"code":"718294"})
});
const data = await res.json();
console.log(data);import requests
res = requests.post(
'https://api.hyperfrp.com/api/users/2fa/disable',
headers={'Authorization': 'Bearer <token>'},
json={
'code': '718294'
},
)
data = res.json()
print(data)package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
var payload bytes.Buffer
json.NewEncoder(&payload).Encode(map[string]any{
"code": "718294",
})
req, _ := http.NewRequest("POST", "https://api.hyperfrp.com/api/users/2fa/disable", &payload)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer <token>")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var result map[string]any
json.NewDecoder(res.Body).Decode(&result)
fmt.Println(result)
}响应字段
| 字段 | 类型 | 说明 |
|---|---|---|
message | string | 固定为 两步验证已禁用 |
响应示例
{
"code": 0,
"message": "成功",
"data": {
"message": "两步验证已禁用"
}
}错误场景
| 场景 | HTTP | 说明 |
|---|---|---|
缺少 code | 400 | 请输入验证码 |
| 未先调用 enable(服务端无该用户的密钥数据)或数据损坏 | 400 | 服务端原始错误文本 |
| 验证码错误 | 400 | 验证码错误 |
| 数据库更新或密钥数据删除失败 | 400 | 该接口将服务层错误统一按 400 返回,文本为原始错误信息 |
注意事项
- 关闭成功后
two_factor_enabled=false且auth_version递增:已签发 JWT 全部失效,需重新登录;重新登录后不再要求 2FA。 - 服务端删除
data/2fa/{userId}.enc,其中未用完的恢复码一并销毁;若日后重新开启,恢复码为全新一组。