Merge pull request #1203 from trheyi/main

Refactor captcha handling in tests and core functionality
This commit is contained in:
Max 2025-10-15 15:43:41 +08:00 committed by GitHub
commit 862e36cd74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1786 additions and 77 deletions

View file

@ -1,24 +1,13 @@
package helper
import (
"bytes"
"encoding/base64"
"time"
"github.com/dchest/captcha"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/any"
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/kun/maps"
utilscaptcha "github.com/yaoapp/yao/utils/captcha"
)
var store = captcha.NewMemoryStore(1024, 10*time.Minute)
func init() {
captcha.SetCustomStore(store)
}
// CaptchaOption 验证码配置
type CaptchaOption struct {
Type string
@ -42,53 +31,36 @@ func NewCaptchaOption() CaptchaOption {
// CaptchaMake 制作验证码
func CaptchaMake(option CaptchaOption) (string, string) {
if option.Width == 0 {
option.Width = 240
// Convert to utils captcha option
utilsOption := utilscaptcha.Option{
Type: option.Type,
Height: option.Height,
Width: option.Width,
Length: option.Length,
Lang: option.Lang,
Background: option.Background,
}
if option.Height == 0 {
option.Width = 80
}
if option.Length == 0 {
option.Length = 6
}
if option.Lang == "" {
option.Lang = "zh"
}
id := captcha.NewLen(option.Length)
var data []byte
var buff = bytes.NewBuffer(data)
switch option.Type {
case "audio":
err := captcha.WriteAudio(buff, id, option.Lang)
if err != nil {
exception.New("make audio captcha error: %s", 500, err).Throw()
}
content := "data:audio/mp3;base64," + base64.StdEncoding.EncodeToString(buff.Bytes())
log.Debug("ID:%s Audio Captcha:%s", id, toString(store.Get(id, false)))
return id, content
default:
err := captcha.WriteImage(buff, id, option.Width, option.Height)
if err != nil {
exception.New("make image captcha error: %s", 500, err).Throw()
}
content := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buff.Bytes())
log.Debug("ID:%s Image Captcha:%s", id, toString(store.Get(id, false)))
return id, content
}
return utilscaptcha.Generate(utilsOption)
}
// CaptchaValidate Validate the captcha
// CaptchaValidate Validate the captcha (image/audio)
func CaptchaValidate(id string, code string) bool {
return captcha.VerifyString(id, code)
return utilscaptcha.Validate(id, code)
}
// CaptchaGet retrieves the captcha answer for testing purposes
// Returns empty string if captcha ID not found or expired
func CaptchaGet(id string) string {
return utilscaptcha.Get(id)
}
// CaptchaValidateCloudflare validates a Cloudflare Turnstile token
// This function makes an HTTP request to Cloudflare's verification endpoint
//
// For testing, use Cloudflare's official test sitekeys:
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
func CaptchaValidateCloudflare(token, secret string) bool {
return utilscaptcha.ValidateCloudflare(token, secret)
}
// ProcessCaptchaValidate xiang.helper.CaptchaValidate image/audio captcha
@ -124,11 +96,3 @@ func ProcessCaptcha(process *process.Process) interface{} {
"content": content,
}
}
func toString(digits []byte) string {
var buf bytes.Buffer
for _, d := range digits {
buf.WriteByte(d + '0')
}
return buf.String()
}

View file

@ -19,7 +19,7 @@ func TestCaptcha(t *testing.T) {
})
assert.IsType(t, "string", id)
assert.IsType(t, "string", content)
assert.True(t, CaptchaValidate(id, toString(store.Get(id, false))))
assert.True(t, CaptchaValidate(id, CaptchaGet(id)))
id, content = CaptchaMake(CaptchaOption{
Type: "math",
@ -30,7 +30,7 @@ func TestCaptcha(t *testing.T) {
})
assert.IsType(t, "string", id)
assert.IsType(t, "string", content)
assert.True(t, CaptchaValidate(id, toString(store.Get(id, false))))
assert.True(t, CaptchaValidate(id, CaptchaGet(id)))
id, content = CaptchaMake(CaptchaOption{
Type: "digit",
@ -41,7 +41,7 @@ func TestCaptcha(t *testing.T) {
})
assert.IsType(t, "string", id)
assert.IsType(t, "string", content)
assert.True(t, CaptchaValidate(id, toString(store.Get(id, false))))
assert.True(t, CaptchaValidate(id, CaptchaGet(id)))
}
func TestProcessCaptcha(t *testing.T) {
@ -53,7 +53,7 @@ func TestProcessCaptcha(t *testing.T) {
assert.IsType(t, "string", res.Get("id"))
assert.IsType(t, "string", res.Get("content"))
value := toString(store.Get(res.Get("id").(string), false))
value := CaptchaGet(res.Get("id").(string))
p = process.New("xiang.helper.CaptchaValidate", res.Get("id"), value)
assert.True(t, p.Run().(bool))
assert.Panics(t, func() {

View file

@ -0,0 +1,518 @@
package user_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/tests/testutils"
"github.com/yaoapp/yao/openapi/user"
"github.com/yaoapp/yao/utils/captcha"
)
// TestEntryVerifyWithExistingUser tests entry verification for an existing user (login flow)
func TestEntryVerifyWithExistingUser(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create a test user in the database
testUserID := fmt.Sprintf("test_user_%s", testUUID)
testEmail := fmt.Sprintf("test_%s@example.com", testUUID)
createUserWithEmail(t, testUserID, testEmail)
// Get image captcha first
captchaID, captchaAnswer := getCaptcha(t, serverURL, baseURL, "image")
// Test successful entry verification for existing user
t.Run("VerifyEntry_ExistingUser_Success", func(t *testing.T) {
verifyData := map[string]interface{}{
"username": testEmail,
"captcha_id": captchaID,
"captcha": captchaAnswer, // Use real captcha answer
"locale": "zh-cn", // Use zh-cn locale for image captcha
}
jsonData, _ := json.Marshal(verifyData)
url := fmt.Sprintf("%s%s/user/entry/verify", serverURL, baseURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
// Read response body for debugging
body, _ := io.ReadAll(resp.Body)
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
assert.Equal(t, http.StatusOK, resp.StatusCode, "Response body: %s", string(body))
var result user.EntryVerifyResponse
err = json.Unmarshal(body, &result)
assert.NoError(t, err)
// Verify response for existing user (login flow)
assert.Equal(t, "login", result.Status)
assert.True(t, result.UserExists)
assert.NotEmpty(t, result.AccessToken)
assert.Equal(t, "Bearer", result.TokenType)
assert.Equal(t, user.ScopeEntryVerification, result.Scope)
assert.Greater(t, result.ExpiresIn, 0)
assert.False(t, result.VerificationSent) // No verification sent for existing user
t.Logf("Login flow: status=%s, user_exists=%t, token=%s", result.Status, result.UserExists, result.AccessToken)
})
}
// TestEntryVerifyWithNewUser tests entry verification for a new user (register flow)
func TestEntryVerifyWithNewUser(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
newUserEmail := fmt.Sprintf("new_user_%s@example.com", testUUID)
// Get image captcha first
captchaID, captchaAnswer := getCaptcha(t, serverURL, baseURL, "image")
// Test successful entry verification for new user
t.Run("VerifyEntry_NewUser_Success", func(t *testing.T) {
verifyData := map[string]interface{}{
"username": newUserEmail,
"captcha_id": captchaID,
"captcha": captchaAnswer, // Use real captcha answer
"locale": "zh-cn", // Use zh-cn locale for image captcha
}
jsonData, _ := json.Marshal(verifyData)
url := fmt.Sprintf("%s%s/user/entry/verify", serverURL, baseURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result user.EntryVerifyResponse
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
// Verify response for new user (register flow)
assert.Equal(t, "register", result.Status)
assert.False(t, result.UserExists)
assert.NotEmpty(t, result.AccessToken)
assert.Equal(t, "Bearer", result.TokenType)
assert.Equal(t, user.ScopeEntryVerification, result.Scope)
assert.Greater(t, result.ExpiresIn, 0)
assert.True(t, result.VerificationSent) // Verification code should be sent for new user
t.Logf("Register flow: status=%s, user_exists=%t, verification_sent=%t, token=%s",
result.Status, result.UserExists, result.VerificationSent, result.AccessToken)
})
}
// TestEntryVerifyValidation tests validation for entry verification endpoint
func TestEntryVerifyValidation(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Test missing username
t.Run("VerifyEntry_MissingUsername", func(t *testing.T) {
verifyData := map[string]interface{}{
// Missing username
"captcha_id": "test",
"captcha": "test",
}
jsonData, _ := json.Marshal(verifyData)
url := fmt.Sprintf("%s%s/user/entry/verify", serverURL, baseURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
// Test invalid username format
t.Run("VerifyEntry_InvalidUsername", func(t *testing.T) {
verifyData := map[string]interface{}{
"username": "invalid-username", // Not email or mobile
"captcha_id": "test",
"captcha": "test",
}
jsonData, _ := json.Marshal(verifyData)
url := fmt.Sprintf("%s%s/user/entry/verify", serverURL, baseURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "Invalid username format")
})
// Test invalid locale (should fallback to default "en" locale)
// Note: This test verifies that the system gracefully handles invalid locales
// by falling back to default configuration
t.Run("VerifyEntry_InvalidLocale", func(t *testing.T) {
// Skip this test for now as it requires understanding the exact locale fallback behavior
// The system should fallback to "en" locale when an invalid locale is provided
// But the captcha configuration might differ between locales
t.Skip("Skipping invalid locale test - requires consistent captcha configuration across locales")
})
}
// TestEntryVerifyWithMobile tests entry verification with mobile number
func TestEntryVerifyWithMobile(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create a test user with mobile
testUserID := fmt.Sprintf("test_user_mobile_%s", testUUID)
testMobile := "+8613800138000" // Valid mobile format
createUserWithMobile(t, testUserID, testMobile)
// Get image captcha first
captchaID, captchaAnswer := getCaptcha(t, serverURL, baseURL, "image")
// Test successful entry verification with mobile
t.Run("VerifyEntry_Mobile_ExistingUser", func(t *testing.T) {
verifyData := map[string]interface{}{
"username": testMobile,
"captcha_id": captchaID,
"captcha": captchaAnswer, // Use real captcha answer
"locale": "zh-cn", // Use zh-cn locale for image captcha
}
jsonData, _ := json.Marshal(verifyData)
url := fmt.Sprintf("%s%s/user/entry/verify", serverURL, baseURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result user.EntryVerifyResponse
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
// Verify response for existing user with mobile
assert.Equal(t, "login", result.Status)
assert.True(t, result.UserExists)
assert.NotEmpty(t, result.AccessToken)
t.Logf("Mobile login flow: status=%s, user_exists=%t", result.Status, result.UserExists)
})
// Test new mobile user (register flow)
t.Run("VerifyEntry_Mobile_NewUser", func(t *testing.T) {
newMobile := "+8613900139000" // Different mobile number
captchaID2, captchaAnswer2 := getCaptcha(t, serverURL, baseURL, "image")
verifyData := map[string]interface{}{
"username": newMobile,
"captcha_id": captchaID2,
"captcha": captchaAnswer2, // Use real captcha answer
"locale": "zh-cn", // Use zh-cn locale for image captcha
}
jsonData, _ := json.Marshal(verifyData)
url := fmt.Sprintf("%s%s/user/entry/verify", serverURL, baseURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result user.EntryVerifyResponse
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
// Verify response for new mobile user
assert.Equal(t, "register", result.Status)
assert.False(t, result.UserExists)
assert.True(t, result.VerificationSent)
t.Logf("Mobile register flow: status=%s, verification_sent=%t", result.Status, result.VerificationSent)
})
}
// TestEntryVerifyCaptcha tests captcha verification
func TestEntryVerifyCaptcha(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
testEmail := fmt.Sprintf("test_%s@example.com", testUUID)
// Test with valid image captcha
t.Run("VerifyEntry_ValidImageCaptcha", func(t *testing.T) {
captchaID, captchaAnswer := getCaptcha(t, serverURL, baseURL, "image")
verifyData := map[string]interface{}{
"username": testEmail,
"captcha_id": captchaID,
"captcha": captchaAnswer, // Use real captcha answer
"locale": "zh-cn", // Use zh-cn locale for image captcha
}
jsonData, _ := json.Marshal(verifyData)
url := fmt.Sprintf("%s%s/user/entry/verify", serverURL, baseURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
// Test with missing captcha for image type
t.Run("VerifyEntry_MissingImageCaptcha", func(t *testing.T) {
verifyData := map[string]interface{}{
"username": testEmail,
// Missing captcha_id and captcha
"locale": "zh-cn", // Use zh-cn locale for image captcha
}
jsonData, _ := json.Marshal(verifyData)
url := fmt.Sprintf("%s%s/user/entry/verify", serverURL, baseURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
// Should fail due to missing captcha in zh-cn config (image type)
// en config uses turnstile which might not require captcha_id
// Let's check the response - it might be OK or BadRequest depending on locale
body, _ := io.ReadAll(resp.Body)
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
})
}
// TestEntryVerifyToken tests the temporary token generation
func TestEntryVerifyToken(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
testEmail := fmt.Sprintf("test_%s@example.com", testUUID)
// Get captcha
captchaID, captchaAnswer := getCaptcha(t, serverURL, baseURL, "image")
// Verify entry and get token
verifyData := map[string]interface{}{
"username": testEmail,
"captcha_id": captchaID,
"captcha": captchaAnswer, // Use real captcha answer
"locale": "zh-cn", // Use zh-cn locale for image captcha
}
jsonData, _ := json.Marshal(verifyData)
url := fmt.Sprintf("%s%s/user/entry/verify", serverURL, baseURL)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
assert.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result user.EntryVerifyResponse
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
// Test that the token is valid
t.Run("ValidateTemporaryToken", func(t *testing.T) {
assert.NotEmpty(t, result.AccessToken)
assert.Equal(t, "Bearer", result.TokenType)
assert.Equal(t, user.ScopeEntryVerification, result.Scope)
// Token should be valid for 10 minutes (600 seconds)
assert.Equal(t, 600, result.ExpiresIn)
t.Logf("Temporary token: %s, expires_in: %d, scope: %s",
result.AccessToken, result.ExpiresIn, result.Scope)
})
}
// Helper functions
// getCaptcha gets a captcha image or turnstile challenge
func getCaptcha(t *testing.T, serverURL, baseURL, captchaType string) (string, string) {
url := fmt.Sprintf("%s%s/user/entry/captcha?type=%s", serverURL, baseURL, captchaType)
resp, err := http.Get(url)
assert.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&result)
assert.NoError(t, err)
captchaID := ""
captchaImage := ""
if id, ok := result["captcha_id"].(string); ok {
captchaID = id
}
if img, ok := result["captcha_image"].(string); ok {
captchaImage = img
}
// Get the actual captcha answer from store for testing
captchaAnswer := captcha.Get(captchaID)
t.Logf("Got captcha: id=%s, answer=%s, image_length=%d", captchaID, captchaAnswer, len(captchaImage))
return captchaID, captchaAnswer
}
// createUserWithEmail creates a user with email in the database
func createUserWithEmail(t *testing.T, userID, email string) {
userData := map[string]interface{}{
"user_id": userID,
"name": "Test User " + userID,
"email": email,
"status": "enabled",
}
provider, err := oauth.OAuth.GetUserProvider()
if err != nil {
t.Fatalf("Failed to get user provider: %v", err)
}
ctx := context.Background()
createdUserID, err := provider.CreateUser(ctx, userData)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
t.Logf("Created user with email: user_id=%s, email=%s", createdUserID, email)
}
// createUserWithMobile creates a user with mobile number in the database
func createUserWithMobile(t *testing.T, userID, mobile string) {
userData := map[string]interface{}{
"user_id": userID,
"name": "Test User " + userID,
"phone_number": mobile, // Use phone_number instead of mobile
"status": "enabled",
}
provider, err := oauth.OAuth.GetUserProvider()
if err != nil {
t.Fatalf("Failed to get user provider: %v", err)
}
ctx := context.Background()
createdUserID, err := provider.CreateUser(ctx, userData)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
t.Logf("Created user with mobile: user_id=%s, mobile=%s", createdUserID, mobile)
}

View file

@ -170,8 +170,14 @@ func TestUserLoginConfigStructure(t *testing.T) {
// Test messenger configuration (for registration)
if config.Messenger != nil {
t.Logf("Messenger configuration found")
assert.IsType(t, "", config.Messenger.Channel, "Messenger channel should be string")
assert.IsType(t, map[string]string{}, config.Messenger.Templates, "Messenger templates should be map")
if config.Messenger.Mail != nil {
assert.IsType(t, "", config.Messenger.Mail.Channel, "Messenger mail channel should be string")
assert.IsType(t, "", config.Messenger.Mail.Template, "Messenger mail template should be string")
}
if config.Messenger.SMS != nil {
assert.IsType(t, "", config.Messenger.SMS.Channel, "Messenger SMS channel should be string")
assert.IsType(t, "", config.Messenger.SMS.Template, "Messenger SMS template should be string")
}
}
} else {
t.Log("No user configuration found")

View file

@ -1,9 +1,20 @@
package user
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/messenger"
messengertypes "github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/openapi/utils"
utilscaptcha "github.com/yaoapp/yao/utils/captcha"
utilsotp "github.com/yaoapp/yao/utils/otp"
)
// getEntryConfig is the handler for get unified auth entry configuration
@ -60,3 +71,340 @@ func entry(c *gin.Context) {
// 2. If exists: proceed with login flow
// 3. If not exists: proceed with registration flow
}
// GinEntryVerify is the handler for verifying entry (login/register)
// It checks if the username exists and sends verification code if needed
func GinEntryVerify(c *gin.Context) {
// Parse request body
var req EntryVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid request body: " + err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Get locale from request or query parameter
locale := req.Locale
if locale == "" {
locale = c.Query("locale")
}
if locale == "" {
locale = "en" // Default locale
}
// Determine username type (email or mobile) - check this first before expensive operations
usernameType := determineUsernameType(req.Username)
if usernameType == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid username format: must be email or mobile number",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Get entry configuration
config := GetEntryConfig(locale)
if config == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Entry configuration not found for locale: " + locale,
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
// Verify captcha
if config.Form != nil && config.Form.Captcha != nil {
err := verifyCaptcha(config.Form.Captcha, req.CaptchaID, req.Captcha)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Captcha verification failed: " + err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
}
// Check if user exists
userExists, userID, err := checkUserExists(c.Request.Context(), usernameType, req.Username)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to check user existence: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Get Yao client config for token generation
yaoClientConfig := GetYaoClientConfig()
if yaoClientConfig == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Client configuration not found",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Generate temporary access token for entry verification (valid for 10 minutes)
var tokenExpire int = 10 * 60 // 10 minutes
// Create subject based on username (temporary subject for verification)
tempSubject := fmt.Sprintf("entry:%s:%s", usernameType, req.Username)
// Extra claims for the token
extraClaims := map[string]interface{}{
"username": req.Username,
"username_type": usernameType,
}
// If user exists, add user_id to claims
if userExists && userID != "" {
extraClaims["user_id"] = userID
}
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, ScopeEntryVerification, tempSubject, tokenExpire, extraClaims)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to generate access token: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Prepare response
verifyResp := EntryVerifyResponse{
AccessToken: accessToken,
ExpiresIn: tokenExpire,
TokenType: "Bearer",
Scope: ScopeEntryVerification,
UserExists: userExists,
}
// If user exists: return login status
if userExists {
verifyResp.Status = "login"
response.RespondWithSuccess(c, response.StatusOK, verifyResp)
return
}
// User doesn't exist: send verification code and return register status
verifyResp.Status = "register"
// Send verification code asynchronously
go func() {
ctx := context.Background()
err := sendEntryVerificationCode(ctx, config, usernameType, req.Username, locale)
if err != nil {
log.Error("Failed to send verification code to %s: %v", req.Username, err)
} else {
log.Info("Verification code sent to %s for registration", req.Username)
}
}()
verifyResp.VerificationSent = true
response.RespondWithSuccess(c, response.StatusOK, verifyResp)
}
// Helper Functions
// verifyCaptcha verifies the captcha based on type (image or turnstile)
func verifyCaptcha(captchaConfig *CaptchaConfig, captchaID, captcha string) error {
if captchaConfig == nil {
return nil // No captcha required
}
switch captchaConfig.Type {
case "image":
// Verify image captcha
if captchaID == "" || captcha == "" {
return fmt.Errorf("captcha_id and captcha are required for image captcha")
}
valid := utilscaptcha.Validate(captchaID, captcha)
if !valid {
return fmt.Errorf("invalid captcha")
}
return nil
case "turnstile":
// Verify Cloudflare Turnstile
if captcha == "" {
return fmt.Errorf("captcha token is required for Turnstile")
}
// Get secret from options
secret := ""
if captchaConfig.Options != nil {
if s, ok := captchaConfig.Options["secret"].(string); ok {
secret = s
}
}
if secret == "" {
return fmt.Errorf("Turnstile secret not configured")
}
// Verify Turnstile token using captcha function
valid := utilscaptcha.ValidateCloudflare(captcha, secret)
if !valid {
return fmt.Errorf("invalid Turnstile token")
}
return nil
default:
return fmt.Errorf("unsupported captcha type: %s", captchaConfig.Type)
}
}
// determineUsernameType determines if the username is email or mobile
func determineUsernameType(username string) string {
// Check if it's an email
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
if emailRegex.MatchString(username) {
return "email"
}
// Check if it's a mobile number (international format)
// Support formats like: +86123456789, 86123456789, 123456789
mobileRegex := regexp.MustCompile(`^\+?[0-9]{10,15}$`)
if mobileRegex.MatchString(username) {
return "mobile"
}
return ""
}
// checkUserExists checks if a user exists with the given email or mobile
// Returns: (userExists bool, userID string, error)
func checkUserExists(ctx context.Context, usernameType, username string) (bool, string, error) {
// Get user provider
userProvider, err := oauth.OAuth.GetUserProvider()
if err != nil {
return false, "", fmt.Errorf("failed to get user provider: %w", err)
}
// Query user by email or mobile
var user map[string]interface{}
switch usernameType {
case "email":
user, err = userProvider.GetUserByEmail(ctx, username)
case "mobile":
// For mobile, use GetUserForAuth with phone_number identifier type
user, err = userProvider.GetUserForAuth(ctx, username, "phone_number")
default:
return false, "", fmt.Errorf("invalid username type: %s", usernameType)
}
if err != nil {
// If user not found, return false without error
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "User not found") {
return false, "", nil
}
return false, "", fmt.Errorf("failed to query user: %w", err)
}
// Extract user_id from the returned map
userID := ""
if user != nil {
if id, ok := user["user_id"].(string); ok {
userID = id
} else if id, ok := user["id"].(string); ok {
userID = id
}
}
if userID == "" {
return false, "", nil
}
return true, userID, nil
}
// sendEntryVerificationCode sends a verification code to the user's email or mobile
func sendEntryVerificationCode(ctx context.Context, config *EntryConfig, usernameType, username, locale string) error {
// Check if messenger is available
if messenger.Instance == nil {
return fmt.Errorf("messenger service not available")
}
// Check messenger configuration
if config.Messenger == nil {
return fmt.Errorf("messenger configuration not found in entry config")
}
// Generate verification code using OTP (6-digit number, 10 minutes expiry)
otpOption := utilsotp.NewOption()
otpOption.Length = 6
otpOption.Type = "numeric"
otpOption.Expiration = 600 // 10 minutes
otpID, verificationCode := utilsotp.Generate(otpOption)
// Store OTP ID in context for later verification
// The OTP code is automatically stored in memory with expiration
log.Debug("Generated OTP for %s: ID=%s", username, otpID)
var channel string
var template string
var messageType messengertypes.MessageType
// Determine channel and template based on username type
switch usernameType {
case "email":
if config.Messenger.Mail == nil {
return fmt.Errorf("email messenger configuration not found")
}
channel = config.Messenger.Mail.Channel
template = config.Messenger.Mail.Template
messageType = messengertypes.MessageTypeEmail
// Default channel if not specified
if channel == "" {
channel = "default"
}
case "mobile":
if config.Messenger.SMS == nil {
return fmt.Errorf("SMS messenger configuration not found")
}
channel = config.Messenger.SMS.Channel
template = config.Messenger.SMS.Template
messageType = messengertypes.MessageTypeSMS
// Default channel if not specified
if channel == "" {
channel = "default"
}
default:
return fmt.Errorf("unsupported username type: %s", usernameType)
}
if template == "" {
return fmt.Errorf("template not configured for %s verification", usernameType)
}
// Prepare template data
templateData := messengertypes.TemplateData{
"to": username,
"verification_code": verificationCode,
"expires_in": "10", // 10 minutes
"locale": locale,
}
// Send verification code
err := messenger.Instance.SendT(ctx, channel, template, templateData, messageType)
if err != nil {
return fmt.Errorf("failed to send verification code: %w", err)
}
return nil
}

View file

@ -10,16 +10,16 @@ import (
"github.com/gin-gonic/gin"
"github.com/yaoapp/gou/session"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/helper"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/oauth/providers/user"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/utils/captcha"
)
// getCaptcha is the handler for get captcha image for entry (login/register)
func getCaptcha(c *gin.Context) {
var option helper.CaptchaOption = helper.NewCaptchaOption()
var option captcha.Option = captcha.NewOption()
err := c.ShouldBindQuery(&option)
if err != nil {
@ -32,7 +32,7 @@ func getCaptcha(c *gin.Context) {
// Set the type to image
option.Type = "image"
id, content := helper.CaptchaMake(option)
id, content := captcha.Generate(option)
// Return in the format expected by the frontend
response.RespondWithSuccess(c, http.StatusOK, gin.H{

View file

@ -21,6 +21,8 @@ const (
ScopeMFAVerification = "mfa_verification"
// ScopeTeamSelection is the team selection scope for temporary access token
ScopeTeamSelection = "team_selection"
// ScopeEntryVerification is the entry verification scope for temporary access token (login or register)
ScopeEntryVerification = "entry_verification"
)
// FormConfig represents the form configuration
@ -93,8 +95,14 @@ type EntryConfig struct {
// MessengerConfig represents the messenger configuration for user registration
type MessengerConfig struct {
Channel string `json:"channel,omitempty"`
Templates map[string]string `json:"templates,omitempty"` // mail, sms templates
Mail *MessengerChannelConfig `json:"mail,omitempty"` // Email verification config
SMS *MessengerChannelConfig `json:"sms,omitempty"` // SMS verification config
}
// MessengerChannelConfig represents a single messenger channel configuration
type MessengerChannelConfig struct {
Channel string `json:"channel,omitempty"` // Messenger channel name (e.g., "default", "aws_ses")
Template string `json:"template,omitempty"` // Template name for this channel
}
// YaoClientConfig represents the Yao OpenAPI Client config
@ -224,6 +232,27 @@ type LoginSuccessResponse struct {
// LoginContext is an alias for the oauth types LoginContext
type LoginContext = oauthtypes.LoginContext
// ==== Entry Verification Types ====
// EntryVerifyRequest represents the request to verify entry (login/register)
type EntryVerifyRequest struct {
Username string `json:"username" binding:"required"` // Email or mobile
CaptchaID string `json:"captcha_id,omitempty"` // Captcha ID (for image captcha)
Captcha string `json:"captcha,omitempty"` // Captcha answer or token
Locale string `json:"locale,omitempty"` // Locale for localized responses
}
// EntryVerifyResponse represents the response for entry verification
type EntryVerifyResponse struct {
Status string `json:"status"` // "login" or "register"
AccessToken string `json:"access_token"` // Temporary token for next step
ExpiresIn int `json:"expires_in"` // Token expiration in seconds
TokenType string `json:"token_type"` // Token type (Bearer)
Scope string `json:"scope"` // Token scope
UserExists bool `json:"user_exists"` // Whether user exists
VerificationSent bool `json:"verification_sent,omitempty"` // Whether verification code was sent (for register)
}
// Built-in preset mapping types
const (
MappingGoogle = "google"

View file

@ -28,9 +28,11 @@ func init() {
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
// User Authentication
group.GET("/entry", getEntryConfig) // Get unified auth entry config (public)
group.POST("/entry", entry) // Unified auth entry (login/register) (public)
group.GET("/entry/captcha", getCaptcha) // Get captcha for login/register (public)
group.GET("/entry", getEntryConfig) // Get unified auth entry config (public)
group.POST("/entry", entry) // Unified auth entry (login/register) (public)
group.GET("/entry/captcha", getCaptcha) // Get captcha for login/register (public)
group.POST("/entry/verify", GinEntryVerify) // Verify login/register email or mobile (public)
group.POST("/logout", oauth.Guard, placeholder) // User logout
// Logined User Settings

160
utils/captcha/captcha.go Normal file
View file

@ -0,0 +1,160 @@
package captcha
import (
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"time"
"github.com/dchest/captcha"
"github.com/yaoapp/kun/log"
)
var store = captcha.NewMemoryStore(1024, 10*time.Minute)
func init() {
captcha.SetCustomStore(store)
}
// Option 验证码配置
type Option struct {
Type string
Height int
Width int
Length int
Lang string
Background string
}
// NewOption 创建验证码配置
func NewOption() Option {
return Option{
Width: 240,
Height: 80,
Length: 6,
Lang: "zh",
Background: "#FFFFFF",
}
}
// Generate 制作验证码
func Generate(option Option) (string, string) {
if option.Width == 0 {
option.Width = 240
}
if option.Height == 0 {
option.Width = 80
}
if option.Length == 0 {
option.Length = 6
}
if option.Lang == "" {
option.Lang = "zh"
}
id := captcha.NewLen(option.Length)
var data []byte
var buff = bytes.NewBuffer(data)
switch option.Type {
case "audio":
err := captcha.WriteAudio(buff, id, option.Lang)
if err != nil {
log.Error("make audio captcha error: %s", err)
return "", ""
}
content := "data:audio/mp3;base64," + base64.StdEncoding.EncodeToString(buff.Bytes())
log.Debug("ID:%s Audio Captcha:%s", id, toString(store.Get(id, false)))
return id, content
default:
err := captcha.WriteImage(buff, id, option.Width, option.Height)
if err != nil {
log.Error("make image captcha error: %s", err)
return "", ""
}
content := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buff.Bytes())
log.Debug("ID:%s Image Captcha:%s", id, toString(store.Get(id, false)))
return id, content
}
}
// Validate validates the captcha (image/audio)
func Validate(id string, code string) bool {
return captcha.VerifyString(id, code)
}
// Get retrieves the captcha answer for testing purposes
// Returns empty string if captcha ID not found or expired
func Get(id string) string {
digits := store.Get(id, false)
if digits == nil {
return ""
}
return toString(digits)
}
// ValidateCloudflare validates a Cloudflare Turnstile token
// This function makes an HTTP request to Cloudflare's verification endpoint
//
// For testing, use Cloudflare's official test sitekeys:
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
func ValidateCloudflare(token, secret string) bool {
if token == "" || secret == "" {
return false
}
// Cloudflare Turnstile verification endpoint
verifyURL := "https://challenges.cloudflare.com/turnstile/v0/siteverify"
// Prepare request body
requestBody := map[string]string{
"secret": secret,
"response": token,
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
log.Error("Failed to marshal Turnstile request: %v", err)
return false
}
// Make HTTP POST request
resp, err := http.Post(verifyURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
log.Error("Failed to verify Turnstile token: %v", err)
return false
}
defer resp.Body.Close()
// Parse response
var result struct {
Success bool `json:"success"`
ErrorCodes []string `json:"error-codes,omitempty"`
}
err = json.NewDecoder(resp.Body).Decode(&result)
if err != nil {
log.Error("Failed to parse Turnstile response: %v", err)
return false
}
if !result.Success && len(result.ErrorCodes) > 0 {
log.Warn("Turnstile verification failed: %v", result.ErrorCodes)
}
return result.Success
}
func toString(digits []byte) string {
var buf bytes.Buffer
for _, d := range digits {
buf.WriteByte(d + '0')
}
return buf.String()
}

View file

@ -0,0 +1,170 @@
package captcha
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGenerate(t *testing.T) {
// Test image captcha
option := NewOption()
option.Type = "image"
option.Length = 6
id, content := Generate(option)
assert.NotEmpty(t, id, "Captcha ID should not be empty")
assert.NotEmpty(t, content, "Captcha content should not be empty")
assert.Contains(t, content, "data:image/png;base64,", "Should return base64 encoded image")
t.Logf("Image captcha: id=%s, content_length=%d", id, len(content))
// Test audio captcha
option.Type = "audio"
option.Length = 4
option.Lang = "en"
id2, content2 := Generate(option)
assert.NotEmpty(t, id2, "Audio captcha ID should not be empty")
assert.NotEmpty(t, content2, "Audio captcha content should not be empty")
assert.Contains(t, content2, "data:audio/mp3;base64,", "Should return base64 encoded audio")
t.Logf("Audio captcha: id=%s, content_length=%d", id2, len(content2))
// Test math captcha (default)
option.Type = "math"
option.Length = 6
id3, content3 := Generate(option)
assert.NotEmpty(t, id3)
assert.NotEmpty(t, content3)
t.Logf("Math captcha: id=%s", id3)
}
func TestValidate(t *testing.T) {
option := NewOption()
option.Type = "math"
option.Length = 6
// Generate captcha
id, _ := Generate(option)
assert.NotEmpty(t, id)
// Get the correct answer
answer := Get(id)
assert.NotEmpty(t, answer, "Should be able to retrieve captcha answer")
t.Logf("Captcha answer: %s", answer)
// Test valid captcha
valid := Validate(id, answer)
assert.True(t, valid, "Valid captcha should pass validation")
// Test invalid captcha
valid = Validate(id, "wrong_answer")
assert.False(t, valid, "Invalid captcha should fail validation")
// Test non-existent ID
valid = Validate("non_existent_id", answer)
assert.False(t, valid, "Non-existent ID should fail validation")
}
func TestGet(t *testing.T) {
option := NewOption()
option.Length = 6
// Generate captcha
id, _ := Generate(option)
// Get answer
answer := Get(id)
assert.NotEmpty(t, answer, "Should retrieve captcha answer")
assert.Equal(t, 6, len(answer), "Answer length should match configured length")
// Verify the answer is correct
valid := Validate(id, answer)
assert.True(t, valid, "Retrieved answer should be valid")
// Test non-existent ID
answer2 := Get("non_existent_id")
assert.Empty(t, answer2, "Non-existent ID should return empty string")
}
func TestValidateCloudflare(t *testing.T) {
// Test with empty values
valid := ValidateCloudflare("", "")
assert.False(t, valid, "Empty token should fail validation")
valid = ValidateCloudflare("token", "")
assert.False(t, valid, "Empty secret should fail validation")
// Note: Testing actual Cloudflare Turnstile requires real API keys and tokens
// For real testing, use Cloudflare's test sitekeys:
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
t.Log("Cloudflare Turnstile validation requires real API keys for full testing")
}
func TestNewOption(t *testing.T) {
option := NewOption()
assert.Equal(t, 240, option.Width, "Default width should be 240")
assert.Equal(t, 80, option.Height, "Default height should be 80")
assert.Equal(t, 6, option.Length, "Default length should be 6")
assert.Equal(t, "zh", option.Lang, "Default language should be zh")
assert.Equal(t, "#FFFFFF", option.Background, "Default background should be #FFFFFF")
}
func TestCaptchaExpiration(t *testing.T) {
option := NewOption()
id, _ := Generate(option)
// Verify captcha exists
answer := Get(id)
assert.NotEmpty(t, answer)
// Validate once (this will delete it from store)
valid := Validate(id, answer)
assert.True(t, valid)
// Try to get again - should be gone after validation
answer2 := Get(id)
assert.Empty(t, answer2, "Captcha should be deleted after validation")
}
func TestCaptchaConcurrency(t *testing.T) {
option := NewOption()
option.Length = 4
// Test concurrent captcha generation and validation
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
id, _ := Generate(option)
assert.NotEmpty(t, id)
answer := Get(id)
assert.NotEmpty(t, answer)
valid := Validate(id, answer)
assert.True(t, valid)
done <- true
}()
}
// Wait for all goroutines
for i := 0; i < 10; i++ {
<-done
}
}
func BenchmarkGenerate(b *testing.B) {
option := NewOption()
for i := 0; i < b.N; i++ {
Generate(option)
}
}
func BenchmarkValidate(b *testing.B) {
option := NewOption()
id, _ := Generate(option)
answer := Get(id)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Validate(id, answer)
}
}

68
utils/captcha/process.go Normal file
View file

@ -0,0 +1,68 @@
package captcha
import (
"github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/kun/maps"
)
// ProcessGenerate utils.captcha.Generate - Generate captcha
func ProcessGenerate(process *process.Process) interface{} {
process.ValidateArgNums(1)
option := NewOption()
// Parse options from process args
if process.NumOfArgs() > 0 {
optMap := process.ArgsMap(0, map[string]interface{}{})
if width, ok := optMap["width"].(int); ok {
option.Width = width
}
if height, ok := optMap["height"].(int); ok {
option.Height = height
}
if length, ok := optMap["length"].(int); ok {
option.Length = length
}
if captchaType, ok := optMap["type"].(string); ok {
option.Type = captchaType
}
if lang, ok := optMap["lang"].(string); ok {
option.Lang = lang
}
if bg, ok := optMap["background"].(string); ok {
option.Background = bg
}
}
id, content := Generate(option)
return maps.Map{
"id": id,
"content": content,
}
}
// ProcessValidate utils.captcha.Verify - Validate captcha
func ProcessValidate(process *process.Process) interface{} {
process.ValidateArgNums(2)
id := process.ArgsString(0)
code := process.ArgsString(1)
if code == "" {
exception.New("Please enter the captcha.", 400).Throw()
return false
}
if !Validate(id, code) {
exception.New("Invalid captcha.", 400).Throw()
return false
}
return true
}
// ProcessGet utils.captcha.Get - Get captcha code (for testing)
func ProcessGet(process *process.Process) interface{} {
process.ValidateArgNums(1)
id := process.ArgsString(0)
return Get(id)
}

122
utils/otp/otp.go Normal file
View file

@ -0,0 +1,122 @@
package otp
import (
"crypto/rand"
"math/big"
"time"
"github.com/dchest/captcha"
"github.com/google/uuid"
)
// OTP store using captcha's MemoryStore
// Stores OTP codes with expiration (default: 10 minutes)
var store = captcha.NewMemoryStore(2048, 10*time.Minute)
// Option OTP configuration
type Option struct {
Length int // Code length (default: 6)
Expiration int // Expiration time in seconds (default: 600)
Type string // Code type: "numeric" (default), "alphanumeric"
}
// NewOption creates default OTP configuration
func NewOption() Option {
return Option{
Length: 6,
Expiration: 600, // 10 minutes
Type: "numeric",
}
}
// Generate generates a new OTP code and returns id and code
// The id is used to identify the OTP, and the code is sent to user
func Generate(option Option) (string, string) {
if option.Length <= 0 {
option.Length = 6
}
if option.Type == "" {
option.Type = "numeric"
}
// Generate unique ID for this OTP
id := uuid.New().String()
// Generate OTP code
var code string
switch option.Type {
case "alphanumeric":
code = generateAlphanumericCode(option.Length)
default:
code = generateNumericCode(option.Length)
}
// Store OTP code as bytes
store.Set(id, []byte(code))
return id, code
}
// Validate validates an OTP code against the stored value
// Returns true if valid, false otherwise
// The clear parameter indicates whether to delete the OTP after validation
func Validate(id string, code string, clear bool) bool {
if id == "" || code == "" {
return false
}
// Get stored OTP code
storedBytes := store.Get(id, clear)
if storedBytes == nil {
return false
}
storedCode := string(storedBytes)
return storedCode == code
}
// Get retrieves the OTP code for testing purposes
// Returns empty string if OTP ID not found or expired
func Get(id string) string {
storedBytes := store.Get(id, false)
if storedBytes == nil {
return ""
}
return string(storedBytes)
}
// Delete deletes an OTP code from the store
func Delete(id string) {
store.Get(id, true)
}
// generateNumericCode generates a random numeric code
func generateNumericCode(length int) string {
const digits = "0123456789"
return generateRandomString(length, digits)
}
// generateAlphanumericCode generates a random alphanumeric code
func generateAlphanumericCode(length int) string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return generateRandomString(length, chars)
}
// generateRandomString generates a random string from the given character set
func generateRandomString(length int, charset string) string {
result := make([]byte, length)
charsetLen := big.NewInt(int64(len(charset)))
for i := 0; i < length; i++ {
num, err := rand.Int(rand.Reader, charsetLen)
if err != nil {
// Fallback to less secure method if crypto/rand fails
num = big.NewInt(int64(i % len(charset)))
}
result[i] = charset[num.Int64()]
}
return string(result)
}

229
utils/otp/otp_test.go Normal file
View file

@ -0,0 +1,229 @@
package otp
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGenerate(t *testing.T) {
option := NewOption()
// Test numeric code generation
id, code := Generate(option)
assert.NotEmpty(t, id)
assert.NotEmpty(t, code)
assert.Equal(t, 6, len(code), "Default numeric code should be 6 digits")
// Verify code is numeric
for _, c := range code {
assert.True(t, c >= '0' && c <= '9', "Code should be numeric")
}
t.Logf("Generated numeric code: id=%s, code=%s", id, code)
// Test custom length
option.Length = 4
_, code4 := Generate(option)
assert.Equal(t, 4, len(code4), "Custom length should be respected")
// Test alphanumeric code
option.Type = "alphanumeric"
option.Length = 8
_, alphaCode := Generate(option)
assert.Equal(t, 8, len(alphaCode), "Alphanumeric code should match length")
// Verify alphanumeric
for _, c := range alphaCode {
assert.True(t, (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'),
"Code should be alphanumeric uppercase")
}
t.Logf("Generated alphanumeric code: %s", alphaCode)
}
func TestValidate(t *testing.T) {
option := NewOption()
// Generate OTP
id, code := Generate(option)
t.Logf("Generated OTP: id=%s, code=%s", id, code)
// Test valid code without clearing
valid := Validate(id, code, false)
assert.True(t, valid, "Valid OTP should pass validation")
// Verify code is still available
storedCode := Get(id)
assert.Equal(t, code, storedCode, "Code should still be available when not cleared")
// Test valid code with clearing
valid = Validate(id, code, true)
assert.True(t, valid, "Valid OTP should pass validation")
// Verify code is deleted
storedCode = Get(id)
assert.Empty(t, storedCode, "OTP should be deleted after validation with clear=true")
// Test invalid code
id2, _ := Generate(option)
valid = Validate(id2, "wrong_code", false)
assert.False(t, valid, "Invalid OTP should fail validation")
// Test empty values
valid = Validate("", "", false)
assert.False(t, valid, "Empty values should fail validation")
// Test non-existent ID
valid = Validate("non-existent-id", "123456", false)
assert.False(t, valid, "Non-existent ID should fail validation")
}
func TestGet(t *testing.T) {
option := NewOption()
// Generate OTP
id, code := Generate(option)
// Test get
retrievedCode := Get(id)
assert.Equal(t, code, retrievedCode, "Should retrieve correct code")
// Test get non-existent
retrievedCode = Get("non-existent-id")
assert.Empty(t, retrievedCode, "Non-existent ID should return empty string")
}
func TestDelete(t *testing.T) {
option := NewOption()
// Generate OTP
id, code := Generate(option)
// Verify exists
retrievedCode := Get(id)
assert.Equal(t, code, retrievedCode)
// Delete
Delete(id)
// Verify deleted
retrievedCode = Get(id)
assert.Empty(t, retrievedCode, "Code should be deleted")
}
func TestNewOption(t *testing.T) {
option := NewOption()
assert.Equal(t, 6, option.Length, "Default length should be 6")
assert.Equal(t, 600, option.Expiration, "Default expiration should be 600 seconds")
assert.Equal(t, "numeric", option.Type, "Default type should be numeric")
}
func TestOTPExpiration(t *testing.T) {
// Note: Testing actual expiration requires time manipulation
// The OTP store has a 10-minute default expiration
t.Skip("Skipping expiration test - requires time manipulation or long wait")
}
func TestOTPConcurrency(t *testing.T) {
option := NewOption()
// Test concurrent OTP generation and validation
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
id, code := Generate(option)
assert.NotEmpty(t, id)
assert.NotEmpty(t, code)
// Validate
valid := Validate(id, code, true)
assert.True(t, valid)
done <- true
}()
}
// Wait for all goroutines
for i := 0; i < 10; i++ {
<-done
}
}
func TestOTPMultipleValidations(t *testing.T) {
option := NewOption()
// Generate OTP
id, code := Generate(option)
// First validation without clearing
valid := Validate(id, code, false)
assert.True(t, valid)
// Second validation without clearing (should still work)
valid = Validate(id, code, false)
assert.True(t, valid)
// Third validation with clearing
valid = Validate(id, code, true)
assert.True(t, valid)
// Fourth validation should fail (OTP was cleared)
valid = Validate(id, code, false)
assert.False(t, valid)
}
func TestOTPZeroValues(t *testing.T) {
// Test with zero/empty option values
option := Option{}
id, code := Generate(option)
assert.NotEmpty(t, id, "Should generate ID even with zero values")
assert.NotEmpty(t, code, "Should generate code even with zero values")
assert.Equal(t, 6, len(code), "Should use default length")
// Should be numeric by default
for _, c := range code {
assert.True(t, c >= '0' && c <= '9', "Should default to numeric")
}
}
func TestOTPInvalidType(t *testing.T) {
option := NewOption()
option.Type = "invalid_type"
id, code := Generate(option)
assert.NotEmpty(t, id)
assert.NotEmpty(t, code)
// Should fallback to numeric
for _, c := range code {
assert.True(t, c >= '0' && c <= '9', "Invalid type should fallback to numeric")
}
}
func BenchmarkGenerate(b *testing.B) {
option := NewOption()
for i := 0; i < b.N; i++ {
Generate(option)
}
}
func BenchmarkValidate(b *testing.B) {
option := NewOption()
id, code := Generate(option)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Validate(id, code, false)
}
}
func BenchmarkGenerateAlphanumeric(b *testing.B) {
option := NewOption()
option.Type = "alphanumeric"
option.Length = 8
for i := 0; i < b.N; i++ {
Generate(option)
}
}

76
utils/otp/process.go Normal file
View file

@ -0,0 +1,76 @@
package otp
import (
"github.com/yaoapp/gou/process"
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/kun/maps"
)
// ProcessGenerate utils.otp.Generate - Generate OTP code
func ProcessGenerate(process *process.Process) interface{} {
process.ValidateArgNums(1)
option := NewOption()
// Parse options from process args
if process.NumOfArgs() > 0 {
optMap := process.ArgsMap(0, map[string]interface{}{})
if length, ok := optMap["length"].(int); ok {
option.Length = length
}
if expiration, ok := optMap["expiration"].(int); ok {
option.Expiration = expiration
}
if codeType, ok := optMap["type"].(string); ok {
option.Type = codeType
}
}
id, code := Generate(option)
return maps.Map{
"id": id,
"code": code,
}
}
// ProcessValidate utils.otp.Validate - Validate OTP code
func ProcessValidate(process *process.Process) interface{} {
process.ValidateArgNums(2)
id := process.ArgsString(0)
code := process.ArgsString(1)
// Default clear to true
clear := true
if process.NumOfArgs() > 2 {
clear = process.ArgsBool(2)
}
if code == "" {
exception.New("OTP code is required", 400).Throw()
return false
}
if !Validate(id, code, clear) {
exception.New("Invalid or expired OTP code", 400).Throw()
return false
}
return true
}
// ProcessGet utils.otp.Get - Get OTP code (for testing)
func ProcessGet(process *process.Process) interface{} {
process.ValidateArgNums(1)
id := process.ArgsString(0)
return Get(id)
}
// ProcessDelete utils.otp.Delete - Delete OTP code
func ProcessDelete(process *process.Process) interface{} {
process.ValidateArgNums(1)
id := process.ArgsString(0)
Delete(id)
return nil
}

View file

@ -2,9 +2,11 @@ package utils
import (
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/utils/captcha"
"github.com/yaoapp/yao/utils/datetime"
"github.com/yaoapp/yao/utils/fmt"
"github.com/yaoapp/yao/utils/json"
"github.com/yaoapp/yao/utils/otp"
"github.com/yaoapp/yao/utils/str"
"github.com/yaoapp/yao/utils/throw"
"github.com/yaoapp/yao/utils/tree"
@ -108,4 +110,19 @@ func Init() {
// JSON
process.Register("utils.json.Validate", json.ProcessValidate)
// ****************************************
// * New Processes Version 0.10.5+
// ****************************************
// Captcha
process.Register("utils.captcha.Generate", captcha.ProcessGenerate)
process.Register("utils.captcha.Validate", captcha.ProcessValidate)
process.Register("utils.captcha.Get", captcha.ProcessGet)
// OTP
process.Register("utils.otp.Generate", otp.ProcessGenerate)
process.Register("utils.otp.Validate", otp.ProcessValidate)
process.Register("utils.otp.Get", otp.ProcessGet)
process.Register("utils.otp.Delete", otp.ProcessDelete)
}