From 2f5f7330e4244f506ff6c485e7d3af3427d6e027 Mon Sep 17 00:00:00 2001 From: Giacomo Sanchietti Date: Tue, 3 Mar 2026 08:34:19 +0100 Subject: [PATCH] feat: add cookie auth This is used to authenticate webssh --- api/middleware/middleware.go | 54 ++++++++++++++++--- api/middleware/middleware_test.go | 86 +++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 6 deletions(-) diff --git a/api/middleware/middleware.go b/api/middleware/middleware.go index a4599750..37a91a65 100644 --- a/api/middleware/middleware.go +++ b/api/middleware/middleware.go @@ -38,6 +38,8 @@ type login struct { Password string `form:"password" json:"password" binding:"required"` } +const cookieName = "ns_jwt" + var jwtMiddleware *jwt.GinJWTMiddleware var identityKey = "id" @@ -243,9 +245,14 @@ func InitJWT() *jwt.GinJWTMiddleware { Data: nil, })) }, - TokenLookup: "header: Authorization, token: jwt", - TokenHeadName: "Bearer", - TimeFunc: time.Now, + SendCookie: true, + CookieName: cookieName, + SecureCookie: gin.Mode() != gin.DebugMode, + CookieHTTPOnly: true, + CookieSameSite: http.SameSiteLaxMode, + TokenLookup: "header: Authorization, token: jwt", + TokenHeadName: "Bearer", + TimeFunc: time.Now, }) // check middleware errors @@ -306,12 +313,47 @@ func BasicUnitAuth() gin.HandlerFunc { func BasicUserAuth() gin.HandlerFunc { return func(c *gin.Context) { + // Try JWT authentication from cookie (used by Traefik ForwardAuth) + cookiePresent := false + tokenStr, err := c.Cookie(cookieName) + if err == nil && tokenStr != "" { + cookiePresent = true + token, err := InstanceJWT().ParseTokenString(tokenStr) + if err == nil && token.Valid { + claims := jwt.ExtractClaimsFromToken(token) + if id, ok := claims[identityKey].(string); ok && methods.CheckTokenValidation(id, token.Raw) { + // Enforce per-unit access check + unitID := c.Param("unit_id") + if unitID != "" && !methods.UserCanAccessUnit(id, unitID) { + c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{ + Code: 403, + Message: "user does not have access to this unit", + Data: nil, + })) + logs.Logs.Println("[INFO][AUTH] user " + id + " does not have access to unit " + unitID) + c.Abort() + return + } + logs.Logs.Println("[INFO][AUTH] user " + id + " authenticated via JWT cookie") + c.Header("X-Auth-User", id) + c.Next() + return + } + } + // Cookie present but invalid/expired: clear it + c.SetCookie(cookieName, "", -1, "/", "", gin.Mode() != gin.DebugMode, true) + } + + // Fall back to Basic Auth username, password, _ := c.Request.BasicAuth() if username == "" || password == "" { - c.JSON(http.StatusBadRequest, structs.Map(response.StatusUnauthorized{ - Code: 400, - Message: "missing username or password", + if cookiePresent { + logs.Logs.Println("[INFO][AUTH] invalid or expired JWT cookie, cleared") + } + c.JSON(http.StatusUnauthorized, structs.Map(response.StatusUnauthorized{ + Code: 401, + Message: "missing or invalid credentials", Data: nil, })) c.Abort() diff --git a/api/middleware/middleware_test.go b/api/middleware/middleware_test.go index e40a9a8d..c8bf4dac 100644 --- a/api/middleware/middleware_test.go +++ b/api/middleware/middleware_test.go @@ -20,8 +20,10 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/NethServer/nethsecurity-api/models" "github.com/NethServer/nethsecurity-controller/api/configuration" "github.com/NethServer/nethsecurity-controller/api/logs" + "github.com/NethServer/nethsecurity-controller/api/methods" ) func TestMain(m *testing.M) { @@ -92,3 +94,87 @@ func TestJWTLogin(t *testing.T) { // Should return 401 since storage fails assert.Equal(t, 401, w.Code) } + +func generateTestToken(t *testing.T, username string) string { + t.Helper() + configuration.Config.SecretJWT = "test_secret" + mw := InstanceJWT() + + token, _, err := mw.TokenGenerator(&models.UserAuthorizations{Username: username}) + assert.NoError(t, err) + assert.NotEmpty(t, token) + + // Register the token as active + methods.SetTokenValidation(username, token) + + return token +} + +func TestBasicUserAuthCookie(t *testing.T) { + token := generateTestToken(t, "testuser") + + r := gin.New() + r.Use(BasicUserAuth()) + r.GET("/auth", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "ok"}) + }) + + // Valid cookie returns 200 and sets X-Auth-User + req, _ := http.NewRequest("GET", "/auth", nil) + req.AddCookie(&http.Cookie{Name: cookieName, Value: token}) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, 200, w.Code) + assert.Equal(t, "testuser", w.Header().Get("X-Auth-User")) + + // Invalid cookie → 401 and cookie is cleared (Set-Cookie with Max-Age=-1) + req, _ = http.NewRequest("GET", "/auth", nil) + req.AddCookie(&http.Cookie{Name: cookieName, Value: "invalid.jwt.token"}) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, 401, w.Code) + assert.Contains(t, w.Header().Get("Set-Cookie"), cookieName+"=;") + + // No cookie and no Basic Auth → 401 + req, _ = http.NewRequest("GET", "/auth", nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, 401, w.Code) + + // Clean up + methods.DelTokenValidation("testuser", token) +} + +func TestBasicUserAuthCookieUnitAccess(t *testing.T) { + token := generateTestToken(t, "limiteduser") + + r := gin.New() + r.Use(BasicUserAuth()) + r.GET("/auth/:unit_id", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "ok"}) + }) + + // Non-admin user with cookie accessing a unit → 403 + // (limiteduser is not in adminUsers and has no unit assignments) + req, _ := http.NewRequest("GET", "/auth/unit-123", nil) + req.AddCookie(&http.Cookie{Name: cookieName, Value: token}) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + assert.Equal(t, 403, w.Code) + assert.Contains(t, w.Body.String(), "user does not have access to this unit") + + // Same user without unit_id param → 200 (no unit check needed) + r2 := gin.New() + r2.Use(BasicUserAuth()) + r2.GET("/auth", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "ok"}) + }) + req, _ = http.NewRequest("GET", "/auth", nil) + req.AddCookie(&http.Cookie{Name: cookieName, Value: token}) + w = httptest.NewRecorder() + r2.ServeHTTP(w, req) + assert.Equal(t, 200, w.Code) + + // Clean up + methods.DelTokenValidation("limiteduser", token) +}