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) } }