Skip to content

Commit 6b6ef75

Browse files
authored
feat: add cookie auth
This is used to authenticate webssh Fixes: NethServer/nethsecurity#1546
1 parent e7e6c93 commit 6b6ef75

2 files changed

Lines changed: 134 additions & 6 deletions

File tree

api/middleware/middleware.go

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ type login struct {
3838
Password string `form:"password" json:"password" binding:"required"`
3939
}
4040

41+
const cookieName = "ns_jwt"
42+
4143
var jwtMiddleware *jwt.GinJWTMiddleware
4244
var identityKey = "id"
4345

@@ -243,9 +245,14 @@ func InitJWT() *jwt.GinJWTMiddleware {
243245
Data: nil,
244246
}))
245247
},
246-
TokenLookup: "header: Authorization, token: jwt",
247-
TokenHeadName: "Bearer",
248-
TimeFunc: time.Now,
248+
SendCookie: true,
249+
CookieName: cookieName,
250+
SecureCookie: gin.Mode() != gin.DebugMode,
251+
CookieHTTPOnly: true,
252+
CookieSameSite: http.SameSiteLaxMode,
253+
TokenLookup: "header: Authorization, token: jwt",
254+
TokenHeadName: "Bearer",
255+
TimeFunc: time.Now,
249256
})
250257

251258
// check middleware errors
@@ -306,12 +313,47 @@ func BasicUnitAuth() gin.HandlerFunc {
306313

307314
func BasicUserAuth() gin.HandlerFunc {
308315
return func(c *gin.Context) {
316+
// Try JWT authentication from cookie (used by Traefik ForwardAuth)
317+
cookiePresent := false
318+
tokenStr, err := c.Cookie(cookieName)
319+
if err == nil && tokenStr != "" {
320+
cookiePresent = true
321+
token, err := InstanceJWT().ParseTokenString(tokenStr)
322+
if err == nil && token.Valid {
323+
claims := jwt.ExtractClaimsFromToken(token)
324+
if id, ok := claims[identityKey].(string); ok && methods.CheckTokenValidation(id, token.Raw) {
325+
// Enforce per-unit access check
326+
unitID := c.Param("unit_id")
327+
if unitID != "" && !methods.UserCanAccessUnit(id, unitID) {
328+
c.JSON(http.StatusForbidden, structs.Map(response.StatusForbidden{
329+
Code: 403,
330+
Message: "user does not have access to this unit",
331+
Data: nil,
332+
}))
333+
logs.Logs.Println("[INFO][AUTH] user " + id + " does not have access to unit " + unitID)
334+
c.Abort()
335+
return
336+
}
337+
logs.Logs.Println("[INFO][AUTH] user " + id + " authenticated via JWT cookie")
338+
c.Header("X-Auth-User", id)
339+
c.Next()
340+
return
341+
}
342+
}
343+
// Cookie present but invalid/expired: clear it
344+
c.SetCookie(cookieName, "", -1, "/", "", gin.Mode() != gin.DebugMode, true)
345+
}
346+
347+
// Fall back to Basic Auth
309348
username, password, _ := c.Request.BasicAuth()
310349

311350
if username == "" || password == "" {
312-
c.JSON(http.StatusBadRequest, structs.Map(response.StatusUnauthorized{
313-
Code: 400,
314-
Message: "missing username or password",
351+
if cookiePresent {
352+
logs.Logs.Println("[INFO][AUTH] invalid or expired JWT cookie, cleared")
353+
}
354+
c.JSON(http.StatusUnauthorized, structs.Map(response.StatusUnauthorized{
355+
Code: 401,
356+
Message: "missing or invalid credentials",
315357
Data: nil,
316358
}))
317359
c.Abort()

api/middleware/middleware_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import (
2020
"github.com/gin-gonic/gin"
2121
"github.com/stretchr/testify/assert"
2222

23+
"github.com/NethServer/nethsecurity-api/models"
2324
"github.com/NethServer/nethsecurity-controller/api/configuration"
2425
"github.com/NethServer/nethsecurity-controller/api/logs"
26+
"github.com/NethServer/nethsecurity-controller/api/methods"
2527
)
2628

2729
func TestMain(m *testing.M) {
@@ -92,3 +94,87 @@ func TestJWTLogin(t *testing.T) {
9294
// Should return 401 since storage fails
9395
assert.Equal(t, 401, w.Code)
9496
}
97+
98+
func generateTestToken(t *testing.T, username string) string {
99+
t.Helper()
100+
configuration.Config.SecretJWT = "test_secret"
101+
mw := InstanceJWT()
102+
103+
token, _, err := mw.TokenGenerator(&models.UserAuthorizations{Username: username})
104+
assert.NoError(t, err)
105+
assert.NotEmpty(t, token)
106+
107+
// Register the token as active
108+
methods.SetTokenValidation(username, token)
109+
110+
return token
111+
}
112+
113+
func TestBasicUserAuthCookie(t *testing.T) {
114+
token := generateTestToken(t, "testuser")
115+
116+
r := gin.New()
117+
r.Use(BasicUserAuth())
118+
r.GET("/auth", func(c *gin.Context) {
119+
c.JSON(200, gin.H{"message": "ok"})
120+
})
121+
122+
// Valid cookie returns 200 and sets X-Auth-User
123+
req, _ := http.NewRequest("GET", "/auth", nil)
124+
req.AddCookie(&http.Cookie{Name: cookieName, Value: token})
125+
w := httptest.NewRecorder()
126+
r.ServeHTTP(w, req)
127+
assert.Equal(t, 200, w.Code)
128+
assert.Equal(t, "testuser", w.Header().Get("X-Auth-User"))
129+
130+
// Invalid cookie → 401 and cookie is cleared (Set-Cookie with Max-Age=-1)
131+
req, _ = http.NewRequest("GET", "/auth", nil)
132+
req.AddCookie(&http.Cookie{Name: cookieName, Value: "invalid.jwt.token"})
133+
w = httptest.NewRecorder()
134+
r.ServeHTTP(w, req)
135+
assert.Equal(t, 401, w.Code)
136+
assert.Contains(t, w.Header().Get("Set-Cookie"), cookieName+"=;")
137+
138+
// No cookie and no Basic Auth → 401
139+
req, _ = http.NewRequest("GET", "/auth", nil)
140+
w = httptest.NewRecorder()
141+
r.ServeHTTP(w, req)
142+
assert.Equal(t, 401, w.Code)
143+
144+
// Clean up
145+
methods.DelTokenValidation("testuser", token)
146+
}
147+
148+
func TestBasicUserAuthCookieUnitAccess(t *testing.T) {
149+
token := generateTestToken(t, "limiteduser")
150+
151+
r := gin.New()
152+
r.Use(BasicUserAuth())
153+
r.GET("/auth/:unit_id", func(c *gin.Context) {
154+
c.JSON(200, gin.H{"message": "ok"})
155+
})
156+
157+
// Non-admin user with cookie accessing a unit → 403
158+
// (limiteduser is not in adminUsers and has no unit assignments)
159+
req, _ := http.NewRequest("GET", "/auth/unit-123", nil)
160+
req.AddCookie(&http.Cookie{Name: cookieName, Value: token})
161+
w := httptest.NewRecorder()
162+
r.ServeHTTP(w, req)
163+
assert.Equal(t, 403, w.Code)
164+
assert.Contains(t, w.Body.String(), "user does not have access to this unit")
165+
166+
// Same user without unit_id param → 200 (no unit check needed)
167+
r2 := gin.New()
168+
r2.Use(BasicUserAuth())
169+
r2.GET("/auth", func(c *gin.Context) {
170+
c.JSON(200, gin.H{"message": "ok"})
171+
})
172+
req, _ = http.NewRequest("GET", "/auth", nil)
173+
req.AddCookie(&http.Cookie{Name: cookieName, Value: token})
174+
w = httptest.NewRecorder()
175+
r2.ServeHTTP(w, req)
176+
assert.Equal(t, 200, w.Code)
177+
178+
// Clean up
179+
methods.DelTokenValidation("limiteduser", token)
180+
}

0 commit comments

Comments
 (0)