Refactor OAuth handling and enhance error management in Signin API
- Removed unused OAuth response structures to streamline the codebase. - Improved the authback function to handle redirect URIs and provider retrieval more effectively, enhancing error responses for invalid requests. - Updated the getOAuthAuthorizationURL function to include better handling of provider configurations and session state management. - Introduced a new function to normalize expiration formats for client secrets, ensuring consistent duration handling across providers.
This commit is contained in:
parent
1a2b366056
commit
39b834c929
4 changed files with 686 additions and 147 deletions
|
|
@ -18,27 +18,6 @@ import (
|
|||
"github.com/yaoapp/yao/openapi/utils"
|
||||
)
|
||||
|
||||
// OAuthAuthorizationURLResponse represents the response for OAuth authorization URL
|
||||
type OAuthAuthorizationURLResponse struct {
|
||||
AuthorizationURL string `json:"authorization_url"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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"`
|
||||
}
|
||||
|
||||
// Attach attaches the signin handlers to the router
|
||||
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
||||
group.GET("/signin", getConfig)
|
||||
|
|
@ -95,12 +74,6 @@ func authbackPrepare(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Remove the redirect URI from the session
|
||||
err = removeRedirectURI(providerID, state)
|
||||
if err != nil {
|
||||
log.Warn("Failed to remove redirect URI: %v", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("code", code)
|
||||
params.Add("state", state)
|
||||
|
|
@ -140,8 +113,47 @@ func authback(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Remove the state from the session
|
||||
err := removeState(providerID, sid)
|
||||
// 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(params.Locale, 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.Replace(c.Request.URL.Path, "/signin/authback/", "/signin/oauth/", 1) + "/authorize/prepare"
|
||||
fmt.Println(pathname)
|
||||
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
|
||||
}
|
||||
|
||||
// Remove the state from the session and cache
|
||||
err = removeState(providerID, sid)
|
||||
if err != nil {
|
||||
log.With(log.F{"sid": sid, "providerID": providerID}).Error("Failed to remove state")
|
||||
errorResp := &response.ErrorResponse{
|
||||
|
|
@ -152,8 +164,23 @@ func authback(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Get UserInfo
|
||||
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
|
||||
}
|
||||
|
||||
// Respond with success
|
||||
response.RespondWithSuccess(c, response.StatusOK, params)
|
||||
response.RespondWithSuccess(c, response.StatusOK, map[string]interface{}{
|
||||
"params": params,
|
||||
"token": tokenResponse,
|
||||
"provider": provider,
|
||||
})
|
||||
}
|
||||
|
||||
// getOAuthAuthorizationURL generates OAuth authorization URL for a provider
|
||||
|
|
@ -173,28 +200,16 @@ func getOAuthAuthorizationURL(c *gin.Context) {
|
|||
state := c.Query("state")
|
||||
locale := c.Query("locale")
|
||||
|
||||
// Get full configuration
|
||||
config := GetFullConfig(locale)
|
||||
if config == nil {
|
||||
provider, err := GetProvider(locale, providerID)
|
||||
if err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "No signin configuration found",
|
||||
ErrorDescription: "Failed to get provider",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the provider
|
||||
var provider *Provider
|
||||
if config.ThirdParty != nil && config.ThirdParty.Providers != nil {
|
||||
for _, p := range config.ThirdParty.Providers {
|
||||
if p.ID == providerID {
|
||||
provider = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if provider == nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
|
|
@ -260,7 +275,7 @@ func getOAuthAuthorizationURL(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Save the state to the session for 20 minutes
|
||||
err := saveState(providerID, sid, state)
|
||||
err = saveState(providerID, sid, state)
|
||||
if err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
|
|
@ -270,18 +285,8 @@ func getOAuthAuthorizationURL(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// if response mode is form_post, save the redirect URI to the session
|
||||
// if response mode is form_post
|
||||
if provider.ResponseMode == "form_post" {
|
||||
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
|
||||
}
|
||||
|
||||
// Replace the redirectURI to
|
||||
pathname := c.Request.URL.Path + "/prepare"
|
||||
newRedirectURI, err := reconstructRedirectURI(redirectURI, pathname, c)
|
||||
|
|
@ -297,6 +302,17 @@ func getOAuthAuthorizationURL(c *gin.Context) {
|
|||
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())
|
||||
|
||||
|
|
@ -385,6 +401,14 @@ func removeRedirectURI(providerID, state string) error {
|
|||
}
|
||||
|
||||
func removeState(providerID, sid string) error {
|
||||
// Get the state from the session
|
||||
state, err := session.Global().ID(sid).Get(fmt.Sprintf("oauth_state_%s", providerID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove the redirect URI from the cache
|
||||
removeRedirectURI(providerID, state.(string))
|
||||
return session.Global().ID(sid).Del(fmt.Sprintf("oauth_state_%s", providerID))
|
||||
}
|
||||
|
||||
|
|
|
|||
394
openapi/signin/provider.go
Normal file
394
openapi/signin/provider.go
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
package signin
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/http"
|
||||
"github.com/yaoapp/kun/log"
|
||||
)
|
||||
|
||||
// GetClientSecret gets the client secret for the provider
|
||||
func (p *Provider) GetClientSecret() (string, error) {
|
||||
if p.ClientSecret != "" {
|
||||
return p.ClientSecret, nil
|
||||
}
|
||||
|
||||
if p.ClientSecretGenerator == nil {
|
||||
return "", fmt.Errorf("client secret generator not found, set client_secret or client_secret_generator at least one")
|
||||
}
|
||||
|
||||
// Generate the client secret using the configured generator
|
||||
return p.GenerateClientSecret()
|
||||
}
|
||||
|
||||
// GetUserInfo gets the user information from the provider
|
||||
func (p *Provider) GetUserInfo(accessToken string) (*OAuthUserInfoResponse, error) {
|
||||
if p.Endpoints == nil {
|
||||
return nil, fmt.Errorf("endpoints not found, set endpoints at least one")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GenerateClientSecret generates client secret based on the configured generator type
|
||||
func (p *Provider) GenerateClientSecret() (string, error) {
|
||||
if p.ClientSecretGenerator == nil {
|
||||
return "", fmt.Errorf("client secret generator not configured")
|
||||
}
|
||||
|
||||
switch p.ClientSecretGenerator.Type {
|
||||
case "JWT_ES256", "JWT_APPLE": // Apple JWT is the same as JWT_ES256
|
||||
return p.generateJWTES256()
|
||||
case "BASIC_CONCAT":
|
||||
return p.generateBasicConcat()
|
||||
case "HMAC_SHA256":
|
||||
return p.generateHMACSignature()
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported client secret generator type: %s", p.ClientSecretGenerator.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// generateJWTES256 generates JWT client secret using ES256 algorithm
|
||||
func (p *Provider) generateJWTES256() (string, error) {
|
||||
gen := p.ClientSecretGenerator
|
||||
|
||||
// Validate required fields
|
||||
if gen.PrivateKey == "" {
|
||||
return "", fmt.Errorf("private_key is required for JWT ES256 generation")
|
||||
}
|
||||
|
||||
if gen.Header == nil {
|
||||
return "", fmt.Errorf("header is required for JWT ES256 generation")
|
||||
}
|
||||
|
||||
if gen.Payload == nil {
|
||||
return "", fmt.Errorf("payload is required for JWT ES256 generation")
|
||||
}
|
||||
|
||||
// Read private key
|
||||
privateKey, err := p.loadPrivateKey(gen.PrivateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load private key: %w", err)
|
||||
}
|
||||
|
||||
// Parse expiration time (already normalized during config loading)
|
||||
expiresIn := time.Hour * 24 * 90 // Default 90 days
|
||||
if gen.ExpiresIn != "" {
|
||||
duration, err := time.ParseDuration(gen.ExpiresIn)
|
||||
if err != nil {
|
||||
// This should not happen since it's normalized during config loading
|
||||
log.Error("Failed to parse normalized expires_in '%s': %v", gen.ExpiresIn, err)
|
||||
// Use default duration
|
||||
} else {
|
||||
expiresIn = duration
|
||||
}
|
||||
}
|
||||
|
||||
// Create JWT token
|
||||
now := time.Now()
|
||||
token := jwt.New(jwt.SigningMethodES256)
|
||||
|
||||
// Set header claims
|
||||
for key, value := range gen.Header {
|
||||
token.Header[key] = value
|
||||
}
|
||||
|
||||
// Set payload claims
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
for key, value := range gen.Payload {
|
||||
claims[key] = value
|
||||
}
|
||||
|
||||
// Set standard claims
|
||||
claims["iat"] = now.Unix()
|
||||
claims["exp"] = now.Add(expiresIn).Unix()
|
||||
|
||||
// Sign the token
|
||||
tokenString, err := token.SignedString(privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// loadPrivateKey loads and parses the ES256 private key
|
||||
func (p *Provider) loadPrivateKey(keyPath string) (*ecdsa.PrivateKey, error) {
|
||||
var keyData []byte
|
||||
var err error
|
||||
|
||||
// Check if keyPath is absolute or relative to openapi/certs
|
||||
if filepath.IsAbs(keyPath) {
|
||||
keyData, err = os.ReadFile(keyPath)
|
||||
} else {
|
||||
// Try relative to openapi/certs directory
|
||||
certPath := filepath.Join("openapi", "certs", keyPath)
|
||||
keyData, err = application.App.Read(certPath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("failed to read private key file: %v", err)
|
||||
return nil, fmt.Errorf("failed to read private key file: %w", err)
|
||||
}
|
||||
|
||||
// Parse PEM block
|
||||
block, _ := pem.Decode(keyData)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode PEM block")
|
||||
}
|
||||
|
||||
// Parse private key
|
||||
switch block.Type {
|
||||
case "EC PRIVATE KEY":
|
||||
return x509.ParseECPrivateKey(block.Bytes)
|
||||
case "PRIVATE KEY":
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ecKey, ok := key.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not an ECDSA private key")
|
||||
}
|
||||
return ecKey, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported private key type: %s", block.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// generateBasicConcat generates client secret by concatenating client_id and other values
|
||||
func (p *Provider) generateBasicConcat() (string, error) {
|
||||
gen := p.ClientSecretGenerator
|
||||
|
||||
// Default pattern: client_id:timestamp
|
||||
parts := []string{p.ClientID}
|
||||
|
||||
// Add custom parts from payload
|
||||
if gen.Payload != nil {
|
||||
for key, value := range gen.Payload {
|
||||
if key == "separator" {
|
||||
continue // Skip separator key
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%v", value))
|
||||
}
|
||||
} else {
|
||||
// Add timestamp if no custom payload
|
||||
parts = append(parts, fmt.Sprintf("%d", time.Now().Unix()))
|
||||
}
|
||||
|
||||
// Get separator from payload, default to ":"
|
||||
separator := ":"
|
||||
if gen.Payload != nil {
|
||||
if sep, ok := gen.Payload["separator"].(string); ok {
|
||||
separator = sep
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, separator), nil
|
||||
}
|
||||
|
||||
// generateHMACSignature generates client secret using HMAC-SHA256 signature
|
||||
func (p *Provider) generateHMACSignature() (string, error) {
|
||||
gen := p.ClientSecretGenerator
|
||||
|
||||
// Get the secret key for HMAC
|
||||
secretKey := ""
|
||||
if gen.PrivateKey != "" {
|
||||
secretKey = gen.PrivateKey
|
||||
} else if gen.Payload != nil {
|
||||
if key, ok := gen.Payload["secret_key"].(string); ok {
|
||||
secretKey = key
|
||||
}
|
||||
}
|
||||
|
||||
if secretKey == "" {
|
||||
return "", fmt.Errorf("secret_key is required for HMAC_SHA256 generation")
|
||||
}
|
||||
|
||||
// Build message to sign
|
||||
message := p.ClientID
|
||||
if gen.Payload != nil {
|
||||
if msg, ok := gen.Payload["message"].(string); ok {
|
||||
message = msg
|
||||
} else if msg, ok := gen.Payload["data"].(string); ok {
|
||||
message = msg
|
||||
}
|
||||
}
|
||||
|
||||
// Add timestamp if configured
|
||||
if gen.Payload != nil {
|
||||
if addTimestamp, ok := gen.Payload["add_timestamp"].(bool); ok && addTimestamp {
|
||||
message += fmt.Sprintf(":%d", time.Now().Unix())
|
||||
}
|
||||
}
|
||||
|
||||
// Create HMAC signature
|
||||
h := hmac.New(sha256.New, []byte(secretKey))
|
||||
h.Write([]byte(message))
|
||||
signature := h.Sum(nil)
|
||||
|
||||
// Return as hex or base64 based on configuration
|
||||
encoding := "hex" // default
|
||||
if gen.Payload != nil {
|
||||
if enc, ok := gen.Payload["encoding"].(string); ok {
|
||||
encoding = enc
|
||||
}
|
||||
}
|
||||
|
||||
switch encoding {
|
||||
case "base64":
|
||||
return base64.StdEncoding.EncodeToString(signature), nil
|
||||
case "hex":
|
||||
return hex.EncodeToString(signature), nil
|
||||
default:
|
||||
return hex.EncodeToString(signature), nil
|
||||
}
|
||||
}
|
||||
|
||||
// AccessToken gets the access token for the provider using OAuth 2.0 authorization code flow
|
||||
func (p *Provider) AccessToken(code, redirectURI string) (*OAuthTokenResponse, error) {
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("authorization code is required")
|
||||
}
|
||||
|
||||
// Get the access token endpoint
|
||||
if p.Endpoints == nil {
|
||||
return nil, fmt.Errorf("endpoints not found, set endpoints at least one")
|
||||
}
|
||||
|
||||
if p.Endpoints.Token == "" {
|
||||
return nil, fmt.Errorf("token endpoint not found, set token endpoint at least one")
|
||||
}
|
||||
|
||||
// Get client secret (handles both ClientSecret and ClientSecretGenerator cases)
|
||||
secret, err := p.GetClientSecret()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client secret: %w", err)
|
||||
}
|
||||
|
||||
// Prepare the request parameters according to OAuth 2.0 spec
|
||||
params := map[string]string{
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": p.ClientID,
|
||||
"client_secret": secret,
|
||||
"redirect_uri": redirectURI,
|
||||
}
|
||||
|
||||
// Create HTTP request using gou/http package (with DNS optimization)
|
||||
req := http.New(p.Endpoints.Token).
|
||||
SetHeader("Content-Type", "application/x-www-form-urlencoded").
|
||||
SetHeader("Accept", "application/json").
|
||||
SetHeader("User-Agent", "Yao-OAuth-Client/1.0")
|
||||
|
||||
// Make the POST request
|
||||
resp := req.Post(params)
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("failed to make token request: no response")
|
||||
}
|
||||
|
||||
// Check for HTTP errors
|
||||
if resp.Code != 200 {
|
||||
if resp.Data != nil {
|
||||
if data, ok := resp.Data.(map[string]interface{}); ok {
|
||||
if err, ok := data["error_description"]; ok {
|
||||
return nil, fmt.Errorf("%v", err)
|
||||
}
|
||||
if err, ok := data["error"]; ok {
|
||||
return nil, fmt.Errorf("%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Message != "" {
|
||||
return nil, fmt.Errorf("token request failed with status %d: %s", resp.Code, resp.Message)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("token request failed with status %d", resp.Code)
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
var tokenResponse OAuthTokenResponse
|
||||
|
||||
// Handle the response data - it could be already parsed JSON or raw bytes
|
||||
switch data := resp.Data.(type) {
|
||||
case map[string]interface{}:
|
||||
// Already parsed JSON, convert to our struct
|
||||
jsonBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal response data: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(jsonBytes, &tokenResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response from parsed JSON: %w", err)
|
||||
}
|
||||
case []byte:
|
||||
// Raw bytes, parse as JSON
|
||||
if err := json.Unmarshal(data, &tokenResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response from bytes: %w", err)
|
||||
}
|
||||
case string:
|
||||
// String response, parse as JSON
|
||||
if err := json.Unmarshal([]byte(data), &tokenResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response from string: %w", err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected response data type: %T", data)
|
||||
}
|
||||
|
||||
// Check for OAuth error response
|
||||
if tokenResponse.Error != "" {
|
||||
errorMsg := tokenResponse.Error
|
||||
if tokenResponse.ErrorDesc != "" {
|
||||
errorMsg += ": " + tokenResponse.ErrorDesc
|
||||
}
|
||||
return nil, fmt.Errorf("OAuth error: %s", errorMsg)
|
||||
}
|
||||
|
||||
// Validate that we got an access token
|
||||
if tokenResponse.AccessToken == "" {
|
||||
return nil, fmt.Errorf("no access token in response")
|
||||
}
|
||||
|
||||
return &tokenResponse, nil
|
||||
}
|
||||
|
||||
// GetProvider gets the provider by ID
|
||||
func GetProvider(locale, providerID string) (*Provider, error) {
|
||||
|
||||
// Get the signin configuration
|
||||
config := GetFullConfig(locale)
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("no signin configuration found")
|
||||
}
|
||||
|
||||
// Find the provider
|
||||
var provider *Provider
|
||||
if config.ThirdParty != nil && config.ThirdParty.Providers != nil {
|
||||
for _, p := range config.ThirdParty.Providers {
|
||||
if p.ID == providerID {
|
||||
provider = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if provider == nil {
|
||||
return nil, fmt.Errorf("OAuth provider '%s' not found", providerID)
|
||||
}
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
|
@ -6,8 +6,10 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/yao/config"
|
||||
|
|
@ -25,96 +27,6 @@ var (
|
|||
configMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// Config represents the signin page configuration
|
||||
type Config struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SuccessURL string `json:"success_url,omitempty"`
|
||||
FailureURL string `json:"failure_url,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 {
|
||||
Register *RegisterConfig `json:"register,omitempty"`
|
||||
Providers []*Provider `json:"providers,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterConfig represents the auto register configuration
|
||||
type RegisterConfig struct {
|
||||
Auto bool `json:"auto,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// Provider represents a third party login provider
|
||||
type Provider struct {
|
||||
ID string `json:"id,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"`
|
||||
Endpoints *Endpoints `json:"endpoints,omitempty"`
|
||||
Mapping map[string]string `json:"mapping,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"`
|
||||
}
|
||||
|
||||
// Load loads all signin configurations from the openapi directory
|
||||
func Load(appConfig config.Config) error {
|
||||
configMutex.Lock()
|
||||
|
|
@ -275,6 +187,19 @@ func processENVVariables(config *Config, rootPath string) {
|
|||
provider.ClientSecretGenerator.PrivateKey = filepath.Join(rootPath, "openapi", "certs", provider.ClientSecretGenerator.PrivateKey)
|
||||
}
|
||||
|
||||
// Process and normalize expires_in format
|
||||
if provider.ClientSecretGenerator.ExpiresIn != "" {
|
||||
normalizedDuration, err := normalizeExpiresIn(provider.ClientSecretGenerator.ExpiresIn)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Invalid expires_in format '%s' for provider '%s': %v",
|
||||
provider.ClientSecretGenerator.ExpiresIn, provider.ID, err)
|
||||
// Set default to 90 days
|
||||
provider.ClientSecretGenerator.ExpiresIn = "2160h" // 90 * 24 hours
|
||||
} else {
|
||||
provider.ClientSecretGenerator.ExpiresIn = normalizedDuration
|
||||
}
|
||||
}
|
||||
|
||||
// Process header values
|
||||
if provider.ClientSecretGenerator.Header != nil {
|
||||
for key, value := range provider.ClientSecretGenerator.Header {
|
||||
|
|
@ -526,3 +451,55 @@ func GetDefaultLanguage() string {
|
|||
}
|
||||
return defaultLang
|
||||
}
|
||||
|
||||
// normalizeExpiresIn converts custom time units to Go standard duration format
|
||||
func normalizeExpiresIn(expiresIn string) (string, error) {
|
||||
if expiresIn == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Try parsing as standard Go duration first
|
||||
if _, err := time.ParseDuration(expiresIn); err == nil {
|
||||
return expiresIn, nil
|
||||
}
|
||||
|
||||
// Custom unit conversion patterns
|
||||
patterns := map[string]func(int) string{
|
||||
"d": func(n int) string { return fmt.Sprintf("%dh", n*24) }, // days to hours
|
||||
"w": func(n int) string { return fmt.Sprintf("%dh", n*24*7) }, // weeks to hours
|
||||
"M": func(n int) string { return fmt.Sprintf("%dh", n*24*30) }, // months to hours (approximate)
|
||||
"y": func(n int) string { return fmt.Sprintf("%dh", n*24*365) }, // years to hours (approximate)
|
||||
"ms": func(n int) string { return fmt.Sprintf("%dms", n) }, // milliseconds
|
||||
"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
|
||||
}
|
||||
|
|
|
|||
144
openapi/signin/types.go
Normal file
144
openapi/signin/types.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package signin
|
||||
|
||||
// Config represents the signin page configuration
|
||||
type Config struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SuccessURL string `json:"success_url,omitempty"`
|
||||
FailureURL string `json:"failure_url,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 {
|
||||
Register *RegisterConfig `json:"register,omitempty"`
|
||||
Providers []*Provider `json:"providers,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterConfig represents the auto register configuration
|
||||
type RegisterConfig struct {
|
||||
Auto bool `json:"auto,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// Provider represents a third party login provider
|
||||
type Provider struct {
|
||||
ID string `json:"id,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"`
|
||||
Endpoints *Endpoints `json:"endpoints,omitempty"`
|
||||
Mapping map[string]string `json:"mapping,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"`
|
||||
}
|
||||
|
||||
// ==== API Types ====
|
||||
|
||||
// OAuthAuthorizationURLResponse represents the response for OAuth authorization URL
|
||||
type OAuthAuthorizationURLResponse struct {
|
||||
AuthorizationURL string `json:"authorization_url"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
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 represents the user information from OAuth provider
|
||||
type OAuthUserInfoResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue