diff --git a/helper/captcha.go b/helper/captcha.go index 3092703a..b893c62c 100644 --- a/helper/captcha.go +++ b/helper/captcha.go @@ -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() -} diff --git a/helper/captcha_test.go b/helper/captcha_test.go index a5c9ead6..b357bcb2 100644 --- a/helper/captcha_test.go +++ b/helper/captcha_test.go @@ -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() { diff --git a/openapi/tests/user/entry_test.go b/openapi/tests/user/entry_test.go new file mode 100644 index 00000000..dc253030 --- /dev/null +++ b/openapi/tests/user/entry_test.go @@ -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) +} diff --git a/openapi/tests/user/login_config_test.go b/openapi/tests/user/login_config_test.go index deb733bf..92d62ec6 100644 --- a/openapi/tests/user/login_config_test.go +++ b/openapi/tests/user/login_config_test.go @@ -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") diff --git a/openapi/user/entry.go b/openapi/user/entry.go index ea8ab02c..7b218ed9 100644 --- a/openapi/user/entry.go +++ b/openapi/user/entry.go @@ -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 +} diff --git a/openapi/user/login.go b/openapi/user/login.go index ae1c6646..ada76086 100644 --- a/openapi/user/login.go +++ b/openapi/user/login.go @@ -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{ diff --git a/openapi/user/types.go b/openapi/user/types.go index 909e21bf..0d759a11 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -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" diff --git a/openapi/user/user.go b/openapi/user/user.go index bb703585..815df534 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -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 diff --git a/utils/captcha/captcha.go b/utils/captcha/captcha.go new file mode 100644 index 00000000..d6040081 --- /dev/null +++ b/utils/captcha/captcha.go @@ -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() +} diff --git a/utils/captcha/captcha_test.go b/utils/captcha/captcha_test.go new file mode 100644 index 00000000..5ee0fd7c --- /dev/null +++ b/utils/captcha/captcha_test.go @@ -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) + } +} diff --git a/utils/captcha/process.go b/utils/captcha/process.go new file mode 100644 index 00000000..1d97ae0f --- /dev/null +++ b/utils/captcha/process.go @@ -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) +} diff --git a/utils/otp/otp.go b/utils/otp/otp.go new file mode 100644 index 00000000..669e3e6a --- /dev/null +++ b/utils/otp/otp.go @@ -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) +} + diff --git a/utils/otp/otp_test.go b/utils/otp/otp_test.go new file mode 100644 index 00000000..320d6de3 --- /dev/null +++ b/utils/otp/otp_test.go @@ -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) + } +} + diff --git a/utils/otp/process.go b/utils/otp/process.go new file mode 100644 index 00000000..af0d6ab0 --- /dev/null +++ b/utils/otp/process.go @@ -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 +} diff --git a/utils/process.go b/utils/process.go index be54bd5e..46d74688 100644 --- a/utils/process.go +++ b/utils/process.go @@ -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) }