yao/openapi/signin/signin.go
Max 315618da47 Add Signin API and update file management endpoints
- Introduced a comprehensive Signin API for user authentication, supporting multiple OAuth providers (Google, GitHub, Microsoft, Apple).
- Updated the file management API endpoints to use a singular `/file` path instead of `/files`, enhancing consistency across the API.
- Revised README documentation to include detailed descriptions of the new Signin API and updated file management endpoints.
- Enhanced test cases to reflect the changes in endpoint structure and ensure robust coverage for the new Signin functionality.
2025-07-30 16:49:28 +08:00

388 lines
11 KiB
Go

package signin
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/yaoapp/gou/application"
"github.com/yaoapp/yao/config"
)
// Global variables to store loaded configurations
var (
// 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)
// Default language code
defaultLang = ""
// Mutex for thread safety
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"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
ClientSecretGenerator *SecretGenerator `json:"client_secret_generator,omitempty"`
Scopes []string `json:"scopes,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()
defer configMutex.Unlock()
// Clear existing configurations
fullConfigs = make(map[string]*Config)
publicConfigs = make(map[string]*Config)
defaultLang = ""
// Find all signin configuration files
files, err := findSigninFiles()
if err != nil {
return fmt.Errorf("failed to find signin files: %v", err)
}
// If no signin files found, that's not necessarily an error
// Some applications might not have signin configurations
if len(files) == 0 {
return nil
}
// Load each configuration file
for _, file := range files {
lang := extractLanguageFromFilename(file)
configPath := filepath.Join("openapi", file)
configRaw, err := application.App.Read(configPath)
if err != nil {
return fmt.Errorf("failed to read signin config %s: %v", file, err)
}
// Parse the configuration
var signinConfig Config
err = application.Parse(configPath, configRaw, &signinConfig)
if err != nil {
return fmt.Errorf("failed to parse signin config %s: %v", file, err)
}
// Process ENV variables in full config
fullConfig := signinConfig
processENVVariables(&fullConfig, appConfig.Root)
// Create public config (without sensitive data)
publicConfig := createPublicConfig(&fullConfig)
// Store configurations
fullConfigs[lang] = &fullConfig
publicConfigs[lang] = &publicConfig
// Set default language
if defaultLang == "" || lang == "en" || file == "signin.yao" {
defaultLang = lang
}
}
return nil
}
// findSigninFiles finds all signin configuration files in the openapi directory
func findSigninFiles() ([]string, error) {
var files []string
signinFilePattern := regexp.MustCompile(`^signin(\.[a-z]{2}(-[a-z]{2})?)?\.yao$`)
// Use Walk to find all signin files in the openapi directory
err := application.App.Walk("openapi", func(root, filename string, isdir bool) error {
if isdir {
return nil
}
baseName := filepath.Base(filename)
if signinFilePattern.MatchString(baseName) {
files = append(files, baseName)
}
return nil
}, "*.yao")
if err != nil {
return nil, err
}
return files, nil
}
// extractLanguageFromFilename extracts language code from filename
func extractLanguageFromFilename(filename string) string {
// signin.yao -> ""
// signin.en.yao -> "en"
// signin.zh-cn.yao -> "zh-cn"
if filename == "signin.yao" {
return ""
}
parts := strings.Split(filename, ".")
if len(parts) >= 3 {
return parts[1]
}
return ""
}
// processENVVariables processes environment variables in the configuration
func processENVVariables(config *Config, rootPath string) {
// Process form captcha options
if config.Form != nil && config.Form.Captcha != nil && config.Form.Captcha.Options != nil {
for key, value := range config.Form.Captcha.Options {
if strValue, ok := value.(string); ok {
config.Form.Captcha.Options[key] = replaceENVVar(strValue)
}
}
}
// Process third party providers
if config.ThirdParty != nil && config.ThirdParty.Providers != nil {
for _, provider := range config.ThirdParty.Providers {
provider.ClientID = replaceENVVar(provider.ClientID)
provider.ClientSecret = replaceENVVar(provider.ClientSecret)
// Process client secret generator
if provider.ClientSecretGenerator != nil {
provider.ClientSecretGenerator.PrivateKey = replaceENVVar(provider.ClientSecretGenerator.PrivateKey)
// Convert relative path to absolute path for private key
if provider.ClientSecretGenerator.PrivateKey != "" && !filepath.IsAbs(provider.ClientSecretGenerator.PrivateKey) {
provider.ClientSecretGenerator.PrivateKey = filepath.Join(rootPath, "openapi", "certs", provider.ClientSecretGenerator.PrivateKey)
}
// Process header values
if provider.ClientSecretGenerator.Header != nil {
for key, value := range provider.ClientSecretGenerator.Header {
if strValue, ok := value.(string); ok {
provider.ClientSecretGenerator.Header[key] = replaceENVVar(strValue)
}
}
}
// Process payload values
if provider.ClientSecretGenerator.Payload != nil {
for key, value := range provider.ClientSecretGenerator.Payload {
if strValue, ok := value.(string); ok {
provider.ClientSecretGenerator.Payload[key] = replaceENVVar(strValue)
}
}
}
}
}
}
}
// replaceENVVar replaces environment variables in the format $ENV.VAR_NAME
func replaceENVVar(value string) string {
if strings.HasPrefix(value, "$ENV.") {
envVar := strings.TrimPrefix(value, "$ENV.")
if envValue := os.Getenv(envVar); envValue != "" {
return envValue
}
}
return value
}
// createPublicConfig creates a public version of the configuration without sensitive data
func createPublicConfig(fullConfig *Config) Config {
publicConfig := *fullConfig
// Remove sensitive data from third party providers
if publicConfig.ThirdParty != nil && publicConfig.ThirdParty.Providers != nil {
publicProviders := make([]*Provider, len(publicConfig.ThirdParty.Providers))
for i, provider := range publicConfig.ThirdParty.Providers {
publicProvider := *provider
// Remove sensitive fields
publicProvider.ClientSecret = ""
publicProvider.ClientSecretGenerator = nil
publicProviders[i] = &publicProvider
}
publicConfig.ThirdParty.Providers = publicProviders
}
return publicConfig
}
// GetFullConfig returns the full configuration for a given language
func GetFullConfig(lang string) *Config {
configMutex.RLock()
defer configMutex.RUnlock()
// Normalize language code to lowercase
if lang != "" {
lang = strings.ToLower(lang)
}
// Try to get specific language config
if config, exists := fullConfigs[lang]; exists {
return config
}
// Fallback to default language
if defaultLang != "" {
if config, exists := fullConfigs[defaultLang]; exists {
return config
}
}
// Return any available config as last resort
for _, config := range fullConfigs {
return config
}
return nil
}
// GetPublicConfig returns the public configuration for a given language
func GetPublicConfig(lang string) *Config {
configMutex.RLock()
defer configMutex.RUnlock()
// Normalize language code to lowercase
if lang != "" {
lang = strings.ToLower(lang)
}
// Try to get specific language config
if config, exists := publicConfigs[lang]; exists {
return config
}
// Fallback to default language
if defaultLang != "" {
if config, exists := publicConfigs[defaultLang]; exists {
return config
}
}
// Return any available config as last resort
for _, config := range publicConfigs {
return config
}
return nil
}
// GetAvailableLanguages returns all available language codes
func GetAvailableLanguages() []string {
configMutex.RLock()
defer configMutex.RUnlock()
var languages []string
for lang := range fullConfigs {
if lang != "" {
languages = append(languages, lang)
}
}
// Add default language if it exists and is empty string
if defaultLang == "" && len(fullConfigs) > 0 {
languages = append(languages, "default")
}
return languages
}
// GetDefaultLanguage returns the default language code
func GetDefaultLanguage() string {
configMutex.RLock()
defer configMutex.RUnlock()
if defaultLang == "" {
return "default"
}
return defaultLang
}