diff --git a/api/authenticate.go b/api/authenticate.go index 0a518b697..6d89d7611 100644 --- a/api/authenticate.go +++ b/api/authenticate.go @@ -5,18 +5,17 @@ package api import ( - "encoding/base64" "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/go-vela/server/database" - "github.com/go-vela/server/router/middleware/token" + "github.com/go-vela/server/internal/token" "github.com/go-vela/server/scm" "github.com/go-vela/server/util" "github.com/go-vela/types" + "github.com/go-vela/types/constants" "github.com/go-vela/types/library" - "github.com/google/uuid" "github.com/sirupsen/logrus" ) @@ -47,7 +46,7 @@ import ( // Set-Cookie: // type: string // schema: -// "$ref": "#/definitions/Login" +// "$ref": "#/definitions/Token" // '307': // description: Redirected for authentication // '401': @@ -65,6 +64,8 @@ import ( func Authenticate(c *gin.Context) { var err error + tm := c.MustGet("token-manager").(*token.Manager) + // capture the OAuth state if present oAuthState := c.Request.FormValue("state") @@ -102,30 +103,15 @@ func Authenticate(c *gin.Context) { u, err := database.FromContext(c).GetUserForName(newUser.GetName()) // create a new user account if len(u.GetName()) == 0 || err != nil { - // create unique id for the user - uid, err := uuid.NewRandom() - if err != nil { - retErr := fmt.Errorf("unable to create UID for user %s: %w", u.GetName(), err) - - util.HandleError(c, http.StatusServiceUnavailable, retErr) - - return - } - // create the user account u := new(library.User) u.SetName(newUser.GetName()) u.SetToken(newUser.GetToken()) - u.SetHash( - base64.StdEncoding.EncodeToString( - []byte(uid.String()), - ), - ) u.SetActive(true) u.SetAdmin(false) // compose jwt tokens for user - rt, at, err := token.Compose(c, u) + rt, at, err := tm.Compose(c, u) if err != nil { retErr := fmt.Errorf("unable to compose token for user %s: %w", u.GetName(), err) @@ -148,7 +134,7 @@ func Authenticate(c *gin.Context) { } // return the jwt access token - c.JSON(http.StatusOK, library.Login{Token: &at}) + c.JSON(http.StatusOK, library.Token{Token: &at}) return } @@ -158,7 +144,7 @@ func Authenticate(c *gin.Context) { u.SetActive(true) // compose jwt tokens for user - rt, at, err := token.Compose(c, u) + rt, at, err := tm.Compose(c, u) if err != nil { retErr := fmt.Errorf("unable to compose token for user %s: %w", u.GetName(), err) @@ -181,7 +167,7 @@ func Authenticate(c *gin.Context) { } // return the user with their jwt access token - c.JSON(http.StatusOK, library.Login{Token: &at}) + c.JSON(http.StatusOK, library.Token{Token: &at}) } // swagger:operation GET /authenticate/web authenticate GetAuthenticateTypeWeb @@ -289,7 +275,7 @@ func AuthenticateType(c *gin.Context) { // '200': // description: Successfully authenticated // schema: -// "$ref": "#/definitions/Login" +// "$ref": "#/definitions/Token" // '401': // description: Unable to authenticate // schema: @@ -325,8 +311,15 @@ func AuthenticateToken(c *gin.Context) { // We don't need refresh token for this scenario // We only need access token and are configured based on the config defined - m := c.MustGet("metadata").(*types.Metadata) - at, err := token.CreateAccessToken(u, m.Vela.AccessTokenDuration) + tm := c.MustGet("token-manager").(*token.Manager) + + // mint token options for access token + amto := &token.MintTokenOpts{ + User: u, + TokenType: constants.UserAccessTokenType, + TokenDuration: tm.UserAccessTokenDuration, + } + at, err := tm.MintToken(amto) if err != nil { retErr := fmt.Errorf("unable to compose token for user %s: %w", u.GetName(), err) @@ -335,5 +328,5 @@ func AuthenticateToken(c *gin.Context) { } // return the user with their jwt access token - c.JSON(http.StatusOK, library.Login{Token: &at}) + c.JSON(http.StatusOK, library.Token{Token: &at}) } diff --git a/api/build.go b/api/build.go index c31c5d5c2..5be0c08d6 100644 --- a/api/build.go +++ b/api/build.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -14,6 +14,8 @@ import ( "strings" "time" + "github.com/go-vela/server/internal/token" + "github.com/go-vela/server/router/middleware/claims" "github.com/go-vela/server/router/middleware/org" "github.com/go-vela/server/compiler" @@ -1899,3 +1901,95 @@ func CancelBuild(c *gin.Context) { c.JSON(http.StatusOK, b) } + +// swagger:operation GET /api/v1/repos/{org}/{repo}/builds/{build}/token builds GetBuildToken +// +// Get a build token +// +// --- +// produces: +// - application/json +// parameters: +// - in: path +// name: repo +// description: Name of the repo +// required: true +// type: string +// - in: path +// name: org +// description: Name of the org +// required: true +// type: string +// - in: path +// name: build +// description: Build number +// required: true +// type: integer +// security: +// - ApiKeyAuth: [] +// responses: +// '200': +// description: Successfully retrieved build token +// schema: +// "$ref": "#/definitions/Token" +// '400': +// description: Bad request +// schema: +// "$ref": "#/definitions/Error" +// '500': +// description: Unable to generate build token +// schema: +// "$ref": "#/definitions/Error" + +// GetBuildToken represents the API handler to generate a build token. +func GetBuildToken(c *gin.Context) { + // capture middleware values + b := build.Retrieve(c) + o := org.Retrieve(c) + r := repo.Retrieve(c) + cl := claims.Retrieve(c) + + // update engine logger with API metadata + // + // https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry.WithFields + logrus.WithFields(logrus.Fields{ + "build": b.GetNumber(), + "org": o, + "repo": r.GetName(), + "user": cl.Subject, + }).Infof("generating build token for build %s/%d", r.GetFullName(), b.GetNumber()) + + // if build is not in a pending state, then a build token should not be needed - bad request + if !strings.EqualFold(b.GetStatus(), constants.StatusPending) { + retErr := fmt.Errorf("unable to mint build token: build is not in pending state") + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + // retrieve token manager from context + tm := c.MustGet("token-manager").(*token.Manager) + + // set expiration to repo timeout plus configurable buffer + exp := (time.Duration(r.GetTimeout()) * time.Minute) + tm.BuildTokenBufferDuration + + // set mint token options + bmto := &token.MintTokenOpts{ + Hostname: cl.Subject, + BuildID: b.GetID(), + Repo: r.GetFullName(), + TokenType: constants.WorkerBuildTokenType, + TokenDuration: exp, + } + + // mint token + bt, err := tm.MintToken(bmto) + if err != nil { + retErr := fmt.Errorf("unable to generate build token: %w", err) + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + + c.JSON(http.StatusOK, library.Token{Token: &bt}) +} diff --git a/api/secret.go b/api/secret.go index c23bd7010..6e1534d01 100644 --- a/api/secret.go +++ b/api/secret.go @@ -12,6 +12,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/go-vela/server/router/middleware/claims" "github.com/go-vela/server/router/middleware/user" "github.com/go-vela/server/scm" "github.com/go-vela/server/secret" @@ -485,6 +486,7 @@ func GetSecrets(c *gin.Context) { // GetSecret gets a secret from the provided secrets service. func GetSecret(c *gin.Context) { // capture middleware values + cl := claims.Retrieve(c) u := user.Retrieve(c) e := util.PathParameter(c, "engine") t := util.PathParameter(c, "type") @@ -533,7 +535,7 @@ func GetSecret(c *gin.Context) { } // only allow workers to access the full secret with the value - if u.GetAdmin() && u.GetName() == "vela-worker" { + if strings.EqualFold(cl.TokenType, constants.WorkerBuildTokenType) { c.JSON(http.StatusOK, secret) return diff --git a/api/token.go b/api/token.go index 1225f149f..87a49288a 100644 --- a/api/token.go +++ b/api/token.go @@ -8,7 +8,8 @@ import ( "fmt" "net/http" - "github.com/go-vela/server/router/middleware/token" + "github.com/go-vela/server/internal/token" + "github.com/go-vela/server/router/middleware/auth" "github.com/go-vela/server/util" "github.com/go-vela/types/library" @@ -29,7 +30,7 @@ import ( // '200': // description: Successfully refreshed a token // schema: -// "$ref": "#/definitions/Login" +// "$ref": "#/definitions/Token" // '401': // description: Unauthorized // schema: @@ -41,7 +42,7 @@ func RefreshAccessToken(c *gin.Context) { // capture the refresh token // TODO: move this into token package and do it internally // since we are already passsing context - rt, err := token.RetrieveRefreshToken(c.Request) + rt, err := auth.RetrieveRefreshToken(c.Request) if err != nil { retErr := fmt.Errorf("refresh token error: %w", err) @@ -50,8 +51,10 @@ func RefreshAccessToken(c *gin.Context) { return } + tm := c.MustGet("token-manager").(*token.Manager) + // validate the refresh token and return a new access token - newAccessToken, err := token.Refresh(c, rt) + newAccessToken, err := tm.Refresh(c, rt) if err != nil { retErr := fmt.Errorf("unable to refresh token: %w", err) @@ -60,5 +63,5 @@ func RefreshAccessToken(c *gin.Context) { return } - c.JSON(http.StatusOK, library.Login{Token: &newAccessToken}) + c.JSON(http.StatusOK, library.Token{Token: &newAccessToken}) } diff --git a/api/user.go b/api/user.go index 37c66d82d..c73fe17e4 100644 --- a/api/user.go +++ b/api/user.go @@ -5,19 +5,17 @@ package api import ( - "encoding/base64" "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/go-vela/server/database" - "github.com/go-vela/server/router/middleware/token" + "github.com/go-vela/server/internal/token" "github.com/go-vela/server/router/middleware/user" "github.com/go-vela/server/scm" "github.com/go-vela/server/util" "github.com/go-vela/types/library" - "github.com/google/uuid" "github.com/sirupsen/logrus" ) @@ -658,7 +656,7 @@ func DeleteUser(c *gin.Context) { // '200': // description: Successfully created a token for the current user // schema: -// "$ref": "#/definitions/Login" +// "$ref": "#/definitions/Token" // '503': // description: Unable to create a token for the current user // schema: @@ -666,6 +664,8 @@ func DeleteUser(c *gin.Context) { // CreateToken represents the API handler to create // a user token in the configured backend. +// +//nolint:dupl // ignore duplicate flag with delete token func CreateToken(c *gin.Context) { // capture middleware values u := user.Retrieve(c) @@ -677,8 +677,10 @@ func CreateToken(c *gin.Context) { "user": u.GetName(), }).Infof("composing token for user %s", u.GetName()) + tm := c.MustGet("token-manager").(*token.Manager) + // compose JWT token for user - rt, at, err := token.Compose(c, u) + rt, at, err := tm.Compose(c, u) if err != nil { retErr := fmt.Errorf("unable to compose token for user %s: %w", u.GetName(), err) @@ -699,7 +701,7 @@ func CreateToken(c *gin.Context) { return } - c.JSON(http.StatusOK, library.Login{Token: &at}) + c.JSON(http.StatusOK, library.Token{Token: &at}) } // swagger:operation DELETE /api/v1/user/token users DeleteToken @@ -723,6 +725,8 @@ func CreateToken(c *gin.Context) { // DeleteToken represents the API handler to revoke // and recreate a user token in the configured backend. +// +//nolint:dupl // ignore duplicate flag with create token func DeleteToken(c *gin.Context) { // capture middleware values u := user.Retrieve(c) @@ -734,24 +738,10 @@ func DeleteToken(c *gin.Context) { "user": u.GetName(), }).Infof("revoking token for user %s", u.GetName()) - // create unique id for the user - uid, err := uuid.NewRandom() - if err != nil { - retErr := fmt.Errorf("unable to create UID for user %s: %w", u.GetName(), err) - - util.HandleError(c, http.StatusServiceUnavailable, retErr) - - return - } - - u.SetHash( - base64.StdEncoding.EncodeToString( - []byte(uid.String()), - ), - ) + tm := c.MustGet("token-manager").(*token.Manager) // compose JWT token for user - rt, at, err := token.Compose(c, u) + rt, at, err := tm.Compose(c, u) if err != nil { retErr := fmt.Errorf("unable to compose token for user %s: %w", u.GetName(), err) @@ -772,5 +762,5 @@ func DeleteToken(c *gin.Context) { return } - c.JSON(http.StatusOK, library.Login{Token: &at}) + c.JSON(http.StatusOK, library.Token{Token: &at}) } diff --git a/cmd/vela-server/main.go b/cmd/vela-server/main.go index 24192ac79..fd1ebfedf 100644 --- a/cmd/vela-server/main.go +++ b/cmd/vela-server/main.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -75,6 +75,11 @@ func main() { Name: "vela-secret", Usage: "secret used for server <-> agent communication", }, + &cli.StringFlag{ + EnvVars: []string{"VELA_SERVER_PRIVATE_KEY"}, + Name: "vela-server-private-key", + Usage: "private key used for signing tokens", + }, &cli.StringFlag{ EnvVars: []string{"VELA_CLONE_IMAGE"}, Name: "clone-image", @@ -123,19 +128,25 @@ func main() { Usage: "override default events for newly activated repositories", Value: cli.NewStringSlice(constants.EventPush), }, - // Security Flags + // Token Manager Flags &cli.DurationFlag{ - EnvVars: []string{"VELA_ACCESS_TOKEN_DURATION", "ACCESS_TOKEN_DURATION"}, - Name: "access-token-duration", - Usage: "sets the duration of the access token", + EnvVars: []string{"VELA_USER_ACCESS_TOKEN_DURATION", "USER_ACCESS_TOKEN_DURATION"}, + Name: "user-access-token-duration", + Usage: "sets the duration of the user access token", Value: 15 * time.Minute, }, &cli.DurationFlag{ - EnvVars: []string{"VELA_REFRESH_TOKEN_DURATION", "REFRESH_TOKEN_DURATION"}, - Name: "refresh-token-duration", - Usage: "sets the duration of the refresh token", + EnvVars: []string{"VELA_USER_REFRESH_TOKEN_DURATION", "USER_REFRESH_TOKEN_DURATION"}, + Name: "user-refresh-token-duration", + Usage: "sets the duration of the user refresh token", Value: 8 * time.Hour, }, + &cli.DurationFlag{ + EnvVars: []string{"VELA_BUILD_TOKEN_BUFFER_DURATION", "BUILD_TOKEN_BUFFER_DURATION"}, + Name: "build-token-buffer-duration", + Usage: "sets the duration of the buffer for build token expiration based on repo build timeout", + Value: 5 * time.Minute, + }, // Compiler Flags &cli.BoolFlag{ EnvVars: []string{"VELA_COMPILER_GITHUB", "COMPILER_GITHUB"}, diff --git a/cmd/vela-server/server.go b/cmd/vela-server/server.go index d12338b2a..8ecb006e4 100644 --- a/cmd/vela-server/server.go +++ b/cmd/vela-server/server.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -88,6 +88,7 @@ func server(c *cli.Context) error { middleware.Database(database), middleware.Logger(logrus.StandardLogger(), time.RFC3339), middleware.Metadata(metadata), + middleware.TokenManager(setupTokenManager(c)), middleware.Queue(queue), middleware.RequestVersion, middleware.Secret(c.String("vela-secret")), diff --git a/cmd/vela-server/token.go b/cmd/vela-server/token.go new file mode 100644 index 000000000..eae471373 --- /dev/null +++ b/cmd/vela-server/token.go @@ -0,0 +1,30 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package main + +import ( + "github.com/golang-jwt/jwt/v4" + + "github.com/sirupsen/logrus" + + "github.com/urfave/cli/v2" + + "github.com/go-vela/server/internal/token" +) + +// helper function to setup the tokenmanager from the CLI arguments. +func setupTokenManager(c *cli.Context) *token.Manager { + logrus.Debug("Creating token manager from CLI configuration") + + tm := &token.Manager{ + PrivateKey: c.String("vela-server-private-key"), + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: c.Duration("user-access-token-duration"), + UserRefreshTokenDuration: c.Duration("user-refresh-token-duration"), + BuildTokenBufferDuration: c.Duration("build-token-buffer-duration"), + } + + return tm +} diff --git a/cmd/vela-server/validate.go b/cmd/vela-server/validate.go index 1de402e2a..f79512e9e 100644 --- a/cmd/vela-server/validate.go +++ b/cmd/vela-server/validate.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -6,9 +6,10 @@ package main import ( "fmt" - "github.com/go-vela/types/constants" "strings" + "github.com/go-vela/types/constants" + "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) @@ -55,6 +56,10 @@ func validateCore(c *cli.Context) error { return fmt.Errorf("vela-secret (VELA_SECRET) flag is not properly configured") } + if len(c.String("vela-server-private-key")) == 0 { + return fmt.Errorf("vela-server-private-key (VELA_SERVER_PRIVATE_KEY) flag is not properly configured") + } + if len(c.String("webui-addr")) == 0 { logrus.Warn("optional flag webui-addr (VELA_WEBUI_ADDR or VELA_WEBUI_HOST) not set") } else { @@ -71,8 +76,12 @@ func validateCore(c *cli.Context) error { } } - if c.Duration("refresh-token-duration").Seconds() <= c.Duration("access-token-duration").Seconds() { - return fmt.Errorf("refresh-token-duration (VELA_REFRESH_TOKEN_DURATION) must be larger than the access-token-duration (VELA_ACCESS_TOKEN_DURATION)") + if c.Duration("token-manager-user-refresh-token-duration").Seconds() <= c.Duration("token-manager-user-access-token-duration").Seconds() { + return fmt.Errorf("token-manager-user-refresh-token-duration (VELA_TOKEN_MANAGER_USER_REFRESH_TOKEN_DURATION) must be larger than the token-manager-user-access-token-duration (VELA_TOKEN_MANAGER_USER_ACCESS_TOKEN_DURATION)") + } + + if c.Duration("token-manager-build-token-buffer-duration").Seconds() < 0 { + return fmt.Errorf("token-manager-build-token-buffer-duration (VELA_TOKEN_MANAGER_BUILD_TOKEN_BUFFER_DURATION) must not be a negative time value") } if c.Int64("default-build-limit") == 0 { diff --git a/database/user/table.go b/database/user/table.go index 456853770..20a450b84 100644 --- a/database/user/table.go +++ b/database/user/table.go @@ -16,8 +16,8 @@ IF NOT EXISTS users ( id SERIAL PRIMARY KEY, name VARCHAR(250), - refresh_token VARCHAR(500), - token VARCHAR(500), + refresh_token VARCHAR(1000), + token VARCHAR(1000), hash VARCHAR(500), favorites VARCHAR(5000), active BOOLEAN, diff --git a/docker-compose.yml b/docker-compose.yml index c3b9501c7..b0d7c5f72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,7 @@ services: VELA_WEBUI_ADDR: 'http://localhost:8888' VELA_LOG_LEVEL: trace VELA_SECRET: 'zB7mrKDTZqNeNTD8z47yG4DHywspAh' + VELA_SERVER_PRIVATE_KEY: 'F534FF2A080E45F38E05DC70752E6787' VELA_REFRESH_TOKEN_DURATION: 90m VELA_ACCESS_TOKEN_DURATION: 60m VELA_DISABLE_WEBHOOK_VALIDATION: 'true' diff --git a/go.mod b/go.mod index 86e17605d..240012799 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,12 @@ require ( github.com/Masterminds/sprig/v3 v3.2.3 github.com/alicebob/miniredis/v2 v2.23.1 github.com/aws/aws-sdk-go v1.44.161 - github.com/buildkite/yaml v0.0.0-20181016232759-0caa5f0796e3 + github.com/buildkite/yaml v0.0.0-20210326113714-4a3f40911396 github.com/drone/envsubst v1.0.3 github.com/gin-gonic/gin v1.8.1 github.com/go-playground/assert/v2 v2.2.0 github.com/go-redis/redis/v8 v8.11.5 - github.com/go-vela/types v0.17.0 + github.com/go-vela/types v0.17.1-0.20230223155025-1c8a34f71425 github.com/golang-jwt/jwt/v4 v4.4.3 github.com/google/go-cmp v0.5.9 github.com/google/go-github/v44 v44.1.0 diff --git a/go.sum b/go.sum index ab2291f6f..f35038998 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,8 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/buildkite/yaml v0.0.0-20181016232759-0caa5f0796e3 h1:q+sMKdA6L8LyGVudTkpGoC73h6ak2iWSPFiFo/pFOU8= -github.com/buildkite/yaml v0.0.0-20181016232759-0caa5f0796e3/go.mod h1:5hCug3EZaHXU3FdCA3gJm0YTNi+V+ooA2qNTiVpky4A= +github.com/buildkite/yaml v0.0.0-20210326113714-4a3f40911396 h1:qLN32md48xyTEqw6XEZMyNMre7njm0XXvDrea6NVwOM= +github.com/buildkite/yaml v0.0.0-20210326113714-4a3f40911396/go.mod h1:AV5wtJnn1/CRaRGlJ8xspkMWfKXV0/pkJVgGleTIrfk= github.com/cenkalti/backoff/v3 v3.0.0 h1:ske+9nBpD9qZsTBoF41nW5L+AIuFBKMeze18XQ3eG1c= github.com/cenkalti/backoff/v3 v3.0.0/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -161,8 +161,8 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= -github.com/go-vela/types v0.17.0 h1:nvKBbNO8BSiLtYPMScT0XWosGqEWX85UKSkkclb2DVA= -github.com/go-vela/types v0.17.0/go.mod h1:6KoRkvXMw9DkAcLdtI7PxPqMlT2Bl0DiigQamLGGjwo= +github.com/go-vela/types v0.17.1-0.20230223155025-1c8a34f71425 h1:tdjas7NJLWlU2vmETaU36wjbd+zvWPLtznE4uwtnFlw= +github.com/go-vela/types v0.17.1-0.20230223155025-1c8a34f71425/go.mod h1:6KoRkvXMw9DkAcLdtI7PxPqMlT2Bl0DiigQamLGGjwo= github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= diff --git a/internal/token/compose.go b/internal/token/compose.go new file mode 100644 index 000000000..61bea7a32 --- /dev/null +++ b/internal/token/compose.go @@ -0,0 +1,75 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package token + +import ( + "net/http" + "net/url" + + "github.com/gin-gonic/gin" + "github.com/go-vela/types" + "github.com/go-vela/types/constants" + "github.com/go-vela/types/library" +) + +// Compose generates a refresh and access token pair unique +// to the provided user and sets a secure cookie. +// It uses the user's hash to sign the token. to +// guarantee the signature is unique per token. The refresh +// token is returned to store with the user +// in the database. +func (tm *Manager) Compose(c *gin.Context, u *library.User) (string, string, error) { + // grab the metadata from the context to pull in provided + // cookie duration information + m := c.MustGet("metadata").(*types.Metadata) + + // mint token options for refresh token + rmto := MintTokenOpts{ + User: u, + TokenType: constants.UserRefreshTokenType, + TokenDuration: tm.UserRefreshTokenDuration, + } + + // create a refresh token with the provided options + refreshToken, err := tm.MintToken(&rmto) + if err != nil { + return "", "", err + } + + // mint token options for access token + amto := MintTokenOpts{ + User: u, + TokenType: constants.UserAccessTokenType, + TokenDuration: tm.UserAccessTokenDuration, + } + + // create an access token with the provided options + accessToken, err := tm.MintToken(&amto) + if err != nil { + return "", "", err + } + + // parse the address for the backend server + // so we can set it for the cookie domain + addr, err := url.Parse(m.Vela.Address) + if err != nil { + return "", "", err + } + + refreshExpiry := int(tm.UserRefreshTokenDuration.Seconds()) + + // set the SameSite value for the cookie + // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#samesite-attribute + // We set to Lax because we will have links from source provider web UI. + // Setting this to Strict would force a login when navigating via source provider web UI links. + c.SetSameSite(http.SameSiteLaxMode) + // set the cookie with the refresh token as a HttpOnly, Secure cookie + // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#httponly-attribute + // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#secure-attribute + c.SetCookie(constants.RefreshTokenName, refreshToken, refreshExpiry, "/", addr.Hostname(), c.Value("securecookie").(bool), true) + + // return the refresh and access tokens + return refreshToken, accessToken, nil +} diff --git a/internal/token/compose_test.go b/internal/token/compose_test.go new file mode 100644 index 000000000..75f113e77 --- /dev/null +++ b/internal/token/compose_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package token + +import ( + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-vela/types" + "github.com/go-vela/types/constants" + "github.com/go-vela/types/library" + + jwt "github.com/golang-jwt/jwt/v4" +) + +func TestToken_Compose(t *testing.T) { + // setup types + u := new(library.User) + u.SetID(1) + u.SetName("foo") + u.SetToken("bar") + u.SetHash("baz") + + tm := &Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + d := time.Minute * 5 + now := time.Now() + exp := now.Add(d) + + claims := &Claims{ + IsActive: u.GetActive(), + IsAdmin: u.GetAdmin(), + TokenType: constants.UserAccessTokenType, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: u.GetName(), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(exp), + }, + } + + tkn := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + want, err := tkn.SignedString([]byte(tm.PrivateKey)) + if err != nil { + t.Errorf("Unable to create test token: %v", err) + } + + m := &types.Metadata{ + Vela: &types.Vela{ + AccessTokenDuration: d, + }, + } + + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, _ := gin.CreateTestContext(resp) + context.Set("metadata", m) + context.Set("securecookie", false) + + // run test + _, got, err := tm.Compose(context, u) + if err != nil { + t.Errorf("Compose returned err: %v", err) + } + + if !strings.EqualFold(got, want) { + t.Errorf("Compose is %v, want %v", got, want) + } +} diff --git a/internal/token/manager.go b/internal/token/manager.go new file mode 100644 index 000000000..121e4fa0f --- /dev/null +++ b/internal/token/manager.go @@ -0,0 +1,28 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package token + +import ( + "time" + + "github.com/golang-jwt/jwt/v4" +) + +type Manager struct { + // PrivateKey key used to sign tokens + PrivateKey string + + // SignMethod method to sign tokens + SignMethod jwt.SigningMethod + + // UserAccessTokenDuration specifies the token duration to use for users + UserAccessTokenDuration time.Duration + + // UserRefreshTokenDuration specifies the token duration for user refresh + UserRefreshTokenDuration time.Duration + + // BuildTokenBufferDuration specifies the additional token duration of build tokens beyond repo timeout + BuildTokenBufferDuration time.Duration +} diff --git a/internal/token/mint.go b/internal/token/mint.go new file mode 100644 index 000000000..e90c0c34f --- /dev/null +++ b/internal/token/mint.go @@ -0,0 +1,89 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package token + +import ( + "errors" + "fmt" + "time" + + "github.com/go-vela/types/constants" + "github.com/go-vela/types/library" + "github.com/golang-jwt/jwt/v4" +) + +// Claims struct is an extension of the JWT standard claims. It +// includes information about the user. +type Claims struct { + BuildID int64 `json:"build_id"` + IsActive bool `json:"is_active"` + IsAdmin bool `json:"is_admin"` + Repo string `json:"repo"` + TokenType string `json:"token_type"` + jwt.RegisteredClaims +} + +// MintTokenOpts is a type to inform the token minter how to construct +// the token. +type MintTokenOpts struct { + BuildID int64 + Hostname string + Repo string + TokenDuration time.Duration + TokenType string + User *library.User +} + +// MintToken mints a Vela JWT Token given a set of options. +func (tm *Manager) MintToken(mto *MintTokenOpts) (string, error) { + // initialize claims struct + var claims = new(Claims) + + // apply claims based on token type + switch mto.TokenType { + case constants.UserAccessTokenType, constants.UserRefreshTokenType: + if mto.User == nil { + return "", fmt.Errorf("no user provided for user access token") + } + + claims.IsActive = mto.User.GetActive() + claims.IsAdmin = mto.User.GetAdmin() + claims.Subject = mto.User.GetName() + + case constants.WorkerBuildTokenType: + if mto.BuildID == 0 { + return "", errors.New("missing build id for build token") + } + + if len(mto.Repo) == 0 { + return "", errors.New("missing repo for build token") + } + + if len(mto.Hostname) == 0 { + return "", errors.New("missing host name for build token") + } + + claims.BuildID = mto.BuildID + claims.Repo = mto.Repo + claims.Subject = mto.Hostname + + default: + return "", errors.New("invalid token type") + } + + claims.IssuedAt = jwt.NewNumericDate(time.Now()) + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(mto.TokenDuration)) + claims.TokenType = mto.TokenType + + tk := jwt.NewWithClaims(tm.SignMethod, claims) + + //sign token with configured private signing key + token, err := tk.SignedString([]byte(tm.PrivateKey)) + if err != nil { + return "", fmt.Errorf("unable to sign token: %w", err) + } + + return token, nil +} diff --git a/internal/token/parse.go b/internal/token/parse.go new file mode 100644 index 000000000..dc9b002d3 --- /dev/null +++ b/internal/token/parse.go @@ -0,0 +1,62 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package token + +import ( + "errors" + + "github.com/golang-jwt/jwt/v4" +) + +// ParseToken scans the signed JWT token as a string and extracts +// the user login from the claims to be looked up in the database. +// This function will return an error for a few different reasons: +// +// * the token signature doesn't match what is expected +// * the token signing method doesn't match what is expected +// * the token is invalid (potentially expired or improper). +func (tm *Manager) ParseToken(token string) (*Claims, error) { + var claims = new(Claims) + + // create a new JWT parser + p := &jwt.Parser{ + // explicitly only allow these signing methods + ValidMethods: []string{jwt.SigningMethodHS256.Name}, + } + + // parse and validate given token + tkn, err := p.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) { + var err error + + // extract the claims from the token + claims = t.Claims.(*Claims) + name := claims.Subject + + // check if subject has a value in claims; + // we can save a db lookup attempt + if len(name) == 0 { + return nil, errors.New("no subject defined") + } + + // ParseWithClaims will skip expiration check + // if expiration has default value; + // forcing a check and exiting if not set + if claims.ExpiresAt == nil { + return nil, errors.New("token has no expiration") + } + + return []byte(tm.PrivateKey), err + }) + + if err != nil { + return nil, errors.New("failed parsing: " + err.Error()) + } + + if !tkn.Valid { + return nil, errors.New("invalid token") + } + + return claims, nil +} diff --git a/internal/token/parse_test.go b/internal/token/parse_test.go new file mode 100644 index 000000000..9ee9ce453 --- /dev/null +++ b/internal/token/parse_test.go @@ -0,0 +1,299 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package token + +import ( + "reflect" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-vela/types/constants" + "github.com/go-vela/types/library" + + jwt "github.com/golang-jwt/jwt/v4" +) + +func TestTokenManager_ParseToken(t *testing.T) { + // setup types + u := new(library.User) + u.SetID(1) + u.SetName("foo") + u.SetToken("bar") + u.SetHash("baz") + + tm := &Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + now := time.Now() + + tests := []struct { + TokenType string + Mto *MintTokenOpts + Want *Claims + }{ + { + TokenType: constants.UserAccessTokenType, + Mto: &MintTokenOpts{ + User: u, + TokenType: constants.UserAccessTokenType, + TokenDuration: tm.UserAccessTokenDuration, + }, + Want: &Claims{ + IsActive: u.GetActive(), + IsAdmin: u.GetAdmin(), + TokenType: constants.UserAccessTokenType, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: u.GetName(), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute * 5)), + }, + }, + }, + { + TokenType: constants.UserRefreshTokenType, + Mto: &MintTokenOpts{ + User: u, + TokenType: constants.UserRefreshTokenType, + TokenDuration: tm.UserRefreshTokenDuration, + }, + Want: &Claims{ + IsActive: u.GetActive(), + IsAdmin: u.GetAdmin(), + TokenType: constants.UserRefreshTokenType, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: u.GetName(), + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute * 30)), + }, + }, + }, + { + TokenType: constants.WorkerBuildTokenType, + Mto: &MintTokenOpts{ + BuildID: 1, + Repo: "foo/bar", + Hostname: "worker", + TokenType: constants.WorkerBuildTokenType, + TokenDuration: time.Minute * 90, + }, + Want: &Claims{ + BuildID: 1, + Repo: "foo/bar", + TokenType: constants.WorkerBuildTokenType, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "worker", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute * 90)), + }, + }, + }, + } + + gin.SetMode(gin.TestMode) + + for _, tt := range tests { + t.Run(tt.TokenType, func(t *testing.T) { + tkn, err := tm.MintToken(tt.Mto) + if err != nil { + t.Errorf("Unable to create token: %v", err) + } + // run test + got, err := tm.ParseToken(tkn) + if err != nil { + t.Errorf("Parse returned err: %v", err) + } + + if !reflect.DeepEqual(got, tt.Want) { + t.Errorf("Parse is %v, want %v", got, tt.Want) + } + }) + } +} + +func TestTokenManager_ParseToken_Error_NoParse(t *testing.T) { + // setup types + u := new(library.User) + u.SetID(1) + u.SetName("foo") + u.SetToken("bar") + u.SetHash("baz") + + tm := &Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + // run test + got, err := tm.ParseToken("!@#$%^&*()") + if err == nil { + t.Errorf("Parse should have returned err") + } + + if got != nil { + t.Errorf("Parse is %v, want nil", got) + } +} + +func TestTokenManager_ParseToken_Expired(t *testing.T) { + // setup types + u := new(library.User) + u.SetID(1) + u.SetName("foo") + u.SetToken("bar") + u.SetHash("baz") + + tm := &Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + mto := &MintTokenOpts{ + User: u, + TokenType: constants.UserAccessTokenType, + TokenDuration: time.Minute * -1, + } + + tkn, err := tm.MintToken(mto) + if err != nil { + t.Errorf("Unable to create token: %v", err) + } + + // run test + _, err = tm.ParseToken(tkn) + if err == nil { + t.Errorf("Parse should return error due to expiration") + } +} + +func TestTokenManager_ParseToken_NoSubject(t *testing.T) { + // setup types + u := new(library.User) + u.SetID(1) + u.SetName("foo") + u.SetToken("bar") + u.SetHash("baz") + + tm := &Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + claims := &Claims{ + IsActive: u.GetActive(), + IsAdmin: u.GetAdmin(), + TokenType: constants.UserRefreshTokenType, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now()), + }, + } + tkn := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + token, err := tkn.SignedString([]byte(tm.PrivateKey)) + if err != nil { + t.Errorf("Unable to create test token: %v", err) + } + + // run test + got, err := tm.ParseToken(token) + if err == nil { + t.Errorf("Parse should have returned err") + } + + if got != nil { + t.Errorf("Parse is %v, want nil", got) + } +} + +func TestTokenManager_ParseToken_Error_InvalidSignature(t *testing.T) { + // setup types + u := new(library.User) + u.SetID(1) + u.SetName("foo") + u.SetToken("bar") + u.SetHash("baz") + + tm := &Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + claims := &Claims{ + IsActive: u.GetActive(), + IsAdmin: u.GetAdmin(), + TokenType: constants.UserAccessTokenType, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: u.GetName(), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute * 1)), + }, + } + tkn := jwt.NewWithClaims(jwt.SigningMethodHS512, claims) + + token, err := tkn.SignedString([]byte(tm.PrivateKey)) + if err != nil { + t.Errorf("Unable to create test token: %v", err) + } + + // run test + got, err := tm.ParseToken(token) + if err == nil { + t.Errorf("Parse should have returned err") + } + + if got != nil { + t.Errorf("Parse is %v, want nil", got) + } +} + +func TestToken_Parse_AccessToken_NoExpiration(t *testing.T) { + // setup types + u := new(library.User) + u.SetID(1) + u.SetName("foo") + u.SetToken("bar") + u.SetHash("baz") + + tm := &Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + claims := &Claims{ + TokenType: constants.UserAccessTokenType, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "user", + }, + } + tkn := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + token, err := tkn.SignedString([]byte(u.GetHash())) + if err != nil { + t.Errorf("Unable to create test token: %v", err) + } + + // run test + got, err := tm.ParseToken(token) + if err == nil { + t.Errorf("Parse should have returned err") + } + + if got != nil { + t.Errorf("Parse is %v, want nil", got) + } +} diff --git a/internal/token/refresh.go b/internal/token/refresh.go new file mode 100644 index 000000000..8cb69f374 --- /dev/null +++ b/internal/token/refresh.go @@ -0,0 +1,43 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package token + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "github.com/go-vela/server/database" + "github.com/go-vela/types/constants" +) + +// Refresh returns a new access token, if the provided refreshToken is valid. +func (tm *Manager) Refresh(c *gin.Context, refreshToken string) (string, error) { + // retrieve claims from token + claims, err := tm.ParseToken(refreshToken) + if err != nil { + return "", err + } + + // look up user in database given claims subject + u, err := database.FromContext(c).GetUserForName(claims.Subject) + if err != nil { + return "", fmt.Errorf("unable to retrieve user %s from database from claims subject: %w", claims.Subject, err) + } + + // options for user access token minting + amto := &MintTokenOpts{ + User: u, + TokenType: constants.UserAccessTokenType, + TokenDuration: tm.UserAccessTokenDuration, + } + + // create a new access token + at, err := tm.MintToken(amto) + if err != nil { + return "", err + } + + return at, nil +} diff --git a/internal/token/refresh_test.go b/internal/token/refresh_test.go new file mode 100644 index 000000000..dda71cb4a --- /dev/null +++ b/internal/token/refresh_test.go @@ -0,0 +1,127 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package token + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-vela/server/database/sqlite" + "github.com/go-vela/types/constants" + "github.com/go-vela/types/library" + jwt "github.com/golang-jwt/jwt/v4" +) + +func TestTokenManager_Refresh(t *testing.T) { + // setup types + u := new(library.User) + u.SetID(1) + u.SetName("foo") + u.SetToken("bar") + u.SetHash("baz") + + tm := &Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + mto := &MintTokenOpts{ + User: u, + TokenType: constants.UserRefreshTokenType, + TokenDuration: tm.UserRefreshTokenDuration, + } + + rt, err := tm.MintToken(mto) + if err != nil { + t.Errorf("unable to create refresh token") + } + + u.SetRefreshToken(rt) + + // setup database + db, _ := sqlite.NewTest() + + defer func() { + db.Sqlite.Exec("delete from users;") + _sql, _ := db.Sqlite.DB() + _sql.Close() + }() + + _ = db.CreateUser(u) + + // set up context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, _ := gin.CreateTestContext(resp) + context.Set("database", db) + + // run tests + got, err := tm.Refresh(context, rt) + if err != nil { + t.Error("Refresh should not error") + } + + if len(got) == 0 { + t.Errorf("Refresh should have returned an access token") + } +} + +func TestTokenManager_Refresh_Expired(t *testing.T) { + // setup types + u := new(library.User) + u.SetID(1) + u.SetName("foo") + u.SetToken("bar") + u.SetHash("baz") + + tm := &Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + mto := &MintTokenOpts{ + User: u, + TokenType: constants.UserRefreshTokenType, + TokenDuration: time.Minute * -1, + } + + rt, err := tm.MintToken(mto) + if err != nil { + t.Errorf("unable to create refresh token") + } + + u.SetRefreshToken(rt) + + // setup database + db, _ := sqlite.NewTest() + + defer func() { + db.Sqlite.Exec("delete from users;") + _sql, _ := db.Sqlite.DB() + _sql.Close() + }() + + _ = db.CreateUser(u) + + // set up context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, _ := gin.CreateTestContext(resp) + context.Set("database", db) + + // run tests + _, err = tm.Refresh(context, rt) + if err == nil { + t.Error("Refresh with expired token should error") + } +} diff --git a/mock/server/authentication.go b/mock/server/authentication.go index 4f9e5055e..e75217565 100644 --- a/mock/server/authentication.go +++ b/mock/server/authentication.go @@ -26,7 +26,7 @@ const ( func getTokenRefresh(c *gin.Context) { data := []byte(TokenRefreshResp) - var body library.Login + var body library.Token _ = json.Unmarshal(data, &body) c.JSON(http.StatusOK, body) @@ -48,7 +48,7 @@ func getAuthenticate(c *gin.Context) { return } - var body library.Login + var body library.Token _ = json.Unmarshal(data, &body) c.SetCookie(constants.RefreshTokenName, "refresh", 2, "/", "", true, true) @@ -68,7 +68,7 @@ func getAuthenticateFromToken(c *gin.Context) { c.AbortWithStatusJSON(http.StatusUnauthorized, types.Error{Message: &err}) } - var body library.Login + var body library.Token _ = json.Unmarshal(data, &body) c.JSON(http.StatusOK, body) diff --git a/mock/server/build.go b/mock/server/build.go index 19f12a950..37809bae4 100644 --- a/mock/server/build.go +++ b/mock/server/build.go @@ -142,6 +142,13 @@ const ( "full_name": "github/octocat" } ]` + + // BuildTokenResp represents a JSON return for requesting a build token + // + //nolint:gosec // not actual credentials + BuildTokenResp = `{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJidWlsZF9pZCI6MSwicmVwbyI6ImZvby9iYXIiLCJzdWIiOiJPY3RvY2F0IiwiaWF0IjoxNTE2MjM5MDIyfQ.hD7gXpaf9acnLBdOBa4GOEa5KZxdzd0ZvK6fGwaN4bc" + }` ) // getBuilds returns mock JSON for a http GET. @@ -305,3 +312,28 @@ func buildQueue(c *gin.Context) { c.JSON(http.StatusOK, body) } + +// buildToken has a param :build returns mock JSON for a http GET. +// +// Pass "0" to :build to test receiving a http 404 response. Pass "2" +// to :build to test receiving a http 400 response. +func buildToken(c *gin.Context) { + b := c.Param("build") + + if strings.EqualFold(b, "0") { + c.AbortWithStatusJSON(http.StatusNotFound, "") + + return + } + + if strings.EqualFold(b, "2") { + c.AbortWithStatusJSON(http.StatusBadRequest, "") + } + + data := []byte(BuildTokenResp) + + var body library.Token + _ = json.Unmarshal(data, &body) + + c.JSON(http.StatusOK, body) +} diff --git a/mock/server/server.go b/mock/server/server.go index 843a2f591..1b1ebd7c7 100644 --- a/mock/server/server.go +++ b/mock/server/server.go @@ -47,6 +47,7 @@ func FakeHandler() http.Handler { e.POST("/api/v1/repos/:org/:repo/builds", addBuild) e.PUT("/api/v1/repos/:org/:repo/builds/:build", updateBuild) e.DELETE("/api/v1/repos/:org/:repo/builds/:build", removeBuild) + e.GET("/api/v1/repos/:org/:repo/builds/:build/token", buildToken) // mock endpoints for deployment calls e.GET("/api/v1/deployments/:org/:repo", getDeployments) diff --git a/router/build.go b/router/build.go index 153f61ad5..3abf130d9 100644 --- a/router/build.go +++ b/router/build.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -54,10 +54,11 @@ func BuildHandlers(base *gin.RouterGroup) { { build.POST("", perm.MustWrite(), api.RestartBuild) build.GET("", perm.MustRead(), api.GetBuild) - build.PUT("", perm.MustWrite(), middleware.Payload(), api.UpdateBuild) + build.PUT("", perm.MustBuildAccess(), middleware.Payload(), api.UpdateBuild) build.DELETE("", perm.MustPlatformAdmin(), api.DeleteBuild) build.DELETE("/cancel", executors.Establish(), perm.MustWrite(), api.CancelBuild) build.GET("/logs", perm.MustRead(), api.GetBuildLogs) + build.GET("/token", perm.MustWorker(), api.GetBuildToken) // Service endpoints // * Log endpoints diff --git a/router/log.go b/router/log.go index d7e8dc8db..fc7498b48 100644 --- a/router/log.go +++ b/router/log.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -23,7 +23,7 @@ func LogServiceHandlers(base *gin.RouterGroup) { { logs.POST("", perm.MustAdmin(), api.CreateServiceLog) logs.GET("", perm.MustRead(), api.GetServiceLog) - logs.PUT("", perm.MustWrite(), api.UpdateServiceLog) + logs.PUT("", perm.MustBuildAccess(), api.UpdateServiceLog) logs.DELETE("", perm.MustPlatformAdmin(), api.DeleteServiceLog) } // end of logs endpoints } @@ -41,7 +41,7 @@ func LogStepHandlers(base *gin.RouterGroup) { { logs.POST("", perm.MustAdmin(), api.CreateStepLog) logs.GET("", perm.MustRead(), api.GetStepLog) - logs.PUT("", perm.MustWrite(), api.UpdateStepLog) + logs.PUT("", perm.MustBuildAccess(), api.UpdateStepLog) logs.DELETE("", perm.MustPlatformAdmin(), api.DeleteStepLog) } // end of logs endpoints } diff --git a/router/middleware/auth/auth.go b/router/middleware/auth/auth.go new file mode 100644 index 000000000..76291a3cb --- /dev/null +++ b/router/middleware/auth/auth.go @@ -0,0 +1,31 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package auth + +import ( + "fmt" + "net/http" + + "github.com/go-vela/types/constants" + + "github.com/golang-jwt/jwt/v4/request" +) + +// RetrieveAccessToken gets the passed in access token from the header in the request. +func RetrieveAccessToken(r *http.Request) (accessToken string, err error) { + return request.AuthorizationHeaderExtractor.ExtractToken(r) +} + +// RetrieveRefreshToken gets the refresh token sent along with the request as a cookie. +func RetrieveRefreshToken(r *http.Request) (string, error) { + refreshToken, err := r.Cookie(constants.RefreshTokenName) + + if refreshToken == nil || len(refreshToken.Value) == 0 { + // cookie will not be sent if it has expired + return "", fmt.Errorf("refresh token expired or not provided") + } + + return refreshToken.Value, err +} diff --git a/router/middleware/auth/auth_test.go b/router/middleware/auth/auth_test.go new file mode 100644 index 000000000..850309c04 --- /dev/null +++ b/router/middleware/auth/auth_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package auth + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/go-vela/types/constants" +) + +func TestToken_Retrieve_Refresh(t *testing.T) { + // setup types + want := "fresh" + + request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + request.AddCookie(&http.Cookie{ + Name: constants.RefreshTokenName, + Value: want, + }) + + // run test + got, err := RetrieveRefreshToken(request) + if err != nil { + t.Errorf("Retrieve returned err: %v", err) + } + + if !strings.EqualFold(got, want) { + t.Errorf("Retrieve is %v, want %v", got, want) + } +} + +func TestToken_Retrieve_Access(t *testing.T) { + // setup types + want := "foobar" + + header := fmt.Sprintf("Bearer %s", want) + request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + request.Header.Set("Authorization", header) + + // run test + got, err := RetrieveAccessToken(request) + if err != nil { + t.Errorf("Retrieve returned err: %v", err) + } + + if !strings.EqualFold(got, want) { + t.Errorf("Retrieve is %v, want %v", got, want) + } +} + +func TestToken_Retrieve_Access_Error(t *testing.T) { + // setup types + request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + + // run test + got, err := RetrieveAccessToken(request) + if err == nil { + t.Errorf("Retrieve should have returned err") + } + + if len(got) > 0 { + t.Errorf("Retrieve is %v, want \"\"", got) + } +} + +func TestToken_Retrieve_Refresh_Error(t *testing.T) { + // setup types + request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) + + // run test + got, err := RetrieveRefreshToken(request) + if err == nil { + t.Errorf("Retrieve should have returned err") + } + + if len(got) > 0 { + t.Errorf("Retrieve is %v, want \"\"", got) + } +} diff --git a/router/middleware/token/doc.go b/router/middleware/auth/doc.go similarity index 80% rename from router/middleware/token/doc.go rename to router/middleware/auth/doc.go index b4c5a498d..e50ae3a1a 100644 --- a/router/middleware/token/doc.go +++ b/router/middleware/auth/doc.go @@ -8,5 +8,5 @@ // // Usage: // -// import "github.com/go-vela/server/router/middleware/token" -package token +// import "github.com/go-vela/server/router/middleware/auth" +package auth diff --git a/router/middleware/claims/claims.go b/router/middleware/claims/claims.go new file mode 100644 index 000000000..bc0726c7a --- /dev/null +++ b/router/middleware/claims/claims.go @@ -0,0 +1,58 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package claims + +import ( + "net/http" + "strings" + + "github.com/go-vela/server/internal/token" + "github.com/go-vela/server/router/middleware/auth" + "github.com/go-vela/server/util" + "github.com/go-vela/types/constants" + + "github.com/gin-gonic/gin" +) + +// Retrieve gets the claims in the given context. +func Retrieve(c *gin.Context) *token.Claims { + return FromContext(c) +} + +// Establish sets the claims in the given context. +func Establish() gin.HandlerFunc { + return func(c *gin.Context) { + claims := new(token.Claims) + + tm := c.MustGet("token-manager").(*token.Manager) + // get the access token from the request + at, err := auth.RetrieveAccessToken(c.Request) + if err != nil { + util.HandleError(c, http.StatusUnauthorized, err) + return + } + + // special handling for workers + secret := c.MustGet("secret").(string) + if strings.EqualFold(at, secret) { + claims.Subject = "vela-worker" + claims.TokenType = constants.ServerWorkerTokenType + ToContext(c, claims) + c.Next() + + return + } + + // parse and validate the token and return the associated the user + claims, err = tm.ParseToken(at) + if err != nil { + util.HandleError(c, http.StatusUnauthorized, err) + return + } + + ToContext(c, claims) + c.Next() + } +} diff --git a/router/middleware/claims/claims_test.go b/router/middleware/claims/claims_test.go new file mode 100644 index 000000000..239c97296 --- /dev/null +++ b/router/middleware/claims/claims_test.go @@ -0,0 +1,279 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package claims + +import ( + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/go-vela/server/database" + "github.com/go-vela/server/database/sqlite" + "github.com/go-vela/server/internal/token" + "github.com/golang-jwt/jwt/v4" + + "github.com/go-vela/types/constants" + "github.com/go-vela/types/library" + + "github.com/gin-gonic/gin" +) + +func TestClaims_Retrieve(t *testing.T) { + // setup types + now := time.Now() + want := &token.Claims{ + TokenType: constants.UserAccessTokenType, + IsAdmin: false, + IsActive: true, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "octocat", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute * 1)), + }, + } + + // setup context + gin.SetMode(gin.TestMode) + + context, _ := gin.CreateTestContext(nil) + ToContext(context, want) + + // run test + got := Retrieve(context) + + if got != want { + t.Errorf("Retrieve is %v, want %v", got, want) + } +} + +func TestClaims_Establish(t *testing.T) { + // setup types + user := new(library.User) + user.SetID(1) + user.SetName("foo") + user.SetRefreshToken("fresh") + user.SetToken("bar") + user.SetHash("baz") + user.SetActive(true) + user.SetAdmin(false) + user.SetFavorites([]string{}) + + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + now := time.Now() + + tests := []struct { + TokenType string + WantClaims *token.Claims + Mto *token.MintTokenOpts + CtxRequest string + Endpoint string + }{ + { + TokenType: constants.UserAccessTokenType, + WantClaims: &token.Claims{ + TokenType: constants.UserAccessTokenType, + IsAdmin: false, + IsActive: true, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "foo", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute * 5)), + }, + }, + Mto: &token.MintTokenOpts{ + User: user, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + }, + CtxRequest: "/repos/foo/bar/builds/1", + Endpoint: "repos/:org/:repo/builds/:build", + }, + { + TokenType: constants.WorkerBuildTokenType, + WantClaims: &token.Claims{ + TokenType: constants.WorkerBuildTokenType, + BuildID: 1, + Repo: "foo/bar", + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "host", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute * 35)), + }, + }, + Mto: &token.MintTokenOpts{ + Hostname: "host", + BuildID: 1, + Repo: "foo/bar", + TokenDuration: time.Minute * 35, + TokenType: constants.WorkerBuildTokenType, + }, + CtxRequest: "/repos/foo/bar/builds/1", + Endpoint: "repos/:org/:repo/builds/:build", + }, + { + TokenType: constants.ServerWorkerTokenType, + WantClaims: &token.Claims{ + TokenType: constants.ServerWorkerTokenType, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "vela-worker", + }, + }, + CtxRequest: "/repos/foo/bar/builds/1", + Endpoint: "repos/:org/:repo/builds/:build", + }, + } + + // setup database + db, _ := sqlite.NewTest() + + defer func() { + db.Sqlite.Exec("delete from users;") + _sql, _ := db.Sqlite.DB() + _sql.Close() + }() + + _ = db.CreateUser(user) + + got := new(token.Claims) + + gin.SetMode(gin.TestMode) + + for _, tt := range tests { + t.Run(tt.TokenType, func(t *testing.T) { + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + context.Request, _ = http.NewRequest(http.MethodPut, tt.CtxRequest, nil) + + var tkn string + + if strings.EqualFold(tt.TokenType, constants.ServerWorkerTokenType) { + tkn = "very-secret" + } else { + tkn, _ = tm.MintToken(tt.Mto) + } + + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tkn)) + + // setup context + gin.SetMode(gin.TestMode) + + // setup vela mock server + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(func(c *gin.Context) { c.Set("secret", "very-secret") }) + engine.Use(Establish()) + engine.PUT(tt.Endpoint, func(c *gin.Context) { + got = Retrieve(c) + + c.Status(http.StatusOK) + }) + + s1 := httptest.NewServer(engine) + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusOK { + t.Errorf("Establish returned %v, want %v", resp.Code, http.StatusOK) + } + + if !reflect.DeepEqual(got, tt.WantClaims) { + t.Errorf("Establish is %v, want %v", got, tt.WantClaims) + } + + s1.Close() + }) + } +} + +func TestClaims_Establish_NoToken(t *testing.T) { + // setup types + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + context.Request, _ = http.NewRequest(http.MethodGet, "/workers/host", nil) + + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(Establish()) + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusUnauthorized { + t.Errorf("Establish returned %v, want %v", resp.Code, http.StatusUnauthorized) + } +} + +func TestClaims_Establish_BadToken(t *testing.T) { + // setup types + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + context.Request, _ = http.NewRequest(http.MethodGet, "/workers/host", nil) + + u := new(library.User) + u.SetID(1) + u.SetName("octocat") + u.SetHash("abc") + + // setup database + db, _ := sqlite.NewTest() + + defer func() { + db.Sqlite.Exec("delete from users;") + _sql, _ := db.Sqlite.DB() + _sql.Close() + }() + + _ = db.CreateUser(u) + + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: time.Minute * -1, + TokenType: constants.UserRefreshTokenType, + } + + tkn, _ := tm.MintToken(mto) + + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tkn)) + + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(func(c *gin.Context) { c.Set("secret", "very-secret") }) + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(Establish()) + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusUnauthorized { + t.Errorf("Establish returned %v, want %v", resp.Code, http.StatusUnauthorized) + } +} diff --git a/router/middleware/claims/context.go b/router/middleware/claims/context.go new file mode 100644 index 000000000..5e51b2b4f --- /dev/null +++ b/router/middleware/claims/context.go @@ -0,0 +1,39 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package claims + +import ( + "context" + + "github.com/go-vela/server/internal/token" +) + +const key = "claims" + +// Setter defines a context that enables setting values. +type Setter interface { + Set(string, interface{}) +} + +// FromContext returns the Claims associated with this context. +func FromContext(c context.Context) *token.Claims { + value := c.Value(key) + if value == nil { + return nil + } + + cl, ok := value.(*token.Claims) + if !ok { + return nil + } + + return cl +} + +// ToContext adds the Claims to this context if it supports +// the Setter interface. +func ToContext(c Setter, cl *token.Claims) { + c.Set(key, cl) +} diff --git a/router/middleware/claims/context_test.go b/router/middleware/claims/context_test.go new file mode 100644 index 000000000..54713010c --- /dev/null +++ b/router/middleware/claims/context_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package claims + +import ( + "testing" + "time" + + "github.com/go-vela/server/internal/token" + "github.com/go-vela/types/constants" + "github.com/golang-jwt/jwt/v4" + + "github.com/gin-gonic/gin" +) + +func TestClaims_FromContext(t *testing.T) { + now := time.Now() + want := &token.Claims{ + TokenType: constants.UserAccessTokenType, + IsAdmin: false, + IsActive: true, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "octocat", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute * 1)), + }, + } + + // setup context + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(nil) + context.Set(key, want) + + // run test + got := FromContext(context) + + if got != want { + t.Errorf("FromContext is %v, want %v", got, want) + } +} + +func TestClaims_FromContext_Bad(t *testing.T) { + // setup context + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(nil) + context.Set(key, nil) + + // run test + got := FromContext(context) + + if got != nil { + t.Errorf("FromContext is %v, want nil", got) + } +} + +func TestClaims_FromContext_WrongType(t *testing.T) { + // setup context + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(nil) + context.Set(key, 1) + + // run test + got := FromContext(context) + + if got != nil { + t.Errorf("FromContext is %v, want nil", got) + } +} + +func TestClaims_FromContext_Empty(t *testing.T) { + // setup context + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(nil) + + // run test + got := FromContext(context) + + if got != nil { + t.Errorf("FromContext is %v, want nil", got) + } +} + +func TestClaims_ToContext(t *testing.T) { + // setup types + now := time.Now() + want := &token.Claims{ + TokenType: constants.UserAccessTokenType, + IsAdmin: false, + IsActive: true, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "octocat", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute * 1)), + }, + } + + // setup context + gin.SetMode(gin.TestMode) + context, _ := gin.CreateTestContext(nil) + ToContext(context, want) + + // run test + got := context.Value(key) + + if got != want { + t.Errorf("ToContext is %v, want %v", got, want) + } +} diff --git a/router/middleware/claims/doc.go b/router/middleware/claims/doc.go new file mode 100644 index 000000000..f91ec309c --- /dev/null +++ b/router/middleware/claims/doc.go @@ -0,0 +1,12 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +// Package claims provides the ability for inserting +// token claims resources into or extracting token claims +// resources from the middleware chain for the API. +// +// Usage: +// +// import "github.com/go-vela/server/router/middleware/claims" +package claims diff --git a/router/middleware/perm/perm.go b/router/middleware/perm/perm.go index 0c4c20243..560753ad8 100644 --- a/router/middleware/perm/perm.go +++ b/router/middleware/perm/perm.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -11,34 +11,123 @@ import ( "github.com/gin-gonic/gin" "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/build" + "github.com/go-vela/server/router/middleware/claims" "github.com/go-vela/server/router/middleware/org" "github.com/go-vela/server/router/middleware/repo" "github.com/go-vela/server/router/middleware/user" "github.com/go-vela/server/scm" "github.com/go-vela/server/util" "github.com/go-vela/types/constants" - "github.com/go-vela/types/library" "github.com/sirupsen/logrus" ) // MustPlatformAdmin ensures the user has admin access to the platform. func MustPlatformAdmin() gin.HandlerFunc { return func(c *gin.Context) { - u := user.Retrieve(c) + cl := claims.Retrieve(c) // update engine logger with API metadata // // https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry.WithFields logrus.WithFields(logrus.Fields{ - "user": u.GetName(), - }).Debugf("verifying user %s is a platform admin", u.GetName()) + "user": cl.Subject, + }).Debugf("verifying user %s is a platform admin", cl.Subject) + + switch { + case cl.IsAdmin: + return + + default: + if strings.EqualFold(cl.TokenType, constants.WorkerBuildTokenType) { + logrus.WithFields(logrus.Fields{ + "user": cl.Subject, + "repo": cl.Repo, + "build": cl.BuildID, + }).Warnf("attempted access of admin endpoint with build token from %s", cl.Subject) + } + + retErr := fmt.Errorf("user %s is not a platform admin", cl.Subject) + util.HandleError(c, http.StatusUnauthorized, retErr) + + return + } + } +} + +// MustWorker ensures the request is coming from an agent. +func MustWorker() gin.HandlerFunc { + return func(c *gin.Context) { + cl := claims.Retrieve(c) + + // global permissions bypass + if cl.IsAdmin { + logrus.WithFields(logrus.Fields{ + "user": cl.Subject, + }).Debugf("user %s has platform admin permissions", cl.Subject) + + return + } + + // update engine logger with API metadata + // + // https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry.WithFields + logrus.WithFields(logrus.Fields{ + "subject": cl.Subject, + }).Debugf("verifying user %s is a worker", cl.Subject) + // validate claims as worker switch { - case globalPerms(u): + case (strings.EqualFold(cl.Subject, "vela-worker") && strings.EqualFold(cl.TokenType, constants.ServerWorkerTokenType)): return default: - retErr := fmt.Errorf("user %s is not a platform admin", u.GetName()) + retErr := fmt.Errorf("user %s is not a worker", cl.Subject) + util.HandleError(c, http.StatusUnauthorized, retErr) + + return + } + } +} + +// MustBuildAccess ensures the token is a build token for the appropriate build. +func MustBuildAccess() gin.HandlerFunc { + return func(c *gin.Context) { + cl := claims.Retrieve(c) + b := build.Retrieve(c) + + // global permissions bypass + if cl.IsAdmin { + logrus.WithFields(logrus.Fields{ + "user": cl.Subject, + }).Debugf("user %s has platform admin permissions", cl.Subject) + + return + } + + // update engine logger with API metadata + // + // https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry.WithFields + logrus.WithFields(logrus.Fields{ + "worker": cl.Subject, + }).Debugf("verifying worker %s has a valid build token", cl.Subject) + + // validate token type and match build id in request with build id in token claims + switch cl.TokenType { + case constants.WorkerBuildTokenType: + if b.GetID() == cl.BuildID { + return + } + + logrus.WithFields(logrus.Fields{ + "user": cl.Subject, + "repo": cl.Repo, + "build": cl.BuildID, + }).Warnf("build token for build %d attempted to be used for build %d by %s", cl.BuildID, b.GetID(), cl.Subject) + + fallthrough + default: + retErr := fmt.Errorf("invalid token: must provide matching worker build token") util.HandleError(c, http.StatusUnauthorized, retErr) return @@ -49,11 +138,13 @@ func MustPlatformAdmin() gin.HandlerFunc { // MustSecretAdmin ensures the user has admin access to the org, repo or team. func MustSecretAdmin() gin.HandlerFunc { return func(c *gin.Context) { + cl := claims.Retrieve(c) u := user.Retrieve(c) e := util.PathParameter(c, "engine") t := util.PathParameter(c, "type") o := util.PathParameter(c, "org") n := util.PathParameter(c, "name") + s := util.PathParameter(c, "secret") m := c.Request.Method // create log fields from API metadata @@ -82,10 +173,56 @@ func MustSecretAdmin() gin.HandlerFunc { // https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry.WithFields logger := logrus.WithFields(fields) - if globalPerms(u) { + if u.GetAdmin() { return } + // if caller is worker with build token, verify it has access to requested secret + if strings.EqualFold(cl.TokenType, constants.WorkerBuildTokenType) { + // split repo full name into org and repo + repoSlice := strings.Split(cl.Repo, "/") + if len(repoSlice) != 2 { + logger.Errorf("unable to parse repo claim in build token") + } + + org := repoSlice[0] + repo := repoSlice[1] + + switch t { + case constants.SecretShared: + return + case constants.SecretOrg: + logger.Debugf("verifying subject %s has token permissions for org %s", cl.Subject, o) + + if strings.EqualFold(org, o) { + return + } + + logger.Warnf("build token for build %s/%d attempted to be used for secret %s/%s by %s", cl.Repo, cl.BuildID, o, s, cl.Subject) + + retErr := fmt.Errorf("subject %s does not have token permissions for the org %s", cl.Subject, o) + + util.HandleError(c, http.StatusUnauthorized, retErr) + + return + + case constants.SecretRepo: + logger.Debugf("verifying subject %s has token permissions for repo %s/%s", cl.Subject, o, n) + + if strings.EqualFold(org, o) && strings.EqualFold(repo, n) { + return + } + + logger.Warnf("build token for build %s/%d attempted to be used for secret %s/%s/%s by %s", cl.Repo, cl.BuildID, o, n, s, cl.Subject) + + retErr := fmt.Errorf("subject %s does not have token permissions for the repo %s/%s", cl.Subject, o, n) + + util.HandleError(c, http.StatusUnauthorized, retErr) + + return + } + } + switch t { case constants.SecretOrg: logger.Debugf("verifying user %s has 'admin' permissions for org %s", u.GetName(), o) @@ -178,7 +315,7 @@ func MustAdmin() gin.HandlerFunc { logger.Debugf("verifying user %s has 'admin' permissions for repo %s", u.GetName(), r.GetFullName()) - if globalPerms(u) { + if u.GetAdmin() { return } @@ -236,7 +373,7 @@ func MustWrite() gin.HandlerFunc { logger.Debugf("verifying user %s has 'write' permissions for repo %s", u.GetName(), r.GetFullName()) - if globalPerms(u) { + if u.GetAdmin() { return } @@ -302,7 +439,7 @@ func MustRead() gin.HandlerFunc { logger.Debugf("verifying user %s has 'read' permissions for repo %s", u.GetName(), r.GetFullName()) - if globalPerms(u) { + if u.GetAdmin() { return } @@ -344,17 +481,3 @@ func MustRead() gin.HandlerFunc { } } } - -// helper function to check if the user is a platform admin. -func globalPerms(user *library.User) bool { - switch { - // Agents have full access to endpoints - case user.GetName() == "vela-worker": - return true - // platform admins have full access to endpoints - case user.GetAdmin(): - return true - } - - return false -} diff --git a/router/middleware/perm/perm_test.go b/router/middleware/perm/perm_test.go index 1b560a63a..a47dcef40 100644 --- a/router/middleware/perm/perm_test.go +++ b/router/middleware/perm/perm_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -11,44 +11,726 @@ import ( "testing" "time" + "github.com/go-vela/server/internal/token" + "github.com/go-vela/server/router/middleware/build" + "github.com/go-vela/server/router/middleware/claims" "github.com/go-vela/server/router/middleware/org" + "github.com/go-vela/server/router/middleware/repo" + "github.com/golang-jwt/jwt/v4" + + "github.com/gin-gonic/gin" + "github.com/go-vela/server/database" + "github.com/go-vela/server/database/sqlite" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/scm" + "github.com/go-vela/server/scm/github" + "github.com/go-vela/types/constants" + "github.com/go-vela/types/library" +) + +func TestPerm_MustPlatformAdmin(t *testing.T) { + // setup types + secret := "superSecret" + + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + u := new(library.User) + u.SetID(1) + u.SetName("foo") + u.SetToken("bar") + u.SetHash("baz") + u.SetAdmin(true) + + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) + + // setup database + db, _ := sqlite.NewTest() + + defer func() { + db.Sqlite.Exec("delete from users;") + _sql, _ := db.Sqlite.DB() + _sql.Close() + }() + + _ = db.CreateUser(u) + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + + context.Request, _ = http.NewRequest(http.MethodGet, "/admin/users", nil) + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tok)) + + // setup github mock server + engine.GET("/api/v3/user", func(c *gin.Context) { + c.String(http.StatusOK, userPayload) + }) + + s := httptest.NewServer(engine) + defer s.Close() + + // setup client + client, _ := github.NewTest(s.URL) + + // setup vela mock server + engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) + engine.Use(user.Establish()) + engine.Use(MustPlatformAdmin()) + engine.GET("/admin/users", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + s1 := httptest.NewServer(engine) + defer s1.Close() + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusOK { + t.Errorf("MustPlatAdmin returned %v, want %v", resp.Code, http.StatusOK) + } +} + +func TestPerm_MustPlatformAdmin_NotAdmin(t *testing.T) { + // setup types + secret := "superSecret" + + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + u := new(library.User) + u.SetID(1) + u.SetName("foo") + u.SetToken("bar") + u.SetHash("baz") + u.SetAdmin(false) + + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + + // setup database + db, _ := sqlite.NewTest() + + defer func() { + db.Sqlite.Exec("delete from users;") + _sql, _ := db.Sqlite.DB() + _sql.Close() + }() + + _ = db.CreateUser(u) + + context.Request, _ = http.NewRequest(http.MethodGet, "/admin/users", nil) + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tok)) + + // setup github mock server + engine.GET("/api/v3/user", func(c *gin.Context) { + c.String(http.StatusOK, userPayload) + }) + + s := httptest.NewServer(engine) + defer s.Close() + + // setup client + client, _ := github.NewTest(s.URL) + + // setup vela mock server + engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) + engine.Use(user.Establish()) + engine.Use(MustPlatformAdmin()) + engine.GET("/admin/users", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + s1 := httptest.NewServer(engine) + defer s1.Close() + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusUnauthorized { + t.Errorf("MustPlatAdmin returned %v, want %v", resp.Code, http.StatusUnauthorized) + } +} + +func TestPerm_MustWorker(t *testing.T) { + // setup types + secret := "superSecret" + + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + + context.Request, _ = http.NewRequest(http.MethodGet, "/test/foo/bar", nil) + context.Request.Header.Add("Authorization", fmt.Sprint(secret)) + + // setup vela mock server + engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(claims.Establish()) + engine.Use(user.Establish()) + engine.Use(MustWorker()) + engine.GET("/test/:org/:repo", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + s1 := httptest.NewServer(engine) + defer s1.Close() + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusOK { + t.Errorf("MustWorker returned %v, want %v", resp.Code, http.StatusOK) + } +} + +func TestPerm_MustWorker_PlatAdmin(t *testing.T) { + // setup types + secret := "superSecret" + + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + u := new(library.User) + u.SetID(1) + u.SetName("vela-worker") + u.SetToken("bar") + u.SetHash("baz") + u.SetAdmin(true) + + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + + // setup database + db, _ := sqlite.NewTest() + + defer func() { + db.Sqlite.Exec("delete from users;") + _sql, _ := db.Sqlite.DB() + _sql.Close() + }() + + _ = db.CreateUser(u) + + context.Request, _ = http.NewRequest(http.MethodGet, "/test/foo/bar", nil) + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tok)) + + // setup vela mock server + engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(claims.Establish()) + engine.Use(user.Establish()) + engine.Use(MustWorker()) + engine.GET("/test/:org/:repo", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + s1 := httptest.NewServer(engine) + defer s1.Close() + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusOK { + t.Errorf("MustWorker returned %v, want %v", resp.Code, http.StatusOK) + } +} + +func TestPerm_MustWorker_UserNamedVelaWorker(t *testing.T) { + // setup types + secret := "superSecret" + + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + u := new(library.User) + u.SetID(1) + u.SetName("vela-worker") + u.SetToken("bar") + u.SetHash("baz") + u.SetAdmin(false) + + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + + // setup database + db, _ := sqlite.NewTest() + + defer func() { + db.Sqlite.Exec("delete from users;") + _sql, _ := db.Sqlite.DB() + _sql.Close() + }() + + _ = db.CreateUser(u) + + context.Request, _ = http.NewRequest(http.MethodGet, "/test/foo/bar", nil) + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tok)) + + // setup vela mock server + engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(claims.Establish()) + engine.Use(user.Establish()) + engine.Use(MustWorker()) + engine.GET("/test/:org/:repo", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + s1 := httptest.NewServer(engine) + defer s1.Close() + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusUnauthorized { + t.Errorf("MustWorker returned %v, want %v", resp.Code, http.StatusUnauthorized) + } +} + +func TestPerm_MustBuildAccess(t *testing.T) { + // setup types + secret := "superSecret" + + r := new(library.Repo) + r.SetID(1) + r.SetUserID(1) + r.SetHash("baz") + r.SetOrg("foo") + r.SetName("bar") + r.SetFullName("foo/bar") + r.SetVisibility("public") + + b := new(library.Build) + b.SetID(1) + b.SetRepoID(1) + b.SetNumber(1) + + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + mto := &token.MintTokenOpts{ + Hostname: "worker", + BuildID: 1, + Repo: "foo/bar", + TokenDuration: time.Minute * 30, + TokenType: constants.WorkerBuildTokenType, + } + + tok, _ := tm.MintToken(mto) + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + + // setup database + db, _ := sqlite.NewTest() + + defer func() { + db.Sqlite.Exec("delete from repos;") + db.Sqlite.Exec("delete from users;") + _sql, _ := db.Sqlite.DB() + _sql.Close() + }() + + _ = db.CreateRepo(r) + _ = db.CreateBuild(b) + + context.Request, _ = http.NewRequest(http.MethodGet, "/test/foo/bar/builds/1", nil) + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tok)) + + // setup vela mock server + engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(claims.Establish()) + engine.Use(user.Establish()) + engine.Use(org.Establish()) + engine.Use(repo.Establish()) + engine.Use(build.Establish()) + engine.Use(MustBuildAccess()) + engine.GET("/test/:org/:repo/builds/:build", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + s1 := httptest.NewServer(engine) + defer s1.Close() + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusOK { + t.Errorf("MustBuildAccess returned %v, want %v", resp.Code, http.StatusOK) + } +} + +func TestPerm_MustBuildAccess_PlatAdmin(t *testing.T) { + // setup types + secret := "superSecret" + + r := new(library.Repo) + r.SetID(1) + r.SetUserID(1) + r.SetHash("baz") + r.SetOrg("foo") + r.SetName("bar") + r.SetFullName("foo/bar") + r.SetVisibility("public") + + b := new(library.Build) + b.SetID(1) + b.SetRepoID(1) + b.SetNumber(1) + + u := new(library.User) + u.SetID(1) + u.SetName("admin") + u.SetToken("bar") + u.SetHash("baz") + u.SetAdmin(true) + + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + + // setup database + db, _ := sqlite.NewTest() + + defer func() { + db.Sqlite.Exec("delete from repos;") + db.Sqlite.Exec("delete from users;") + db.Sqlite.Exec("delete from builds;") + _sql, _ := db.Sqlite.DB() + _sql.Close() + }() + + _ = db.CreateRepo(r) + _ = db.CreateBuild(b) + _ = db.CreateUser(u) + + context.Request, _ = http.NewRequest(http.MethodGet, "/test/foo/bar/builds/1", nil) + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tok)) + + // setup vela mock server + engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(claims.Establish()) + engine.Use(user.Establish()) + engine.Use(org.Establish()) + engine.Use(repo.Establish()) + engine.Use(build.Establish()) + engine.Use(MustBuildAccess()) + engine.GET("/test/:org/:repo/builds/:build", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + s1 := httptest.NewServer(engine) + defer s1.Close() + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusOK { + t.Errorf("MustBuildAccess returned %v, want %v", resp.Code, http.StatusOK) + } +} + +func TestPerm_MustBuildToken_WrongBuild(t *testing.T) { + // setup types + secret := "superSecret" + + r := new(library.Repo) + r.SetID(1) + r.SetUserID(1) + r.SetHash("baz") + r.SetOrg("foo") + r.SetName("bar") + r.SetFullName("foo/bar") + r.SetVisibility("public") + + b := new(library.Build) + b.SetID(1) + b.SetRepoID(1) + b.SetNumber(1) + + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + mto := &token.MintTokenOpts{ + Hostname: "worker", + BuildID: 2, + Repo: "foo/bar", + TokenDuration: time.Minute * 30, + TokenType: constants.WorkerBuildTokenType, + } + + tok, _ := tm.MintToken(mto) + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + + // setup database + db, _ := sqlite.NewTest() + + defer func() { + db.Sqlite.Exec("delete from repos;") + db.Sqlite.Exec("delete from users;") + _sql, _ := db.Sqlite.DB() + _sql.Close() + }() - "github.com/gin-gonic/gin" - "github.com/go-vela/server/database" - "github.com/go-vela/server/database/sqlite" - "github.com/go-vela/server/router/middleware/repo" - "github.com/go-vela/server/router/middleware/token" - "github.com/go-vela/server/router/middleware/user" - "github.com/go-vela/server/scm" - "github.com/go-vela/server/scm/github" - "github.com/go-vela/types/library" -) + _ = db.CreateRepo(r) + _ = db.CreateBuild(b) -const accessTokenDuration = time.Minute * 15 + context.Request, _ = http.NewRequest(http.MethodGet, "/test/foo/bar/builds/1", nil) + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tok)) -func TestPerm_MustPlatformAdmin(t *testing.T) { + // setup vela mock server + engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(claims.Establish()) + engine.Use(user.Establish()) + engine.Use(org.Establish()) + engine.Use(repo.Establish()) + engine.Use(build.Establish()) + engine.Use(MustBuildAccess()) + engine.GET("/test/:org/:repo/builds/:build", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + s1 := httptest.NewServer(engine) + defer s1.Close() + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusUnauthorized { + t.Errorf("MustBuildAccess returned %v, want %v", resp.Code, http.StatusOK) + } +} + +func TestPerm_MustSecretAdmin_BuildToken_Repo(t *testing.T) { // setup types secret := "superSecret" - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - u.SetAdmin(true) + r := new(library.Repo) + r.SetID(1) + r.SetUserID(1) + r.SetHash("baz") + r.SetOrg("foo") + r.SetName("bar") + r.SetFullName("foo/bar") + r.SetVisibility("public") + + b := new(library.Build) + b.SetID(1) + b.SetRepoID(1) + b.SetNumber(1) + + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + mto := &token.MintTokenOpts{ + Hostname: "worker", + BuildID: 1, + Repo: "foo/bar", + TokenDuration: time.Minute * 30, + TokenType: constants.WorkerBuildTokenType, + } + + tok, _ := tm.MintToken(mto) + + // setup context + gin.SetMode(gin.TestMode) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) // setup database db, _ := sqlite.NewTest() defer func() { + db.Sqlite.Exec("delete from repos;") db.Sqlite.Exec("delete from users;") _sql, _ := db.Sqlite.DB() _sql.Close() }() - _ = db.CreateUser(u) + _ = db.CreateRepo(r) + _ = db.CreateBuild(b) + + context.Request, _ = http.NewRequest(http.MethodGet, "/test/native/repo/foo/bar/baz", nil) + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tok)) + + // setup vela mock server + engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(claims.Establish()) + engine.Use(user.Establish()) + engine.Use(MustSecretAdmin()) + engine.GET("/test/:engine/:type/:org/:name/:secret", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + s1 := httptest.NewServer(engine) + defer s1.Close() + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusOK { + t.Errorf("MustBuildAccess returned %v, want %v", resp.Code, http.StatusOK) + } +} + +func TestPerm_MustSecretAdmin_BuildToken_Org(t *testing.T) { + // setup types + secret := "superSecret" + + r := new(library.Repo) + r.SetID(1) + r.SetUserID(1) + r.SetHash("baz") + r.SetOrg("foo") + r.SetName("bar") + r.SetFullName("foo/bar") + r.SetVisibility("public") + + b := new(library.Build) + b.SetID(1) + b.SetRepoID(1) + b.SetNumber(1) + + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + mto := &token.MintTokenOpts{ + Hostname: "worker", + BuildID: 1, + Repo: "foo/bar", + TokenDuration: time.Minute * 30, + TokenType: constants.WorkerBuildTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -56,27 +738,30 @@ func TestPerm_MustPlatformAdmin(t *testing.T) { resp := httptest.NewRecorder() context, engine := gin.CreateTestContext(resp) - context.Request, _ = http.NewRequest(http.MethodGet, "/admin/users", nil) - context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tok)) + // setup database + db, _ := sqlite.NewTest() - // setup github mock server - engine.GET("/api/v3/user", func(c *gin.Context) { - c.String(http.StatusOK, userPayload) - }) + defer func() { + db.Sqlite.Exec("delete from repos;") + db.Sqlite.Exec("delete from users;") + _sql, _ := db.Sqlite.DB() + _sql.Close() + }() - s := httptest.NewServer(engine) - defer s.Close() + _ = db.CreateRepo(r) + _ = db.CreateBuild(b) - // setup client - client, _ := github.NewTest(s.URL) + context.Request, _ = http.NewRequest(http.MethodGet, "/test/native/org/foo/*/baz", nil) + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tok)) // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) - engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) - engine.Use(MustPlatformAdmin()) - engine.GET("/admin/users", func(c *gin.Context) { + engine.Use(MustSecretAdmin()) + engine.GET("/test/:engine/:type/:org/:name/:secret", func(c *gin.Context) { c.Status(http.StatusOK) }) @@ -87,22 +772,44 @@ func TestPerm_MustPlatformAdmin(t *testing.T) { engine.ServeHTTP(context.Writer, context.Request) if resp.Code != http.StatusOK { - t.Errorf("MustPlatAdmin returned %v, want %v", resp.Code, http.StatusOK) + t.Errorf("MustSecretAdmin returned %v, want %v", resp.Code, http.StatusOK) } } -func TestPerm_MustPlatformAdmin_NotAdmin(t *testing.T) { +func TestPerm_MustSecretAdmin_BuildToken_Shared(t *testing.T) { // setup types secret := "superSecret" - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - u.SetAdmin(false) + r := new(library.Repo) + r.SetID(1) + r.SetUserID(1) + r.SetHash("baz") + r.SetOrg("foo") + r.SetName("bar") + r.SetFullName("foo/bar") + r.SetVisibility("public") + + b := new(library.Build) + b.SetID(1) + b.SetRepoID(1) + b.SetNumber(1) + + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + mto := &token.MintTokenOpts{ + Hostname: "worker", + BuildID: 1, + Repo: "foo/bar", + TokenDuration: time.Minute * 30, + TokenType: constants.WorkerBuildTokenType, + } - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -114,34 +821,26 @@ func TestPerm_MustPlatformAdmin_NotAdmin(t *testing.T) { db, _ := sqlite.NewTest() defer func() { + db.Sqlite.Exec("delete from repos;") db.Sqlite.Exec("delete from users;") _sql, _ := db.Sqlite.DB() _sql.Close() }() - _ = db.CreateUser(u) + _ = db.CreateRepo(r) + _ = db.CreateBuild(b) - context.Request, _ = http.NewRequest(http.MethodGet, "/admin/users", nil) + context.Request, _ = http.NewRequest(http.MethodGet, "/test/native/shared/foo/*/*", nil) context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tok)) - // setup github mock server - engine.GET("/api/v3/user", func(c *gin.Context) { - c.String(http.StatusOK, userPayload) - }) - - s := httptest.NewServer(engine) - defer s.Close() - - // setup client - client, _ := github.NewTest(s.URL) - // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) - engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) - engine.Use(MustPlatformAdmin()) - engine.GET("/admin/users", func(c *gin.Context) { + engine.Use(MustSecretAdmin()) + engine.GET("/test/:engine/:type/:org/:name/:secret", func(c *gin.Context) { c.Status(http.StatusOK) }) @@ -151,8 +850,8 @@ func TestPerm_MustPlatformAdmin_NotAdmin(t *testing.T) { // run test engine.ServeHTTP(context.Writer, context.Request) - if resp.Code != http.StatusUnauthorized { - t.Errorf("MustPlatAdmin returned %v, want %v", resp.Code, http.StatusUnauthorized) + if resp.Code != http.StatusOK { + t.Errorf("MustSecretAdmin returned %v, want %v", resp.Code, http.StatusOK) } } @@ -160,6 +859,13 @@ func TestPerm_MustAdmin(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -176,7 +882,13 @@ func TestPerm_MustAdmin(t *testing.T) { u.SetHash("baz") u.SetAdmin(false) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -216,8 +928,10 @@ func TestPerm_MustAdmin(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -241,6 +955,13 @@ func TestPerm_MustAdmin_PlatAdmin(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -257,7 +978,13 @@ func TestPerm_MustAdmin_PlatAdmin(t *testing.T) { u.SetHash("baz") u.SetAdmin(true) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -297,8 +1024,10 @@ func TestPerm_MustAdmin_PlatAdmin(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -322,6 +1051,13 @@ func TestPerm_MustAdmin_NotAdmin(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -338,7 +1074,13 @@ func TestPerm_MustAdmin_NotAdmin(t *testing.T) { u.SetHash("baz") u.SetAdmin(false) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -378,8 +1120,10 @@ func TestPerm_MustAdmin_NotAdmin(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -403,6 +1147,13 @@ func TestPerm_MustWrite(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -419,7 +1170,13 @@ func TestPerm_MustWrite(t *testing.T) { u.SetHash("baz") u.SetAdmin(false) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -459,8 +1216,10 @@ func TestPerm_MustWrite(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -484,6 +1243,13 @@ func TestPerm_MustWrite_PlatAdmin(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -500,7 +1266,13 @@ func TestPerm_MustWrite_PlatAdmin(t *testing.T) { u.SetHash("baz") u.SetAdmin(true) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -540,8 +1312,10 @@ func TestPerm_MustWrite_PlatAdmin(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -565,6 +1339,13 @@ func TestPerm_MustWrite_RepoAdmin(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -581,7 +1362,13 @@ func TestPerm_MustWrite_RepoAdmin(t *testing.T) { u.SetHash("baz") u.SetAdmin(false) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -621,8 +1408,10 @@ func TestPerm_MustWrite_RepoAdmin(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -646,6 +1435,13 @@ func TestPerm_MustWrite_NotWrite(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -662,7 +1458,13 @@ func TestPerm_MustWrite_NotWrite(t *testing.T) { u.SetHash("baz") u.SetAdmin(false) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -702,8 +1504,10 @@ func TestPerm_MustWrite_NotWrite(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -727,6 +1531,13 @@ func TestPerm_MustRead(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -743,7 +1554,13 @@ func TestPerm_MustRead(t *testing.T) { u.SetHash("baz") u.SetAdmin(false) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -783,8 +1600,10 @@ func TestPerm_MustRead(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -808,6 +1627,13 @@ func TestPerm_MustRead_PlatAdmin(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -824,7 +1650,13 @@ func TestPerm_MustRead_PlatAdmin(t *testing.T) { u.SetHash("baz") u.SetAdmin(true) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -864,8 +1696,10 @@ func TestPerm_MustRead_PlatAdmin(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -889,6 +1723,13 @@ func TestPerm_MustRead_RepoAdmin(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -905,7 +1746,13 @@ func TestPerm_MustRead_RepoAdmin(t *testing.T) { u.SetHash("baz") u.SetAdmin(false) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -945,8 +1792,10 @@ func TestPerm_MustRead_RepoAdmin(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -970,6 +1819,13 @@ func TestPerm_MustRead_RepoWrite(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -986,7 +1842,13 @@ func TestPerm_MustRead_RepoWrite(t *testing.T) { u.SetHash("baz") u.SetAdmin(false) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -1026,8 +1888,10 @@ func TestPerm_MustRead_RepoWrite(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -1051,6 +1915,13 @@ func TestPerm_MustRead_RepoPublic(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -1067,7 +1938,13 @@ func TestPerm_MustRead_RepoPublic(t *testing.T) { u.SetHash("baz") u.SetAdmin(false) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -1107,8 +1984,10 @@ func TestPerm_MustRead_RepoPublic(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -1132,6 +2011,13 @@ func TestPerm_MustRead_NotRead(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -1148,7 +2034,13 @@ func TestPerm_MustRead_NotRead(t *testing.T) { u.SetHash("baz") u.SetAdmin(false) - tok, _ := token.CreateAccessToken(u, accessTokenDuration) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + tok, _ := tm.MintToken(mto) // setup context gin.SetMode(gin.TestMode) @@ -1188,8 +2080,10 @@ func TestPerm_MustRead_NotRead(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(user.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) @@ -1209,57 +2103,6 @@ func TestPerm_MustRead_NotRead(t *testing.T) { } } -func TestPerm_globalPerms(t *testing.T) { - // setup types - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - u.SetAdmin(false) - - // run test - got := globalPerms(u) - - if got { - t.Errorf("globalPerms returned %v, want false", got) - } -} - -func TestPerm_globalPerms_Agent(t *testing.T) { - // setup types - u := new(library.User) - u.SetID(1) - u.SetName("vela-worker") - u.SetToken("bar") - u.SetHash("baz") - u.SetAdmin(false) - - // run test - got := globalPerms(u) - - if !got { - t.Errorf("globalPerms returned %v, want true", got) - } -} - -func TestPerm_globalPerms_Admin(t *testing.T) { - // setup types - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - u.SetAdmin(true) - - // run test - got := globalPerms(u) - - if !got { - t.Errorf("globalPerms returned %v, want true", got) - } -} - const permAdminPayload = ` { "permission": "admin", diff --git a/router/middleware/pipeline/pipeline_test.go b/router/middleware/pipeline/pipeline_test.go index 573d2ae0a..090a3b3f5 100644 --- a/router/middleware/pipeline/pipeline_test.go +++ b/router/middleware/pipeline/pipeline_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -18,14 +18,17 @@ import ( "github.com/go-vela/server/compiler/native" "github.com/go-vela/server/database" "github.com/go-vela/server/database/sqlite" + "github.com/go-vela/server/internal/token" + "github.com/go-vela/server/router/middleware/claims" "github.com/go-vela/server/router/middleware/org" "github.com/go-vela/server/router/middleware/repo" - "github.com/go-vela/server/router/middleware/token" "github.com/go-vela/server/router/middleware/user" "github.com/go-vela/server/scm" "github.com/go-vela/server/scm/github" "github.com/go-vela/types" + "github.com/go-vela/types/constants" "github.com/go-vela/types/library" + "github.com/golang-jwt/jwt/v4" "github.com/urfave/cli/v2" ) @@ -210,6 +213,13 @@ func TestPipeline_Establish_NoPipeline(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + r := new(library.Repo) r.SetID(1) r.SetUserID(1) @@ -246,9 +256,15 @@ func TestPipeline_Establish_NoPipeline(t *testing.T) { }, } - tok, err := token.CreateAccessToken(u, time.Minute*15) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + at, err := tm.MintToken(mto) if err != nil { - t.Errorf("unable to create access token: %v", err) + t.Errorf("unable to mint user access token: %v", err) } set := flag.NewFlagSet("test", 0) @@ -278,7 +294,7 @@ func TestPipeline_Establish_NoPipeline(t *testing.T) { resp := httptest.NewRecorder() context, engine := gin.CreateTestContext(resp) context.Request, _ = http.NewRequest(http.MethodGet, "/pipelines/foo/bar/148afb5bdc41ad69bf22588491333f7cf71135163", nil) - context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", tok)) + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", at)) // setup github mock server engine.GET("/api/v3/repos/:org/:repo/contents/:path", func(c *gin.Context) { @@ -295,10 +311,12 @@ func TestPipeline_Establish_NoPipeline(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("metadata", m) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) engine.Use(func(c *gin.Context) { compiler.WithGinContext(c, comp) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(org.Establish()) engine.Use(repo.Establish()) engine.Use(user.Establish()) diff --git a/router/middleware/token/token.go b/router/middleware/token/token.go deleted file mode 100644 index 7bd9ae257..000000000 --- a/router/middleware/token/token.go +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. -// -// Use of this source code is governed by the LICENSE file in this repository. - -package token - -import ( - "errors" - "fmt" - "net/http" - "net/url" - "time" - - "github.com/gin-gonic/gin" - "github.com/go-vela/server/database" - "github.com/go-vela/types" - "github.com/go-vela/types/constants" - "github.com/go-vela/types/library" - - "github.com/golang-jwt/jwt/v4" - "github.com/golang-jwt/jwt/v4/request" - "github.com/sirupsen/logrus" -) - -//lint:ignore SA1019 ignore deprecated -type Claims struct { - IsAdmin bool `json:"is_admin"` - IsActive bool `json:"is_active"` - jwt.StandardClaims -} - -// Compose generates an refresh and access token pair unique -// to the provided user and sets a secure cookie. -// It uses a secret hash, which is unique for every user. -// The hash signs the token to guarantee the signature is unique -// per token. The refresh token is returned to store with the user -// in the database. -func Compose(c *gin.Context, u *library.User) (string, string, error) { - // grab the metadata from the context to pull in provided - // cookie duration information - m := c.MustGet("metadata").(*types.Metadata) - - // create a refresh with the provided duration - refreshToken, refreshExpiry, err := CreateRefreshToken(u, m.Vela.RefreshTokenDuration) - if err != nil { - return "", "", err - } - - // create an access token with the provided duration - accessToken, err := CreateAccessToken(u, m.Vela.AccessTokenDuration) - if err != nil { - return "", "", err - } - - // parse the address for the backend server - // so we can set it for the cookie domain - addr, err := url.Parse(m.Vela.Address) - if err != nil { - return "", "", err - } - - // set the SameSite value for the cookie - // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#samesite-attribute - // We set to Lax because we will have links from source provider web UI. - // Setting this to Strict would force a login when navigating via source provider web UI links. - c.SetSameSite(http.SameSiteLaxMode) - // set the cookie with the refresh token as a HttpOnly, Secure cookie - // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#httponly-attribute - // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#secure-attribute - c.SetCookie(constants.RefreshTokenName, refreshToken, refreshExpiry, "/", addr.Hostname(), c.Value("securecookie").(bool), true) - - // return the refresh and access tokens - return refreshToken, accessToken, nil -} - -// Parse scans the signed JWT token as a string and extracts -// the user login from the claims to be looked up in the database. -// This function will return an error for a few different reasons: -// -// * the token signature doesn't match what is expected -// * the token signing method doesn't match what is expected -// * the token is invalid (potentially expired or improper). -func Parse(t string, db database.Service) (*library.User, error) { - u := new(library.User) - - // create a new JWT parser - p := &jwt.Parser{ - // explicitly only allow these signing methods - ValidMethods: []string{jwt.SigningMethodHS256.Name}, - } - - // parse the signed JWT token string - // parse also validates the claims and token by default. - _, err := p.ParseWithClaims(t, &Claims{}, func(token *jwt.Token) (interface{}, error) { - var err error - - // extract the claims from the token - claims := token.Claims.(*Claims) - name := claims.Subject - - // check if subject has a value in claims; - // we can save a db lookup attempt - if len(name) == 0 { - return nil, errors.New("no subject defined") - } - - // ParseWithClaims will skip expiration check - // if expiration has default value; - // forcing a check and exiting if not set - if claims.ExpiresAt == 0 { - return nil, errors.New("token has no expiration") - } - - // lookup the user in the database - logrus.WithField("user", name).Debugf("reading user %s", name) - u, err = db.GetUserForName(name) - return []byte(u.GetHash()), err - }) - - // there will be an error if we're not able to parse - // the token, eg. due to expiration, invalid signature, etc - if err != nil { - return nil, fmt.Errorf("invalid token provided for %s: %w", u.GetName(), err) - } - - return u, nil -} - -// RetrieveAccessToken gets the passed in access token from the header in the request. -func RetrieveAccessToken(r *http.Request) (accessToken string, err error) { - accessToken, err = request.AuthorizationHeaderExtractor.ExtractToken(r) - - return -} - -// RetrieveRefreshToken gets the refresh token sent along with the request as a cookie. -func RetrieveRefreshToken(r *http.Request) (string, error) { - refreshToken, err := r.Cookie(constants.RefreshTokenName) - - if refreshToken == nil || len(refreshToken.Value) == 0 { - // cookie will not be sent if it has expired - return "", fmt.Errorf("refresh token expired or not provided") - } - - return refreshToken.Value, err -} - -// CreateAccessToken creates a new access token for the given user and duration. -// -//nolint:staticcheck // ignore deprecated -func CreateAccessToken(u *library.User, d time.Duration) (string, error) { - now := time.Now() - exp := now.Add(d) - - claims := &Claims{ - IsActive: u.GetActive(), - IsAdmin: u.GetAdmin(), - StandardClaims: jwt.StandardClaims{ - Subject: u.GetName(), - IssuedAt: now.Unix(), - ExpiresAt: exp.Unix(), - }, - } - - t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - token, err := t.SignedString([]byte(u.GetHash())) - if err != nil { - return "", err - } - - return token, nil -} - -// CreateCreateRefreshToken creates a new refresh token for the given user and duration. -// -//nolint:staticcheck // ignore deprecated -func CreateRefreshToken(u *library.User, d time.Duration) (string, int, error) { - exp := time.Now().Add(d) - - claims := jwt.StandardClaims{} - claims.Subject = u.GetName() - claims.ExpiresAt = exp.Unix() - - t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - refreshToken, err := t.SignedString([]byte(u.GetHash())) - if err != nil { - return "", 0, err - } - - return refreshToken, int(d.Seconds()), nil -} - -// Refresh returns a new access token, if the provided refreshToken is valid. -func Refresh(c *gin.Context, refreshToken string) (string, error) { - // get the metadata - m := c.MustGet("metadata").(*types.Metadata) - // get a reference to the database - db := database.FromContext(c) - - // parse (which also validates) the token - u, err := Parse(refreshToken, db) - if err != nil { - return "", err - } - - // create a new access token - at, err := CreateAccessToken(u, m.Vela.AccessTokenDuration) - if err != nil { - return "", err - } - - return at, nil -} diff --git a/router/middleware/token/token_test.go b/router/middleware/token/token_test.go deleted file mode 100644 index 6add3ed56..000000000 --- a/router/middleware/token/token_test.go +++ /dev/null @@ -1,541 +0,0 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. -// -// Use of this source code is governed by the LICENSE file in this repository. - -//nolint:staticcheck // ignore deprecated -package token - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "reflect" - "strings" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/go-vela/server/database/sqlite" - "github.com/go-vela/types" - "github.com/go-vela/types/constants" - "github.com/go-vela/types/library" - - jwt "github.com/golang-jwt/jwt/v4" -) - -func TestToken_Compose(t *testing.T) { - // setup types - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - - d := time.Minute * 5 - now := time.Now() - exp := now.Add(d) - - claims := &Claims{ - IsActive: u.GetActive(), - IsAdmin: u.GetAdmin(), - StandardClaims: jwt.StandardClaims{ - Subject: u.GetName(), - IssuedAt: now.Unix(), - ExpiresAt: exp.Unix(), - }, - } - - tkn := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - want, err := tkn.SignedString([]byte(u.GetHash())) - if err != nil { - t.Errorf("Unable to create test token: %v", err) - } - - m := &types.Metadata{ - Vela: &types.Vela{ - AccessTokenDuration: d, - }, - } - - gin.SetMode(gin.TestMode) - - resp := httptest.NewRecorder() - context, _ := gin.CreateTestContext(resp) - context.Set("metadata", m) - context.Set("securecookie", false) - - // run test - _, got, err := Compose(context, u) - if err != nil { - t.Errorf("Compose returned err: %v", err) - } - - if !strings.EqualFold(got, want) { - t.Errorf("Compose is %v, want %v", got, want) - } -} - -func TestToken_Parse(t *testing.T) { - // setup types - want := new(library.User) - want.SetID(1) - want.SetName("foo") - want.SetRefreshToken("fresh") - want.SetToken("bar") - want.SetHash("baz") - want.SetActive(false) - want.SetAdmin(false) - want.SetFavorites([]string{}) - - m := &types.Metadata{ - Vela: &types.Vela{ - AccessTokenDuration: time.Minute * 5, - }, - } - - gin.SetMode(gin.TestMode) - - resp := httptest.NewRecorder() - context, _ := gin.CreateTestContext(resp) - context.Set("metadata", m) - - tkn, err := CreateAccessToken(want, time.Minute*5) - if err != nil { - t.Errorf("Unable to create token: %v", err) - } - - // setup database - db, _ := sqlite.NewTest() - - defer func() { - db.Sqlite.Exec("delete from users;") - _sql, _ := db.Sqlite.DB() - _sql.Close() - }() - - _ = db.CreateUser(want) - - // run test - got, err := Parse(tkn, db) - if err != nil { - t.Errorf("Parse returned err: %v", err) - } - - if !reflect.DeepEqual(got, want) { - t.Errorf("Parse is %v, want %v", got, want) - } -} - -func TestToken_Parse_Error_NoParse(t *testing.T) { - // setup types - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - - // setup database - db, _ := sqlite.NewTest() - - defer func() { - db.Sqlite.Exec("delete from users;") - _sql, _ := db.Sqlite.DB() - _sql.Close() - }() - - _ = db.CreateUser(u) - - // run test - got, err := Parse("!@#$%^&*()", db) - if err == nil { - t.Errorf("Parse should have returned err") - } - - if got != nil { - t.Errorf("Parse is %v, want nil", got) - } -} - -func TestToken_Parse_Error_InvalidSignature(t *testing.T) { - // setup types - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - - claims := &Claims{ - IsActive: u.GetActive(), - IsAdmin: u.GetAdmin(), - StandardClaims: jwt.StandardClaims{ - Subject: u.GetName(), - }, - } - tkn := jwt.NewWithClaims(jwt.SigningMethodHS512, claims) - - token, err := tkn.SignedString([]byte(u.GetHash())) - if err != nil { - t.Errorf("Unable to create test token: %v", err) - } - - // setup database - db, _ := sqlite.NewTest() - - defer func() { - db.Sqlite.Exec("delete from users;") - _sql, _ := db.Sqlite.DB() - _sql.Close() - }() - - _ = db.CreateUser(u) - - // run test - got, err := Parse(token, db) - if err == nil { - t.Errorf("Parse should have returned err") - } - - if got != nil { - t.Errorf("Parse is %v, want nil", got) - } -} - -func TestToken_Parse_AccessToken_Expired(t *testing.T) { - // setup types - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - - tkn, err := CreateAccessToken(u, time.Minute*-1) - if err != nil { - t.Errorf("Unable to create token: %v", err) - } - - // setup database - db, _ := sqlite.NewTest() - - defer func() { - db.Sqlite.Exec("delete from users;") - _sql, _ := db.Sqlite.DB() - _sql.Close() - }() - - _ = db.CreateUser(u) - - // run test - _, err = Parse(tkn, db) - if err == nil { - t.Errorf("Parse should return error due to expiration") - } -} - -func TestToken_Parse_AccessToken_NoSubject(t *testing.T) { - // setup types - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - - claims := &Claims{ - IsActive: u.GetActive(), - IsAdmin: u.GetAdmin(), - StandardClaims: jwt.StandardClaims{ - ExpiresAt: 42, - }, - } - tkn := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - token, err := tkn.SignedString([]byte(u.GetHash())) - if err != nil { - t.Errorf("Unable to create test token: %v", err) - } - - // setup database - db, _ := sqlite.NewTest() - - defer func() { - db.Sqlite.Exec("delete from users;") - _sql, _ := db.Sqlite.DB() - _sql.Close() - }() - - _ = db.CreateUser(u) - - // run test - got, err := Parse(token, db) - if err == nil { - t.Errorf("Parse should have returned err") - } - - if got != nil { - t.Errorf("Parse is %v, want nil", got) - } -} - -func TestToken_Parse_AccessToken_NoExpiration(t *testing.T) { - // setup types - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - - claims := &Claims{ - IsActive: u.GetActive(), - IsAdmin: u.GetAdmin(), - StandardClaims: jwt.StandardClaims{ - Subject: u.GetName(), - }, - } - tkn := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - - token, err := tkn.SignedString([]byte(u.GetHash())) - if err != nil { - t.Errorf("Unable to create test token: %v", err) - } - - // setup database - db, _ := sqlite.NewTest() - - defer func() { - db.Sqlite.Exec("delete from users;") - _sql, _ := db.Sqlite.DB() - _sql.Close() - }() - - _ = db.CreateUser(u) - - // run test - got, err := Parse(token, db) - if err == nil { - t.Errorf("Parse should have returned err") - } - - if got != nil { - t.Errorf("Parse is %v, want nil", got) - } -} - -func TestToken_Refresh(t *testing.T) { - // setup types - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - - d := time.Minute * 5 - - m := &types.Metadata{ - Vela: &types.Vela{ - AccessTokenDuration: d, - }, - } - - rt, _, err := CreateRefreshToken(u, d) - if err != nil { - t.Errorf("unable to create refresh token") - } - - u.SetRefreshToken(rt) - - // setup database - db, _ := sqlite.NewTest() - - defer func() { - db.Sqlite.Exec("delete from users;") - _sql, _ := db.Sqlite.DB() - _sql.Close() - }() - - _ = db.CreateUser(u) - - // set up context - gin.SetMode(gin.TestMode) - - resp := httptest.NewRecorder() - context, _ := gin.CreateTestContext(resp) - context.Set("metadata", m) - context.Set("database", db) - - // run tests - got, err := Refresh(context, rt) - if err != nil { - t.Error("Refresh should not error") - } - - if len(got) == 0 { - t.Errorf("Refresh should have returned an access token") - } -} - -func TestToken_Refresh_Expired(t *testing.T) { - // setup types - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - - d := time.Minute * -1 - - m := &types.Metadata{ - Vela: &types.Vela{ - AccessTokenDuration: d, - }, - } - - rt, _, err := CreateRefreshToken(u, d) - if err != nil { - t.Errorf("unable to create refresh token") - } - - u.SetRefreshToken(rt) - - // setup database - db, _ := sqlite.NewTest() - - defer func() { - db.Sqlite.Exec("delete from users;") - _sql, _ := db.Sqlite.DB() - _sql.Close() - }() - - _ = db.CreateUser(u) - - // set up context - gin.SetMode(gin.TestMode) - - resp := httptest.NewRecorder() - context, _ := gin.CreateTestContext(resp) - context.Set("metadata", m) - context.Set("database", db) - - // run tests - _, err = Refresh(context, rt) - if err == nil { - t.Error("Refresh with expired token should error") - } -} - -func TestToken_Refresh_TokenMissing(t *testing.T) { - // setup types - u := new(library.User) - u.SetID(1) - u.SetName("foo") - u.SetToken("bar") - u.SetHash("baz") - - d := time.Minute * -1 - - m := &types.Metadata{ - Vela: &types.Vela{ - AccessTokenDuration: d, - }, - } - - rt, _, err := CreateRefreshToken(u, d) - if err != nil { - t.Errorf("unable to create refresh token") - } - - // setup database - db, _ := sqlite.NewTest() - - defer func() { - db.Sqlite.Exec("delete from users;") - _sql, _ := db.Sqlite.DB() - _sql.Close() - }() - - _ = db.CreateUser(u) - - // set up context - gin.SetMode(gin.TestMode) - - resp := httptest.NewRecorder() - context, _ := gin.CreateTestContext(resp) - context.Set("metadata", m) - context.Set("database", db) - - // run tests - _, err = Refresh(context, rt) - if err == nil { - t.Error("Refresh with token that doesn't exist in database should error") - } -} - -func TestToken_Retrieve_Refresh(t *testing.T) { - // setup types - want := "fresh" - - request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) - request.AddCookie(&http.Cookie{ - Name: constants.RefreshTokenName, - Value: want, - }) - - // run test - got, err := RetrieveRefreshToken(request) - if err != nil { - t.Errorf("Retrieve returned err: %v", err) - } - - if !strings.EqualFold(got, want) { - t.Errorf("Retrieve is %v, want %v", got, want) - } -} - -func TestToken_Retrieve_Access(t *testing.T) { - // setup types - want := "foobar" - - header := fmt.Sprintf("Bearer %s", want) - request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) - request.Header.Set("Authorization", header) - - // run test - got, err := RetrieveAccessToken(request) - if err != nil { - t.Errorf("Retrieve returned err: %v", err) - } - - if !strings.EqualFold(got, want) { - t.Errorf("Retrieve is %v, want %v", got, want) - } -} - -func TestToken_Retrieve_Access_Error(t *testing.T) { - // setup types - request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) - - // run test - got, err := RetrieveAccessToken(request) - if err == nil { - t.Errorf("Retrieve should have returned err") - } - - if len(got) > 0 { - t.Errorf("Retrieve is %v, want \"\"", got) - } -} - -func TestToken_Retrieve_Refresh_Error(t *testing.T) { - // setup types - request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil) - - // run test - got, err := RetrieveRefreshToken(request) - if err == nil { - t.Errorf("Retrieve should have returned err") - } - - if len(got) > 0 { - t.Errorf("Retrieve is %v, want \"\"", got) - } -} diff --git a/router/middleware/token_manager.go b/router/middleware/token_manager.go new file mode 100644 index 000000000..0d8d78108 --- /dev/null +++ b/router/middleware/token_manager.go @@ -0,0 +1,20 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package middleware + +import ( + "github.com/gin-gonic/gin" + + "github.com/go-vela/server/internal/token" +) + +// TokenManager is a middleware function that attaches the token manager +// to the context of every http.Request. +func TokenManager(m *token.Manager) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("token-manager", m) + c.Next() + } +} diff --git a/router/middleware/token_manager_test.go b/router/middleware/token_manager_test.go new file mode 100644 index 000000000..2ba6e23f2 --- /dev/null +++ b/router/middleware/token_manager_test.go @@ -0,0 +1,53 @@ +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. +// +// Use of this source code is governed by the LICENSE file in this repository. + +package middleware + +import ( + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/go-vela/server/internal/token" + + "github.com/gin-gonic/gin" +) + +func TestMiddleware_TokenManager(t *testing.T) { + // setup types + s := httptest.NewServer(http.NotFoundHandler()) + defer s.Close() + + var got *token.Manager + + want := new(token.Manager) + want.PrivateKey = "123abc" + + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + context, engine := gin.CreateTestContext(resp) + context.Request, _ = http.NewRequest(http.MethodGet, "/health", nil) + + // setup mock server + engine.Use(TokenManager(want)) + engine.GET("/health", func(c *gin.Context) { + got = c.MustGet("token-manager").(*token.Manager) + + c.Status(http.StatusOK) + }) + + // run test + engine.ServeHTTP(context.Writer, context.Request) + + if resp.Code != http.StatusOK { + t.Errorf("TokenManager returned %v, want %v", resp.Code, http.StatusOK) + } + + if !reflect.DeepEqual(got, want) { + t.Errorf("TokenManager is %v, want %v", got, want) + } +} diff --git a/router/middleware/user/user.go b/router/middleware/user/user.go index 58d2cab87..1dd894a10 100644 --- a/router/middleware/user/user.go +++ b/router/middleware/user/user.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -9,9 +9,10 @@ import ( "strings" "github.com/go-vela/server/database" - "github.com/go-vela/server/router/middleware/token" + "github.com/go-vela/server/router/middleware/claims" "github.com/go-vela/server/util" + "github.com/go-vela/types/constants" "github.com/go-vela/types/library" "github.com/gin-gonic/gin" @@ -26,20 +27,11 @@ func Retrieve(c *gin.Context) *library.User { // Establish sets the user in the given context. func Establish() gin.HandlerFunc { return func(c *gin.Context) { - // get the access token from the request - at, err := token.RetrieveAccessToken(c.Request) - if err != nil { - util.HandleError(c, http.StatusUnauthorized, err) - return - } + cl := claims.Retrieve(c) - // special handling for workers - secret := c.MustGet("secret").(string) - if strings.EqualFold(at, secret) { + // if token is not a user token, establish empty user to better handle nil checks + if !strings.EqualFold(cl.TokenType, constants.UserAccessTokenType) { u := new(library.User) - u.SetName("vela-worker") - u.SetActive(true) - u.SetAdmin(true) ToContext(c, u) c.Next() @@ -49,8 +41,8 @@ func Establish() gin.HandlerFunc { logrus.Debugf("parsing user access token") - // parse and validate the token and return the associated the user - u, err := token.Parse(at, database.FromContext(c)) + // lookup user in claims subject in the database + u, err := database.FromContext(c).GetUserForName(cl.Subject) if err != nil { util.HandleError(c, http.StatusUnauthorized, err) return diff --git a/router/middleware/user/user_test.go b/router/middleware/user/user_test.go index 724ac1cdb..46034914b 100644 --- a/router/middleware/user/user_test.go +++ b/router/middleware/user/user_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -14,9 +14,11 @@ import ( "github.com/go-vela/server/database" "github.com/go-vela/server/database/sqlite" - "github.com/go-vela/server/router/middleware/token" + "github.com/go-vela/server/internal/token" + "github.com/go-vela/server/router/middleware/claims" "github.com/go-vela/server/scm" "github.com/go-vela/server/scm/github" + "github.com/golang-jwt/jwt/v4" "github.com/go-vela/types/constants" "github.com/go-vela/types/library" @@ -47,6 +49,13 @@ func TestUser_Establish(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + want := new(library.User) want.SetID(1) want.SetName("foo") @@ -65,7 +74,13 @@ func TestUser_Establish(t *testing.T) { context, engine := gin.CreateTestContext(resp) context.Request, _ = http.NewRequest(http.MethodGet, "/users/foo", nil) - at, _ := token.CreateAccessToken(want, time.Minute*5) + mto := &token.MintTokenOpts{ + User: want, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } + + at, _ := tm.MintToken(mto) context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", at)) context.Request.AddCookie(&http.Cookie{ @@ -100,8 +115,10 @@ func TestUser_Establish(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(claims.Establish()) engine.Use(Establish()) engine.GET("/users/:user", func(c *gin.Context) { got = Retrieve(c) @@ -125,6 +142,14 @@ func TestUser_Establish(t *testing.T) { } func TestUser_Establish_NoToken(t *testing.T) { + // setup types + secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } // setup database db, _ := sqlite.NewTest() @@ -138,7 +163,10 @@ func TestUser_Establish_NoToken(t *testing.T) { context.Request, _ = http.NewRequest(http.MethodGet, "/users/foo", nil) // setup mock server + engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(claims.Establish()) engine.Use(Establish()) // run test @@ -149,14 +177,18 @@ func TestUser_Establish_NoToken(t *testing.T) { } } -func TestUser_Establish_SecretValid(t *testing.T) { +func TestUser_Establish_DiffTokenType(t *testing.T) { // setup types secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + want := new(library.User) - want.SetName("vela-worker") - want.SetActive(true) - want.SetAdmin(true) got := new(library.User) @@ -170,6 +202,8 @@ func TestUser_Establish_SecretValid(t *testing.T) { // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(claims.Establish()) engine.Use(Establish()) engine.GET("/users/:user", func(c *gin.Context) { got = Retrieve(c) @@ -196,6 +230,13 @@ func TestUser_Establish_NoAuthorizeUser(t *testing.T) { // setup database secret := "superSecret" + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + // setup database db, _ := sqlite.NewTest() @@ -215,6 +256,8 @@ func TestUser_Establish_NoAuthorizeUser(t *testing.T) { engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(claims.Establish()) engine.Use(Establish()) // run test @@ -227,8 +270,19 @@ func TestUser_Establish_NoAuthorizeUser(t *testing.T) { func TestUser_Establish_NoUser(t *testing.T) { // setup types + tm := &token.Manager{ + PrivateKey: "123abc", + SignMethod: jwt.SigningMethodHS256, + UserAccessTokenDuration: time.Minute * 5, + UserRefreshTokenDuration: time.Minute * 30, + } + + u := new(library.User) + u.SetID(1) + u.SetName("foo") + + // setup database secret := "superSecret" - got := new(library.User) // setup database db, _ := sqlite.NewTest() @@ -242,30 +296,30 @@ func TestUser_Establish_NoUser(t *testing.T) { context, engine := gin.CreateTestContext(resp) context.Request, _ = http.NewRequest(http.MethodGet, "/users/foo?access_token=bar", nil) - // setup github mock server - engine.GET("/api/v3/user", func(c *gin.Context) { - c.String(http.StatusOK, userPayload) - }) + mto := &token.MintTokenOpts{ + User: u, + TokenDuration: tm.UserAccessTokenDuration, + TokenType: constants.UserAccessTokenType, + } - s := httptest.NewServer(engine) - defer s.Close() + at, _ := tm.MintToken(mto) + + context.Request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", at)) + context.Request.AddCookie(&http.Cookie{ + Name: constants.RefreshTokenName, + Value: "fresh", + }) // setup client - client, _ := github.NewTest(s.URL) + client, _ := github.NewTest("") // setup vela mock server engine.Use(func(c *gin.Context) { c.Set("secret", secret) }) engine.Use(func(c *gin.Context) { database.ToContext(c, db) }) engine.Use(func(c *gin.Context) { scm.ToContext(c, client) }) + engine.Use(func(c *gin.Context) { c.Set("token-manager", tm) }) + engine.Use(claims.Establish()) engine.Use(Establish()) - engine.GET("/users/:user", func(c *gin.Context) { - got = Retrieve(c) - - c.Status(http.StatusOK) - }) - - s1 := httptest.NewServer(engine) - defer s1.Close() // run test engine.ServeHTTP(context.Writer, context.Request) @@ -273,10 +327,6 @@ func TestUser_Establish_NoUser(t *testing.T) { if resp.Code != http.StatusUnauthorized { t.Errorf("Establish returned %v, want %v", resp.Code, http.StatusUnauthorized) } - - if got.GetID() != 0 { - t.Errorf("Establish is %v, want 0", got) - } } const userPayload = ` diff --git a/router/router.go b/router/router.go index 9b33c700f..3327e4a7b 100644 --- a/router/router.go +++ b/router/router.go @@ -34,6 +34,7 @@ package router import ( "github.com/go-vela/server/api" "github.com/go-vela/server/router/middleware" + "github.com/go-vela/server/router/middleware/claims" "github.com/go-vela/server/router/middleware/org" "github.com/go-vela/server/router/middleware/repo" "github.com/go-vela/server/router/middleware/user" @@ -92,7 +93,7 @@ func Load(options ...gin.HandlerFunc) *gin.Engine { } // API endpoints - baseAPI := r.Group(base, user.Establish()) + baseAPI := r.Group(base, claims.Establish(), user.Establish()) { // Admin endpoints AdminHandlers(baseAPI) diff --git a/router/service.go b/router/service.go index 400feb4b5..7fe39d0e9 100644 --- a/router/service.go +++ b/router/service.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -37,7 +37,7 @@ func ServiceHandlers(base *gin.RouterGroup) { service := services.Group("/:service", service.Establish()) { service.GET("", perm.MustRead(), api.GetService) - service.PUT("", perm.MustPlatformAdmin(), middleware.Payload(), api.UpdateService) + service.PUT("", perm.MustBuildAccess(), middleware.Payload(), api.UpdateService) service.DELETE("", perm.MustPlatformAdmin(), api.DeleteService) service.POST("/stream", perm.MustPlatformAdmin(), api.PostServiceStream) diff --git a/router/step.go b/router/step.go index 5ac9b05de..80aad489a 100644 --- a/router/step.go +++ b/router/step.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Target Brands, Inc. All rights reserved. +// Copyright (c) 2023 Target Brands, Inc. All rights reserved. // // Use of this source code is governed by the LICENSE file in this repository. @@ -37,7 +37,7 @@ func StepHandlers(base *gin.RouterGroup) { step := steps.Group("/:step", step.Establish()) { step.GET("", perm.MustRead(), api.GetStep) - step.PUT("", perm.MustPlatformAdmin(), middleware.Payload(), api.UpdateStep) + step.PUT("", perm.MustBuildAccess(), middleware.Payload(), api.UpdateStep) step.DELETE("", perm.MustPlatformAdmin(), api.DeleteStep) step.POST("/stream", perm.MustPlatformAdmin(), api.PostStepStream) diff --git a/router/worker.go b/router/worker.go index 7f7df3a3b..7853c2a82 100644 --- a/router/worker.go +++ b/router/worker.go @@ -24,14 +24,14 @@ func WorkerHandlers(base *gin.RouterGroup) { // Workers endpoints workers := base.Group("/workers") { - workers.POST("", perm.MustPlatformAdmin(), middleware.Payload(), api.CreateWorker) + workers.POST("", perm.MustWorker(), middleware.Payload(), api.CreateWorker) workers.GET("", api.GetWorkers) // Worker endpoints w := workers.Group("/:worker") { w.GET("", worker.Establish(), api.GetWorker) - w.PUT("", perm.MustPlatformAdmin(), worker.Establish(), api.UpdateWorker) + w.PUT("", perm.MustWorker(), worker.Establish(), api.UpdateWorker) w.DELETE("", perm.MustPlatformAdmin(), worker.Establish(), api.DeleteWorker) } // end of worker endpoints } // end of workers endpoints