135 lines
3.7 KiB
Go
135 lines
3.7 KiB
Go
package captcha
|
||
|
||
import (
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"time"
|
||
|
||
"github.com/alibabacloud-go/tea/tea"
|
||
captcha20230305 "github.com/alibabacloud-go/captcha-20230305/client"
|
||
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||
)
|
||
|
||
var (
|
||
ErrCaptchaVerifyFailed = errors.New("图形验证码校验失败")
|
||
ErrCaptchaConfig = errors.New("验证码配置错误")
|
||
ErrCaptchaEncryptMissing = errors.New("加密模式需要配置 EncryptKey(控制台 ekey)")
|
||
)
|
||
|
||
// CaptchaConfig 阿里云验证码配置
|
||
type CaptchaConfig struct {
|
||
AccessKeyID string
|
||
AccessKeySecret string
|
||
EndpointURL string
|
||
SceneID string
|
||
// EncryptKey 加密模式使用的密钥(控制台 ekey,Base64 编码的 32 字节),用于生成 EncryptedSceneId
|
||
EncryptKey string
|
||
}
|
||
|
||
// CaptchaService 阿里云验证码服务
|
||
type CaptchaService struct {
|
||
config CaptchaConfig
|
||
}
|
||
|
||
// NewCaptchaService 创建验证码服务实例
|
||
func NewCaptchaService(config CaptchaConfig) *CaptchaService {
|
||
return &CaptchaService{
|
||
config: config,
|
||
}
|
||
}
|
||
|
||
// Verify 验证滑块验证码
|
||
func (s *CaptchaService) Verify(captchaVerifyParam string) error {
|
||
if captchaVerifyParam == "" {
|
||
return ErrCaptchaVerifyFailed
|
||
}
|
||
|
||
if s.config.AccessKeyID == "" || s.config.AccessKeySecret == "" {
|
||
return ErrCaptchaConfig
|
||
}
|
||
|
||
clientCfg := &openapi.Config{
|
||
AccessKeyId: tea.String(s.config.AccessKeyID),
|
||
AccessKeySecret: tea.String(s.config.AccessKeySecret),
|
||
}
|
||
clientCfg.Endpoint = tea.String(s.config.EndpointURL)
|
||
|
||
client, err := captcha20230305.NewClient(clientCfg)
|
||
if err != nil {
|
||
return errors.Join(ErrCaptchaConfig, err)
|
||
}
|
||
|
||
req := &captcha20230305.VerifyIntelligentCaptchaRequest{
|
||
SceneId: tea.String(s.config.SceneID),
|
||
CaptchaVerifyParam: tea.String(captchaVerifyParam),
|
||
}
|
||
|
||
resp, err := client.VerifyIntelligentCaptcha(req)
|
||
if err != nil {
|
||
return errors.Join(ErrCaptchaVerifyFailed, err)
|
||
}
|
||
|
||
if resp.Body == nil || !tea.BoolValue(resp.Body.Result.VerifyResult) {
|
||
return ErrCaptchaVerifyFailed
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetEncryptedSceneId 生成加密场景 ID(EncryptedSceneId),供前端加密模式初始化验证码使用。
|
||
// 算法:AES-256-CBC,明文 sceneId×tamp&expireTime,密钥为控制台 ekey(Base64 解码后 32 字节)。
|
||
// expireTimeSec 有效期为 1~86400 秒。
|
||
func (s *CaptchaService) GetEncryptedSceneId(expireTimeSec int) (string, error) {
|
||
if expireTimeSec <= 0 || expireTimeSec > 86400 {
|
||
return "", fmt.Errorf("expireTimeSec 必须在 1~86400 之间")
|
||
}
|
||
if s.config.EncryptKey == "" {
|
||
return "", ErrCaptchaEncryptMissing
|
||
}
|
||
if s.config.SceneID == "" {
|
||
return "", ErrCaptchaConfig
|
||
}
|
||
|
||
keyBytes, err := base64.StdEncoding.DecodeString(s.config.EncryptKey)
|
||
if err != nil || len(keyBytes) != 32 {
|
||
return "", errors.Join(ErrCaptchaConfig, fmt.Errorf("EncryptKey 必须为 Base64 编码的 32 字节"))
|
||
}
|
||
|
||
plaintext := fmt.Sprintf("%s&%d&%d", s.config.SceneID, time.Now().Unix(), expireTimeSec)
|
||
plainBytes := []byte(plaintext)
|
||
plainBytes = pkcs7Pad(plainBytes, aes.BlockSize)
|
||
|
||
block, err := aes.NewCipher(keyBytes)
|
||
if err != nil {
|
||
return "", errors.Join(ErrCaptchaConfig, err)
|
||
}
|
||
|
||
iv := make([]byte, aes.BlockSize)
|
||
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
mode := cipher.NewCBCEncrypter(block, iv)
|
||
ciphertext := make([]byte, len(plainBytes))
|
||
mode.CryptBlocks(ciphertext, plainBytes)
|
||
|
||
result := make([]byte, len(iv)+len(ciphertext))
|
||
copy(result, iv)
|
||
copy(result[len(iv):], ciphertext)
|
||
return base64.StdEncoding.EncodeToString(result), nil
|
||
}
|
||
|
||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||
n := blockSize - (len(data) % blockSize)
|
||
pad := make([]byte, n)
|
||
for i := range pad {
|
||
pad[i] = byte(n)
|
||
}
|
||
return append(data, pad...)
|
||
}
|