Merge pull request #1151 from trheyi/main

Add user configuration loading and update authentication routes
This commit is contained in:
Max 2025-09-18 09:03:38 +08:00 committed by GitHub
commit e8734849c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 3515 additions and 8 deletions

View file

@ -63,6 +63,12 @@ func Load(appConfig config.Config) (*OpenAPI, error) {
return nil, err
}
// Load user configurations
err = user.Load(appConfig)
if err != nil {
return nil, err
}
// Create the OpenAPI server
Server = &OpenAPI{Config: &config, OAuth: oauthService}
return Server, nil

View file

@ -0,0 +1,168 @@
package user_test
import (
"encoding/json"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
"github.com/yaoapp/yao/openapi/user"
)
func TestUserLoginConfig(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
}
// Register a test client first (needed for user.Load validation)
testClient := testutils.RegisterTestClient(t, "User Config Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Note: user.Load is automatically called by openapi.Load in testutils.Prepare
// Test API endpoints
testCases := []struct {
name string
endpoint string
expectCode int
}{
{"get config without locale", "/user/login", 200},
{"get config with en locale", "/user/login?locale=en", 200},
{"get config with zh-cn locale", "/user/login?locale=zh-cn", 200},
{"get config with invalid locale", "/user/login?locale=invalid", 200}, // should fallback to default
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestURL := serverURL + baseURL + tc.endpoint
resp, err := http.Get(requestURL)
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)
if resp.StatusCode == 200 {
// Parse response body
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err, "Should read response body")
var config user.Config
err = json.Unmarshal(body, &config)
assert.NoError(t, err, "Should parse JSON response")
t.Logf("API response for %s: %s", tc.endpoint, config.Title)
// 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 TestUserLoginConfigLoad(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL // Server URL not needed for this test
// Test loading user configurations
err := user.Load(config.Conf)
assert.NoError(t, err, "user.Load should succeed")
// Test that we can get public config
publicConfig := user.GetPublicConfig("")
if publicConfig != nil {
t.Logf("Public config loaded with title: %s", publicConfig.Title)
assert.IsType(t, &user.Config{}, publicConfig, "Should return correct config type")
} else {
t.Log("No public config found")
}
}
func TestUserLoginConfigStructure(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL // Server URL not needed for this test
// Note: user.Load is automatically called by openapi.Load in testutils.Prepare
// Get a config to test structure
config := user.GetPublicConfig("")
if config != nil {
t.Logf("Config loaded successfully with title: %s", config.Title)
// Verify config structure is valid
assert.IsType(t, &user.Config{}, config, "Should return correct config type")
// Test new configuration fields
assert.IsType(t, "", config.ClientID, "ClientID should be string")
assert.IsType(t, "", config.ClientSecret, "ClientSecret should be string")
assert.IsType(t, false, config.Default, "Default should be boolean")
t.Logf("Config has ClientID: %t, ClientSecret: %t, Default: %t",
config.ClientID != "", config.ClientSecret != "", config.Default)
// Test form configuration
if config.Form != nil {
t.Logf("Form configuration found")
if config.Form.Username != nil {
assert.IsType(t, []string{}, config.Form.Username.Fields, "Username fields should be string slice")
}
if config.Form.Captcha != nil {
assert.IsType(t, map[string]interface{}{}, config.Form.Captcha.Options, "Captcha options should be map")
}
}
// Test third party configuration
if config.ThirdParty != nil {
t.Logf("Third party configuration found with %d providers", len(config.ThirdParty.Providers))
if config.ThirdParty.Providers != nil {
assert.IsType(t, []*user.Provider{}, config.ThirdParty.Providers, "Providers should be slice of Provider pointers")
for i, provider := range config.ThirdParty.Providers {
t.Logf("Provider %d: %s", i, provider.ID)
// In the new structure, ThirdParty providers only contain display information
// Sensitive configuration data is stored separately in the global providers map
assert.NotEmpty(t, provider.ID, "Provider ID should not be empty")
assert.NotEmpty(t, provider.Title, "Provider title should not be empty")
}
}
}
} else {
t.Log("No user configuration found")
}
}

View file

@ -0,0 +1,182 @@
package user_test
import (
"bytes"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
func TestUserLogin(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
}
// Register a test client first (needed for user.Load validation)
testClient := testutils.RegisterTestClient(t, "User Login Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Note: user.Load is automatically called by openapi.Load in testutils.Prepare
// Test login endpoint (currently empty implementation)
testCases := []struct {
name string
method string
endpoint string
body map[string]interface{}
expectCode int
}{
{
"post login without credentials",
"POST",
"/user/login",
map[string]interface{}{},
200, // Currently empty implementation, may change when implemented
},
{
"post login with credentials",
"POST",
"/user/login",
map[string]interface{}{
"username": "testuser",
"password": "testpass",
},
200, // Currently empty implementation, may change when implemented
},
{
"post login with email",
"POST",
"/user/login",
map[string]interface{}{
"email": "test@example.com",
"password": "testpass",
},
200, // Currently empty implementation, may change when implemented
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestURL := serverURL + baseURL + tc.endpoint
// Prepare request body
var req *http.Request
var err error
if tc.method == "POST" {
bodyBytes, _ := json.Marshal(tc.body)
req, err = http.NewRequest(tc.method, requestURL, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req, err = http.NewRequest(tc.method, requestURL, nil)
}
assert.NoError(t, err, "Should create HTTP request")
client := &http.Client{}
resp, err := client.Do(req)
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)
t.Logf("Login test %s: status=%d", tc.name, resp.StatusCode)
// Note: Since login is currently not implemented (empty function),
// we can't test actual login functionality yet.
// This test serves as a placeholder for when login is implemented.
}
})
}
}
func TestUserLoginValidation(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
}
// Note: user.Load is automatically called by openapi.Load in testutils.Prepare
// Test various login validation scenarios
// Note: These tests are prepared for when login validation is implemented
testCases := []struct {
name string
body map[string]interface{}
expected string // Expected behavior description
}{
{
"empty credentials",
map[string]interface{}{},
"Should handle empty credentials gracefully",
},
{
"missing password",
map[string]interface{}{
"username": "testuser",
},
"Should handle missing password",
},
{
"missing username",
map[string]interface{}{
"password": "testpass",
},
"Should handle missing username",
},
{
"invalid json format",
nil, // Will send invalid JSON
"Should handle invalid JSON format",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestURL := serverURL + baseURL + "/user/login"
var req *http.Request
var err error
if tc.body == nil {
// Send invalid JSON
req, err = http.NewRequest("POST", requestURL, bytes.NewBufferString("invalid json"))
} else {
bodyBytes, _ := json.Marshal(tc.body)
req, err = http.NewRequest("POST", requestURL, bytes.NewBuffer(bodyBytes))
}
req.Header.Set("Content-Type", "application/json")
assert.NoError(t, err, "Should create HTTP request")
client := &http.Client{}
resp, err := client.Do(req)
assert.NoError(t, err, "HTTP request should succeed")
if resp != nil {
defer resp.Body.Close()
t.Logf("Validation test %s: status=%d, expected=%s", tc.name, resp.StatusCode, tc.expected)
// Note: Since login validation is not implemented yet,
// we can't assert specific status codes.
// These tests will be updated when login is implemented.
}
})
}
}

View file

@ -0,0 +1,264 @@
package user_test
import (
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
func TestUserOAuthAuthorizationURL(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
}
// Register a test client first (needed for user.Load validation)
testClient := testutils.RegisterTestClient(t, "User OAuth Authorize Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Note: user.Load is automatically called by openapi.Load in testutils.Prepare
// Test OAuth authorization URL endpoints
// Note: These should return 200 when OAuth client credentials are properly configured
// (which they are in this test environment). Only nonexistent providers should return 404.
testCases := []struct {
name string
provider string
query string
expectCode int
expectErrorMsg string
}{
{"get google oauth url", "google", "", 200, ""},
{"get microsoft oauth url", "microsoft", "", 200, ""},
{"get apple oauth url", "apple", "", 200, ""},
{"get github oauth url", "github", "", 200, ""},
{"get oauth url with redirect_uri", "google", "?redirect_uri=https://example.com/callback", 200, ""},
{"get oauth url with state", "google", "?state=test-state-123", 200, ""},
{"get oauth url for nonexistent provider", "nonexistent", "", 404, "Failed to get provider"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestURL := serverURL + baseURL + "/user/oauth/" + tc.provider + "/authorize" + tc.query
resp, err := http.Get(requestURL)
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))
var response map[string]interface{}
err = json.Unmarshal(body, &response)
assert.NoError(t, err, "Should parse JSON response")
if tc.expectCode == 200 {
// Success case - should have authorization_url
if authURL, hasAuthURL := response["authorization_url"]; hasAuthURL {
authURLStr, ok := authURL.(string)
assert.True(t, ok, "authorization_url should be string")
assert.NotEmpty(t, authURLStr, "authorization_url should not be empty")
t.Logf("Authorization URL generated successfully for %s", tc.provider)
// Verify the URL contains expected OAuth parameters
assert.Contains(t, authURLStr, "client_id=", "Authorization URL should contain client_id")
assert.Contains(t, authURLStr, "response_type=code", "Authorization URL should contain response_type=code")
assert.Contains(t, authURLStr, "redirect_uri=", "Authorization URL should contain redirect_uri")
assert.Contains(t, authURLStr, "state=", "Authorization URL should contain state")
// Check for state in response
if state, hasState := response["state"]; hasState {
stateStr, ok := state.(string)
assert.True(t, ok, "state should be string")
assert.NotEmpty(t, stateStr, "state should not be empty")
t.Logf("State generated: %s", stateStr)
}
// Check for warnings (optional)
if warnings, hasWarnings := response["warnings"]; hasWarnings {
warningsSlice, ok := warnings.([]interface{})
if ok && len(warningsSlice) > 0 {
t.Logf("Warnings: %v", warningsSlice)
}
}
} else {
t.Errorf("Success response should contain authorization_url field")
}
} else {
// Error case - should have error fields
if errorDescription, hasError := response["error_description"]; hasError {
errorDescStr, ok := errorDescription.(string)
assert.True(t, ok, "error_description should be string")
if tc.expectErrorMsg != "" {
assert.Contains(t, errorDescStr, tc.expectErrorMsg, "Error message should contain expected text")
}
} else {
t.Errorf("Error response should contain error_description field")
}
// Verify error code is present
if errorCode, hasErrorCode := response["error"]; hasErrorCode {
assert.Equal(t, "invalid_request", errorCode, "Error code should be invalid_request")
} else {
t.Errorf("Error response should contain error field")
}
}
}
})
}
}
func TestUserOAuthAuthorizationURLParameters(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
}
// Register a test client first (needed for user.Load validation)
testClient := testutils.RegisterTestClient(t, "User OAuth URL Params Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Note: user.Load is automatically called by openapi.Load in testutils.Prepare
// Test various OAuth parameters
testCases := []struct {
name string
provider string
redirectURI string
state string
expectCode int
}{
{
"with custom redirect_uri",
"google",
"https://myapp.example.com/callback",
"",
200,
},
{
"with custom state",
"google",
"",
"my-custom-state-12345",
200,
},
{
"with both redirect_uri and state",
"google",
"https://myapp.example.com/callback",
"my-custom-state-12345",
200,
},
{
"with UUID state format",
"google",
"",
"550e8400-e29b-41d4-a716-446655440000",
200,
},
{
"with non-UUID state format",
"google",
"",
"simple-state",
200,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Build query parameters
query := ""
params := []string{}
if tc.redirectURI != "" {
params = append(params, "redirect_uri="+tc.redirectURI)
}
if tc.state != "" {
params = append(params, "state="+tc.state)
}
if len(params) > 0 {
query = "?" + strings.Join(params, "&")
}
requestURL := serverURL + baseURL + "/user/oauth/" + tc.provider + "/authorize" + query
resp, err := http.Get(requestURL)
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")
var response map[string]interface{}
err = json.Unmarshal(body, &response)
assert.NoError(t, err, "Should parse JSON response")
if tc.expectCode == 200 {
// Verify authorization URL is generated
if authURL, hasAuthURL := response["authorization_url"]; hasAuthURL {
authURLStr, ok := authURL.(string)
assert.True(t, ok, "authorization_url should be string")
assert.NotEmpty(t, authURLStr, "authorization_url should not be empty")
// Verify custom parameters are included in the URL
if tc.redirectURI != "" {
// Parse the authorization URL and check parameters
parsedURL, err := url.Parse(authURLStr)
assert.NoError(t, err, "Authorization URL should be valid")
// Check if redirect_uri parameter matches
redirectURI := parsedURL.Query().Get("redirect_uri")
assert.Equal(t, tc.redirectURI, redirectURI, "Authorization URL should contain custom redirect_uri")
}
// Verify state parameter
if state, hasState := response["state"]; hasState {
stateStr, ok := state.(string)
assert.True(t, ok, "state should be string")
assert.NotEmpty(t, stateStr, "state should not be empty")
if tc.state != "" {
assert.Equal(t, tc.state, stateStr, "State should match provided state")
}
// Check for warnings about non-UUID state
if warnings, hasWarnings := response["warnings"]; hasWarnings {
warningsSlice, ok := warnings.([]interface{})
if ok {
t.Logf("Warnings for state '%s': %v", stateStr, warningsSlice)
}
}
}
t.Logf("Test %s passed: URL=%s", tc.name, authURLStr)
}
}
}
})
}
}

View file

@ -0,0 +1,294 @@
package user_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
func TestUserOAuthCallback(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
}
// Register a test client first (needed for user.Load validation)
testClient := testutils.RegisterTestClient(t, "User OAuth Callback Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Note: user.Load is automatically called by openapi.Load in testutils.Prepare
// Note: OAuth callback testing requires a complex setup with valid OAuth state
// and authorization codes. For now, we test the endpoint accessibility and
// basic error handling.
testCases := []struct {
name string
provider string
method string
body map[string]interface{}
expectCode int
expectMsg string
}{
{
"callback without parameters",
"google",
"POST",
map[string]interface{}{},
400, // Should return bad request for missing parameters
"State is required",
},
{
"callback with invalid state",
"google",
"POST",
map[string]interface{}{
"code": "test-auth-code",
"state": "invalid-state",
},
400, // Should return bad request for invalid state
"Invalid state",
},
{
"callback for nonexistent provider",
"nonexistent",
"POST",
map[string]interface{}{
"code": "test-auth-code",
"state": "test-state",
},
404, // Should return not found for nonexistent provider
"Failed to get provider",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestURL := serverURL + baseURL + "/user/oauth/" + tc.provider + "/callback"
// Prepare request body
bodyBytes, _ := json.Marshal(tc.body)
req, err := http.NewRequest(tc.method, requestURL, bytes.NewBuffer(bodyBytes))
assert.NoError(t, err, "Should create HTTP request")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
assert.NoError(t, err, "HTTP request should succeed")
if resp != nil {
defer resp.Body.Close()
t.Logf("OAuth callback test %s: status=%d", tc.name, resp.StatusCode)
// Note: The exact status codes may vary based on implementation
// These tests verify the endpoint is accessible and handles basic errors
assert.True(t, resp.StatusCode >= 400 || resp.StatusCode < 300,
"Should return either success or client/server error")
// For error responses, try to parse error message
if resp.StatusCode >= 400 {
body, err := io.ReadAll(resp.Body)
if err == nil {
var response map[string]interface{}
if json.Unmarshal(body, &response) == nil {
if errorDesc, hasError := response["error_description"]; hasError {
errorDescStr, ok := errorDesc.(string)
if ok && tc.expectMsg != "" {
t.Logf("Error message: %s", errorDescStr)
// Note: Exact error message matching may vary
// We just verify the endpoint responds with error details
}
}
}
}
}
}
})
}
}
func TestUserOAuthCallbackPrepare(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
}
// Register a test client first (needed for user.Load validation)
testClient := testutils.RegisterTestClient(t, "User OAuth Prepare Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Note: user.Load is automatically called by openapi.Load in testutils.Prepare
// Test OAuth callback prepare endpoint (form_post mode)
testCases := []struct {
name string
provider string
formData map[string]string
expectCode int
}{
{
"prepare without parameters",
"apple", // Apple typically uses form_post mode
map[string]string{},
500, // Should return error for missing parameters
},
{
"prepare with code and state",
"apple",
map[string]string{
"code": "test-auth-code",
"state": "test-state",
},
500, // Will fail due to invalid state, but endpoint should be accessible
},
{
"prepare with user info",
"apple",
map[string]string{
"code": "test-auth-code",
"state": "test-state",
"user": `{"name":{"firstName":"John","lastName":"Doe"},"email":"john@example.com"}`,
},
500, // Will fail due to invalid state, but endpoint should be accessible
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestURL := serverURL + baseURL + "/user/oauth/" + tc.provider + "/authorize/prepare"
// Prepare form data
formData := url.Values{}
for key, value := range tc.formData {
formData.Set(key, value)
}
req, err := http.NewRequest("POST", requestURL, strings.NewReader(formData.Encode()))
assert.NoError(t, err, "Should create HTTP request")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Don't follow redirects, we want to test the response
return http.ErrUseLastResponse
},
}
resp, err := client.Do(req)
assert.NoError(t, err, "HTTP request should succeed")
if resp != nil {
defer resp.Body.Close()
t.Logf("OAuth prepare test %s: status=%d", tc.name, resp.StatusCode)
// The prepare endpoint may redirect or return errors
// We just verify it's accessible and responds appropriately
assert.True(t, resp.StatusCode == 302 || resp.StatusCode >= 400,
"Should return redirect or error response")
// If it's a redirect, check the location header
if resp.StatusCode == 302 {
location := resp.Header.Get("Location")
if location != "" {
t.Logf("Redirect location: %s", location)
assert.Contains(t, location, "code=", "Redirect should contain code parameter")
assert.Contains(t, location, "state=", "Redirect should contain state parameter")
}
}
}
})
}
}
func TestUserOAuthProviderValidation(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
}
// Register a test client first (needed for user.Load validation)
testClient := testutils.RegisterTestClient(t, "User OAuth Validation Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Note: user.Load is automatically called by openapi.Load in testutils.Prepare
// Test provider validation
providers := []string{"google", "microsoft", "apple", "github", "nonexistent"}
for _, provider := range providers {
t.Run("provider_"+provider, func(t *testing.T) {
// Test authorize endpoint
authorizeURL := serverURL + baseURL + "/user/oauth/" + provider + "/authorize"
resp, err := http.Get(authorizeURL)
assert.NoError(t, err, "HTTP request should succeed")
if resp != nil {
defer resp.Body.Close()
if provider == "nonexistent" {
assert.Equal(t, 404, resp.StatusCode, "Nonexistent provider should return 404")
} else {
// Known providers should return 200 or other valid response
assert.True(t, resp.StatusCode == 200 || resp.StatusCode == 500,
"Known provider should return 200 or 500 (if not configured)")
}
t.Logf("Provider %s authorize endpoint: status=%d", provider, resp.StatusCode)
}
// Test callback endpoint
callbackURL := serverURL + baseURL + "/user/oauth/" + provider + "/callback"
bodyBytes, _ := json.Marshal(map[string]interface{}{
"code": "test-code",
"state": "test-state",
})
req, err := http.NewRequest("POST", callbackURL, bytes.NewBuffer(bodyBytes))
assert.NoError(t, err, "Should create HTTP request")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err = client.Do(req)
assert.NoError(t, err, "HTTP request should succeed")
if resp != nil {
defer resp.Body.Close()
if provider == "nonexistent" {
assert.Equal(t, 404, resp.StatusCode, "Nonexistent provider should return 404")
} else {
// Known providers should return 400 (bad request due to invalid state) or other error
assert.True(t, resp.StatusCode >= 400, "Known provider should return error for invalid request")
}
t.Logf("Provider %s callback endpoint: status=%d", provider, resp.StatusCode)
}
})
}
}

419
openapi/user/config.go Normal file
View file

@ -0,0 +1,419 @@
package user
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/yaoapp/gou/application"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// Global variables to store loaded configurations
var (
// Client config
yaoClientConfig *YaoClientConfig
// Full configurations with sensitive data (for backend use)
fullConfigs = make(map[string]*Config)
// Public configurations without sensitive data (for frontend use)
publicConfigs = make(map[string]*Config)
// Global providers map (decoupled from locale-specific configs)
providers = make(map[string]*Provider)
// Default configuration (marked with default: true)
defaultConfig *Config
// Mutex for thread safety
configMutex sync.RWMutex
)
// Load loads all signin configurations from the openapi/user directory
func Load(appConfig config.Config) error {
configMutex.Lock()
defer configMutex.Unlock()
// Clear existing configurations
fullConfigs = make(map[string]*Config)
publicConfigs = make(map[string]*Config)
providers = make(map[string]*Provider)
defaultConfig = nil
// Load signin configurations
err := loadSigninConfigs(appConfig.Root)
if err != nil {
return fmt.Errorf("failed to load signin configs: %v", err)
}
// Load providers first
err = loadProviders(appConfig.Root)
if err != nil {
return fmt.Errorf("failed to load providers: %v", err)
}
// Load client config
err = loadClientConfig()
if err != nil {
return fmt.Errorf("failed to load client config: %v", err)
}
return nil
}
// loadClientConfig loads the client config from the openapi/user/client.yao file
func loadClientConfig() error {
// Check if client config exists
exists, err := application.App.Exists("openapi/user/client.yao")
if err != nil {
return fmt.Errorf("failed to check if client config exists: %v", err)
}
if !exists {
return fmt.Errorf("client config not found")
}
// Read client config
clientConfigRaw, err := application.App.Read("openapi/user/client.yao")
if err != nil {
return fmt.Errorf("failed to read client config: %v", err)
}
var clientConfig YaoClientConfig
err = application.Parse("openapi/user/client.yao", clientConfigRaw, &clientConfig)
if err != nil {
return fmt.Errorf("failed to parse client config: %v", err)
}
// Process ENV variables in client config
clientConfig.ClientID = replaceENVVar(clientConfig.ClientID)
clientConfig.ClientSecret = replaceENVVar(clientConfig.ClientSecret)
// Validate client config
err = validateClientConfig(&clientConfig)
if err != nil {
return fmt.Errorf("failed to validate client config: %v", err)
}
yaoClientConfig = &clientConfig
return nil
}
// validateClientConfig validates the client config
func validateClientConfig(clientConfig *YaoClientConfig) error {
// Validate client ID
err := oauth.OAuth.ValidateClientID(clientConfig.ClientID)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Validate client is registered
c := oauth.OAuth.GetClientProvider()
_, err = c.GetClientByID(ctx, clientConfig.ClientID)
if err != nil {
// If client is not registered, register it
if strings.Contains(err.Error(), "Client not found") {
yaoClientConfig, err = registerClient(clientConfig.ClientID)
if err != nil {
return fmt.Errorf("failed to register client: %v", err)
}
return nil
}
return fmt.Errorf("failed to get client: %v", err)
}
return nil
}
// registerClient registers the client config with the OAuth server
func registerClient(clientID string) (*YaoClientConfig, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Register client
response, err := oauth.OAuth.DynamicClientRegistration(ctx, &types.DynamicClientRegistrationRequest{
ClientID: clientID,
ClientName: "Yao OpenAPI Client",
ResponseTypes: []string{"code"},
GrantTypes: []string{"client_credentials"},
ApplicationType: types.ApplicationTypeWeb,
})
if err != nil {
return nil, fmt.Errorf("failed to create client: %v", err)
}
var clientConfig *YaoClientConfig = &YaoClientConfig{}
clientConfig.ClientID = response.ClientID
clientConfig.ClientSecret = response.ClientSecret
clientConfig.ExpiresIn = 3600 * 24 // 24 hours
clientConfig.RefreshTokenExpiresIn = 3600 * 24 * 30 // 30 days
clientConfig.Scopes = []string{"openid", "profile", "email"}
return clientConfig, nil
}
// loadProviders loads all provider configurations from the openapi/user/providers directory
func loadProviders(_ string) error {
// Use Walk to find all provider files in the signin/providers directory
err := application.App.Walk("openapi/user/providers", func(root, filename string, isdir bool) error {
if isdir {
return nil
}
// Only process .yao files
if !strings.HasSuffix(filename, ".yao") {
return nil
}
// Skip client.yao file
if filename == "client.yao" {
return nil
}
// Extract provider ID from filename (basename without extension)
baseName := filepath.Base(filename)
providerID := strings.TrimSuffix(baseName, ".yao")
// Read provider configuration
configRaw, err := application.App.Read(filename)
if err != nil {
return fmt.Errorf("failed to read provider config %s: %v", filename, err)
}
// Parse the provider configuration
var provider Provider
err = application.Parse(filename, configRaw, &provider)
if err != nil {
return fmt.Errorf("failed to parse provider config %s: %v", filename, err)
}
// Set the provider ID
provider.ID = providerID
// Process ENV variables in the provider configuration
provider.ClientID = replaceENVVar(provider.ClientID)
provider.ClientSecret = replaceENVVar(provider.ClientSecret)
// Store the provider globally
providers[providerID] = &provider
return nil
})
if err != nil {
return fmt.Errorf("failed to walk providers directory: %v", err)
}
return nil
}
// loadSigninConfigs loads all signin configurations from the openapi/user directory
func loadSigninConfigs(_ string) error {
// Use Walk to find all configuration files in the signin directory
err := application.App.Walk("openapi/user", func(root, filename string, isdir bool) error {
if isdir {
return nil
}
// Only process .yao files
if !strings.HasSuffix(filename, ".yao") {
return nil
}
// Skip providers directory and client.yao file
if strings.Contains(filename, "providers/") || filepath.Base(filename) == "client.yao" {
return nil
}
// Extract locale from filename (basename without extension)
baseName := filepath.Base(filename)
locale := strings.TrimSuffix(baseName, ".yao")
// Read configuration
configRaw, err := application.App.Read(filename)
if err != nil {
return fmt.Errorf("failed to read config %s: %v", filename, err)
}
// Parse the configuration
var config Config
err = application.Parse(filename, configRaw, &config)
if err != nil {
return fmt.Errorf("failed to parse config %s: %v", filename, err)
}
// Process ENV variables in the configuration
config.ClientID = replaceENVVar(config.ClientID)
config.ClientSecret = replaceENVVar(config.ClientSecret)
// Store full configuration
fullConfigs[locale] = &config
// Create public configuration (without sensitive data)
publicConfig := config
publicConfig.ClientSecret = "" // Remove sensitive data
// Remove captcha secret from public config
if publicConfig.Form != nil && publicConfig.Form.Captcha != nil && publicConfig.Form.Captcha.Options != nil {
// Create a copy of captcha options without the secret
captchaOptions := make(map[string]interface{})
for k, v := range publicConfig.Form.Captcha.Options {
if k != "secret" {
captchaOptions[k] = v
}
}
publicConfig.Form.Captcha.Options = captchaOptions
}
publicConfigs[locale] = &publicConfig
// Set as default if marked
if config.Default {
defaultConfig = &config
}
return nil
})
if err != nil {
return fmt.Errorf("failed to walk signin directory: %v", err)
}
return nil
}
// GetPublicConfig returns the public configuration for a given locale
func GetPublicConfig(locale string) *Config {
configMutex.RLock()
defer configMutex.RUnlock()
// Try to get the specific locale configuration
if config, exists := publicConfigs[locale]; exists {
return config
}
// Fallback to default configuration
if defaultConfig != nil {
// Create a copy of default config for public use
publicDefault := *defaultConfig
publicDefault.ClientSecret = "" // Remove sensitive data
// Remove captcha secret from public config
if publicDefault.Form != nil && publicDefault.Form.Captcha != nil && publicDefault.Form.Captcha.Options != nil {
// Create a copy of captcha options without the secret
captchaOptions := make(map[string]interface{})
for k, v := range publicDefault.Form.Captcha.Options {
if k != "secret" {
captchaOptions[k] = v
}
}
publicDefault.Form.Captcha.Options = captchaOptions
}
return &publicDefault
}
// If no default, try to get any available configuration
for _, config := range publicConfigs {
return config
}
return nil
}
// GetProvider returns a provider by ID
func GetProvider(providerID string) (*Provider, error) {
configMutex.RLock()
defer configMutex.RUnlock()
provider, exists := providers[providerID]
if !exists {
return nil, fmt.Errorf("provider '%s' not found", providerID)
}
return provider, nil
}
// GetYaoClientConfig returns the current yaoClientConfig
func GetYaoClientConfig() *YaoClientConfig {
configMutex.RLock()
defer configMutex.RUnlock()
return yaoClientConfig
}
// replaceENVVar replaces environment variables in a string
func replaceENVVar(value string) string {
if value == "" {
return value
}
// Replace ${ENV_VAR} or $ENV.VAR patterns
re := regexp.MustCompile(`\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_.]*)`)
return re.ReplaceAllStringFunc(value, func(match string) string {
var envVar string
if strings.HasPrefix(match, "${") {
// Extract from ${VAR} format
envVar = match[2 : len(match)-1]
} else {
// Extract from $VAR format, remove $ENV. prefix if present
envVar = match[1:]
envVar = strings.TrimPrefix(envVar, "ENV.")
}
if envValue := os.Getenv(envVar); envValue != "" {
return envValue
}
return match // Return original if env var not found
})
}
// normalizeDuration normalizes various duration formats to Go's time.ParseDuration format
func normalizeDuration(expiresIn string) (string, error) {
if expiresIn == "" {
return "", fmt.Errorf("empty duration")
}
// Common patterns and their conversions
patterns := map[string]func(int) string{
"s": func(n int) string { return fmt.Sprintf("%ds", n) }, // seconds
"m": func(n int) string { return fmt.Sprintf("%dm", n) }, // minutes
"h": func(n int) string { return fmt.Sprintf("%dh", n) }, // hours
}
// Extract number and unit using regex
re := regexp.MustCompile(`^(\d+)(\w+)$`)
matches := re.FindStringSubmatch(expiresIn)
if len(matches) != 3 {
return "", fmt.Errorf("invalid duration format: %s", expiresIn)
}
number, err := strconv.Atoi(matches[1])
if err != nil {
return "", fmt.Errorf("invalid number in duration: %s", matches[1])
}
unit := matches[2]
converter, exists := patterns[unit]
if !exists {
return "", fmt.Errorf("unsupported time unit: %s", unit)
}
normalized := converter(number)
// Validate the normalized duration
if _, err := time.ParseDuration(normalized); err != nil {
return "", fmt.Errorf("failed to create valid duration: %v", err)
}
return normalized, nil
}

181
openapi/user/login.go Normal file
View file

@ -0,0 +1,181 @@
package user
import (
"context"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/gou/session"
"github.com/yaoapp/kun/log"
"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/openapi/utils"
)
// getLoginConfig is the handler for get login configuration (mapped from /signin)
func getLoginConfig(c *gin.Context) {
// Get locale from query parameter (optional)
locale := c.Query("locale")
// Get public configuration for the specified locale
config := GetPublicConfig(locale)
// Set session id if not exists
sid := utils.GetSessionID(c)
if sid == "" {
sid = generateSessionID()
response.SendSessionCookie(c, sid)
}
// If no configuration found, return error
if config == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "No signin configuration found for the requested locale",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
// Return the public configuration
response.RespondWithSuccess(c, response.StatusOK, config)
}
// login is the handler for login (password login, mapped from /signin)
func login(c *gin.Context) {
// This is a placeholder - the original signin function was empty
// You may need to implement the actual login logic here
}
// LoginThirdParty is the handler for third party login
func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, ip string) (*LoginResponse, error) {
// Get provider
provider, err := GetProvider(providerID)
if err != nil {
return nil, err
}
// Check if user exists
userProvider, err := oauth.OAuth.GetUserProvider()
if err != nil {
return nil, err
}
// Auto register user if not exists
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var userID string
// Auto register user if not exists
if provider.Register != nil && provider.Register.Auto {
userID, err = userProvider.GetOAuthUserID(ctx, providerID, userinfo.Sub)
if err != nil && err.Error() == user.ErrOAuthAccountNotFound {
userData := map[string]interface{}{
"name": userinfo.Name,
"given_name": userinfo.GivenName,
"family_name": userinfo.FamilyName,
"picture": userinfo.Picture,
"role_id": provider.Register.Role,
"status": "active",
}
// Auto register user
userID, err = userProvider.CreateUser(ctx, userData)
if err != nil {
return nil, err
}
// Create OAuth account
userData = userinfo.Map()
userData["provider"] = providerID
_, err = userProvider.CreateOAuthAccount(ctx, userID, userData)
if err != nil {
return nil, err
}
}
}
// Get User ID from OAuth account
userID, err = userProvider.GetOAuthUserID(ctx, providerID, userinfo.Sub)
if err != nil {
return nil, err
}
return LoginByUserID(userID, ip)
}
// LoginByUserID is the handler for login
func LoginByUserID(userid string, ip string) (*LoginResponse, error) {
// Get User
userProvider, err := oauth.OAuth.GetUserProvider()
if err != nil {
return nil, err
}
// Get User
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
user, err := userProvider.GetUserWithScopes(ctx, userid)
if err != nil {
return nil, err
}
// Update Last Login
err = userProvider.UpdateUserLastLogin(ctx, userid, ip)
if err != nil {
log.Warn("Failed to update last login: %s", err.Error())
}
yaoClientConfig := GetYaoClientConfig()
var scopes []string = yaoClientConfig.Scopes
if v, ok := user["scopes"].([]string); ok {
scopes = v
}
subject, err := oauth.OAuth.Subject(yaoClientConfig.ClientID, userid)
if err != nil {
log.Warn("Failed to store user fingerprint: %s", err.Error())
}
oidcUserInfo := oauthtypes.MakeOIDCUserInfo(user)
oidcUserInfo.Sub = subject
// OIDC Token
oidcToken, err := oauth.OAuth.SignIDToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), yaoClientConfig.ExpiresIn, oidcUserInfo)
if err != nil {
return nil, err
}
// Access Token
accessToken, err := oauth.OAuth.MakeAccessToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.ExpiresIn)
if err != nil {
return nil, err
}
// Refresh Token
refreshToken, err := oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(scopes, " "), subject, yaoClientConfig.RefreshTokenExpiresIn)
if err != nil {
return nil, err
}
return &LoginResponse{
AccessToken: accessToken,
IDToken: oidcToken,
RefreshToken: refreshToken,
ExpiresIn: yaoClientConfig.ExpiresIn,
RefreshTokenExpiresIn: yaoClientConfig.RefreshTokenExpiresIn,
TokenType: "Bearer",
Scope: strings.Join(scopes, " "),
}, nil
}
// generateSessionID generates a session ID
func generateSessionID() string {
return session.ID()
}

644
openapi/user/oauth.go Normal file
View file

@ -0,0 +1,644 @@
package user
import (
"fmt"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/yaoapp/gou/session"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/openapi/utils"
)
// authbackPrepare receives the post data and forwards to the authback handler
func authbackPrepare(c *gin.Context) {
code := c.PostForm("code")
state := c.PostForm("state")
user := c.PostForm("user") // form_post may include user info
providerID := c.Param("provider")
redirectURI, err := getRedirectURI(providerID, state)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Failed to get redirect URI",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Cache user info if provided (form_post mode)
if user != "" {
saveUserInfo(providerID, state, user)
}
params := url.Values{}
params.Add("code", code)
params.Add("state", state)
c.Redirect(http.StatusFound, redirectURI+"?"+params.Encode())
}
// authback is the handler for OAuth callback
func authback(c *gin.Context) {
sid := utils.GetSessionID(c)
var params OAuthAuthbackRequest
providerID := c.Param("provider")
// Check if provider exists first
provider, err := GetProvider(providerID)
if err != nil || 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
}
if err := c.ShouldBind(&params); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid request",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
if params.State == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "State is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
if err := validateState(providerID, sid, params.State); err != nil {
log.With(log.F{"sid": sid, "state": params.State}).Error("Invalid state")
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid state",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Get redirect URI
redirectURI, err := getRedirectURI(providerID, params.State)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Failed to get redirect URI",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Get provider
provider, err = GetProvider(providerID)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: fmt.Sprintf("Failed to get provider: %v", err),
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
// if response mode is form_post
if provider.ResponseMode == "form_post" {
// Replace the redirectURI to
pathname := strings.TrimSuffix(c.Request.URL.Path, "/callback") + "/authorize/prepare"
newRedirectURI, err := reconstructRedirectURI(redirectURI, pathname, c)
if err != nil {
log.Error("Failed to reconstruct redirectURI: %v", err)
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid redirect URI format",
})
return
}
redirectURI = newRedirectURI
}
// Get AccessToken
tokenResponse, err := provider.AccessToken(params.Code, redirectURI)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: fmt.Sprintf("Failed to get user info: %v", err),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Read cached user info before cleaning up (for form_post mode)
cachedUserInfo, _ := getUserInfo(providerID, params.State)
// Remove the state from the session and cache (also cleans up user cache automatically)
err = removeState(providerID, sid)
if err != nil {
log.With(log.F{"sid": sid, "providerID": providerID}).Error("Failed to remove state")
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Failed to remove state",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Get UserInfo - use different method based on user_info_source
var userInfo *OAuthUserInfoResponse
if provider.UserInfoSource == UserInfoSourceIDToken {
// For OAuth providers that use id_token, pass cached user info for merging
userInfo, err = provider.GetUserInfoFromTokenResponse(tokenResponse, cachedUserInfo)
} else {
// For standard OAuth providers that use userinfo endpoint
userInfo, err = provider.GetUserInfo(tokenResponse.AccessToken, tokenResponse.TokenType)
}
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: fmt.Sprintf("Failed to get user info: %v", err),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// LoginThirdParty(providerID, userInfo)
loginResponse, err := LoginThirdParty(providerID, userInfo, userIPAddress(c))
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Failed to login: " + err.Error(),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Authorize Cookie
accessToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.AccessToken)
refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken)
// Send Cookie
expires := time.Now().Add(time.Duration(loginResponse.ExpiresIn) * time.Second)
refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second)
response.SendAccessTokenCookieWithExpiry(c, accessToken, expires)
response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires)
// Send IDToken to the client
response.RespondWithSuccess(c, response.StatusOK, map[string]interface{}{"id_token": loginResponse.IDToken})
}
// 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")
provider, err := GetProvider(providerID)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Failed to get provider",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
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
}
// Check if state is provided by user and validate format
var warnings []string
// 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
}
} else {
// User provided state - check if it's in UUID format
if !isValidUUID(state) {
warnings = append(warnings, "State parameter is not in UUID format. For better uniqueness and security, consider using UUID format.")
}
}
// 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, " "))
}
// Add response_mode if specified (required for Apple with name/email scopes)
if provider.ResponseMode != "" {
params.Add("response_mode", provider.ResponseMode)
}
// Set session id if not exists
sid := utils.GetSessionID(c)
if sid == "" {
sid = generateSessionID()
response.SendSessionCookie(c, sid)
}
// Save the state to the session for 20 minutes
err = saveState(providerID, sid, state)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Failed to save OAuth state",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// if response mode is form_post
if provider.ResponseMode == "form_post" {
// Replace the redirectURI to
pathname := c.Request.URL.Path + "/prepare"
newRedirectURI, err := reconstructRedirectURI(redirectURI, pathname, c)
if err != nil {
log.Error("Failed to reconstruct redirectURI: %v", err)
response.RespondWithError(c, response.StatusBadRequest, &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid redirect URI format",
})
return
}
params.Set("redirect_uri", newRedirectURI)
}
// Save the redirect URI to the cache
err = saveRedirectURI(providerID, state, redirectURI)
if err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Failed to save OAuth redirect URI",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
return
}
// Build the authorization URL
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,
Warnings: warnings,
})
}
// Helper functions for OAuth state management
// generateRandomState generates a UUID-based state parameter for better uniqueness
func generateRandomState() (string, error) {
u := uuid.New()
return u.String(), nil
}
// isValidUUID checks if a string is a valid UUID format
func isValidUUID(s string) bool {
// UUID v4 format: 8-4-4-4-12 hexadecimal characters
uuidRegex := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
return uuidRegex.MatchString(strings.ToLower(s))
}
// 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"
}
// reconstructRedirectURI reconstructs redirectURI with new path while preserving the original host
func reconstructRedirectURI(originalRedirectURI, newPath string, c *gin.Context) (string, error) {
// Parse the original redirectURI to extract host
parsedURL, err := url.Parse(originalRedirectURI)
if err != nil {
return "", fmt.Errorf("failed to parse redirectURI: %v", err)
}
// Reconstruct with the original host and new path
newRedirectURI := fmt.Sprintf("%s://%s%s", getScheme(c), parsedURL.Host, newPath)
return newRedirectURI, nil
}
// Cache management functions
// userInfoKey returns the key for the user info
func userInfoKey(providerID, state string) string {
return fmt.Sprintf("signin:user_info:%s:%s", providerID, state)
}
// stateKey returns the key for the state
func stateKey(providerID string) string {
return fmt.Sprintf("signin:state:%s", providerID)
}
// redirectURIKey returns the key for the redirect URI
func redirectURIKey(providerID, state string) string {
return fmt.Sprintf("signin:redirect_uri:%s:%s", providerID, state)
}
// saveState saves the state to the session
func saveState(providerID, sid, state string) error {
return session.Global().ID(sid).SetWithEx(stateKey(providerID), state, 20*time.Minute)
}
// saveRedirectURI saves the redirect URI to the session
func saveRedirectURI(providerID, state, redirectURI string) error {
key := redirectURIKey(providerID, state)
store := oauth.OAuth.GetCache()
return store.Set(key, redirectURI, 20*time.Minute)
}
// getRedirectURI gets the redirect URI from the session
func getRedirectURI(providerID, state string) (string, error) {
key := redirectURIKey(providerID, state)
store := oauth.OAuth.GetCache()
value, ok := store.Get(key)
if !ok || value == nil {
return "", fmt.Errorf("redirect URI not found")
}
return value.(string), nil
}
func removeRedirectURI(providerID, state string) error {
key := redirectURIKey(providerID, state)
store := oauth.OAuth.GetCache()
return store.Del(key)
}
// saveUserInfo saves the user info to cache (for form_post mode)
func saveUserInfo(providerID, state, userInfo string) error {
key := userInfoKey(providerID, state)
store := oauth.OAuth.GetCache()
return store.Set(key, userInfo, 20*time.Minute)
}
// getUserInfo gets the user info from cache
func getUserInfo(providerID, state string) (string, error) {
key := userInfoKey(providerID, state)
store := oauth.OAuth.GetCache()
value, ok := store.Get(key)
if !ok || value == nil {
return "", fmt.Errorf("user info not found")
}
return value.(string), nil
}
// removeUserInfo removes the user info from cache
func removeUserInfo(providerID, state string) error {
key := userInfoKey(providerID, state)
store := oauth.OAuth.GetCache()
return store.Del(key)
}
// removeState removes the state from the session
func removeState(providerID, sid string) error {
// Get the state from the session
state, err := session.Global().ID(sid).Get(stateKey(providerID))
if err != nil {
return err
}
// Safely convert state to string
stateStr, ok := state.(string)
if !ok {
return fmt.Errorf("invalid state type: expected string, got %T", state)
}
// Remove all related cached data
removeRedirectURI(providerID, stateStr)
removeUserInfo(providerID, stateStr)
return session.Global().ID(sid).Del(stateKey(providerID))
}
// validateState validates the state from the session
func validateState(providerID, sid, state string) error {
value, err := session.Global().ID(sid).Get(stateKey(providerID))
if err != nil {
return err
}
// Safely convert value to string
stateStr, ok := value.(string)
if !ok {
return fmt.Errorf("invalid state type: expected string, got %T", value)
}
if stateStr != state {
return fmt.Errorf("invalid state")
}
return nil
}
// getUserRealIP is the function to get the real IP address of the user
func userIPAddress(c *gin.Context) string {
// Define HTTP headers to check, ordered by priority
headers := []string{
"X-Real-IP", // Nginx proxy_set_header X-Real-IP
"X-Forwarded-For", // Standard proxy header
"X-Client-IP", // Apache mod_remoteip, Squid
"X-Forwarded", // Legacy proxy standard
"X-Cluster-Client-IP", // Cluster environment
"Forwarded-For", // Pre-RFC 7239 standard
"Forwarded", // RFC 7239 standard
"CF-Connecting-IP", // Cloudflare
"True-Client-IP", // Akamai, CloudFlare Enterprise
"X-Original-Forwarded-For", // Original forwarded
}
// Check each header one by one
for _, header := range headers {
value := c.GetHeader(header)
if value == "" {
continue
}
// Handle cases that may contain multiple IPs (e.g., X-Forwarded-For: client, proxy1, proxy2)
ips := parseIPList(value)
for _, ip := range ips {
if isValidPublicIP(ip) {
return ip
}
}
}
// If none found, use the remote address of the connection
remoteAddr := c.Request.RemoteAddr
if ip := extractIPFromAddr(remoteAddr); ip != "" && isValidPublicIP(ip) {
return ip
}
// Final fallback, return RemoteAddr (may include port)
return extractIPFromAddr(remoteAddr)
}
// parseIPList parses IP list string, handles comma-separated multiple IPs
func parseIPList(value string) []string {
var ips []string
// Handle RFC 7239 Forwarded header format: for=192.0.2.60;proto=http;by=203.0.113.43
if strings.Contains(value, "for=") {
parts := strings.Split(value, ";")
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "for=") {
ip := strings.TrimPrefix(part, "for=")
// Remove possible quotes and brackets
ip = strings.Trim(ip, "\"[]")
if ip != "" {
ips = append(ips, ip)
}
}
}
} else {
// Handle comma-separated IP list
parts := strings.Split(value, ",")
for _, part := range parts {
ip := strings.TrimSpace(part)
if ip != "" {
ips = append(ips, ip)
}
}
}
return ips
}
// extractIPFromAddr extracts IP from address (which may include port)
func extractIPFromAddr(addr string) string {
if addr == "" {
return ""
}
// Handle IPv6 format [::1]:8080
if strings.HasPrefix(addr, "[") {
if idx := strings.Index(addr, "]:"); idx != -1 {
return addr[1:idx]
}
return strings.Trim(addr, "[]")
}
// Handle IPv4 format 127.0.0.1:8080
if idx := strings.LastIndex(addr, ":"); idx != -1 {
return addr[:idx]
}
return addr
}
// isValidPublicIP checks if the IP is a valid public IP
func isValidPublicIP(ipStr string) bool {
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
// Filter out private IPs, local IPs, etc.
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return false
}
// Check if it's a private IP range
if ip.To4() != nil {
// IPv4 private address ranges
return !isPrivateIPv4(ip)
}
// IPv6 private address ranges
return !isPrivateIPv6(ip)
}
// isPrivateIPv4 checks if it's an IPv4 private address
func isPrivateIPv4(ip net.IP) bool {
// 10.0.0.0/8
if ip[12] == 10 {
return true
}
// 172.16.0.0/12
if ip[12] == 172 && ip[13] >= 16 && ip[13] <= 31 {
return true
}
// 192.168.0.0/16
if ip[12] == 192 && ip[13] == 168 {
return true
}
// 169.254.0.0/16 (Link-Local)
if ip[12] == 169 && ip[13] == 254 {
return true
}
return false
}
// isPrivateIPv6 checks if it's an IPv6 private address
func isPrivateIPv6(ip net.IP) bool {
// fc00::/7 (Unique Local)
if ip[0] >= 0xfc && ip[0] <= 0xfd {
return true
}
// fe80::/10 (Link-Local)
if ip[0] == 0xfe && (ip[1]&0xc0) == 0x80 {
return true
}
return false
}

1159
openapi/user/provider.go Normal file

File diff suppressed because it is too large Load diff

190
openapi/user/types.go Normal file
View file

@ -0,0 +1,190 @@
package user
import (
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
)
// Config represents the signin page configuration
type Config struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Default bool `json:"default,omitempty"`
SuccessURL string `json:"success_url,omitempty"`
FailureURL string `json:"failure_url,omitempty"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
Form *FormConfig `json:"form,omitempty"`
Token *TokenConfig `json:"token,omitempty"`
ThirdParty *ThirdParty `json:"third_party,omitempty"`
}
// FormConfig represents the form configuration
type FormConfig struct {
Username *UsernameConfig `json:"username,omitempty"`
Password *PasswordConfig `json:"password,omitempty"`
Captcha *CaptchaConfig `json:"captcha,omitempty"`
ForgotPasswordLink bool `json:"forgot_password_link,omitempty"`
RememberMe bool `json:"remember_me,omitempty"`
RegisterLink string `json:"register_link,omitempty"`
TermsOfServiceLink string `json:"terms_of_service_link,omitempty"`
PrivacyPolicyLink string `json:"privacy_policy_link,omitempty"`
}
// UsernameConfig represents the username field configuration
type UsernameConfig struct {
Placeholder string `json:"placeholder,omitempty"`
Fields []string `json:"fields,omitempty"`
}
// PasswordConfig represents the password field configuration
type PasswordConfig struct {
Placeholder string `json:"placeholder,omitempty"`
}
// CaptchaConfig represents the captcha configuration
type CaptchaConfig struct {
Type string `json:"type,omitempty"`
Options map[string]interface{} `json:"options,omitempty"`
}
// TokenConfig represents the token configuration
type TokenConfig struct {
ExpiresIn string `json:"expires_in,omitempty"`
RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"`
}
// ThirdParty represents the third party login configuration
type ThirdParty struct {
Providers []*Provider `json:"providers,omitempty"`
}
// RegisterConfig represents the auto register configuration
type RegisterConfig struct {
Auto bool `json:"auto,omitempty"`
Role string `json:"role,omitempty"`
}
// YaoClientConfig represents the Yao OpenAPI Client config
type YaoClientConfig struct {
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
Scopes []string `json:"scopes,omitempty"` // Default scopes if not set in the provider config
ExpiresIn int `json:"expires_in,omitempty"` // Default expires in for the access token (optional) in seconds
RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"` // Default expires in for the refresh token (optional) in seconds
}
// Provider represents a third party login provider
type Provider struct {
ID string `json:"id,omitempty"`
Label string `json:"label,omitempty"`
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"`
Scopes []string `json:"scopes,omitempty"`
ResponseMode string `json:"response_mode,omitempty"`
UserInfoSource string `json:"user_info_source,omitempty"` // "endpoint" (default) | "id_token" | "access_token"
Endpoints *Endpoints `json:"endpoints,omitempty"`
Mapping interface{} `json:"mapping,omitempty"` // string (preset) | map[string]string (custom) | nil (generic)
Register *RegisterConfig `json:"register,omitempty"`
}
// SecretGenerator represents the client secret generator configuration
type SecretGenerator struct {
Type string `json:"type,omitempty"`
ExpiresIn string `json:"expires_in,omitempty"`
PrivateKey string `json:"private_key,omitempty"`
Header map[string]interface{} `json:"header,omitempty"`
Payload map[string]interface{} `json:"payload,omitempty"`
}
// Endpoints represents the OAuth endpoints
type Endpoints struct {
Authorization string `json:"authorization,omitempty"`
Token string `json:"token,omitempty"`
UserInfo string `json:"user_info,omitempty"`
JWKS string `json:"jwks,omitempty"` // JSON Web Key Set endpoint for token verification
}
// ==== API Types ====
// OAuthAuthorizationURLResponse represents the response for OAuth authorization URL
type OAuthAuthorizationURLResponse struct {
AuthorizationURL string `json:"authorization_url"`
State string `json:"state"`
Warnings []string `json:"warnings,omitempty"` // Optional warnings about state format or other issues
}
// OAuthCallbackResponse represents the response for OAuth callback
type OAuthCallbackResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
// OAuthAuthbackRequest represents the request for OAuth callback
type OAuthAuthbackRequest struct {
Locale string `json:"locale" form:"locale"`
Code string `json:"code" form:"code"`
State string `json:"state" form:"state"`
Provider string `json:"provider" form:"provider"`
Scope string `json:"scope,omitempty" form:"scope,omitempty"`
}
// OAuthTokenResponse represents the response from OAuth token endpoint
type OAuthTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
IDToken string `json:"id_token,omitempty"` // JWT token containing user info (Apple, etc.)
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
// OAuthTokenRequest represents the request to OAuth token endpoint
type OAuthTokenRequest struct {
GrantType string `json:"grant_type" form:"grant_type"`
Code string `json:"code" form:"code"`
ClientID string `json:"client_id" form:"client_id"`
ClientSecret string `json:"client_secret" form:"client_secret"`
RedirectURI string `json:"redirect_uri,omitempty" form:"redirect_uri,omitempty"`
}
// OAuthUserInfoResponse is an alias for OIDC standard user information type
type OAuthUserInfoResponse = oauthtypes.OIDCUserInfo
// OIDCAddress is an alias for OIDC standard address claim type
type OIDCAddress = oauthtypes.OIDCAddress
// LoginResponse represents the response for login
type LoginResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"`
TokenType string `json:"token_type,omitempty"`
Scope string `json:"scope,omitempty"`
}
// Built-in preset mapping types
const (
MappingGoogle = "google"
MappingGitHub = "github"
MappingMicrosoft = "microsoft"
MappingApple = "apple"
MappingWeChat = "wechat"
MappingGeneric = "generic"
)
// User info source types
const (
UserInfoSourceEndpoint = "endpoint" // Default: Get user info from dedicated endpoint
UserInfoSourceIDToken = "id_token" // Extract user info from ID token (JWT)
UserInfoSourceAccessToken = "access_token" // Extract user info from access token response
)

View file

@ -10,9 +10,9 @@ import (
// Attach attaches the signin handlers to the router
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
// User Authentication
group.GET("/login", placeholder) // Get login page config (public)
group.POST("/login", placeholder) // User login (public)
// User Authentication (migrated from /signin)
group.GET("/login", getLoginConfig) // Get login page config (public) - migrated from /signin
group.POST("/login", login) // User login (public) - migrated from /signin
group.POST("/register", placeholder) // User register (public)
group.POST("/logout", oauth.Guard, placeholder) // User logout
@ -229,11 +229,11 @@ func attachThirdParty(group *gin.RouterGroup, oauth types.OAuth) {
thirdParty.GET("/providers", oauth.Guard, placeholder) // Get linked OAuth providers
thirdParty.DELETE("/:provider", oauth.Guard, placeholder) // Unlink OAuth provider
thirdParty.GET("/providers/available", placeholder) // Get available OAuth providers
thirdParty.GET("/:provider/authorize", placeholder) // Get OAuth authorization URL
thirdParty.POST("/:provider/connect", oauth.Guard, placeholder) // Connect OAuth provider
thirdParty.POST("/:provider/authorize/prepare", placeholder) // Get OAuth authorization URL
thirdParty.POST("/:provider/callback", placeholder) // Handle OAuth callback
thirdParty.GET("/providers/available", placeholder) // Get available OAuth providers
thirdParty.GET("/:provider/authorize", getOAuthAuthorizationURL) // Get OAuth authorization URL - migrated from /signin/oauth/:provider/authorize
thirdParty.POST("/:provider/connect", oauth.Guard, placeholder) // Connect OAuth provider
thirdParty.POST("/:provider/authorize/prepare", authbackPrepare) // OAuth authorization prepare - migrated from /signin/oauth/:provider/authorize/prepare
thirdParty.POST("/:provider/callback", authback) // Handle OAuth callback - migrated from /signin/oauth/:provider/authback
}