Add Cloudflare Turnstile configuration and enhance OAuth handling in Signin API

- Added Cloudflare Turnstile site key and secret to the environment variables in both `pr-test.yml` and `unit-test.yml` workflows.
- Introduced a new endpoint in the Signin API for generating OAuth authorization URLs, improving support for third-party authentication providers.
- Enhanced the handling of OAuth provider configurations, including validation and error responses for missing or incomplete settings.
- Updated tests to cover the new OAuth authorization URL functionality and ensure sensitive data is not exposed in public configurations.
This commit is contained in:
Max 2025-07-30 20:19:55 +08:00
parent fcb59be5b5
commit 409151037a
5 changed files with 447 additions and 7 deletions

View file

@ -99,6 +99,10 @@ env:
GITHUBUSER_CLIENT_ID: ${{ secrets.GITHUBUSER_CLIENT_ID }}
GITHUBUSER_CLIENT_SECRET: ${{ secrets.GITHUBUSER_CLIENT_SECRET }}
## Cloudflare Turnstile
CLOUDFLARE_TURNSTILE_SITEKEY: ${{ secrets.CLOUDFLARE_TURNSTILE_SITEKEY }}
CLOUDFLARE_TURNSTILE_SECRET: ${{ secrets.CLOUDFLARE_TURNSTILE_SECRET }}
jobs:
UnitTest:
runs-on: ubuntu-latest

View file

@ -104,6 +104,10 @@ env:
GITHUBUSER_CLIENT_ID: ${{ secrets.GITHUBUSER_CLIENT_ID }}
GITHUBUSER_CLIENT_SECRET: ${{ secrets.GITHUBUSER_CLIENT_SECRET }}
## Cloudflare Turnstile
CLOUDFLARE_TURNSTILE_SITEKEY: ${{ secrets.CLOUDFLARE_TURNSTILE_SITEKEY }}
CLOUDFLARE_TURNSTILE_SECRET: ${{ secrets.CLOUDFLARE_TURNSTILE_SECRET }}
jobs:
unit-test:
runs-on: ubuntu-latest

View file

@ -1,6 +1,12 @@
package signin
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
@ -11,6 +17,7 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
group.GET("/signin", getConfig)
group.POST("/signin", signin)
group.GET("/signin/authback/:id", authback)
group.GET("/signin/oauth/:provider/authorize", getOAuthAuthorizationURL)
}
// getConfig is the handler for get signin configuration
@ -40,3 +47,127 @@ func signin(c *gin.Context) {}
// authback is the handler for authback
func authback(c *gin.Context) {}
// OAuthAuthorizationURLResponse represents the response for OAuth authorization URL
type OAuthAuthorizationURLResponse struct {
AuthorizationURL string `json:"authorization_url"`
State string `json:"state"`
}
// getOAuthAuthorizationURL generates OAuth authorization URL for a provider
func getOAuthAuthorizationURL(c *gin.Context) {
providerID := c.Param("provider")
if providerID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Provider ID is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Get optional parameters
redirectURI := c.Query("redirect_uri")
state := c.Query("state")
locale := c.Query("locale")
// Get full configuration
config := GetFullConfig(locale)
if config == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "No signin configuration found",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
// Find the provider
var provider *Provider
if config.ThirdParty != nil && config.ThirdParty.Providers != nil {
for _, p := range config.ThirdParty.Providers {
if p.ID == providerID {
provider = p
break
}
}
}
if provider == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: fmt.Sprintf("OAuth provider '%s' not found", providerID),
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
// Validate required provider configuration
if provider.ClientID == "" || provider.Endpoints == nil || provider.Endpoints.Authorization == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Provider configuration is incomplete",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Generate state if not provided
if state == "" {
var err error
state, err = generateRandomState()
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Failed to generate OAuth state",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
}
// Set default redirect URI if not provided
if redirectURI == "" {
redirectURI = fmt.Sprintf("%s://%s/auth/callback", getScheme(c), c.Request.Host)
}
// Build authorization URL
params := url.Values{}
params.Add("client_id", provider.ClientID)
params.Add("response_type", "code")
params.Add("redirect_uri", redirectURI)
params.Add("state", state)
// Add scopes
if len(provider.Scopes) > 0 {
params.Add("scope", strings.Join(provider.Scopes, " "))
} else {
params.Add("scope", "openid profile email")
}
authorizationURL := fmt.Sprintf("%s?%s", provider.Endpoints.Authorization, params.Encode())
// Return the authorization URL and state
response.RespondWithSuccess(c, response.StatusOK, &OAuthAuthorizationURLResponse{
AuthorizationURL: authorizationURL,
State: state,
})
}
// generateRandomState generates a cryptographically secure random state parameter
func generateRandomState() (string, error) {
bytes := make([]byte, 16)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// getScheme returns the request scheme (http or https)
func getScheme(c *gin.Context) string {
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
return "https"
}
return "http"
}

View file

@ -2,6 +2,7 @@ package signin
import (
"fmt"
"log"
"os"
"path/filepath"
"regexp"
@ -88,6 +89,7 @@ type Provider struct {
Title string `json:"title,omitempty"`
Logo string `json:"logo,omitempty"`
Color string `json:"color,omitempty"`
TextColor string `json:"text_color,omitempty"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
ClientSecretGenerator *SecretGenerator `json:"client_secret_generator,omitempty"`
@ -217,10 +219,19 @@ func extractLanguageFromFilename(filename string) string {
// processENVVariables processes environment variables in the configuration
func processENVVariables(config *Config, rootPath string) {
var missingEnvVars []string
// Process form captcha options
if config.Form != nil && config.Form.Captcha != nil && config.Form.Captcha.Options != nil {
for key, value := range config.Form.Captcha.Options {
if strValue, ok := value.(string); ok {
// Check if ENV variable exists before replacement
if strings.HasPrefix(strValue, "$ENV.") {
envVar := strings.TrimPrefix(strValue, "$ENV.")
if _, exists := os.LookupEnv(envVar); !exists {
missingEnvVars = append(missingEnvVars, envVar)
}
}
config.Form.Captcha.Options[key] = replaceENVVar(strValue)
}
}
@ -229,11 +240,33 @@ func processENVVariables(config *Config, rootPath string) {
// Process third party providers
if config.ThirdParty != nil && config.ThirdParty.Providers != nil {
for _, provider := range config.ThirdParty.Providers {
// Check ClientID
if strings.HasPrefix(provider.ClientID, "$ENV.") {
envVar := strings.TrimPrefix(provider.ClientID, "$ENV.")
if _, exists := os.LookupEnv(envVar); !exists {
missingEnvVars = append(missingEnvVars, envVar)
}
}
provider.ClientID = replaceENVVar(provider.ClientID)
// Check ClientSecret
if strings.HasPrefix(provider.ClientSecret, "$ENV.") {
envVar := strings.TrimPrefix(provider.ClientSecret, "$ENV.")
if _, exists := os.LookupEnv(envVar); !exists {
missingEnvVars = append(missingEnvVars, envVar)
}
}
provider.ClientSecret = replaceENVVar(provider.ClientSecret)
// Process client secret generator
if provider.ClientSecretGenerator != nil {
// Check PrivateKey
if strings.HasPrefix(provider.ClientSecretGenerator.PrivateKey, "$ENV.") {
envVar := strings.TrimPrefix(provider.ClientSecretGenerator.PrivateKey, "$ENV.")
if _, exists := os.LookupEnv(envVar); !exists {
missingEnvVars = append(missingEnvVars, envVar)
}
}
provider.ClientSecretGenerator.PrivateKey = replaceENVVar(provider.ClientSecretGenerator.PrivateKey)
// Convert relative path to absolute path for private key
@ -245,6 +278,12 @@ func processENVVariables(config *Config, rootPath string) {
if provider.ClientSecretGenerator.Header != nil {
for key, value := range provider.ClientSecretGenerator.Header {
if strValue, ok := value.(string); ok {
if strings.HasPrefix(strValue, "$ENV.") {
envVar := strings.TrimPrefix(strValue, "$ENV.")
if _, exists := os.LookupEnv(envVar); !exists {
missingEnvVars = append(missingEnvVars, envVar)
}
}
provider.ClientSecretGenerator.Header[key] = replaceENVVar(strValue)
}
}
@ -254,6 +293,12 @@ func processENVVariables(config *Config, rootPath string) {
if provider.ClientSecretGenerator.Payload != nil {
for key, value := range provider.ClientSecretGenerator.Payload {
if strValue, ok := value.(string); ok {
if strings.HasPrefix(strValue, "$ENV.") {
envVar := strings.TrimPrefix(strValue, "$ENV.")
if _, exists := os.LookupEnv(envVar); !exists {
missingEnvVars = append(missingEnvVars, envVar)
}
}
provider.ClientSecretGenerator.Payload[key] = replaceENVVar(strValue)
}
}
@ -261,15 +306,25 @@ func processENVVariables(config *Config, rootPath string) {
}
}
}
// Log warning for missing environment variables
if len(missingEnvVars) > 0 {
log.Printf("Warning: The following environment variables are not set and may cause configuration issues: %v", missingEnvVars)
log.Printf("Please set these environment variables to avoid exposing placeholder values in configuration")
}
}
// replaceENVVar replaces environment variables in the format $ENV.VAR_NAME
func replaceENVVar(value string) string {
if strings.HasPrefix(value, "$ENV.") {
envVar := strings.TrimPrefix(value, "$ENV.")
if envValue := os.Getenv(envVar); envValue != "" {
envValue, exists := os.LookupEnv(envVar)
if exists {
return envValue
}
// If environment variable doesn't exist, return empty string for security
// Never expose ENV placeholder values to prevent configuration leakage
return ""
}
return value
}
@ -278,15 +333,40 @@ func replaceENVVar(value string) string {
func createPublicConfig(fullConfig *Config) Config {
publicConfig := *fullConfig
// Remove sensitive data from captcha configuration
if publicConfig.Form != nil && publicConfig.Form.Captcha != nil && publicConfig.Form.Captcha.Options != nil {
// Create a new options map without sensitive fields
publicOptions := make(map[string]interface{})
for key, value := range publicConfig.Form.Captcha.Options {
// Only include non-sensitive fields
switch key {
case "sitekey", "theme", "size", "action", "cdata":
// These are safe to expose to frontend
publicOptions[key] = value
case "secret":
// Remove secret field - this should never be exposed to frontend
continue
default:
// For unknown fields, be conservative and exclude them
continue
}
}
publicConfig.Form.Captcha.Options = publicOptions
}
// Remove sensitive data from third party providers
if publicConfig.ThirdParty != nil && publicConfig.ThirdParty.Providers != nil {
publicProviders := make([]*Provider, len(publicConfig.ThirdParty.Providers))
for i, provider := range publicConfig.ThirdParty.Providers {
publicProvider := *provider
// Remove sensitive fields
publicProvider.ClientSecret = ""
publicProvider.ClientSecretGenerator = nil
publicProvider := Provider{
ID: provider.ID,
Title: provider.Title,
Logo: provider.Logo,
Color: provider.Color,
TextColor: provider.TextColor,
// Only expose display fields for frontend
// Remove sensitive fields: ClientID, ClientSecret, ClientSecretGenerator, Scopes, Endpoints, Mapping
}
publicProviders[i] = &publicProvider
}

View file

@ -58,13 +58,39 @@ func TestSigninGetConfigs(t *testing.T) {
t.Logf("Full config for '%s': %+v", lang, fullConfig.Title)
assert.NotNil(t, publicConfig, "Public config should exist if full config exists")
// Test that public config removes sensitive data
// Test that public config removes sensitive data from OAuth providers
if fullConfig.ThirdParty != nil && fullConfig.ThirdParty.Providers != nil {
for i := range fullConfig.ThirdParty.Providers {
if publicConfig.ThirdParty != nil && i < len(publicConfig.ThirdParty.Providers) {
publicProvider := publicConfig.ThirdParty.Providers[i]
// Check that sensitive OAuth fields are removed
assert.Empty(t, publicProvider.ClientID, "Client ID should be empty in public config")
assert.Empty(t, publicProvider.ClientSecret, "Client secret should be empty in public config")
assert.Nil(t, publicProvider.ClientSecretGenerator, "Client secret generator should be nil in public config")
assert.Empty(t, publicProvider.Scopes, "Scopes should be empty in public config")
assert.Nil(t, publicProvider.Endpoints, "Endpoints should be nil in public config")
assert.Empty(t, publicProvider.Mapping, "Mapping should be empty in public config")
// Check that display fields are preserved
assert.NotEmpty(t, publicProvider.ID, "Provider ID should be preserved in public config")
assert.NotEmpty(t, publicProvider.Title, "Provider title should be preserved in public config")
// Logo, Color, TextColor might be empty depending on config, so we don't assert NotEmpty
}
}
}
// Test that public config removes sensitive data from captcha configuration
if fullConfig.Form != nil && fullConfig.Form.Captcha != nil && fullConfig.Form.Captcha.Options != nil {
if publicConfig.Form != nil && publicConfig.Form.Captcha != nil && publicConfig.Form.Captcha.Options != nil {
// Check that secret field is removed
_, hasSecret := publicConfig.Form.Captcha.Options["secret"]
assert.False(t, hasSecret, "Captcha secret should be removed from public config")
// Check that safe fields are preserved (if they exist in full config)
if _, hasSitekey := fullConfig.Form.Captcha.Options["sitekey"]; hasSitekey {
_, publicHasSitekey := publicConfig.Form.Captcha.Options["sitekey"]
assert.True(t, publicHasSitekey, "Captcha sitekey should be preserved in public config")
}
}
}
@ -190,12 +216,207 @@ func TestSigninAPI(t *testing.T) {
// Verify it's public config (no sensitive data)
if config.ThirdParty != nil && config.ThirdParty.Providers != nil {
for _, provider := range config.ThirdParty.Providers {
// Check that sensitive OAuth fields are removed from API response
assert.Empty(t, provider.ClientID, "Client ID should be empty in API response")
assert.Empty(t, provider.ClientSecret, "Client secret should be empty in API response")
assert.Nil(t, provider.ClientSecretGenerator, "Client secret generator should be nil in API response")
assert.Empty(t, provider.Scopes, "Scopes should be empty in API response")
assert.Nil(t, provider.Endpoints, "Endpoints should be nil in API response")
assert.Empty(t, provider.Mapping, "Mapping should be empty in API response")
// Check that display fields are preserved in API response
assert.NotEmpty(t, provider.ID, "Provider ID should be preserved in API response")
assert.NotEmpty(t, provider.Title, "Provider title should be preserved in API response")
}
}
// Verify captcha sensitive data is removed from API response
if config.Form != nil && config.Form.Captcha != nil && config.Form.Captcha.Options != nil {
_, hasSecret := config.Form.Captcha.Options["secret"]
assert.False(t, hasSecret, "Captcha secret should be removed from API response")
}
}
}
})
}
}
func TestSigninOAuthAuthorizationURL(t *testing.T) {
// Initialize test environment
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 OAuth authorization URL endpoints
// Note: These should return 500 because OAuth client credentials (CLIENT_ID, etc.)
// are not set in the test environment, making the provider configuration incomplete.
// This is the expected secure behavior.
testCases := []struct {
name string
provider string
query string
expectCode int
expectErrorMsg string
}{
{"get google oauth url", "google", "", 500, "Provider configuration is incomplete"},
{"get microsoft oauth url", "microsoft", "", 500, "Provider configuration is incomplete"},
{"get apple oauth url", "apple", "", 500, "Provider configuration is incomplete"},
{"get github oauth url", "github", "", 500, "Provider configuration is incomplete"},
{"get oauth url with locale", "google", "?locale=en", 500, "Provider configuration is incomplete"},
{"get oauth url with redirect_uri", "google", "?redirect_uri=https://example.com/callback", 500, "Provider configuration is incomplete"},
{"get oauth url with state", "google", "?state=test-state-123", 500, "Provider configuration is incomplete"},
{"get oauth url for nonexistent provider", "nonexistent", "", 404, "OAuth provider 'nonexistent' not found"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := serverURL + baseURL + "/signin/oauth/" + tc.provider + "/authorize" + tc.query
resp, err := http.Get(url)
assert.NoError(t, err, "HTTP request should succeed")
if resp != nil {
defer resp.Body.Close()
assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d", tc.expectCode)
// Parse response body
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err, "Should read response body")
t.Logf("Response for %s: status=%d, body=%s", tc.provider, resp.StatusCode, string(body))
// Parse error response to verify the error message
var errorResponse map[string]interface{}
err = json.Unmarshal(body, &errorResponse)
assert.NoError(t, err, "Should parse JSON error response")
// Verify error message matches expected
if errorDescription, hasError := errorResponse["error_description"]; hasError {
errorDescStr, ok := errorDescription.(string)
assert.True(t, ok, "error_description should be string")
assert.Equal(t, tc.expectErrorMsg, errorDescStr, "Error message should match expected")
} else {
t.Errorf("Response should contain error_description field")
}
// Verify error code is present
if errorCode, hasErrorCode := errorResponse["error"]; hasErrorCode {
assert.Equal(t, "invalid_request", errorCode, "Error code should be invalid_request")
} else {
t.Errorf("Response should contain error field")
}
}
})
}
}
func TestSigninENVVariableReplacement(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL // Server URL not needed for this test
// Load signin configurations to trigger ENV variable processing
err := signin.Load(config.Conf)
assert.NoError(t, err, "signin.Load should succeed")
// Get full config to check ENV variable replacement
fullConfig := signin.GetFullConfig("")
assert.NotNil(t, fullConfig, "Should have a signin configuration")
// Test captcha ENV variable replacement
if fullConfig.Form != nil && fullConfig.Form.Captcha != nil && fullConfig.Form.Captcha.Options != nil {
if sitekey, hasSitekey := fullConfig.Form.Captcha.Options["sitekey"]; hasSitekey {
sitekeyStr, ok := sitekey.(string)
assert.True(t, ok, "Sitekey should be string")
// Should not contain ENV placeholder (either replaced or empty)
assert.NotContains(t, sitekeyStr, "$ENV.", "Sitekey should not contain ENV placeholder")
t.Logf("Captcha sitekey after ENV replacement: %s", sitekeyStr)
}
if secret, hasSecret := fullConfig.Form.Captcha.Options["secret"]; hasSecret {
secretStr, ok := secret.(string)
assert.True(t, ok, "Secret should be string")
// Should not contain ENV placeholder (either replaced or empty)
assert.NotContains(t, secretStr, "$ENV.", "Secret should not contain ENV placeholder")
t.Logf("Captcha secret after ENV replacement: %s", secretStr)
}
}
// Test OAuth provider ENV variable replacement
if fullConfig.ThirdParty != nil && fullConfig.ThirdParty.Providers != nil {
for _, provider := range fullConfig.ThirdParty.Providers {
// Check ClientID replacement
if provider.ClientID != "" {
assert.NotContains(t, provider.ClientID, "$ENV.", "ClientID should not contain ENV placeholder")
t.Logf("Provider %s ClientID after ENV replacement: %s", provider.ID, provider.ClientID)
}
// Check ClientSecret replacement
if provider.ClientSecret != "" {
assert.NotContains(t, provider.ClientSecret, "$ENV.", "ClientSecret should not contain ENV placeholder")
t.Logf("Provider %s ClientSecret after ENV replacement: [REDACTED]", provider.ID)
}
}
}
// Test that public config doesn't expose ENV variables or actual sensitive values
publicConfig := signin.GetPublicConfig("")
assert.NotNil(t, publicConfig, "Should have a public signin configuration")
// Public config should not contain sensitive data even if ENV variables are set
if publicConfig.Form != nil && publicConfig.Form.Captcha != nil && publicConfig.Form.Captcha.Options != nil {
_, hasSecret := publicConfig.Form.Captcha.Options["secret"]
assert.False(t, hasSecret, "Public config should not contain captcha secret")
}
if publicConfig.ThirdParty != nil && publicConfig.ThirdParty.Providers != nil {
for _, provider := range publicConfig.ThirdParty.Providers {
assert.Empty(t, provider.ClientID, "Public config should not contain ClientID")
assert.Empty(t, provider.ClientSecret, "Public config should not contain ClientSecret")
}
}
}
func TestSigninENVVariableMissingHandling(t *testing.T) {
// This test verifies that missing ENV variables are handled securely
// by returning empty strings instead of exposing the placeholder
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL // Server URL not needed for this test
// Load signin configurations
err := signin.Load(config.Conf)
assert.NoError(t, err, "signin.Load should succeed")
// Get public config (this is what the API returns)
publicConfig := signin.GetPublicConfig("")
assert.NotNil(t, publicConfig, "Should have a public signin configuration")
// Verify that even if ENV variables are missing, no placeholders are exposed
if publicConfig.Form != nil && publicConfig.Form.Captcha != nil && publicConfig.Form.Captcha.Options != nil {
for key, value := range publicConfig.Form.Captcha.Options {
if valueStr, ok := value.(string); ok {
assert.NotContains(t, valueStr, "$ENV.", "Public config should not contain ENV placeholders in %s", key)
}
}
}
if publicConfig.ThirdParty != nil && publicConfig.ThirdParty.Providers != nil {
for _, provider := range publicConfig.ThirdParty.Providers {
// These should be empty in public config anyway, but verify no ENV placeholders
assert.NotContains(t, provider.ClientID, "$ENV.", "Public config ClientID should not contain ENV placeholders")
assert.NotContains(t, provider.ClientSecret, "$ENV.", "Public config ClientSecret should not contain ENV placeholders")
}
}
t.Log("ENV variable security test passed: no ENV placeholders exposed in public configuration")
}