mirror of
https://github.com/zhufuyi/sponge.git
synced 2025-09-26 20:51:14 +08:00
Merge pull request #128 from Eric-Guo/rails_cookie_auth
Rails cookie auth
This commit is contained in:
28
pkg/gin/middleware/rails_cookie_auth.go
Normal file
28
pkg/gin/middleware/rails_cookie_auth.go
Normal 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()
|
||||
}
|
||||
}
|
167
pkg/gin/middleware/rails_cookie_auth.md
Normal file
167
pkg/gin/middleware/rails_cookie_auth.md
Normal 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
130
pkg/rails/cookie.go
Normal 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
16
pkg/rails/cookie_test.go
Normal 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)
|
||||
}
|
Reference in New Issue
Block a user