355 lines
12 KiB
Go
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)
|
|
}
|
|
}
|