feat: add WithSessions server-side session middleware
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
0ab962a258
commit
e00ef00db8
4 changed files with 225 additions and 0 deletions
4
go.mod
4
go.mod
|
|
@ -8,6 +8,7 @@ require (
|
|||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-contrib/gzip v1.2.5
|
||||
github.com/gin-contrib/secure v1.1.2
|
||||
github.com/gin-contrib/sessions v1.0.4
|
||||
github.com/gin-contrib/slog v1.2.0
|
||||
github.com/gin-contrib/static v1.1.5
|
||||
github.com/gin-contrib/timeout v1.1.0
|
||||
|
|
@ -38,6 +39,9 @@ require (
|
|||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
|
|
|
|||
10
go.sum
10
go.sum
|
|
@ -28,6 +28,8 @@ github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjck
|
|||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||
github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U=
|
||||
github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w=
|
||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
|
||||
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
|
|
@ -65,6 +67,14 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
|||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
|
|
|
|||
13
options.go
13
options.go
|
|
@ -11,6 +11,8 @@ import (
|
|||
"github.com/gin-contrib/cors"
|
||||
gingzip "github.com/gin-contrib/gzip"
|
||||
"github.com/gin-contrib/secure"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
ginslog "github.com/gin-contrib/slog"
|
||||
"github.com/gin-contrib/static"
|
||||
"github.com/gin-contrib/timeout"
|
||||
|
|
@ -206,3 +208,14 @@ func WithCache(ttl time.Duration) Option {
|
|||
e.middlewares = append(e.middlewares, cacheMiddleware(store, ttl))
|
||||
}
|
||||
}
|
||||
|
||||
// WithSessions adds server-side session management middleware via
|
||||
// gin-contrib/sessions using a cookie-based store. The name parameter
|
||||
// sets the session cookie name (e.g. "session") and secret is the key
|
||||
// used for cookie signing and encryption.
|
||||
func WithSessions(name string, secret []byte) Option {
|
||||
return func(e *Engine) {
|
||||
store := cookie.NewStore(secret)
|
||||
e.middlewares = append(e.middlewares, sessions.Sessions(name, store))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
198
sessions_test.go
Normal file
198
sessions_test.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
api "forge.lthn.ai/core/go-api"
|
||||
)
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// sessionTestGroup provides /sess/set and /sess/get endpoints for session tests.
|
||||
type sessionTestGroup struct{}
|
||||
|
||||
func (s *sessionTestGroup) Name() string { return "sess" }
|
||||
func (s *sessionTestGroup) BasePath() string { return "/sess" }
|
||||
func (s *sessionTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.POST("/set", func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Set("key", "value")
|
||||
session.Save()
|
||||
c.JSON(http.StatusOK, api.OK("saved"))
|
||||
})
|
||||
rg.GET("/get", func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
val := session.Get("key")
|
||||
c.JSON(http.StatusOK, api.OK(val))
|
||||
})
|
||||
}
|
||||
|
||||
// ── WithSessions ────────────────────────────────────────────────────────
|
||||
|
||||
func TestWithSessions_Good_SetsSessionCookie(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!")))
|
||||
e.Register(&sessionTestGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
found := false
|
||||
for _, c := range cookies {
|
||||
if c.Name == "session" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected Set-Cookie header with name 'session'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithSessions_Good_SessionPersistsAcrossRequests(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!")))
|
||||
e.Register(&sessionTestGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
|
||||
// First request: set session value.
|
||||
w1 := httptest.NewRecorder()
|
||||
req1, _ := http.NewRequest(http.MethodPost, "/sess/set", nil)
|
||||
h.ServeHTTP(w1, req1)
|
||||
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("set: expected 200, got %d", w1.Code)
|
||||
}
|
||||
|
||||
// Extract the session cookie from the response.
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range w1.Result().Cookies() {
|
||||
if c.Name == "session" {
|
||||
sessionCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("set: expected session cookie in response")
|
||||
}
|
||||
|
||||
// Second request: get session value, sending the cookie back.
|
||||
w2 := httptest.NewRecorder()
|
||||
req2, _ := http.NewRequest(http.MethodGet, "/sess/get", nil)
|
||||
req2.AddCookie(sessionCookie)
|
||||
h.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("get: expected 200, got %d", w2.Code)
|
||||
}
|
||||
|
||||
var resp api.Response[any]
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp.Data.(string)
|
||||
if !ok || data != "value" {
|
||||
t.Fatalf("expected Data=%q, got %v", "value", resp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithSessions_Good_EmptySessionReturnsNil(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!")))
|
||||
e.Register(&sessionTestGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/sess/get", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp api.Response[any]
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
|
||||
if resp.Data != nil {
|
||||
t.Fatalf("expected nil Data for empty session, got %v", resp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithSessions_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
e, _ := api.New(
|
||||
api.WithSessions("session", []byte("test-secret-key!")),
|
||||
api.WithRequestID(),
|
||||
)
|
||||
e.Register(&sessionTestGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Session cookie should be present.
|
||||
found := false
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == "session" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected session cookie")
|
||||
}
|
||||
|
||||
// Request ID should also be present.
|
||||
rid := w.Header().Get("X-Request-ID")
|
||||
if rid == "" {
|
||||
t.Fatal("expected X-Request-ID header from WithRequestID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithSessions_Ugly_DoubleSessionsDoesNotPanic(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Applying WithSessions twice should not panic.
|
||||
e, err := api.New(
|
||||
api.WithSessions("session", []byte("secret-one-here!")),
|
||||
api.WithSessions("session", []byte("secret-two-here!")),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
e.Register(&sessionTestGroup{})
|
||||
|
||||
h := e.Handler()
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil)
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue