Files
2026-09-25 00:27:15 +08:00

355 lines
12 KiB
Go

package auth
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/core/logs"
)
// 人机验证方式(与平台登录验证配置 login_verify_type 保持一致)
const (
VerifyTypeCaptcha = "captcha" // 本地图形验证码
VerifyTypeSMS = "sms" // 短信验证码
VerifyTypeEmail = "email" // 邮箱验证码
VerifyTypeGeetest = "geetest" // 极验 4.0
VerifyTypeGeetest4 = "geetest4" // 极验 4.0
VerifyTypeGeetest3 = "geetest3" // 极验 3.0
)
// geetest4ValidateURL 极验 4.0 服务端二次校验地址
const geetest4ValidateURL = "https://gcaptcha4.geetest.com/validate"
// 人机验证失败类别
const (
captchaFailRequired = "required" // 未完成验证 / 参数缺失
captchaFailFailed = "failed" // 极验服务端判定未通过
captchaFailService = "service" // 极验校验服务不可用(网络/超时/响应异常)
captchaFailNotSet = "notset" // 未配置极验
)
// captchaError 人机验证失败,msg 可直接展示给用户
type captchaError struct {
kind string
msg string
}
func (e *captchaError) Error() string { return e.msg }
// 人机验证错误
var (
ErrCaptchaRequired = errors.New("请先完成人机验证")
ErrCaptchaFailed = errors.New("人机验证未通过,请重新验证")
ErrCaptchaNotSet = errors.New("人机验证未配置,请联系管理员")
)
// LoginVerifyConfig 登录页使用的人机验证配置
type LoginVerifyConfig struct {
OpenVerify bool `json:"openVerify"` // 是否开启登录验证
VerifyType string `json:"verifyType"` // captcha/sms/email/geetest/geetest4/geetest3
GeetestVersion string `json:"geetestVersion"` // 极验版本:4 / 3
CaptchaID string `json:"captchaId"` // 极验 captcha_id,未配置时为空
geetestKey string // 服务端校验密钥,不下发给前端
}
// NeedGeetest 是否需要弹出极验。
// 目前认证中心只接入了极验 4.0(gt4),极验 3.0 待接入后再开启强制校验。
func (c LoginVerifyConfig) NeedGeetest() bool {
if !c.OpenVerify {
return false
}
switch strings.ToLower(strings.TrimSpace(c.VerifyType)) {
case VerifyTypeGeetest, VerifyTypeGeetest4:
return true
default:
return false
}
}
// GetLoginVerifyConfig 读取登录验证配置。
//
// 登录发生在选择企业之前,租户未知,因此以平台全局配置为准;
// 若传入 tid 且该租户单独配置了验证方式,则用租户配置覆盖开关与类型,
// 极验参数(captcha_id / key)始终取全局配置。
func GetLoginVerifyConfig(tid uint64) LoginVerifyConfig {
cfg := LoginVerifyConfig{VerifyType: VerifyTypeCaptcha}
openVerify := false
verifyType := VerifyTypeCaptcha
var g3ID, g3Key, g4ID, g4Key string
if global, err := models.GetPlatformLoginVerify(); err == nil && global != nil {
openVerify = global.OpenVerifyEnabled == 1
if t := strings.ToLower(strings.TrimSpace(global.VerifyType)); t != "" {
verifyType = t
}
if global.Geetest3ID != nil {
g3ID = *global.Geetest3ID
}
if global.Geetest3Key != nil {
g3Key = *global.Geetest3Key
}
if global.Geetest4ID != nil {
g4ID = *global.Geetest4ID
}
if global.Geetest4Key != nil {
g4Key = *global.Geetest4Key
}
}
if tid > 0 {
var tenantCfg models.AuthTenantAuthConfig
if err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
Filter("tid", tid).One(&tenantCfg); err == nil {
openVerify = tenantCfg.OpenVerify == 1
if t := strings.ToLower(strings.TrimSpace(tenantCfg.VerifyType)); t != "" {
verifyType = t
}
}
}
cfg.OpenVerify = openVerify
cfg.VerifyType = verifyType
switch verifyType {
case VerifyTypeGeetest, VerifyTypeGeetest4:
cfg.GeetestVersion = "4"
cfg.CaptchaID = g4ID
cfg.geetestKey = g4Key
case VerifyTypeGeetest3:
cfg.GeetestVersion = "3"
cfg.CaptchaID = g3ID
cfg.geetestKey = g3Key
}
return cfg
}
// CaptchaResult 前端极验验证通过后提交的校验参数
type CaptchaResult struct {
CaptchaID string `json:"captcha_id"`
LotNumber string `json:"lot_number"`
PassToken string `json:"pass_token"`
GenTime string `json:"gen_time"`
CaptchaOutput string `json:"captcha_output"`
}
// VerifyLoginCaptcha 校验人机验证结果,通过返回 nil。
//
// 仅在开启极验时拦截:未开启或为图形码/短信/邮箱/极验3 时不做处理,
// 避免登录页没有对应 UI 却被服务端拒绝。
func VerifyLoginCaptcha(tid uint64, result CaptchaResult) error {
cfg := GetLoginVerifyConfig(tid)
if !cfg.NeedGeetest() {
return nil
}
if cfg.CaptchaID == "" {
return &captchaError{kind: captchaFailNotSet, msg: ErrCaptchaNotSet.Error()}
}
if result.LotNumber == "" || result.PassToken == "" || result.GenTime == "" || result.CaptchaOutput == "" {
logs.Warn("[auth] 登录缺少极验参数 captcha_id=%s lot_number=%q pass_token=%v gen_time=%v captcha_output=%v",
cfg.CaptchaID, result.LotNumber, result.PassToken != "", result.GenTime, result.CaptchaOutput != "")
return &captchaError{kind: captchaFailRequired, msg: ErrCaptchaRequired.Error()}
}
// 未配置极验 KEY 时无法做服务端二次校验,仅保证参数完整
if cfg.geetestKey == "" {
logs.Warn("[auth] 未配置极验 captcha_key,跳过服务端二次校验(仅校验参数完整性)")
return nil
}
err := verifyGeetest4(cfg, result)
if err == nil {
return nil
}
// 逃生开关:极验服务端不可用时避免整站无法登录
if geetestFailOpen() {
logs.Warn("[auth] 极验服务端校验失败但已开启 fail_open,按配置放行: %v", err)
return nil
}
return err
}
// geetestFailOpen 极验二次校验失败时是否放行。
// 平台设置 login_verify_geetest4_fail_open = "1" 时开启(默认 0,严格拒绝)。
// 用于极验密钥配置错误或服务不可用的应急恢复。
func geetestFailOpen() bool {
return strings.TrimSpace(models.GetPlatformSettingValue("login_verify_geetest4_fail_open", "0")) == "1"
}
// verifyGeetest4 调用极验 4.0 二次校验接口,确认验证结果未被伪造
func verifyGeetest4(cfg LoginVerifyConfig, result CaptchaResult) error {
// sign_token = HMAC-SHA256(captcha_key, lot_number) 的十六进制串
mac := hmac.New(sha256.New, []byte(cfg.geetestKey))
mac.Write([]byte(result.LotNumber))
signToken := hex.EncodeToString(mac.Sum(nil))
payload, err := json.Marshal(map[string]string{
"lot_number": result.LotNumber,
"captcha_output": result.CaptchaOutput,
"pass_token": result.PassToken,
"gen_time": result.GenTime,
"sign_token": signToken,
})
if err != nil {
return ErrCaptchaFailed
}
captchaID := strings.TrimSpace(result.CaptchaID)
if captchaID == "" {
captchaID = cfg.CaptchaID
}
target := geetest4ValidateURL + "?captcha_id=" + url.QueryEscape(captchaID)
req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(payload))
if err != nil {
return ErrCaptchaFailed
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 8 * time.Second}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
logs.Error("[auth] 调用极验二次校验失败: %v", err)
return &captchaError{kind: captchaFailService, msg: "人机验证服务暂时不可用,请稍后重试"}
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
logs.Error("[auth] 极验二次校验返回异常 status=%d body=%s", resp.StatusCode, string(body))
return &captchaError{kind: captchaFailService, msg: "人机验证服务暂时不可用,请稍后重试"}
}
var res struct {
Result string `json:"result"`
Status string `json:"status"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(body, &res); err != nil {
logs.Error("[auth] 解析极验二次校验响应失败: %v body=%s", err, string(body))
return &captchaError{kind: captchaFailService, msg: "人机验证服务暂时不可用,请稍后重试"}
}
if res.Result == "success" || (res.Result == "" && res.Status == "success") {
logs.Info("[auth] 极验二次校验通过 captcha_id=%s 耗时=%dms", captchaID, time.Since(start).Milliseconds())
return nil
}
// 校验未通过:记录原始响应,并把原因转成可读提示,便于定位是密钥配置问题还是验证过期
logs.Warn("[auth] 极验二次校验未通过 captcha_id=%s lot_number=%s result=%q status=%q reason=%q body=%s",
captchaID, result.LotNumber, res.Result, res.Status, res.Reason, string(body))
return &captchaError{kind: captchaFailFailed, msg: geetestFailReason(res.Reason)}
}
// DiagnoseGeetest4 极验 4.0 配置自检。
//
// 用一个固定的 lot_number 调二次校验接口,依据极验返回判断问题所在:
// - 请求超时/失败 → 服务器无法访问极验服务(网络或防火墙)
// - sign_token error → 网络可达,但 captcha_key 与 captcha_id 不匹配
// - lot_number error → 网络可达且密钥正确(lot_number 是捏造的,报错属正常)
func DiagnoseGeetest4() map[string]interface{} {
cfg := GetLoginVerifyConfig(0)
out := map[string]interface{}{
"openVerify": cfg.OpenVerify,
"verifyType": cfg.VerifyType,
"needGeetest": cfg.NeedGeetest(),
"captchaId": cfg.CaptchaID,
"keySet": cfg.geetestKey != "",
}
if cfg.CaptchaID == "" {
out["ok"] = false
out["msg"] = "未配置极验 captcha_id"
return out
}
if cfg.geetestKey == "" {
out["ok"] = false
out["msg"] = "未配置极验 captcha_key(此时认证中心只校验参数完整性,不做服务端二次校验)"
return out
}
mac := hmac.New(sha256.New, []byte(cfg.geetestKey))
mac.Write([]byte("diagnose-lot-number"))
payload, _ := json.Marshal(map[string]string{
"lot_number": "diagnose-lot-number",
"captcha_output": "",
"pass_token": "",
"gen_time": "",
"sign_token": hex.EncodeToString(mac.Sum(nil)),
})
target := geetest4ValidateURL + "?captcha_id=" + url.QueryEscape(cfg.CaptchaID)
req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(payload))
if err != nil {
out["ok"] = false
out["msg"] = "构造自检请求失败: " + err.Error()
return out
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Do(req)
if err != nil {
out["ok"] = false
out["msg"] = "无法访问极验服务(" + err.Error() + "),请检查服务器出网/防火墙"
return out
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
var res struct {
Result string `json:"result"`
Status string `json:"status"`
Reason string `json:"reason"`
}
_ = json.Unmarshal(body, &res)
out["raw"] = string(body)
switch {
case resp.StatusCode != http.StatusOK:
out["ok"] = false
out["msg"] = fmt.Sprintf("极验返回 HTTP %d,请检查 captcha_id 是否正确", resp.StatusCode)
case strings.Contains(strings.ToLower(res.Reason), "sign_token"):
out["ok"] = false
out["msg"] = "网络可达,但 captcha_key 与 captcha_id 不匹配,请核对极验后台的验证密钥"
case strings.Contains(strings.ToLower(res.Reason), "lot_number"),
strings.Contains(strings.ToLower(res.Reason), "captcha_output"),
strings.Contains(strings.ToLower(res.Reason), "pass_token"):
out["ok"] = true
out["msg"] = "网络可达且 captcha_key 校验通过(lot_number 为自检用假数据,报错属正常)"
default:
out["ok"] = false
out["msg"] = "极验返回异常: " + string(body)
}
return out
}
// geetestFailReason 极验失败原因(reason)转中文提示,帮助区分“密钥配置错误”与“验证已失效”
func geetestFailReason(reason string) string {
r := strings.ToLower(strings.TrimSpace(reason))
switch r {
case "":
return ErrCaptchaFailed.Error()
case "sign_token error", "sign_token_error", "captcha_key error", "captcha_key_error":
return "人机验证未通过:极验密钥(captcha_key)配置有误,请联系管理员"
case "captcha_id error", "captcha_id_error":
return "人机验证未通过:极验 captcha_id 与密钥不匹配,请联系管理员"
case "lot_number error", "lot_number_error":
return "人机验证已失效,请重新验证"
case "gen_time error", "gen_time_error", "timeout":
return "人机验证已超时,请重新验证"
default:
return fmt.Sprintf("人机验证未通过(%s),请重新验证", reason)
}
}