From 219c7d97ed4150b852d5b965cafd42fea0062268 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 31 Jul 2025 18:49:25 +0800 Subject: [PATCH] Implement secure cookie handling and session management in Signin API - Introduced SecureCookieOptions struct to define secure cookie configurations, enhancing cookie security. - Added functions for sending secure cookies, including session and access token cookies, with customizable options. - Updated signin and OAuth authorization URL handling to manage session IDs and state validation, improving security and user experience. - Enhanced error handling for OAuth state and redirect URI management, ensuring robust session management during authentication flows. --- openapi/response/response.go | 259 +++++++++++++++++++++++++++++++++++ openapi/signin/api.go | 222 ++++++++++++++++++++++++++++-- openapi/signin/signin.go | 2 +- openapi/utils/session.go | 57 ++++++++ 4 files changed, 528 insertions(+), 12 deletions(-) create mode 100644 openapi/utils/session.go diff --git a/openapi/response/response.go b/openapi/response/response.go index c6168a04..0c25c84b 100644 --- a/openapi/response/response.go +++ b/openapi/response/response.go @@ -2,6 +2,7 @@ package response import ( "net/http" + "time" "github.com/gin-gonic/gin" "github.com/yaoapp/yao/openapi/oauth/types" @@ -178,6 +179,264 @@ func RespondWithError(c *gin.Context, statusCode int, err *ErrorResponse) { c.JSON(statusCode, err) } +// SecureCookieOptions defines options for secure cookie configuration +type SecureCookieOptions struct { + // MaxAge specifies the max age for the cookie in seconds (0 = session cookie, negative = delete cookie) + // MaxAge takes precedence over Expires if both are set + MaxAge int + // Expires specifies the absolute expiration time for the cookie + // If MaxAge is 0 and Expires is set, Expires will be used + Expires *time.Time + // Path specifies the cookie path (default: "/") + Path string + // Domain specifies the cookie domain (empty for current domain) + Domain string + // SameSite specifies the SameSite attribute ("Strict", "Lax", or "None") + SameSite string + // UseHostPrefix determines if __Host- prefix should be used (most secure) + UseHostPrefix bool + // UseSecurePrefix determines if __Secure- prefix should be used + UseSecurePrefix bool +} + +// NewSecureCookieOptions creates a new SecureCookieOptions with secure defaults +func NewSecureCookieOptions() *SecureCookieOptions { + return &SecureCookieOptions{ + MaxAge: 0, // Session cookie by default + Path: "/", // Root path + Domain: "", // Current domain + SameSite: "Lax", // Default SameSite policy + UseHostPrefix: true, // Use most secure __Host- prefix + } +} + +// WithMaxAge sets the MaxAge in seconds +func (o *SecureCookieOptions) WithMaxAge(maxAge int) *SecureCookieOptions { + o.MaxAge = maxAge + return o +} + +// WithExpires sets the absolute expiration time +func (o *SecureCookieOptions) WithExpires(expires time.Time) *SecureCookieOptions { + o.Expires = &expires + return o +} + +// WithDuration sets expiration based on duration from now +func (o *SecureCookieOptions) WithDuration(duration time.Duration) *SecureCookieOptions { + expires := time.Now().Add(duration) + o.Expires = &expires + o.MaxAge = int(duration.Seconds()) + return o +} + +// WithPath sets the cookie path +func (o *SecureCookieOptions) WithPath(path string) *SecureCookieOptions { + o.Path = path + return o +} + +// WithDomain sets the cookie domain +func (o *SecureCookieOptions) WithDomain(domain string) *SecureCookieOptions { + o.Domain = domain + return o +} + +// WithSameSite sets the SameSite attribute +func (o *SecureCookieOptions) WithSameSite(sameSite string) *SecureCookieOptions { + o.SameSite = sameSite + return o +} + +// WithSecurePrefix uses __Secure- prefix instead of __Host- +func (o *SecureCookieOptions) WithSecurePrefix() *SecureCookieOptions { + o.UseHostPrefix = false + o.UseSecurePrefix = true + return o +} + +// WithoutPrefix disables security prefixes +func (o *SecureCookieOptions) WithoutPrefix() *SecureCookieOptions { + o.UseHostPrefix = false + o.UseSecurePrefix = false + return o +} + +// SendSecretCookie sends a secure cookie to the client with RFC 6265bis compliance +// For sensitive data like session_id, access_token, etc. +func SendSecretCookie(c *gin.Context, key string, value string) { + options := &SecureCookieOptions{ + MaxAge: 0, // Session cookie by default + Path: "/", // Root path + Domain: "", // Current domain + SameSite: "Lax", // Default SameSite policy + UseHostPrefix: true, // Use most secure __Host- prefix + } + SendSecureCookieWithOptions(c, key, value, options) +} + +// SendSecureCookieWithOptions sends a secure cookie with custom options +func SendSecureCookieWithOptions(c *gin.Context, key string, value string, options *SecureCookieOptions) { + // Apply RFC 6265bis prefix requirements + cookieName := key + cookiePath := options.Path + cookieDomain := options.Domain + + if options.UseHostPrefix { + // __Host- prefix: Requires Secure flag, no Domain attribute, Path=/ + cookieName = "__Host-" + key + cookiePath = "/" // Must be "/" for __Host- prefix + cookieDomain = "" // Must be empty for __Host- prefix + } else if options.UseSecurePrefix { + // __Secure- prefix: Requires Secure flag, allows Domain and Path + cookieName = "__Secure-" + key + } + + // Ensure secure defaults + if cookiePath == "" { + cookiePath = "/" + } + + // Determine effective MaxAge + effectiveMaxAge := options.MaxAge + if effectiveMaxAge == 0 && options.Expires != nil { + // If MaxAge is 0 but Expires is set, calculate MaxAge from Expires + duration := time.Until(*options.Expires) + if duration > 0 { + effectiveMaxAge = int(duration.Seconds()) + } else { + effectiveMaxAge = -1 // Expired cookie + } + } + + // Set the cookie with secure flags + // Gin's SetCookie: (name, value, maxAge, path, domain, secure, httpOnly) + c.SetCookie( + cookieName, // name (with security prefix if specified) + value, // value + effectiveMaxAge, // maxAge (calculated from Expires if needed) + cookiePath, // path + cookieDomain, // domain + true, // secure (HTTPS only) - required for security prefixes + true, // httpOnly (prevent XSS access) + ) + + // Get existing Set-Cookie headers for additional attributes + cookies := c.Writer.Header()["Set-Cookie"] + if len(cookies) > 0 { + lastCookie := cookies[len(cookies)-1] + + // Add SameSite attribute if specified + if options.SameSite != "" { + lastCookie += "; SameSite=" + options.SameSite + } + + // Add Expires attribute if specified and MaxAge is not used + if options.Expires != nil && options.MaxAge == 0 { + lastCookie += "; Expires=" + options.Expires.UTC().Format(time.RFC1123) + } + + // Replace the last Set-Cookie header with enhanced version + cookies[len(cookies)-1] = lastCookie + c.Writer.Header()["Set-Cookie"] = cookies + } +} + +// SendSessionCookie sends a session cookie with __Host- prefix for maximum security +func SendSessionCookie(c *gin.Context, sessionID string) { + options := NewSecureCookieOptions().WithSameSite("Lax") + SendSecureCookieWithOptions(c, "session_id", sessionID, options) +} + +// SendAccessTokenCookie sends an access token cookie with appropriate security settings +func SendAccessTokenCookie(c *gin.Context, accessToken string, maxAge int) { + options := NewSecureCookieOptions(). + WithMaxAge(maxAge). + WithSameSite("Strict") + SendSecureCookieWithOptions(c, "access_token", accessToken, options) +} + +// SendAccessTokenCookieWithExpiry sends an access token cookie with absolute expiration time +func SendAccessTokenCookieWithExpiry(c *gin.Context, accessToken string, expires time.Time) { + options := NewSecureCookieOptions(). + WithExpires(expires). + WithSameSite("Strict") + SendSecureCookieWithOptions(c, "access_token", accessToken, options) +} + +// SendAccessTokenCookieWithDuration sends an access token cookie with duration-based expiration +func SendAccessTokenCookieWithDuration(c *gin.Context, accessToken string, duration time.Duration) { + options := NewSecureCookieOptions(). + WithDuration(duration). + WithSameSite("Strict") + SendSecureCookieWithOptions(c, "access_token", accessToken, options) +} + +// SendRefreshTokenCookie sends a refresh token cookie with strict security settings +func SendRefreshTokenCookie(c *gin.Context, refreshToken string, maxAge int) { + options := NewSecureCookieOptions(). + WithMaxAge(maxAge). + WithPath("/auth"). + WithSameSite("Strict") + SendSecureCookieWithOptions(c, "refresh_token", refreshToken, options) +} + +// SendRefreshTokenCookieWithExpiry sends a refresh token cookie with absolute expiration time +func SendRefreshTokenCookieWithExpiry(c *gin.Context, refreshToken string, expires time.Time) { + options := NewSecureCookieOptions(). + WithExpires(expires). + WithPath("/auth"). + WithSameSite("Strict") + SendSecureCookieWithOptions(c, "refresh_token", refreshToken, options) +} + +// SendRefreshTokenCookieWithDuration sends a refresh token cookie with duration-based expiration +func SendRefreshTokenCookieWithDuration(c *gin.Context, refreshToken string, duration time.Duration) { + options := NewSecureCookieOptions(). + WithDuration(duration). + WithPath("/auth"). + WithSameSite("Strict") + SendSecureCookieWithOptions(c, "refresh_token", refreshToken, options) +} + +// DeleteSecureCookie deletes a secure cookie by setting it to expire immediately +func DeleteSecureCookie(c *gin.Context, key string) { + options := NewSecureCookieOptions().WithMaxAge(-1) // Negative MaxAge deletes the cookie + SendSecureCookieWithOptions(c, key, "", options) +} + +// DeleteAllAuthCookies deletes all authentication-related cookies +func DeleteAllAuthCookies(c *gin.Context) { + DeleteSecureCookie(c, "session_id") + DeleteSecureCookie(c, "access_token") + + // Also delete refresh token with its specific path + options := NewSecureCookieOptions(). + WithMaxAge(-1). + WithPath("/auth") + SendSecureCookieWithOptions(c, "refresh_token", "", options) +} + +// Common duration constants for cookie expiration +const ( + // Session cookies (expires when browser closes) + SessionCookie = 0 + // Short-lived tokens (typically for access tokens) + OneHour = 1 * time.Hour + TwoHours = 2 * time.Hour + SixHours = 6 * time.Hour + TwelveHours = 12 * time.Hour + // Medium-lived tokens + OneDay = 24 * time.Hour + OneWeek = 7 * 24 * time.Hour + TwoWeeks = 14 * 24 * time.Hour + // Long-lived tokens (typically for refresh tokens) + OneMonth = 30 * 24 * time.Hour + ThreeMonths = 90 * 24 * time.Hour + SixMonths = 180 * 24 * time.Hour + OneYear = 365 * 24 * time.Hour +) + // RespondWithAuthorizationError sends an authorization endpoint error via redirect func RespondWithAuthorizationError(c *gin.Context, redirectURI string, err *ErrorResponse, state string) { // Build error redirect URL diff --git a/openapi/signin/api.go b/openapi/signin/api.go index 8f5a2742..d8197d0b 100644 --- a/openapi/signin/api.go +++ b/openapi/signin/api.go @@ -4,20 +4,34 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "net/http" "net/url" "strings" + "time" "github.com/gin-gonic/gin" + "github.com/yaoapp/gou/session" + "github.com/yaoapp/gou/store" + "github.com/yaoapp/kun/log" + "github.com/yaoapp/kun/maps" "github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/response" + "github.com/yaoapp/yao/openapi/utils" ) +// OAuthAuthorizationURLResponse represents the response for OAuth authorization URL +type OAuthAuthorizationURLResponse struct { + AuthorizationURL string `json:"authorization_url"` + State string `json:"state"` +} + // 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.GET("/signin/authback/:id", authback) + group.POST("/signin/authback/:provider", 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 @@ -28,6 +42,13 @@ func getConfig(c *gin.Context) { // 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{ @@ -46,12 +67,73 @@ func getConfig(c *gin.Context) { func signin(c *gin.Context) {} // authback is the handler for authback -func authback(c *gin.Context) {} +func authbackPrepare(c *gin.Context) { + code := c.PostForm("code") + state := c.PostForm("state") + 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 + } -// OAuthAuthorizationURLResponse represents the response for OAuth authorization URL -type OAuthAuthorizationURLResponse struct { - AuthorizationURL string `json:"authorization_url"` - State string `json:"state"` + // Remove the redirect URI from the session + err = removeRedirectURI(providerID, state) + if err != nil { + log.Warn("Failed to remove redirect URI: %v", err) + } + + params := url.Values{} + params.Add("code", code) + params.Add("state", state) + c.Redirect(http.StatusFound, redirectURI+"?"+params.Encode()) +} + +// authback is the handler for authback +func authback(c *gin.Context) { + sid := utils.GetSessionID(c) + providerID := c.Param("provider") + state := c.PostForm("state") + + if state == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "State is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + if err := validateState(providerID, sid, state); err != nil { + log.With(log.F{"sid": sid, "state": state}).Error("Invalid state") + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid state", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Remove the state from the session + 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 + } + + // Respond with success + response.RespondWithSuccess(c, response.StatusOK, maps.Map{ + "state": state, + }) } // getOAuthAuthorizationURL generates OAuth authorization URL for a provider @@ -71,8 +153,6 @@ func getOAuthAuthorizationURL(c *gin.Context) { state := c.Query("state") locale := c.Query("locale") - fmt.Println("redirect_uri", redirectURI) - // Get full configuration config := GetFullConfig(locale) if config == nil { @@ -140,9 +220,6 @@ func getOAuthAuthorizationURL(c *gin.Context) { params.Add("redirect_uri", redirectURI) params.Add("state", state) - fmt.Println("client_id", provider.ClientID) - fmt.Println("redirectURI", redirectURI) - // Add scopes if len(provider.Scopes) > 0 { params.Add("scope", strings.Join(provider.Scopes, " ")) @@ -155,6 +232,52 @@ func getOAuthAuthorizationURL(c *gin.Context) { 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, save the redirect URI to the session + if provider.ResponseMode == "form_post" { + err := saveRedirectURI(providerID, state, redirectURI) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Failed to save OAuth redirect URI", + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Replace the redirectURI to + pathname := c.Request.URL.Path + "/prepare" + newRedirectURI, err := reconstructRedirectURI(redirectURI, pathname, c) + 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) + } + + // Build the authorization URL authorizationURL := fmt.Sprintf("%s?%s", provider.Endpoints.Authorization, params.Encode()) // Return the authorization URL and state @@ -181,3 +304,80 @@ func getScheme(c *gin.Context) string { } 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() +} + +// saveState saves the state to the session +func saveState(providerID, sid, state string) error { + return session.Global().ID(sid).SetWithEx(fmt.Sprintf("oauth_state_%s", providerID), state, 20*time.Minute) +} + +// saveRedirectURI saves the redirect URI to the session +func saveRedirectURI(providerID, state, redirectURI string) error { + key := fmt.Sprintf("oauth_redirect_uri_%s_%s", providerID, state) + store, err := store.Get("__yao.oauth.cache") + if err != nil { + return err + } + store.Set(key, redirectURI, 20*time.Minute) + return nil +} + +// getRedirectURI gets the redirect URI from the session +func getRedirectURI(providerID, state string) (string, error) { + key := fmt.Sprintf("oauth_redirect_uri_%s_%s", providerID, state) + store, err := store.Get("__yao.oauth.cache") + if err != nil { + return "", err + } + + 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 := fmt.Sprintf("oauth_redirect_uri_%s_%s", providerID, state) + store, err := store.Get("__yao.oauth.cache") + if err != nil { + return err + } + store.Del(key) + return nil +} + +func removeState(providerID, sid string) error { + return session.Global().ID(sid).Del(fmt.Sprintf("oauth_state_%s", providerID)) +} + +// validateState validates the state from the session +func validateState(providerID, sid, state string) error { + value, err := session.Global().ID(sid).Get(fmt.Sprintf("oauth_state_%s", providerID)) + if err != nil { + return err + } + + if value != state { + return fmt.Errorf("invalid state") + } + + return nil +} diff --git a/openapi/signin/signin.go b/openapi/signin/signin.go index 99445e96..617f5e82 100644 --- a/openapi/signin/signin.go +++ b/openapi/signin/signin.go @@ -377,7 +377,7 @@ func createPublicConfig(fullConfig *Config) Config { for key, value := range fullConfig.Form.Captcha.Options { // Only include non-sensitive fields switch key { - case "sitekey", "theme", "size", "action", "cdata": + case "sitekey", "theme", "size", "action", "cdata", "response_mode": // These are safe to expose to frontend publicOptions[key] = value case "secret": diff --git a/openapi/utils/session.go b/openapi/utils/session.go new file mode 100644 index 00000000..af0d0e0c --- /dev/null +++ b/openapi/utils/session.go @@ -0,0 +1,57 @@ +package utils + +import ( + "github.com/gin-gonic/gin" +) + +// SessionGuard is a guard that checks if the session ID is valid +func SessionGuard(c *gin.Context) { + sid := GetSessionID(c) + if sid != "" { + c.Set("__sid", sid) + } +} + +// GetSessionID retrieves the session ID from cookies +// Tries to get session ID from different possible cookie names (with and without security prefixes) +func GetSessionID(c *gin.Context) string { + return getCookieWithPrefixes(c, "session_id") +} + +// GetAccessToken retrieves the access token from cookies +func GetAccessToken(c *gin.Context) string { + return getCookieWithPrefixes(c, "access_token") +} + +// GetRefreshToken retrieves the refresh token from cookies (checks /auth path) +func GetRefreshToken(c *gin.Context) string { + return getCookieWithPrefixes(c, "refresh_token") +} + +// getCookieWithPrefixes tries to get a cookie value from different possible names with security prefixes +func getCookieWithPrefixes(c *gin.Context, baseName string) string { + // Try to get cookie from different naming conventions (most secure first) + cookieNames := []string{ + "__Host-" + baseName, // Most secure with __Host- prefix + "__Secure-" + baseName, // With __Secure- prefix + baseName, // Plain cookie name (fallback) + } + + for _, cookieName := range cookieNames { + if value, err := c.Cookie(cookieName); err == nil && value != "" { + return value + } + } + + return "" +} + +// HasValidSession checks if there's a valid session ID in the cookies +func HasValidSession(c *gin.Context) bool { + return GetSessionID(c) != "" +} + +// GetTokenFromCookie gets any named token from cookies with security prefix support +func GetTokenFromCookie(c *gin.Context, tokenName string) string { + return getCookieWithPrefixes(c, tokenName) +}