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.
This commit is contained in:
Max 2025-07-31 18:49:25 +08:00
parent 0361cfe5fa
commit 219c7d97ed
4 changed files with 528 additions and 12 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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":

57
openapi/utils/session.go Normal file
View file

@ -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)
}