From 7977b7f48e04118fe95d289d3bb32254c9412d13 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 22 Sep 2025 11:04:00 +0800 Subject: [PATCH] Remove signin module and refactor OpenAPI to eliminate signin dependencies - Deleted the entire signin module, including related files and configurations, to streamline the authentication process. - Updated OpenAPI to remove references to signin, including the loading of signin configurations and associated handlers. - Refactored user authentication routes to integrate captcha functionality directly within the user module, enhancing user experience and security. - Adjusted routing structure to reflect the removal of signin, ensuring clarity and consistency in user management operations. --- openapi/openapi.go | 10 - openapi/signin/api.go | 693 -------------------- openapi/signin/login.go | 136 ---- openapi/signin/provider.go | 1170 ---------------------------------- openapi/signin/signin.go | 680 -------------------- openapi/signin/types.go | 190 ------ openapi/tests/signin_test.go | 510 --------------- openapi/user/config.go | 78 ++- openapi/user/login.go | 52 ++ openapi/user/user.go | 1 + 10 files changed, 111 insertions(+), 3409 deletions(-) delete mode 100644 openapi/signin/api.go delete mode 100644 openapi/signin/login.go delete mode 100644 openapi/signin/provider.go delete mode 100644 openapi/signin/signin.go delete mode 100644 openapi/signin/types.go delete mode 100644 openapi/tests/signin_test.go diff --git a/openapi/openapi.go b/openapi/openapi.go index 874c098c..91d2a26b 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -15,7 +15,6 @@ import ( "github.com/yaoapp/yao/openapi/kb" "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/types" - "github.com/yaoapp/yao/openapi/signin" "github.com/yaoapp/yao/openapi/team" "github.com/yaoapp/yao/openapi/user" ) @@ -57,12 +56,6 @@ func Load(appConfig config.Config) (*OpenAPI, error) { return nil, err } - // Load signin configurations - err = signin.Load(appConfig) - if err != nil { - return nil, err - } - // Load user configurations err = user.Load(appConfig) if err != nil { @@ -110,9 +103,6 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) { // Chat handlers chat.Attach(group.Group("/chat"), openapi.OAuth) - // Signin handlers - signin.Attach(group, openapi.OAuth) - // Captcha handlers captcha.Attach(group.Group("/captcha"), openapi.OAuth) diff --git a/openapi/signin/api.go b/openapi/signin/api.go deleted file mode 100644 index 650ba2a3..00000000 --- a/openapi/signin/api.go +++ /dev/null @@ -1,693 +0,0 @@ -package signin - -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/oauth/types" - "github.com/yaoapp/yao/openapi/response" - "github.com/yaoapp/yao/openapi/utils" -) - -// Attach attaches the signin handlers to the router -func Attach(group *gin.RouterGroup, oauth types.OAuth) { - group.GET("/signin", getConfig) - group.POST("/signin", signin) - group.POST("/signin/oauth/:provider/authback", authback) - group.GET("/signin/oauth/:provider/authorize", getOAuthAuthorizationURL) - group.POST("/signin/oauth/:provider/authorize/prepare", authbackPrepare) // Receive the post data and forward to the authback handler -} - -// getConfig is the handler for get signin configuration -func getConfig(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) -} - -// signin is the handler for signin (password login) -func signin(c *gin.Context) {} - -// authback is the handler for authback -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 authback -func authback(c *gin.Context) { - sid := utils.GetSessionID(c) - var params OAuthAuthbackRequest - providerID := c.Param("provider") - 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, "/authback") + "/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 - } - - // Send all login cookies (access token, refresh token, and session ID) - SendLoginCookies(c, loginResponse, sid) - - // 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, - }) -} - -// 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 -} - -// generateSessionID generates a session ID -func generateSessionID() string { - return session.ID() -} - -// 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 -} - -// SendLoginCookies sends all necessary cookies for a successful login -// This includes access token, refresh token, and session ID cookies with appropriate security settings -func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID string) { - // Format tokens with Bearer prefix - accessToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.AccessToken) - refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken) - - // Calculate expiration times - expires := time.Now().Add(time.Duration(loginResponse.ExpiresIn) * time.Second) - refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second) - - // Send access token cookie - response.SendAccessTokenCookieWithExpiry(c, accessToken, expires) - - // Send refresh token cookie - response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires) - - // Send session ID cookie with the same expiration as access token - // Using HTTP-only flag for security - options := response.NewSecureCookieOptions(). - WithExpires(expires). - WithSameSite("Strict") - response.SendSecureCookieWithOptions(c, "session_id", sessionID, options) -} diff --git a/openapi/signin/login.go b/openapi/signin/login.go deleted file mode 100644 index c26930a6..00000000 --- a/openapi/signin/login.go +++ /dev/null @@ -1,136 +0,0 @@ -package signin - -import ( - "context" - "strings" - - "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" -) - -// 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()) - } - - 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 -} diff --git a/openapi/signin/provider.go b/openapi/signin/provider.go deleted file mode 100644 index e0a9ce4e..00000000 --- a/openapi/signin/provider.go +++ /dev/null @@ -1,1170 +0,0 @@ -package signin - -import ( - "crypto/ecdsa" - "crypto/hmac" - "crypto/rsa" - "crypto/sha256" - "crypto/x509" - "encoding/base64" - "encoding/hex" - "encoding/json" - "encoding/pem" - "fmt" - "math/big" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/golang-jwt/jwt/v4" - "github.com/yaoapp/gou/application" - "github.com/yaoapp/gou/http" - "github.com/yaoapp/kun/log" - oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" -) - -// convertToString converts various types to string, avoiding scientific notation for numbers -func (p *Provider) convertToString(value interface{}) string { - // Handle nil values - if value == nil { - return "" - } - - switch v := value.(type) { - case string: - return v - case int: - return strconv.Itoa(v) - case int64: - return strconv.FormatInt(v, 10) - case float64: - // Check if it's actually an integer value - if v == float64(int64(v)) { - return strconv.FormatInt(int64(v), 10) - } - return strconv.FormatFloat(v, 'f', -1, 64) - case float32: - // Check if it's actually an integer value - if v == float32(int64(v)) { - return strconv.FormatInt(int64(v), 10) - } - return strconv.FormatFloat(float64(v), 'f', -1, 32) - case bool: - return strconv.FormatBool(v) - case []interface{}: - // Handle empty arrays - if len(v) == 0 { - return "" - } - return fmt.Sprintf("%v", v) - default: - return fmt.Sprintf("%v", v) - } -} - -// getPresetMappings returns built-in field mappings for different providers -func getPresetMappings() map[string]map[string]string { - return map[string]map[string]string{ - MappingGoogle: { - "sub": "sub", - "id": "sub", // fallback - "name": "name", - "given_name": "given_name", - "family_name": "family_name", - "email": "email", - "email_verified": "email_verified", - "picture": "picture", - "locale": "locale", - }, - MappingGitHub: { - "id": "sub", - "login": "preferred_username", - "name": "name", - "email": "email", - "avatar_url": "picture", - "blog": "website", - "html_url": "profile", - "location": "address.formatted", - "updated_at": "updated_at", - }, - MappingMicrosoft: { - "id": "sub", - "displayName": "name", - "givenName": "given_name", - "surname": "family_name", - "mail": "email", - "userPrincipalName": "preferred_username", - "mobilePhone": "phone_number", // Priority 1: Personal mobile phone - "businessPhones[0]": "phone_number", // Priority 2: First business phone using array access - "preferredLanguage": "locale", - "officeLocation": "address.locality", - // jobTitle will remain in raw data as OIDC has no direct equivalent - }, - MappingApple: { - "sub": "sub", - "email": "email", - "email_verified": "email_verified", - "preferred_username": "preferred_username", - // form_post provides name information in nested structure - using generic nested access - "name.firstName": "given_name", - "name.lastName": "family_name", - "name": "name", // Full name object will be handled by mapping logic - }, - MappingWeChat: { - "openid": "sub", - "nickname": "nickname", - "headimgurl": "picture", - "sex": "gender", - "country": "address.country", - "province": "address.region", - "city": "address.locality", - }, - MappingGeneric: { - "sub": "sub", - "id": "sub", - "user_id": "sub", - "openid": "sub", - "name": "name", - "display_name": "name", - "displayName": "name", - "full_name": "name", - "fullName": "name", - "given_name": "given_name", - "first_name": "given_name", - "firstName": "given_name", - "family_name": "family_name", - "last_name": "family_name", - "lastName": "family_name", - "surname": "family_name", - "middle_name": "middle_name", - "middleName": "middle_name", - "nickname": "nickname", - "nick": "nickname", - "preferred_username": "preferred_username", - "username": "preferred_username", - "login": "preferred_username", - "screen_name": "preferred_username", - "user_name": "preferred_username", - "profile": "profile", - "profile_url": "profile", - "picture": "picture", - "avatar": "picture", - "avatar_url": "picture", - "profile_image_url": "picture", - "headimgurl": "picture", - "website": "website", - "blog": "website", - "url": "website", - "email": "email", - "mail": "email", - "email_address": "email", - "email_verified": "email_verified", - "verified_email": "email_verified", - "gender": "gender", - "sex": "gender", - "birthdate": "birthdate", - "birthday": "birthdate", - "birth_date": "birthdate", - "zoneinfo": "zoneinfo", - "timezone": "zoneinfo", - "time_zone": "zoneinfo", - "locale": "locale", - "language": "locale", - "lang": "locale", - "phone_number": "phone_number", - "phone": "phone_number", - "mobile": "phone_number", - "mobile_phone": "phone_number", - "mobilePhone": "phone_number", - "updated_at": "updated_at", - "last_modified": "updated_at", - "modified_at": "updated_at", - }, - } -} - -// getFieldMapping resolves the mapping configuration and returns the actual field mapping -func (p *Provider) getFieldMapping() map[string]string { - if p.Mapping == nil { - // Case 3: nil/empty - use generic mapping - return getPresetMappings()[MappingGeneric] - } - - switch mapping := p.Mapping.(type) { - case string: - // Case 1: string (preset enum) - if presetMapping, exists := getPresetMappings()[mapping]; exists { - return presetMapping - } - // If preset not found, fallback to generic - log.Warn("Unknown preset mapping '%s', falling back to generic mapping", mapping) - return getPresetMappings()[MappingGeneric] - - case map[string]interface{}: - // Convert map[string]interface{} to map[string]string - result := make(map[string]string) - for k, v := range mapping { - if strVal, ok := v.(string); ok { - result[k] = strVal - } - } - return result - - case map[string]string: - // Case 2: map[string]string (custom mapping) - return mapping - - default: - // Invalid type, fallback to generic - log.Warn("Invalid mapping type %T, falling back to generic mapping", mapping) - return getPresetMappings()[MappingGeneric] - } -} - -// 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, tokenType string) (*oauthtypes.OIDCUserInfo, error) { - if accessToken == "" { - return nil, fmt.Errorf("access_token is required") - } - - // Set default token type if not provided - if tokenType == "" { - tokenType = "Bearer" - } - - // Determine user info source (default to "endpoint") - userInfoSource := p.UserInfoSource - if userInfoSource == "" { - userInfoSource = UserInfoSourceEndpoint - } - - // Handle different user info sources - switch userInfoSource { - case UserInfoSourceEndpoint: - return p.getUserInfoFromEndpoint(accessToken, tokenType) - case UserInfoSourceIDToken: - // For id_token source, we need a different approach since we need the token response - return nil, fmt.Errorf("id_token source requires GetUserInfoFromTokenResponse method instead") - case UserInfoSourceAccessToken: - return p.getUserInfoFromAccessToken(accessToken) - default: - return nil, fmt.Errorf("unsupported user_info_source: %s", userInfoSource) - } -} - -// GetUserInfoFromTokenResponse gets user info from complete token response (for Apple OAuth with id_token) -func (p *Provider) GetUserInfoFromTokenResponse(tokenResponse *OAuthTokenResponse, mergeUserInfo ...string) (*oauthtypes.OIDCUserInfo, error) { - if tokenResponse == nil { - return nil, fmt.Errorf("token response is required") - } - - // Determine user info source (default to "endpoint") - userInfoSource := p.UserInfoSource - if userInfoSource == "" { - userInfoSource = UserInfoSourceEndpoint - } - - // Get user info from different sources - var userInfo *oauthtypes.OIDCUserInfo - var err error - - switch userInfoSource { - case UserInfoSourceEndpoint: - userInfo, err = p.getUserInfoFromEndpoint(tokenResponse.AccessToken, tokenResponse.TokenType) - case UserInfoSourceIDToken: - if tokenResponse.IDToken == "" { - return nil, fmt.Errorf("id_token not found in token response") - } - // Get raw claims from ID token - rawClaims, err := p.verifyIDTokenAndGetClaims(tokenResponse.IDToken) - if err != nil { - return nil, fmt.Errorf("failed to verify ID token: %w", err) - } - - // Merge cached user info into raw claims before mapping - if len(mergeUserInfo) > 0 && mergeUserInfo[0] != "" { - p.mergeFormPostDataIntoClaims(rawClaims, mergeUserInfo[0]) - } - - // Map the merged claims to our standard user info structure - userInfo = p.mapUserInfoResponse(rawClaims) - case UserInfoSourceAccessToken: - userInfo, err = p.getUserInfoFromAccessToken(tokenResponse.AccessToken) - default: - return nil, fmt.Errorf("unsupported user_info_source: %s", userInfoSource) - } - - if err != nil { - return nil, err - } - - return userInfo, nil -} - -// mergeFormPostDataIntoClaims merges user info from form_post data into raw claims before mapping -func (p *Provider) mergeFormPostDataIntoClaims(rawClaims map[string]interface{}, cachedUserInfo string) { - var userData map[string]interface{} - if err := json.Unmarshal([]byte(cachedUserInfo), &userData); err != nil { - log.Warn("Failed to parse cached user info: %v", err) - return - } - - // Merge cached data into raw claims, but preserve existing claims (ID Token data is more reliable) - // The mapping logic will handle all field conversions - for key, value := range userData { - if _, exists := rawClaims[key]; !exists { - rawClaims[key] = value - } - } -} - -// getUserInfoFromEndpoint gets user info from a dedicated endpoint (default behavior) -func (p *Provider) getUserInfoFromEndpoint(accessToken string, tokenType string) (*oauthtypes.OIDCUserInfo, error) { - if p.Endpoints == nil { - return nil, fmt.Errorf("endpoints not found, set endpoints at least one") - } - - if p.Endpoints.UserInfo == "" { - return nil, fmt.Errorf("user_info endpoint not found, set user_info endpoint at least one") - } - - // Create HTTP request with authorization header - req := http.New(p.Endpoints.UserInfo). - SetHeader("Authorization", fmt.Sprintf("%s %s", tokenType, accessToken)). - SetHeader("Accept", "application/json"). - SetHeader("User-Agent", "Yao-OAuth-Client/1.0") - - // Make the GET request - resp := req.Get() - if resp == nil { - return nil, fmt.Errorf("failed to make user info request: no response") - } - - // Check for HTTP errors - if resp.Code != 200 { - if resp.Data != nil { - - // === Parse the response data === - if data, ok := resp.Data.(map[string]interface{}); ok { - // Handle standard OAuth error format - if err, ok := data["error_description"]; ok { - return nil, fmt.Errorf("%v", err) - } - if err, ok := data["error"]; ok { - // Handle Microsoft Graph nested error format - if errorObj, isMap := err.(map[string]interface{}); isMap { - if code, hasCode := errorObj["code"]; hasCode { - if message, hasMessage := errorObj["message"]; hasMessage && message != "" { - return nil, fmt.Errorf("Microsoft Graph error %v: %v", code, message) - } - return nil, fmt.Errorf("Microsoft Graph error: %v", code) - } - } - return nil, fmt.Errorf("%v", err) - } - } - } - - if resp.Message != "" { - return nil, fmt.Errorf("user info request failed with status %d: %s", resp.Code, resp.Message) - } - return nil, fmt.Errorf("user info request failed with status %d", resp.Code) - } - - // Parse the response data - var rawData map[string]interface{} - switch data := resp.Data.(type) { - case map[string]interface{}: - rawData = data - case []byte: - if err := json.Unmarshal(data, &rawData); err != nil { - return nil, fmt.Errorf("failed to parse user info response from bytes: %w", err) - } - case string: - if err := json.Unmarshal([]byte(data), &rawData); err != nil { - return nil, fmt.Errorf("failed to parse user info response from string: %w", err) - } - default: - return nil, fmt.Errorf("unexpected response data type: %T", data) - } - - // Map the raw response to our standard structure - userInfo := p.mapUserInfoResponse(rawData) - - return userInfo, nil -} - -// verifyIDTokenAndGetClaims verifies ID token signature and returns raw claims for user info mapping -func (p *Provider) verifyIDTokenAndGetClaims(idToken string) (map[string]interface{}, error) { - // Parse token to get header for key ID - token, err := jwt.Parse(idToken, func(token *jwt.Token) (interface{}, error) { - // Verify signing method - if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - - // Get key ID from token header - kid, ok := token.Header["kid"].(string) - if !ok { - return nil, fmt.Errorf("missing key ID in token header") - } - - // Get public key from JWKS endpoint for verification - publicKey, err := p.getJWKSPublicKey(kid) - if err != nil { - return nil, fmt.Errorf("failed to get JWKS public key: %w", err) - } - - return publicKey, nil - }) - - if err != nil { - return nil, fmt.Errorf("failed to parse/verify JWT: %w", err) - } - - if !token.Valid { - return nil, fmt.Errorf("invalid JWT token") - } - - // Extract claims - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return nil, fmt.Errorf("failed to extract JWT claims") - } - - // Basic validation - if aud, ok := claims["aud"].(string); ok && aud != p.ClientID { - return nil, fmt.Errorf("invalid audience: %s", aud) - } - if exp, ok := claims["exp"].(float64); ok && time.Now().Unix() > int64(exp) { - return nil, fmt.Errorf("token expired") - } - - // Convert jwt.MapClaims to map[string]interface{} - rawClaims := make(map[string]interface{}) - for key, value := range claims { - rawClaims[key] = value - } - - return rawClaims, nil -} - -// getJWKSPublicKey fetches public key from provider's JWKS endpoint -func (p *Provider) getJWKSPublicKey(keyID string) (interface{}, error) { - // Check if JWKS endpoint is configured - if p.Endpoints == nil || p.Endpoints.JWKS == "" { - return nil, fmt.Errorf("JWKS endpoint not configured") - } - - jwksURL := p.Endpoints.JWKS - - // Make HTTP request to get JWKS - req := http.New(jwksURL). - SetHeader("Accept", "application/json"). - SetHeader("User-Agent", "Yao-OAuth-Client/1.0") - - resp := req.Get() - if resp == nil { - return nil, fmt.Errorf("failed to fetch JWKS from %s: no response", jwksURL) - } - - if resp.Code != 200 { - return nil, fmt.Errorf("failed to fetch JWKS from %s: status %d", jwksURL, resp.Code) - } - - // Parse JWKS response - var jwks struct { - Keys []struct { - Kid string `json:"kid"` - Kty string `json:"kty"` - Use string `json:"use"` - Alg string `json:"alg"` - N string `json:"n"` - E string `json:"e"` - } `json:"keys"` - } - - // Handle different response data types - switch data := resp.Data.(type) { - case map[string]interface{}: - jsonBytes, err := json.Marshal(data) - if err != nil { - return nil, fmt.Errorf("failed to marshal JWKS response: %w", err) - } - if err := json.Unmarshal(jsonBytes, &jwks); err != nil { - return nil, fmt.Errorf("failed to parse JWKS response: %w", err) - } - case []byte: - if err := json.Unmarshal(data, &jwks); err != nil { - return nil, fmt.Errorf("failed to parse JWKS response: %w", err) - } - case string: - if err := json.Unmarshal([]byte(data), &jwks); err != nil { - return nil, fmt.Errorf("failed to parse JWKS response: %w", err) - } - default: - return nil, fmt.Errorf("unexpected JWKS response data type: %T", data) - } - - // Find the key with matching kid - for _, key := range jwks.Keys { - if key.Kid == keyID && key.Kty == "RSA" { - // Decode RSA public key components - nBytes, err := base64.RawURLEncoding.DecodeString(key.N) - if err != nil { - return nil, fmt.Errorf("failed to decode RSA modulus: %w", err) - } - eBytes, err := base64.RawURLEncoding.DecodeString(key.E) - if err != nil { - return nil, fmt.Errorf("failed to decode RSA exponent: %w", err) - } - - // Convert exponent bytes to int - var eInt int64 - for _, b := range eBytes { - eInt = eInt<<8 + int64(b) - } - - // Create RSA public key - rsaKey := &rsa.PublicKey{ - N: big.NewInt(0).SetBytes(nBytes), - E: int(eInt), - } - - return rsaKey, nil - } - } - - return nil, fmt.Errorf("public key not found for key ID: %s", keyID) -} - -// getUserInfoFromAccessToken gets user info from access token response -func (p *Provider) getUserInfoFromAccessToken(accessToken string) (*oauthtypes.OIDCUserInfo, error) { - // This is a placeholder implementation - // In a real implementation, this would parse structured data from the access token response - // or decode a JWT access token if the provider uses JWT access tokens - return &oauthtypes.OIDCUserInfo{ - Sub: "access_token_user", // Placeholder - Raw: map[string]interface{}{ - "note": "User info extracted from access token", - "access_token": accessToken, - }, - }, nil -} - -// mapUserInfoResponse maps raw OAuth user info response to our standard structure -func (p *Provider) mapUserInfoResponse(rawData map[string]interface{}) *oauthtypes.OIDCUserInfo { - userInfo := &oauthtypes.OIDCUserInfo{ - Raw: rawData, // Keep raw data for debugging/custom processing - } - - // Get the appropriate field mapping (preset, custom, or generic) - fieldMapping := p.getFieldMapping() - - // Apply field mappings with support for nested field access - for sourceField, targetField := range fieldMapping { - var value interface{} - var exists bool - - // Check if it's a nested field (contains dots or array notation) - if strings.Contains(sourceField, ".") || strings.Contains(sourceField, "[") { - value = p.getNestedValue(rawData, sourceField) - exists = (value != nil) - } else { - // Simple field access - value, exists = rawData[sourceField] - } - - if exists { - p.setUserInfoField(userInfo, targetField, value) - } - } - - // Post-processing: set fallback values - p.applyFallbackValues(userInfo, rawData) - - return userInfo -} - -// getNestedValue retrieves a value from nested object/array using dot notation and array indexing -// Supports: "name.firstName", "address.country", "businessPhones[0]", "roles[1].name" -func (p *Provider) getNestedValue(data map[string]interface{}, path string) interface{} { - // Split path by dots - parts := strings.Split(path, ".") - current := interface{}(data) - - for _, part := range parts { - // Handle array indexing: fieldName[index] - if strings.Contains(part, "[") && strings.HasSuffix(part, "]") { - // Extract field name and index - openBracket := strings.Index(part, "[") - fieldName := part[:openBracket] - indexStr := part[openBracket+1 : len(part)-1] - - // Get the field first - if currentMap, ok := current.(map[string]interface{}); ok { - if field, exists := currentMap[fieldName]; exists { - current = field - } else { - return nil - } - } else { - return nil - } - - // Handle array access - if currentArray, ok := current.([]interface{}); ok { - if index, err := strconv.Atoi(indexStr); err == nil && index >= 0 && index < len(currentArray) { - current = currentArray[index] - } else { - return nil - } - } else { - return nil - } - } else { - // Handle simple field access - if currentMap, ok := current.(map[string]interface{}); ok { - if field, exists := currentMap[part]; exists { - current = field - } else { - return nil - } - } else { - return nil - } - } - } - - return current -} - -// setUserInfoField sets a field in the user info structure -func (p *Provider) setUserInfoField(userInfo *oauthtypes.OIDCUserInfo, fieldName string, value interface{}) { - // Handle nested address fields - if strings.HasPrefix(fieldName, "address.") { - stringValue := p.convertToString(value) - // Skip empty values - if stringValue == "" { - return - } - - if userInfo.Address == nil { - userInfo.Address = &oauthtypes.OIDCAddress{} - } - - addressField := strings.TrimPrefix(fieldName, "address.") - - switch addressField { - case "formatted": - userInfo.Address.Formatted = stringValue - case "street_address": - userInfo.Address.StreetAddress = stringValue - case "locality": - userInfo.Address.Locality = stringValue - case "region": - userInfo.Address.Region = stringValue - case "postal_code": - userInfo.Address.PostalCode = stringValue - case "country": - userInfo.Address.Country = stringValue - } - return - } - - stringValue := p.convertToString(value) - - // Skip empty values for most fields - if stringValue == "" && fieldName != "phone_number" { - return - } - - switch fieldName { - // OIDC Standard Claims - case "sub": - userInfo.Sub = stringValue - case "name": - // Handle name as object (e.g., Apple form_post: {"firstName": "John", "lastName": "Doe"}) - if nameObj, ok := value.(map[string]interface{}); ok { - var nameParts []string - if firstName, exists := nameObj["firstName"]; exists { - if firstNameStr := p.convertToString(firstName); firstNameStr != "" { - nameParts = append(nameParts, firstNameStr) - if userInfo.GivenName == "" { - userInfo.GivenName = firstNameStr - } - } - } - if lastName, exists := nameObj["lastName"]; exists { - if lastNameStr := p.convertToString(lastName); lastNameStr != "" { - nameParts = append(nameParts, lastNameStr) - if userInfo.FamilyName == "" { - userInfo.FamilyName = lastNameStr - } - } - } - if len(nameParts) > 0 { - userInfo.Name = strings.Join(nameParts, " ") - } - } else { - // Handle name as string - userInfo.Name = stringValue - } - case "given_name": - userInfo.GivenName = stringValue - case "family_name": - userInfo.FamilyName = stringValue - case "middle_name": - userInfo.MiddleName = stringValue - case "nickname": - userInfo.Nickname = stringValue - case "preferred_username": - userInfo.PreferredUsername = stringValue - case "profile": - userInfo.Profile = stringValue - case "picture": - userInfo.Picture = stringValue - case "website": - userInfo.Website = stringValue - case "email": - userInfo.Email = stringValue - case "email_verified": - if boolValue, ok := value.(bool); ok { - userInfo.EmailVerified = &boolValue - } - case "gender": - // Handle special gender conversion for WeChat - if floatValue, ok := value.(float64); ok { - switch int(floatValue) { - case 1: - userInfo.Gender = "male" - case 2: - userInfo.Gender = "female" - default: - userInfo.Gender = "unknown" - } - } else { - userInfo.Gender = stringValue - } - case "birthdate": - userInfo.Birthdate = stringValue - case "zoneinfo": - userInfo.Zoneinfo = stringValue - case "locale": - userInfo.Locale = stringValue - case "phone_number": - // Only set if we don't already have a phone number - if userInfo.PhoneNumber != "" { - return - } - - // Handle array type for Microsoft businessPhones - if phoneArray, ok := value.([]interface{}); ok && len(phoneArray) > 0 { - // Take the first non-empty phone number from the array - for _, phone := range phoneArray { - if phoneStr := p.convertToString(phone); phoneStr != "" { - userInfo.PhoneNumber = phoneStr - break - } - } - } else { - // Handle single phone number (mobilePhone) - if stringValue != "" { - userInfo.PhoneNumber = stringValue - } - } - case "phone_number_verified": - if boolValue, ok := value.(bool); ok { - userInfo.PhoneNumberVerified = &boolValue - } - case "updated_at": - if intValue, ok := value.(int64); ok { - userInfo.UpdatedAt = &intValue - } else if stringValue, ok := value.(string); ok { - // Handle ISO 8601 time strings (e.g., from GitHub) - if parsedTime, err := time.Parse(time.RFC3339, stringValue); err == nil { - timestamp := parsedTime.Unix() - userInfo.UpdatedAt = ×tamp - } - } - } -} - -// applyFallbackValues applies fallback values and data cleanup -func (p *Provider) applyFallbackValues(userInfo *oauthtypes.OIDCUserInfo, rawData map[string]interface{}) { - // OIDC Standard: If no name but have given_name/family_name, combine them - if userInfo.Name == "" && (userInfo.GivenName != "" || userInfo.FamilyName != "") { - parts := []string{} - if userInfo.GivenName != "" { - parts = append(parts, userInfo.GivenName) - } - if userInfo.MiddleName != "" { - parts = append(parts, userInfo.MiddleName) - } - if userInfo.FamilyName != "" { - parts = append(parts, userInfo.FamilyName) - } - userInfo.Name = strings.Join(parts, " ") - } - - // Set preferred_username fallbacks - if userInfo.PreferredUsername == "" && userInfo.Email != "" { - if atIndex := strings.Index(userInfo.Email, "@"); atIndex > 0 { - userInfo.PreferredUsername = userInfo.Email[:atIndex] - } - } - - // OIDC requires Sub to be always set - if userInfo.Sub == "" { - log.Error("Subject identifier (sub) not found in OAuth response for provider '%s'", p.ID) - } -} - -// 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 from the global providers map -func GetProvider(providerID string) (*Provider, error) { - // Get provider from global providers map - provider, exists := providers[providerID] - if !exists { - return nil, fmt.Errorf("OAuth provider '%s' not found", providerID) - } - - return provider, nil -} diff --git a/openapi/signin/signin.go b/openapi/signin/signin.go deleted file mode 100644 index 3bf5804e..00000000 --- a/openapi/signin/signin.go +++ /dev/null @@ -1,680 +0,0 @@ -package signin - -import ( - "context" - "fmt" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "sync" - "time" - - "github.com/yaoapp/gou/application" - "github.com/yaoapp/kun/log" - "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/signin 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/signin/client.yao file -func loadClientConfig() error { - // Check if client config exists - exists, err := application.App.Exists("openapi/signin/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/signin/client.yao") - if err != nil { - return fmt.Errorf("failed to read client config: %v", err) - } - - var clientConfig YaoClientConfig - err = application.Parse("openapi/signin/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/signin/providers directory -func loadProviders(rootPath string) error { - // Use Walk to find all provider files in the signin/providers directory - err := application.App.Walk("openapi/signin/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 provider config - processProviderENVVariables(&provider, rootPath) - - // Store the provider - providers[providerID] = &provider - - return nil - }, "*.yao") - - return err -} - -// loadSigninConfigs loads all signin configurations from the openapi/signin directory -func loadSigninConfigs(rootPath string) error { - // Use Walk to find all signin config files in the signin directory (but not subdirectories) - err := application.App.Walk("openapi/signin", func(root, filename string, isdir bool) error { - if isdir { - return nil - } - - // Skip files in subdirectories (like providers/) - if filepath.Dir(filename) != "openapi/signin" { - return nil - } - - // Only process .yao files - if !strings.HasSuffix(filename, ".yao") { - return nil - } - - // Extract language code from filename - baseName := filepath.Base(filename) - lang := extractLanguageFromFilename(baseName) - - // Read signin configuration - configRaw, err := application.App.Read(filename) - if err != nil { - return fmt.Errorf("failed to read signin config %s: %v", filename, err) - } - - // Parse the configuration - var signinConfig Config - err = application.Parse(filename, configRaw, &signinConfig) - if err != nil { - return fmt.Errorf("failed to parse signin config %s: %v", filename, err) - } - - // Process ENV variables in full config - fullConfig := signinConfig - processConfigENVVariables(&fullConfig, rootPath) - - // Set as default config if marked as default - if fullConfig.Default { - defaultConfig = &fullConfig - } - - // Create public config (without sensitive data) - publicConfig := createPublicConfig(&fullConfig) - - // Store configurations - fullConfigs[lang] = &fullConfig - publicConfigs[lang] = &publicConfig - - return nil - }, "*.yao") - - return err -} - -// extractLanguageFromFilename extracts language code from filename -func extractLanguageFromFilename(filename string) string { - // New naming convention: - // en.yao -> "en" - // zh-cn.yao -> "zh-cn" - // default.yao -> "default" - - baseName := strings.TrimSuffix(filename, ".yao") - return strings.ToLower(baseName) -} - -// processProviderENVVariables processes environment variables in the provider configuration -func processProviderENVVariables(provider *Provider, rootPath string) { - var missingEnvVars []string - - // Process ClientID - if strings.HasPrefix(provider.ClientID, "$ENV.") { - envVar := strings.TrimPrefix(provider.ClientID, "$ENV.") - if _, exists := os.LookupEnv(envVar); !exists { - missingEnvVars = append(missingEnvVars, envVar) - } - } - provider.ClientID = replaceENVVar(provider.ClientID) - - // Process ClientSecret - if strings.HasPrefix(provider.ClientSecret, "$ENV.") { - envVar := strings.TrimPrefix(provider.ClientSecret, "$ENV.") - if _, exists := os.LookupEnv(envVar); !exists { - missingEnvVars = append(missingEnvVars, envVar) - } - } - provider.ClientSecret = replaceENVVar(provider.ClientSecret) - - // Process client secret generator - if provider.ClientSecretGenerator != nil { - // Check PrivateKey - if strings.HasPrefix(provider.ClientSecretGenerator.PrivateKey, "$ENV.") { - envVar := strings.TrimPrefix(provider.ClientSecretGenerator.PrivateKey, "$ENV.") - if _, exists := os.LookupEnv(envVar); !exists { - missingEnvVars = append(missingEnvVars, envVar) - } - } - provider.ClientSecretGenerator.PrivateKey = replaceENVVar(provider.ClientSecretGenerator.PrivateKey) - - // Convert relative path to absolute path for private key - if provider.ClientSecretGenerator.PrivateKey != "" && !filepath.IsAbs(provider.ClientSecretGenerator.PrivateKey) { - 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.Warn("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 { - if strValue, ok := value.(string); ok { - if strings.HasPrefix(strValue, "$ENV.") { - envVar := strings.TrimPrefix(strValue, "$ENV.") - if _, exists := os.LookupEnv(envVar); !exists { - missingEnvVars = append(missingEnvVars, envVar) - } - } - provider.ClientSecretGenerator.Header[key] = replaceENVVar(strValue) - } - } - } - - // Process payload values - if provider.ClientSecretGenerator.Payload != nil { - for key, value := range provider.ClientSecretGenerator.Payload { - if strValue, ok := value.(string); ok { - if strings.HasPrefix(strValue, "$ENV.") { - envVar := strings.TrimPrefix(strValue, "$ENV.") - if _, exists := os.LookupEnv(envVar); !exists { - missingEnvVars = append(missingEnvVars, envVar) - } - } - provider.ClientSecretGenerator.Payload[key] = replaceENVVar(strValue) - } - } - } - } - - // Log warning for missing environment variables - if len(missingEnvVars) > 0 { - log.Warn("The following environment variables are not set for provider '%s': %v", provider.ID, missingEnvVars) - } -} - -// processConfigENVVariables processes environment variables in the signin configuration -func processConfigENVVariables(config *Config, rootPath string) { - var missingEnvVars []string - - // Process client_id and client_secret - if strings.HasPrefix(config.ClientID, "$ENV.") { - envVar := strings.TrimPrefix(config.ClientID, "$ENV.") - if _, exists := os.LookupEnv(envVar); !exists { - missingEnvVars = append(missingEnvVars, envVar) - } - } - config.ClientID = replaceENVVar(config.ClientID) - - if strings.HasPrefix(config.ClientSecret, "$ENV.") { - envVar := strings.TrimPrefix(config.ClientSecret, "$ENV.") - if _, exists := os.LookupEnv(envVar); !exists { - missingEnvVars = append(missingEnvVars, envVar) - } - } - config.ClientSecret = replaceENVVar(config.ClientSecret) - - // Process form captcha options - if config.Form != nil && config.Form.Captcha != nil && config.Form.Captcha.Options != nil { - for key, value := range config.Form.Captcha.Options { - if strValue, ok := value.(string); ok { - // Check if ENV variable exists before replacement - if strings.HasPrefix(strValue, "$ENV.") { - envVar := strings.TrimPrefix(strValue, "$ENV.") - if _, exists := os.LookupEnv(envVar); !exists { - missingEnvVars = append(missingEnvVars, envVar) - } - } - config.Form.Captcha.Options[key] = replaceENVVar(strValue) - } - } - } - - // Note: Third party providers are now handled separately in loadProviders() - // No need to process provider configurations here anymore - - // Log warning for missing environment variables - if len(missingEnvVars) > 0 { - log.Warn("The following environment variables are not set in signin configuration: %v", missingEnvVars) - log.Warn("Please set these environment variables to avoid exposing placeholder values in configuration") - } -} - -// replaceENVVar replaces environment variables in the format $ENV.VAR_NAME -func replaceENVVar(value string) string { - if strings.HasPrefix(value, "$ENV.") { - envVar := strings.TrimPrefix(value, "$ENV.") - envValue, exists := os.LookupEnv(envVar) - if exists { - return envValue - } - // If environment variable doesn't exist, return empty string for security - // Never expose ENV placeholder values to prevent configuration leakage - return "" - } - return value -} - -// createPublicConfig creates a public version of the configuration without sensitive data -func createPublicConfig(fullConfig *Config) Config { - // Perform deep copy to avoid modifying the original fullConfig - publicConfig := Config{ - Title: fullConfig.Title, - Description: fullConfig.Description, - SuccessURL: fullConfig.SuccessURL, - FailureURL: fullConfig.FailureURL, - } - - // Deep copy Form configuration - if fullConfig.Form != nil { - publicConfig.Form = &FormConfig{ - ForgotPasswordLink: fullConfig.Form.ForgotPasswordLink, - RememberMe: fullConfig.Form.RememberMe, - RegisterLink: fullConfig.Form.RegisterLink, - TermsOfServiceLink: fullConfig.Form.TermsOfServiceLink, - PrivacyPolicyLink: fullConfig.Form.PrivacyPolicyLink, - } - - // Deep copy Username configuration - if fullConfig.Form.Username != nil { - publicConfig.Form.Username = &UsernameConfig{ - Placeholder: fullConfig.Form.Username.Placeholder, - Fields: append([]string(nil), fullConfig.Form.Username.Fields...), - } - } - - // Deep copy Password configuration - if fullConfig.Form.Password != nil { - publicConfig.Form.Password = &PasswordConfig{ - Placeholder: fullConfig.Form.Password.Placeholder, - } - } - - // Deep copy Captcha configuration with sensitive data removal - if fullConfig.Form.Captcha != nil { - publicConfig.Form.Captcha = &CaptchaConfig{ - Type: fullConfig.Form.Captcha.Type, - } - - if fullConfig.Form.Captcha.Options != nil { - // Create a new options map without sensitive fields - publicOptions := make(map[string]interface{}) - for key, value := range fullConfig.Form.Captcha.Options { - // Only include non-sensitive fields - switch key { - case "sitekey", "theme", "size", "action", "cdata", "response_mode": - // These are safe to expose to frontend - publicOptions[key] = value - case "secret": - // Remove secret field - this should never be exposed to frontend - continue - default: - // For unknown fields, be conservative and exclude them - continue - } - } - publicConfig.Form.Captcha.Options = publicOptions - } - } - } - - // Deep copy Token configuration - if fullConfig.Token != nil { - publicConfig.Token = &TokenConfig{ - ExpiresIn: fullConfig.Token.ExpiresIn, - RememberMeExpiresIn: fullConfig.Token.RememberMeExpiresIn, - } - } - - // Deep copy ThirdParty configuration with sensitive data removal - if fullConfig.ThirdParty != nil { - publicConfig.ThirdParty = &ThirdParty{} - - // Deep copy Providers with sensitive data removal - if fullConfig.ThirdParty.Providers != nil { - publicProviders := make([]*Provider, len(fullConfig.ThirdParty.Providers)) - for i, provider := range fullConfig.ThirdParty.Providers { - publicProvider := Provider{ - ID: provider.ID, - Title: provider.Title, - Label: provider.Label, - Logo: provider.Logo, - Color: provider.Color, - TextColor: provider.TextColor, - // Only expose display fields for frontend - // Remove sensitive fields: ClientID, ClientSecret, ClientSecretGenerator, Scopes, Endpoints, Mapping, Register - } - - 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 config (marked with default: true) - if defaultConfig != nil { - return defaultConfig - } - - // 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 config's public version - if defaultConfig != nil { - // Find the public version of the default config - for lang, fullConfig := range fullConfigs { - if fullConfig == defaultConfig { - if publicConfig, exists := publicConfigs[lang]; exists { - return publicConfig - } - } - } - } - - // 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 { - languages = append(languages, lang) - } - - return languages -} - -// GetDefaultLanguage returns the default language code -func GetDefaultLanguage() string { - configMutex.RLock() - defer configMutex.RUnlock() - - // Find the language code for the default config - if defaultConfig != nil { - for lang, config := range fullConfigs { - if config == defaultConfig { - return lang - } - } - } - - // Return the first available language as fallback - for lang := range fullConfigs { - return lang - } - - return "" -} - -// 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 -} diff --git a/openapi/signin/types.go b/openapi/signin/types.go deleted file mode 100644 index 2ae9e4da..00000000 --- a/openapi/signin/types.go +++ /dev/null @@ -1,190 +0,0 @@ -package signin - -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 -) diff --git a/openapi/tests/signin_test.go b/openapi/tests/signin_test.go deleted file mode 100644 index 497623b9..00000000 --- a/openapi/tests/signin_test.go +++ /dev/null @@ -1,510 +0,0 @@ -package openapi_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/signin" - "github.com/yaoapp/yao/openapi/tests/testutils" -) - -func TestSigninLoad(t *testing.T) { - // Initialize test environment - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - _ = serverURL // Server URL not needed for this test - - // Test loading signin configurations - err := signin.Load(config.Conf) - assert.NoError(t, err, "signin.Load should succeed") - - // Test that we can get available languages - languages := signin.GetAvailableLanguages() - assert.IsType(t, []string{}, languages, "Should return string slice") - t.Logf("Available languages: %v", languages) - - // Test default language - defaultLang := signin.GetDefaultLanguage() - assert.IsType(t, "", defaultLang, "Should return string") - t.Logf("Default language: %s", defaultLang) -} - -func TestSigninGetConfigs(t *testing.T) { - // Initialize test environment - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - _ = serverURL // Server URL not needed for this test - - // Load signin configurations - err := signin.Load(config.Conf) - assert.NoError(t, err, "signin.Load should succeed") - - // Test getting configs for different languages - testCases := []string{"", "en", "zh-cn", "fr"} - - for _, lang := range testCases { - t.Run("lang_"+lang, func(t *testing.T) { - fullConfig := signin.GetFullConfig(lang) - publicConfig := signin.GetPublicConfig(lang) - - if fullConfig != nil { - t.Logf("Full config for '%s': %+v", lang, fullConfig.Title) - assert.NotNil(t, publicConfig, "Public config should exist if full config exists") - - // Test that public config removes sensitive data from OAuth providers - if fullConfig.ThirdParty != nil && fullConfig.ThirdParty.Providers != nil { - for i := range fullConfig.ThirdParty.Providers { - if publicConfig.ThirdParty != nil && i < len(publicConfig.ThirdParty.Providers) { - publicProvider := publicConfig.ThirdParty.Providers[i] - - // In the new structure, providers in ThirdParty only contain display info - // Sensitive data is now stored separately in the global providers map - - // Check that only display fields are present in public config - assert.NotEmpty(t, publicProvider.ID, "Provider ID should be preserved in public config") - assert.NotEmpty(t, publicProvider.Title, "Provider title should be preserved in public config") - - // Sensitive fields should be empty in the ThirdParty providers (they're in global map now) - assert.Empty(t, publicProvider.ClientID, "Client ID should be empty in ThirdParty providers") - assert.Empty(t, publicProvider.ClientSecret, "Client secret should be empty in ThirdParty providers") - assert.Nil(t, publicProvider.ClientSecretGenerator, "Client secret generator should be nil in ThirdParty providers") - assert.Empty(t, publicProvider.Scopes, "Scopes should be empty in ThirdParty providers") - assert.Nil(t, publicProvider.Endpoints, "Endpoints should be nil in ThirdParty providers") - assert.Empty(t, publicProvider.Mapping, "Mapping should be empty in ThirdParty providers") - assert.Nil(t, publicProvider.Register, "Register config should be nil in public config (sensitive data)") - } - } - } - - // Test that public config removes sensitive data from captcha configuration - if fullConfig.Form != nil && fullConfig.Form.Captcha != nil && fullConfig.Form.Captcha.Options != nil { - if publicConfig.Form != nil && publicConfig.Form.Captcha != nil && publicConfig.Form.Captcha.Options != nil { - // Check that secret field is removed - _, hasSecret := publicConfig.Form.Captcha.Options["secret"] - assert.False(t, hasSecret, "Captcha secret should be removed from public config") - - // Check that safe fields are preserved (if they exist in full config) - if _, hasSitekey := fullConfig.Form.Captcha.Options["sitekey"]; hasSitekey { - _, publicHasSitekey := publicConfig.Form.Captcha.Options["sitekey"] - assert.True(t, publicHasSitekey, "Captcha sitekey should be preserved in public config") - } - } - } - } else { - t.Logf("No config found for language: %s", lang) - } - }) - } -} - -func TestSigninLanguageNormalization(t *testing.T) { - // Initialize test environment - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - _ = serverURL // Server URL not needed for this test - - // Load signin configurations - err := signin.Load(config.Conf) - assert.NoError(t, err, "signin.Load should succeed") - - // Test that language codes are normalized to lowercase - config1 := signin.GetFullConfig("EN") - config2 := signin.GetFullConfig("en") - assert.Equal(t, config1, config2, "Language codes should be normalized to lowercase") - - config3 := signin.GetPublicConfig("ZH-CN") - config4 := signin.GetPublicConfig("zh-cn") - assert.Equal(t, config3, config4, "Language codes should be normalized to lowercase") -} - -func TestSigninConfigStructure(t *testing.T) { - // Initialize test environment - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - _ = serverURL // Server URL not needed for this test - - // Load signin configurations - err := signin.Load(config.Conf) - assert.NoError(t, err, "signin.Load should succeed") - - // Get a config to test structure - config := signin.GetFullConfig("") - if config != nil { - t.Logf("Config loaded successfully with title: %s", config.Title) - - // Verify config structure is valid - assert.IsType(t, &signin.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, []*signin.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") - - // These fields should be empty in ThirdParty providers (they're in global map now) - assert.Empty(t, provider.Scopes, "Provider scopes should be empty in ThirdParty providers") - assert.Nil(t, provider.Mapping, "Provider mapping should be nil in ThirdParty providers") - assert.Nil(t, provider.Endpoints, "Provider endpoints should be nil in ThirdParty providers") - assert.Nil(t, provider.Register, "Provider register should be nil in ThirdParty providers (it's in global map now)") - } - } - } - } else { - t.Log("No signin configuration found") - } -} - -func TestSigninGlobalProvidersMap(t *testing.T) { - // Initialize test environment - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - _ = serverURL // Server URL not needed for this test - - // Load signin configurations - err := signin.Load(config.Conf) - assert.NoError(t, err, "signin.Load should succeed") - - // Test that providers can be retrieved from global map (no locale needed) - providerIDs := []string{"google", "microsoft", "apple", "github"} - - for _, providerID := range providerIDs { - t.Run("provider_"+providerID, func(t *testing.T) { - provider, err := signin.GetProvider(providerID) - - // Note: Provider might not be found if configuration files don't exist - // or if environment variables are not set, which is normal in test environment - if err != nil { - t.Logf("Provider '%s' not found (expected in test environment): %v", providerID, err) - return - } - - if provider != nil { - assert.Equal(t, providerID, provider.ID, "Provider ID should match") - t.Logf("Provider '%s' loaded successfully", providerID) - - // Test provider structure - assert.IsType(t, "", provider.ClientID, "ClientID should be string") - assert.IsType(t, "", provider.ClientSecret, "ClientSecret should be string") - assert.IsType(t, []string{}, provider.Scopes, "Scopes should be string slice") - - if provider.Endpoints != nil { - assert.IsType(t, "", provider.Endpoints.Authorization, "Authorization endpoint should be string") - assert.IsType(t, "", provider.Endpoints.Token, "Token endpoint should be string") - assert.IsType(t, "", provider.Endpoints.UserInfo, "UserInfo endpoint should be string") - } - - // Test register configuration (should be present in global providers) - if provider.Register != nil { - assert.IsType(t, false, provider.Register.Auto, "Register auto should be boolean") - assert.IsType(t, "", provider.Register.Role, "Register role should be string") - t.Logf("Provider '%s' has register config: auto=%t, role=%s", - providerID, provider.Register.Auto, provider.Register.Role) - } - } - }) - } - - // Test nonexistent provider - t.Run("nonexistent_provider", func(t *testing.T) { - provider, err := signin.GetProvider("nonexistent") - assert.Error(t, err, "Should return error for nonexistent provider") - assert.Nil(t, provider, "Should return nil provider for nonexistent provider") - assert.Contains(t, err.Error(), "not found", "Error message should indicate provider not found") - }) -} - -func TestSigninAPI(t *testing.T) { - // Initialize test environment - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - // Get base URL from server config - baseURL := "" - if openapi.Server != nil && openapi.Server.Config != nil { - baseURL = openapi.Server.Config.BaseURL - } - - // Test API endpoints - testCases := []struct { - name string - endpoint string - expectCode int - }{ - {"get config without locale", "/signin", 200}, - {"get config with en locale", "/signin?locale=en", 200}, - {"get config with zh-cn locale", "/signin?locale=zh-cn", 200}, - {"get config with invalid locale", "/signin?locale=invalid", 200}, // should fallback to default - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - url := serverURL + baseURL + tc.endpoint - resp, err := http.Get(url) - assert.NoError(t, err, "HTTP request should succeed") - - if resp != nil { - defer resp.Body.Close() - assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d", tc.expectCode) - - if resp.StatusCode == 200 { - // Parse response body - body, err := io.ReadAll(resp.Body) - assert.NoError(t, err, "Should read response body") - - var config signin.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 TestSigninOAuthAuthorizationURL(t *testing.T) { - // Initialize test environment - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - // Get base URL from server config - baseURL := "" - if openapi.Server != nil && openapi.Server.Config != nil { - baseURL = openapi.Server.Config.BaseURL - } - - // Test OAuth authorization URL endpoints - // Note: These should return 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) { - url := serverURL + baseURL + "/signin/oauth/" + tc.provider + "/authorize" + tc.query - resp, err := http.Get(url) - assert.NoError(t, err, "HTTP request should succeed") - - if resp != nil { - defer resp.Body.Close() - assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d", tc.expectCode) - - // Parse response body - body, err := io.ReadAll(resp.Body) - assert.NoError(t, err, "Should read response body") - - t.Logf("Response for %s: status=%d, body=%s", tc.provider, resp.StatusCode, string(body)) - - 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) - } 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") - assert.Equal(t, tc.expectErrorMsg, errorDescStr, "Error message should match expected") - } 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 TestSigninENVVariableReplacement(t *testing.T) { - // Initialize test environment - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - _ = serverURL // Server URL not needed for this test - - // Load signin configurations to trigger ENV variable processing - err := signin.Load(config.Conf) - assert.NoError(t, err, "signin.Load should succeed") - - // Get full config to check ENV variable replacement - fullConfig := signin.GetFullConfig("") - assert.NotNil(t, fullConfig, "Should have a signin configuration") - - // Test captcha ENV variable replacement - if fullConfig.Form != nil && fullConfig.Form.Captcha != nil && fullConfig.Form.Captcha.Options != nil { - if sitekey, hasSitekey := fullConfig.Form.Captcha.Options["sitekey"]; hasSitekey { - sitekeyStr, ok := sitekey.(string) - assert.True(t, ok, "Sitekey should be string") - // Should not contain ENV placeholder (either replaced or empty) - assert.NotContains(t, sitekeyStr, "$ENV.", "Sitekey should not contain ENV placeholder") - t.Logf("Captcha sitekey after ENV replacement: %s", sitekeyStr) - } - - if secret, hasSecret := fullConfig.Form.Captcha.Options["secret"]; hasSecret { - secretStr, ok := secret.(string) - assert.True(t, ok, "Secret should be string") - // Should not contain ENV placeholder (either replaced or empty) - assert.NotContains(t, secretStr, "$ENV.", "Secret should not contain ENV placeholder") - t.Logf("Captcha secret after ENV replacement: %s", secretStr) - } - } - - // Test OAuth provider ENV variable replacement - if fullConfig.ThirdParty != nil && fullConfig.ThirdParty.Providers != nil { - for _, provider := range fullConfig.ThirdParty.Providers { - // Check ClientID replacement - if provider.ClientID != "" { - assert.NotContains(t, provider.ClientID, "$ENV.", "ClientID should not contain ENV placeholder") - t.Logf("Provider %s ClientID after ENV replacement: %s", provider.ID, provider.ClientID) - } - - // Check ClientSecret replacement - if provider.ClientSecret != "" { - assert.NotContains(t, provider.ClientSecret, "$ENV.", "ClientSecret should not contain ENV placeholder") - t.Logf("Provider %s ClientSecret after ENV replacement: [REDACTED]", provider.ID) - } - } - } - - // Test that public config doesn't expose ENV variables or actual sensitive values - publicConfig := signin.GetPublicConfig("") - assert.NotNil(t, publicConfig, "Should have a public signin configuration") - - // Public config should not contain sensitive data even if ENV variables are set - if publicConfig.Form != nil && publicConfig.Form.Captcha != nil && publicConfig.Form.Captcha.Options != nil { - _, hasSecret := publicConfig.Form.Captcha.Options["secret"] - assert.False(t, hasSecret, "Public config should not contain captcha secret") - } - - if publicConfig.ThirdParty != nil && publicConfig.ThirdParty.Providers != nil { - for _, provider := range publicConfig.ThirdParty.Providers { - assert.Empty(t, provider.ClientID, "Public config should not contain ClientID") - assert.Empty(t, provider.ClientSecret, "Public config should not contain ClientSecret") - } - } -} - -func TestSigninENVVariableMissingHandling(t *testing.T) { - // This test verifies that missing ENV variables are handled securely - // by returning empty strings instead of exposing the placeholder - - // Initialize test environment - serverURL := testutils.Prepare(t) - defer testutils.Clean() - - _ = serverURL // Server URL not needed for this test - - // Load signin configurations - err := signin.Load(config.Conf) - assert.NoError(t, err, "signin.Load should succeed") - - // Get public config (this is what the API returns) - publicConfig := signin.GetPublicConfig("") - assert.NotNil(t, publicConfig, "Should have a public signin configuration") - - // Verify that even if ENV variables are missing, no placeholders are exposed - if publicConfig.Form != nil && publicConfig.Form.Captcha != nil && publicConfig.Form.Captcha.Options != nil { - for key, value := range publicConfig.Form.Captcha.Options { - if valueStr, ok := value.(string); ok { - assert.NotContains(t, valueStr, "$ENV.", "Public config should not contain ENV placeholders in %s", key) - } - } - } - - if publicConfig.ThirdParty != nil && publicConfig.ThirdParty.Providers != nil { - for _, provider := range publicConfig.ThirdParty.Providers { - // These should be empty in public config anyway, but verify no ENV placeholders - assert.NotContains(t, provider.ClientID, "$ENV.", "Public config ClientID should not contain ENV placeholders") - assert.NotContains(t, provider.ClientSecret, "$ENV.", "Public config ClientSecret should not contain ENV placeholders") - } - } - - t.Log("ENV variable security test passed: no ENV placeholders exposed in public configuration") -} diff --git a/openapi/user/config.go b/openapi/user/config.go index b0de582d..d47b42b2 100644 --- a/openapi/user/config.go +++ b/openapi/user/config.go @@ -216,9 +216,9 @@ func loadProviders(_ string) error { return nil } -// loadSigninConfigs loads all signin configurations from the openapi/user directory +// loadSigninConfigs loads all signin configurations from the openapi/signin directory func loadSigninConfigs(_ string) error { - // Use Walk to find all configuration files in the signin directory + // Use Walk to find all configuration files in the user directory err := application.App.Walk("openapi/user", func(root, filename string, isdir bool) error { if isdir { return nil @@ -236,7 +236,7 @@ func loadSigninConfigs(_ string) error { // Extract locale from filename (basename without extension) baseName := filepath.Base(filename) - locale := strings.TrimSuffix(baseName, ".yao") + locale := strings.ToLower(strings.TrimSuffix(baseName, ".yao")) // Read configuration configRaw, err := application.App.Read(filename) @@ -252,8 +252,7 @@ func loadSigninConfigs(_ string) error { } // Process ENV variables in the configuration - config.ClientID = replaceENVVar(config.ClientID) - config.ClientSecret = replaceENVVar(config.ClientSecret) + processConfigENVVariables(&config) // Store full configuration fullConfigs[locale] = &config @@ -296,30 +295,26 @@ func GetPublicConfig(locale string) *Config { configMutex.RLock() defer configMutex.RUnlock() + // Normalize language code to lowercase + if locale != "" { + locale = strings.ToLower(locale) + } + // Try to get the specific locale configuration if config, exists := publicConfigs[locale]; exists { return config } - // Fallback to default configuration + // Fallback to default config's public version 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 + // Find the public version of the default config + for lang, fullConfig := range fullConfigs { + if fullConfig == defaultConfig { + if publicConfig, exists := publicConfigs[lang]; exists { + return publicConfig } } - publicDefault.Form.Captcha.Options = captchaOptions } - - return &publicDefault } // If no default, try to get any available configuration @@ -417,3 +412,46 @@ func normalizeDuration(expiresIn string) (string, error) { return normalized, nil } + +// processConfigENVVariables processes environment variables in the signin configuration +func processConfigENVVariables(config *Config) { + var missingEnvVars []string + + // Process client_id and client_secret + if strings.HasPrefix(config.ClientID, "$ENV.") { + envVar := strings.TrimPrefix(config.ClientID, "$ENV.") + if _, exists := os.LookupEnv(envVar); !exists { + missingEnvVars = append(missingEnvVars, envVar) + } + } + config.ClientID = replaceENVVar(config.ClientID) + + if strings.HasPrefix(config.ClientSecret, "$ENV.") { + envVar := strings.TrimPrefix(config.ClientSecret, "$ENV.") + if _, exists := os.LookupEnv(envVar); !exists { + missingEnvVars = append(missingEnvVars, envVar) + } + } + config.ClientSecret = replaceENVVar(config.ClientSecret) + + // Process form captcha options + if config.Form != nil && config.Form.Captcha != nil && config.Form.Captcha.Options != nil { + for key, value := range config.Form.Captcha.Options { + if strValue, ok := value.(string); ok { + // Check if ENV variable exists before replacement + if strings.HasPrefix(strValue, "$ENV.") { + envVar := strings.TrimPrefix(strValue, "$ENV.") + if _, exists := os.LookupEnv(envVar); !exists { + missingEnvVars = append(missingEnvVars, envVar) + } + } + config.Form.Captcha.Options[key] = replaceENVVar(strValue) + } + } + } + + // Log warning for missing environment variables (optional, can be removed if log package not available) + if len(missingEnvVars) > 0 { + fmt.Printf("Warning: The following environment variables are not set in user configuration: %v\n", missingEnvVars) + } +} diff --git a/openapi/user/login.go b/openapi/user/login.go index 961c27cd..f1427ab1 100644 --- a/openapi/user/login.go +++ b/openapi/user/login.go @@ -3,12 +3,14 @@ package user import ( "context" "fmt" + "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/yaoapp/gou/session" "github.com/yaoapp/kun/log" + "github.com/yaoapp/yao/helper" "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/providers/user" oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" @@ -51,6 +53,56 @@ func login(c *gin.Context) { // You may need to implement the actual login logic here } +// getCaptcha is the handler for get captcha image for login +func getCaptcha(c *gin.Context) { + var option helper.CaptchaOption = helper.NewCaptchaOption() + + err := c.ShouldBindQuery(&option) + if err != nil { + response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + }) + return + } + + // Set the type to image + option.Type = "image" + id, content := helper.CaptchaMake(option) + + // Return in the format expected by the frontend + response.RespondWithSuccess(c, http.StatusOK, gin.H{ + "captcha_id": id, + "captcha_image": content, + "expires_in": 300, // 5 minutes + }) +} + +// refreshCaptcha is the handler for refresh captcha image +func refreshCaptcha(c *gin.Context) { + var option helper.CaptchaOption = helper.NewCaptchaOption() + + err := c.ShouldBindQuery(&option) + if err != nil { + response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + }) + return + } + + // Set the type to image + option.Type = "image" + id, content := helper.CaptchaMake(option) + + // Return in the format expected by the frontend + response.RespondWithSuccess(c, http.StatusOK, gin.H{ + "captcha_id": id, + "captcha_image": content, + "expires_in": 300, // 5 minutes + }) +} + // LoginThirdParty is the handler for third party login func LoginThirdParty(providerID string, userinfo *oauthtypes.OIDCUserInfo, ip string) (*LoginResponse, error) { diff --git a/openapi/user/user.go b/openapi/user/user.go index 6dc81335..4ec545d2 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -13,6 +13,7 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) { // 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.GET("/login/captcha", getCaptcha) // Get captcha for login (public) group.POST("/register", placeholder) // User register (public) group.POST("/logout", oauth.Guard, placeholder) // User logout