Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 48 additions & 6 deletions api/middleware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
86 changes: 86 additions & 0 deletions api/middleware/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Loading