Skip to content

Commit 20179fa

Browse files
authored
Fix webhook authorization when AUTH_ENABLED=true (apache#9008)
* test(webhook): add test for webhook api key with activated auth * fix(webhook): fix webhook authorization
1 parent c4a15bf commit 20179fa

3 files changed

Lines changed: 139 additions & 6 deletions

File tree

backend/server/api/middlewares.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"github.com/apache/incubator-devlake/core/errors"
3434
"github.com/apache/incubator-devlake/core/models/common"
3535
"github.com/apache/incubator-devlake/helpers/apikeyhelper"
36+
"github.com/apache/incubator-devlake/server/api/shared"
3637
"github.com/gin-gonic/gin"
3738
)
3839

@@ -272,9 +273,13 @@ func CheckAuthorizationHeader(c *gin.Context, logger log.Logger, db dal.Dal, api
272273

273274
logger.Info("redirect path: %s to: %s", c.Request.URL.Path, path)
274275
c.Request.URL.Path = path
275-
c.Set(common.USER, &common.User{
276+
user := &common.User{
276277
Name: apiKey.Creator.Creator,
277278
Email: apiKey.Creator.CreatorEmail,
278-
})
279+
}
280+
c.Set(common.USER, user)
281+
// Also store in the request context so the user survives gin's HandleContext
282+
// resetting c.Keys when rerouting from /rest/... to /plugins/...
283+
c.Request = shared.SetRestAuthUser(c.Request, user)
279284
return true
280285
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package api
19+
20+
import (
21+
"net/http"
22+
"net/http/httptest"
23+
"testing"
24+
25+
"github.com/apache/incubator-devlake/core/config"
26+
coremodels "github.com/apache/incubator-devlake/core/models"
27+
"github.com/apache/incubator-devlake/core/models/common"
28+
"github.com/apache/incubator-devlake/helpers/apikeyhelper"
29+
contextimpl "github.com/apache/incubator-devlake/impls/context"
30+
"github.com/apache/incubator-devlake/impls/logruslog"
31+
mockdal "github.com/apache/incubator-devlake/mocks/core/dal"
32+
"github.com/apache/incubator-devlake/server/api/shared"
33+
"github.com/gin-gonic/gin"
34+
"github.com/stretchr/testify/mock"
35+
)
36+
37+
// requireUserGate simulates RequireAuth with AUTH_ENABLED=true: it rejects any
38+
// request whose gin context does not carry an authenticated user. This is the
39+
// exact check that caused 401s for valid REST API key requests.
40+
func requireUserGate() gin.HandlerFunc {
41+
return func(c *gin.Context) {
42+
if _, ok := shared.GetUser(c); !ok {
43+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
44+
"success": false,
45+
"message": "unauthorized",
46+
})
47+
return
48+
}
49+
c.Next()
50+
}
51+
}
52+
53+
// TestRestAuthKeyReachesHandlerWhenAuthEnabled sends a valid /rest/... Bearer
54+
// token request through a router that also has a RequireAuth-style gate. The
55+
// request must reach the downstream handler (200) rather than being rejected
56+
// by the auth gate (401).
57+
//
58+
// Without the fix, gin's HandleContext resets c.Keys, wiping the user that
59+
// RestAuthentication stored before rerouting, so the auth gate sees no user
60+
// and returns 401.
61+
func TestRestAuthKeyReachesHandlerWhenAuthEnabled(t *testing.T) {
62+
gin.SetMode(gin.TestMode)
63+
64+
// apikeyhelper reads ENCRYPTION_SECRET from the global viper config.
65+
const encryptionSecret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 32 bytes
66+
config.GetConfig().Set("ENCRYPTION_SECRET", encryptionSecret)
67+
68+
basicRes := contextimpl.NewDefaultBasicRes(config.GetConfig(), logruslog.Global, nil)
69+
helper := apikeyhelper.NewApiKeyHelper(basicRes, logruslog.Global)
70+
71+
const plaintext = "test-api-key-plaintext"
72+
hashedKey, hashErr := helper.DigestToken(plaintext)
73+
if hashErr != nil {
74+
t.Fatalf("DigestToken: %v", hashErr)
75+
}
76+
77+
// Mock DAL: when First is called, populate the destination with a valid key
78+
// whose AllowedPath covers the webhook endpoint under test.
79+
db := &mockdal.Dal{}
80+
db.On("First", mock.Anything, mock.Anything).
81+
Run(func(args mock.Arguments) {
82+
dst := args.Get(0).(*coremodels.ApiKey)
83+
dst.ApiKey = hashedKey
84+
dst.AllowedPath = `/plugins/webhook/connections/1/.*`
85+
dst.Creator = common.Creator{Creator: "test-user"}
86+
}).
87+
Return(nil)
88+
89+
basicRes = contextimpl.NewDefaultBasicRes(config.GetConfig(), logruslog.Global, db)
90+
91+
router := gin.New()
92+
router.Use(RestAuthentication(router, basicRes))
93+
router.Use(requireUserGate())
94+
router.POST("/plugins/webhook/connections/:id/deployments", func(c *gin.Context) {
95+
c.Status(http.StatusOK)
96+
})
97+
98+
req := httptest.NewRequest(http.MethodPost, "/rest/plugins/webhook/connections/1/deployments", nil)
99+
req.Header.Set("Authorization", "Bearer "+plaintext)
100+
resp := httptest.NewRecorder()
101+
router.ServeHTTP(resp, req)
102+
103+
if resp.Code != http.StatusOK {
104+
t.Fatalf("expected 200 with valid REST API key when auth gate is active, got %d: %s",
105+
resp.Code, resp.Body.String())
106+
}
107+
}

backend/server/api/shared/gin_utils.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,36 @@ limitations under the License.
1818
package shared
1919

2020
import (
21+
"context"
22+
"net/http"
23+
2124
"github.com/apache/incubator-devlake/core/models/common"
2225
"github.com/gin-gonic/gin"
2326
)
2427

28+
// restAuthKey is an unexported type used as a request-context key so it cannot
29+
// collide with keys set by other packages.
30+
type restAuthKey struct{}
31+
32+
// SetRestAuthUser stores the authenticated user in the HTTP request context.
33+
// This is necessary because gin's HandleContext calls c.reset(), which clears
34+
// c.Keys but leaves c.Request (and its context) intact. RestAuthentication
35+
// calls this before rerouting so the user survives the reset.
36+
func SetRestAuthUser(r *http.Request, user *common.User) *http.Request {
37+
return r.WithContext(context.WithValue(r.Context(), restAuthKey{}, user))
38+
}
39+
2540
func GetUser(c *gin.Context) (*common.User, bool) {
2641
userObj, exist := c.Get(common.USER)
27-
if !exist {
28-
return nil, false
42+
if exist {
43+
if user, ok := userObj.(*common.User); ok {
44+
return user, true
45+
}
46+
}
47+
// Fallback: RestAuthentication stores the user here before calling
48+
// HandleContext, which resets c.Keys but preserves c.Request.
49+
if user, ok := c.Request.Context().Value(restAuthKey{}).(*common.User); ok && user != nil {
50+
return user, true
2951
}
30-
user := userObj.(*common.User)
31-
return user, true
52+
return nil, false
3253
}

0 commit comments

Comments
 (0)