Merge pull request #128 from Eric-Guo/rails_cookie_auth

Rails cookie auth
This commit is contained in:
zhuyasen
2025-08-18 12:12:57 +08:00
committed by GitHub
4 changed files with 341 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
package middleware
import (
"github.com/go-dev-frame/sponge/pkg/rails"
"github.com/gin-gonic/gin"
)
// RailsCookieAuthMiddleware validates and decrypts a Rails encrypted cookie,
// attaches the session payload to context under key "rails_session".
func RailsCookieAuthMiddleware(secretKeyBase string, cookieName string) gin.HandlerFunc {
return func(c *gin.Context) {
cookie, err := c.Cookie(cookieName)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "Missing cookie"})
return
}
session, err := rails.DecodeSignedCookie(secretKeyBase, cookie, cookieName)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "Invalid cookie"})
return
}
c.Set("rails_session", session)
c.Next()
}
}

View File

@@ -0,0 +1,167 @@
## Rails cookie authorization middleware
Validate and decrypt a Rails 7.1+ encrypted session cookie using `secret_key_base`, then attach the decoded session payload to Gin context under key `rails_session`.
This middleware is useful when a Go service needs to trust Rails session cookies issued by an existing Rails app (SSO between Rails and Go services).
<br>
### Requirements
- Rails 7.1+ encrypted cookies (AES-256-GCM, PBKDF2-HMAC-SHA256, purpose: `cookie.<cookie_name>`)
- Rails `secret_key_base`
- The Rails session cookie name (from `config/initializers/session_store.rb`)
Note: Rails < 7.1 used a different encryption scheme (AES-CBC + HMAC) and is not supported by this middleware.
<br>
### Configuration example
If you keep app configuration in YAML, you can add a section like the following (example from a reference service):
```yaml
rails:
secretKeyBase: "change-me" # run: rails credentials:show
cookieName: "_coreui_pro_rails_starter_session" # from session_store.rb or browser devtools
userID: 1137 # optional: restrict to a specific logged-in user id
```
<br>
### Usage
Attach the middleware at the group or router level where you want Rails cookie authentication enforced:
```go
import (
"github.com/gin-gonic/gin"
"github.com/go-dev-frame/sponge/pkg/gin/middleware"
)
func NewRouter(secretKeyBase, cookieName string) *gin.Engine {
r := gin.Default()
g := r.Group("/api/v1")
g.Use(middleware.RailsCookieAuthMiddleware(secretKeyBase, cookieName))
// routes protected by Rails cookie auth
g.GET("/me", func(c *gin.Context) { /* ... */ })
return r
}
```
The middleware:
- Reads the Rails session cookie by `cookieName`
- Verifies and decrypts it using `secretKeyBase`
- Puts the decoded session map into Gin context at key `"rails_session"`
<br>
### Verifying the logged-in user (optional)
If you also want to ensure the request belongs to a specific user id (as stored by Devise/Warden), you can add a follow-up middleware that extracts the user id from the Rails session and compares it. The helper below shows the pattern:
```go
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/go-dev-frame/sponge/pkg/rails"
)
func VerifyRailsSessionUserIdIs(userID int64) gin.HandlerFunc {
return func(c *gin.Context) {
v, ok := c.Get("rails_session")
if !ok {
c.AbortWithStatusJSON(401, gin.H{"error": "rails_session missing"})
return
}
session, ok := v.(map[string]any)
if !ok {
c.AbortWithStatusJSON(401, gin.H{"error": "invalid rails_session"})
return
}
uidVal, ok := rails.UserIDFromSession(session) // reads warden.user.user.key -> [[id], ...]
if !ok {
c.AbortWithStatusJSON(401, gin.H{"error": "user id not found in session"})
return
}
var uid int64
switch vv := uidVal.(type) {
case int64:
uid = vv
case int:
uid = int64(vv)
case float64:
uid = int64(vv)
case string:
parsed, err := strconv.ParseInt(vv, 10, 64)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "invalid user id in session"})
return
}
uid = parsed
default:
c.AbortWithStatusJSON(401, gin.H{"error": "invalid user id type in session"})
return
}
if uid != userID {
c.AbortWithStatusJSON(403, gin.H{"error": "forbidden"})
return
}
c.Next()
}
}
```
Then chain it after `RailsCookieAuthMiddleware`:
```go
g.Use(middleware.RailsCookieAuthMiddleware(secretKeyBase, cookieName))
g.Use(VerifyRailsSessionUserIdIs(1137))
```
<br>
### Accessing the Rails session in handlers
```go
v, ok := c.Get("rails_session")
if !ok {
// not present
}
session := v.(map[string]any)
// Example: extract the logged-in user id saved by Warden/Devise
uid, ok := rails.UserIDFromSession(session)
// uid can be number or string depending on Rails serialization
```
Common keys you may see in the session map include `"_csrf_token"` and `"warden.user.user.key"`.
<br>
### Testing with curl
```bash
curl -i \
-H "Cookie: <cookie_name>=<encrypted_cookie_value>" \
http://localhost:8080/api/v1/me
```
Replace `<cookie_name>` with your Rails session cookie name (e.g. `_coreui_pro_rails_starter_session`).
<br>
### Troubleshooting
- 401 Missing cookie: ensure the browser sends the Rails session cookie to this domain/path
- 401 Invalid cookie: check `secret_key_base`, cookie name, and that the cookie was created by the same Rails environment; requires Rails 7.1+
- 401 user id not found: verify your app uses Devise/Warden and the session contains `warden.user.user.key`
- 403 forbidden: your additional user id check failed
Security tips: never log the cookie value; use HTTPS in production so cookies are transmitted securely.

130
pkg/rails/cookie.go Normal file
View File

@@ -0,0 +1,130 @@
package rails
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"crypto/sha256"
"golang.org/x/crypto/pbkdf2"
)
// DecodeSignedCookie decrypts a Rails 7.1+ encrypted cookie using the provided
// secretKeyBase and validates that its purpose matches the given cookieName.
// It returns the decoded session payload (the JSON contained in _rails.message).
//
// The Rails encrypted cookie format is: base64(data)--base64(iv)--base64(authTag)
// Key derivation: PBKDF2-HMAC-SHA256(secret_key_base, "authenticated encrypted cookie", 1000, 32)
// Cipher: AES-256-GCM, AAD: empty
func DecodeSignedCookie(secretKeyBase string, decodedCookie string, cookieName string) (map[string]any, error) {
if secretKeyBase == "" {
return nil, errors.New("missing secretKeyBase")
}
if decodedCookie == "" {
return nil, errors.New("missing cookie value")
}
parts := strings.Split(decodedCookie, "--")
if len(parts) != 3 {
return nil, errors.New("invalid cookie format")
}
data, err := base64.StdEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("failed to base64 decode data: %w", err)
}
iv, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to base64 decode iv: %w", err)
}
authTag, err := base64.StdEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("failed to base64 decode auth tag: %w", err)
}
if len(authTag) != 16 { // GCM tag size (bytes)
return nil, errors.New("invalid auth tag size")
}
// Derive key
const (
salt = "authenticated encrypted cookie"
iterations = 1000
keyLength = 32 // AES-256
)
key := pbkdf2.Key([]byte(secretKeyBase), []byte(salt), iterations, keyLength, sha256.New)
// AES-GCM decrypt
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create gcm: %w", err)
}
if len(iv) != gcm.NonceSize() {
return nil, errors.New("invalid iv size")
}
// In Go, GCM expects ciphertext || tag
ciphertext := make([]byte, 0, len(data)+len(authTag))
ciphertext = append(ciphertext, data...)
ciphertext = append(ciphertext, authTag...)
plaintext, err := gcm.Open(nil, iv, ciphertext, nil)
if err != nil {
return nil, errors.New("decryption failed")
}
// Parse envelope
var envelope struct {
Rails struct {
Pur string `json:"pur"`
Message string `json:"message"`
} `json:"_rails"`
}
if unmarshalEnvelopeErr := json.Unmarshal(plaintext, &envelope); unmarshalEnvelopeErr != nil {
return nil, fmt.Errorf("failed to unmarshal envelope: %w", unmarshalEnvelopeErr)
}
if envelope.Rails.Pur == "" || envelope.Rails.Message == "" {
return nil, errors.New("invalid envelope data")
}
if envelope.Rails.Pur != fmt.Sprintf("cookie.%s", cookieName) {
return nil, errors.New("invalid cookie purpose")
}
// Decode inner message (base64 JSON)
msgBytes, err := base64.StdEncoding.DecodeString(envelope.Rails.Message)
if err != nil {
return nil, fmt.Errorf("failed to base64 decode message: %w", err)
}
var session map[string]any
if unmarshalSessionErr := json.Unmarshal(msgBytes, &session); unmarshalSessionErr != nil {
return nil, fmt.Errorf("failed to unmarshal session: %w", unmarshalSessionErr)
}
return session, nil
}
// UserIDFromSession tries to extract the warden user id from a Rails session.
// It returns the id and true if found, otherwise (nil, false).
func UserIDFromSession(session map[string]any) (any, bool) {
val, ok := session["warden.user.user.key"]
if !ok {
return nil, false
}
// Expecting [[id], ...]
outer, ok := val.([]any)
if !ok || len(outer) == 0 {
return nil, false
}
inner, ok := outer[0].([]any)
if !ok || len(inner) == 0 {
return nil, false
}
return inner[0], true
}

16
pkg/rails/cookie_test.go Normal file
View File

@@ -0,0 +1,16 @@
package rails
import (
"fmt"
"testing"
)
func TestDecodeSignedCookie_MissingSecret(t *testing.T) {
result, err := DecodeSignedCookie("b1870c9c2d472d577b91a25f3ae9daa626725afffa70876d2fd9e004720e9a4f822bdcf0ddc07f3c54ae110d9ff852d5b5f648be56a275338f028287f90e8a85",
"fpkxE8E9Xksk0W2YXDwXAUhluSaIjMfaKzhII7cAzlwU+hG+7p6nNld+JCa7JyA18Zcl+TvDFJiFS5vRh46PRj6LhmUuxti5PdMH2oPM7UiyllHVcveJcm2ucqZokgx6cMCtrcXfAg+2D3L74JlYvJ9iy6M2mpA1oDCg5jfosvMm8GD0QZfh/DSLjqlZdMUA9S/hcjhak20sG5ZOsq/E9jMnH3DYQoMCxa1oaa+pGcZOcjAkxMFx0FkKjvCGbw9iRO/J0Y8XBBuOrNVBp4U+Zyz4U739RvlO3cG7Odk9s3MCUC+WRw8juIkJ9EMUWJwmIc5uJILZimSdVfwh+Qoj7lEZzwdGw6pFTA91pYpGeUuC1sxnLmIQCUYeoamevPwfFa/tN+eAWZuLq2iAlGWQUf70ECUakrGef6k5JME=--Fgbc3j45HzLzebZK--FzkwNBBEImauLsbCzdz/TA==",
"_coreui_pro_rails_starter_session")
if err != nil {
t.Fatalf("expected error for missing secretKeyBase")
}
fmt.Println(result)
}