Merge pull request #1151 from trheyi/main
Add user configuration loading and update authentication routes
This commit is contained in:
commit
e8734849c2
11 changed files with 3515 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
168
openapi/tests/user/login_config_test.go
Normal file
168
openapi/tests/user/login_config_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
182
openapi/tests/user/login_test.go
Normal file
182
openapi/tests/user/login_test.go
Normal 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.
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
264
openapi/tests/user/oauth_authorize_test.go
Normal file
264
openapi/tests/user/oauth_authorize_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
294
openapi/tests/user/oauth_callback_test.go
Normal file
294
openapi/tests/user/oauth_callback_test.go
Normal 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
419
openapi/user/config.go
Normal 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
181
openapi/user/login.go
Normal 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
644
openapi/user/oauth.go
Normal 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(¶ms); 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
1159
openapi/user/provider.go
Normal file
File diff suppressed because it is too large
Load diff
190
openapi/user/types.go
Normal file
190
openapi/user/types.go
Normal 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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue