Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Login with multiple forges #3822

Open
wants to merge 57 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 50 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
56f7095
login with selected forge
anbraten Apr 22, 2024
29f8a31
allow to select forge on login
anbraten Apr 23, 2024
017099b
Merge remote-tracking branch 'upstream/main' into forge-login
anbraten Apr 29, 2024
ff11457
Merge remote-tracking branch 'upstream/main' into auth-id-token
anbraten May 12, 2024
e1a1c24
undo
anbraten May 12, 2024
5645696
undo
anbraten May 12, 2024
24266ef
Merge remote-tracking branch 'upstream/main' into forge-login
anbraten May 12, 2024
ccb2270
update comment
anbraten May 13, 2024
2fdbdd9
update token
anbraten May 13, 2024
7106c45
use explicit token claims
anbraten May 14, 2024
c49bfbe
skip internal claims
anbraten May 14, 2024
25e34a3
Merge branch 'main' into auth-id-token
anbraten May 14, 2024
a4320f1
add todo
anbraten May 14, 2024
40f1dfd
Merge branch 'auth-id-token' of github.com:anbraten/woodpecker into a…
anbraten May 14, 2024
cbfdfb3
Merge remote-tracking branch 'upstream/main' into forge-login
anbraten May 20, 2024
a260db4
Merge branch 'auth-id-token' into forge-login
anbraten May 20, 2024
4e4a8f7
add state
anbraten May 20, 2024
85fb52c
adjust claim copy and add source for the registered claims
anbraten May 20, 2024
4e9facd
Merge branch 'auth-id-token' into forge-login
anbraten May 20, 2024
a5b85d2
Merge remote-tracking branch 'upstream/main' into forge-login
anbraten May 27, 2024
e18e6fe
enhance
anbraten May 27, 2024
c211ee5
rename improve
anbraten May 27, 2024
3810054
Merge remote-tracking branch 'upstream/main' into forge-login
anbraten May 27, 2024
6783d35
Merge remote-tracking branch 'upstream/main' into forge-login
anbraten Jun 5, 2024
0907ebd
Merge remote-tracking branch 'upstream/main' into forge-login
anbraten Jun 16, 2024
0ab0ba8
Cleanup auth
anbraten Jun 20, 2024
77b1195
rm duplicate error handling
anbraten Jun 20, 2024
bae4eb5
fix ui
anbraten Jun 20, 2024
e7be09d
fix and add test
anbraten Jun 20, 2024
f63924d
fix test
anbraten Jun 20, 2024
e6d6e22
fix redirect
anbraten Jun 20, 2024
fb46de5
add more tests
anbraten Jun 20, 2024
0c72e12
improve tests, login and org creation
anbraten Jun 20, 2024
46ed710
undo some changes to simplify pr
anbraten Jun 20, 2024
3dd2bd4
fix
anbraten Jun 20, 2024
bfaf3fb
undo
anbraten Jun 20, 2024
894f2ee
undo
anbraten Jun 20, 2024
9055912
Merge remote-tracking branch 'upstream/main' into forge-login
anbraten Jun 20, 2024
34d5145
Merge branch 'cleanup-login' into forge-login
anbraten Jun 20, 2024
9e9b1f0
jwt secret
anbraten Jun 20, 2024
6985d45
rm comment
anbraten Jun 20, 2024
f461001
undo
anbraten Jun 20, 2024
00036c9
load forges
anbraten Jun 20, 2024
3700c2a
Merge remote-tracking branch 'upstream/main' into forge-login
anbraten Jun 21, 2024
9f38b50
Merge remote-tracking branch 'upstream/main' into forge-login
anbraten Jun 21, 2024
e2a21b9
adjust hook to enhance repo detection
anbraten Jun 22, 2024
e668860
enhance token
anbraten Jun 22, 2024
331899d
fix jwt
anbraten Jun 22, 2024
f7a8e36
use correct token type
anbraten Jun 22, 2024
a1bc5cd
fix test name
anbraten Jun 22, 2024
544d4cd
Update server/api/hook.go
anbraten Jun 22, 2024
2b720cb
Merge branch 'main' into forge-login
anbraten Jun 24, 2024
60d3bea
add forge iocn
anbraten Jun 26, 2024
1d8362e
Merge remote-tracking branch 'upstream/main' into forge-login
anbraten Jun 26, 2024
f9388c4
Merge remote-tracking branch 'upstream/main' into forge-login
anbraten Jun 27, 2024
c14361b
adjust login
anbraten Jun 27, 2024
afef97d
cleanup
anbraten Jun 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ func setupEvilGlobals(c *cli.Context, s store.Store) error {
server.Config.Pipeline.Proxy.HTTPS = c.String("backend-https-proxy")

// server configuration
server.Config.Server.JWTSecret, err = setupJWTSecret(s)
if err != nil {
return fmt.Errorf("could not setup jwt secret: %w", err)
}
server.Config.Server.Cert = c.String("server-cert")
server.Config.Server.Key = c.String("server-key")
server.Config.Server.AgentToken = c.String("agent-secret")
Expand Down
27 changes: 27 additions & 0 deletions cmd/server/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ package main

import (
"context"
"encoding/base32"
"errors"
"fmt"
"os"
"time"

"github.com/gorilla/securecookie"
"github.com/prometheus/client_golang/prometheus"
prometheus_auto "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/rs/zerolog/log"
Expand All @@ -34,6 +37,7 @@ import (
"go.woodpecker-ci.org/woodpecker/v2/server/services/log/file"
"go.woodpecker-ci.org/woodpecker/v2/server/store"
"go.woodpecker-ci.org/woodpecker/v2/server/store/datastore"
"go.woodpecker-ci.org/woodpecker/v2/server/store/types"
)

func setupStore(c *cli.Context) (store.Store, error) {
Expand Down Expand Up @@ -165,3 +169,26 @@ func setupLogStore(c *cli.Context, s store.Store) (logService.Service, error) {
return s, nil
}
}

const jwtSecretID = "jwt-secret"

func setupJWTSecret(_store store.Store) (string, error) {
jwtSecret, err := _store.ServerConfigGet(jwtSecretID)
if errors.Is(err, types.RecordNotExist) {
jwtSecret := base32.StdEncoding.EncodeToString(
securecookie.GenerateRandomKey(32),
)
err = _store.ServerConfigSet(jwtSecretID, jwtSecret)
if err != nil {
return "", err
}
log.Debug().Msg("created jwt secret")
return jwtSecret, nil
}

if err != nil {
return "", err
}

return jwtSecret, nil
}
108 changes: 57 additions & 51 deletions server/api/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,18 +104,43 @@ func BlockTilQueueHasRunningItem(c *gin.Context) {
func PostHook(c *gin.Context) {
_store := store.FromContext(c)

_forge, err := server.Config.Services.Manager.ForgeMain() // TODO: get the forge for the specific repo somehow
//
// 1. Check if the webhook is valid and authorized
//

parsedToken, err := token.ParseRequest(token.HookToken, c.Request, func(t *token.Token) (string, error) {
repo, err := getRepoFromToken(_store, t)
if err != nil {
return "", err
}

return repo.Hash, nil
})
if err != nil {
msg := "failure to parse token from hook"
log.Error().Err(err).Msg(msg)
c.String(http.StatusBadRequest, msg)
return
}

repo, err := getRepoFromToken(_store, parsedToken)
if err != nil {
log.Error().Err(err).Msg("Cannot get repo")
c.AbortWithStatus(http.StatusInternalServerError)
}

_forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)
if err != nil {
log.Error().Err(err).Msg("Cannot get main forge")
c.AbortWithStatus(http.StatusInternalServerError)
return
}

//
// 1. Parse webhook
// 2. Parse the webhook data
//

tmpRepo, tmpPipeline, err := _forge.Hook(c, c.Request)
repoFromForge, pipelineFromForge, err := _forge.Hook(c, c.Request)
if err != nil {
if errors.Is(err, &types.ErrIgnoreEvent{}) {
msg := fmt.Sprintf("forge driver: %s", err)
Expand All @@ -130,35 +155,38 @@ func PostHook(c *gin.Context) {
return
}

if tmpPipeline == nil {
if pipelineFromForge == nil {
msg := "ignoring hook: hook parsing resulted in empty pipeline"
log.Debug().Msg(msg)
c.String(http.StatusOK, msg)
return
}
if tmpRepo == nil {
if repoFromForge == nil {
msg := "failure to ascertain repo from hook"
log.Debug().Msg(msg)
c.String(http.StatusBadRequest, msg)
return
}

//
// 2. Get related repo from store and take repo renaming into account
// 3. Check the repo from the token is matching the repo returned by the forge
//

repo, err := _store.GetRepoNameFallback(tmpRepo.ForgeRemoteID, tmpRepo.FullName)
if err != nil {
log.Error().Err(err).Msgf("failure to get repo %s from store", tmpRepo.FullName)
handleDBError(c, err)
if repo.ForgeRemoteID != repoFromForge.ForgeRemoteID {
log.Debug().Msgf("ignoring hook: repo %s does not match the repo from the token", repo.FullName)
anbraten marked this conversation as resolved.
Show resolved Hide resolved
c.String(http.StatusBadRequest, "failure to parse token from hook")
return
}

//
// 4. Check if the repo is active and has an owner
//

if !repo.IsActive {
log.Debug().Msgf("ignoring hook: repo %s is inactive", tmpRepo.FullName)
log.Debug().Msgf("ignoring hook: repo %s is inactive", repoFromForge.FullName)
c.Status(http.StatusNoContent)
return
}
currentRepoFullName := repo.FullName

if repo.UserID == 0 {
log.Warn().Msgf("ignoring hook. repo %s has no owner.", repo.FullName)
Expand All @@ -174,44 +202,10 @@ func PostHook(c *gin.Context) {
forge.Refresh(c, _forge, _store, user)

//
// 3. Check if the webhook is a valid and authorized one
// 4. Update the repo
//

// get the token and verify the hook is authorized
parsedToken, err := token.ParseRequest(c.Request, func(_ *token.Token) (string, error) {
return repo.Hash, nil
})
if err != nil {
msg := fmt.Sprintf("failure to parse token from hook for %s", repo.FullName)
log.Error().Err(err).Msg(msg)
c.String(http.StatusBadRequest, msg)
return
}

// TODO: remove fallback for text full name in next major release
verifiedKey := parsedToken.Get("repo-id") == strconv.FormatInt(repo.ID, 10) || parsedToken.Get("text") == currentRepoFullName
if !verifiedKey {
verifiedKey, err = _store.HasRedirectionForRepo(repo.ID, repo.FullName)
if err != nil {
msg := "failure to verify token from hook. Could not check for redirections of the repo"
log.Error().Err(err).Msg(msg)
c.String(http.StatusInternalServerError, msg)
return
}
}

if !verifiedKey {
msg := fmt.Sprintf("failure to verify token from hook. Expected %s, got %s", repo.FullName, parsedToken.Get("text"))
log.Debug().Msg(msg)
c.String(http.StatusForbidden, msg)
return
}

//
// 4. Update repo
//

if currentRepoFullName != tmpRepo.FullName {
if repo.FullName != repoFromForge.FullName {
// create a redirection
err = _store.CreateRedirection(&model.Redirection{RepoID: repo.ID, FullName: repo.FullName})
if err != nil {
Expand All @@ -220,7 +214,7 @@ func PostHook(c *gin.Context) {
}
}

repo.Update(tmpRepo)
repo.Update(repoFromForge)
err = _store.UpdateRepo(repo)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
Expand All @@ -231,7 +225,7 @@ func PostHook(c *gin.Context) {
// 5. Check if pull requests are allowed for this repo
//

if (tmpPipeline.Event == model.EventPull || tmpPipeline.Event == model.EventPullClosed) && !repo.AllowPull {
if (pipelineFromForge.Event == model.EventPull || pipelineFromForge.Event == model.EventPullClosed) && !repo.AllowPull {
log.Debug().Str("repo", repo.FullName).Msg("ignoring hook: pull requests are disabled for this repo in woodpecker")
c.Status(http.StatusNoContent)
return
Expand All @@ -241,10 +235,22 @@ func PostHook(c *gin.Context) {
// 6. Finally create a pipeline
//

pl, err := pipeline.Create(c, _store, repo, tmpPipeline)
pl, err := pipeline.Create(c, _store, repo, pipelineFromForge)
if err != nil {
handlePipelineErr(c, err)
} else {
c.JSON(http.StatusOK, pl)
}
}

func getRepoFromToken(store store.Store, t *token.Token) (*model.Repo, error) {
// try to get the repo by the repo-id
repoID, err := strconv.ParseInt(t.Get("repo-id"), 10, 64)
if err == nil {
return store.GetRepo(repoID)
}

// try to get the repo by the repo name or by its redirection
repoName := t.Get("text")
return store.GetRepoName(repoName)
}
104 changes: 104 additions & 0 deletions server/api/hook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package api_test

import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/franela/goblin"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"

"go.woodpecker-ci.org/woodpecker/v2/server"
"go.woodpecker-ci.org/woodpecker/v2/server/api"
mocks_forge "go.woodpecker-ci.org/woodpecker/v2/server/forge/mocks"
"go.woodpecker-ci.org/woodpecker/v2/server/model"
mocks_config_service "go.woodpecker-ci.org/woodpecker/v2/server/services/config/mocks"
mocks_services "go.woodpecker-ci.org/woodpecker/v2/server/services/mocks"
"go.woodpecker-ci.org/woodpecker/v2/server/services/permissions"
mocks_registry_service "go.woodpecker-ci.org/woodpecker/v2/server/services/registry/mocks"
mocks_secret_service "go.woodpecker-ci.org/woodpecker/v2/server/services/secret/mocks"
mocks_store "go.woodpecker-ci.org/woodpecker/v2/server/store/mocks"
"go.woodpecker-ci.org/woodpecker/v2/shared/token"
)

func TestHook(t *testing.T) {
gin.SetMode(gin.TestMode)

g := goblin.Goblin(t)
g.Describe("Hook", func() {
g.It("should handle a correct webhook payload", func() {
_manager := mocks_services.NewManager(t)
_forge := mocks_forge.NewForge(t)
_store := mocks_store.NewStore(t)
_configService := mocks_config_service.NewService(t)
_secretService := mocks_secret_service.NewService(t)
_registryService := mocks_registry_service.NewService(t)
server.Config.Services.Manager = _manager
server.Config.Permissions.Open = true
server.Config.Permissions.Orgs = permissions.NewOrgs(nil)
server.Config.Permissions.Admins = permissions.NewAdmins(nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("store", _store)
user := &model.User{
ID: 123,
}
repo := &model.Repo{
ID: 123,
ForgeRemoteID: "123",
Owner: "owner",
Name: "name",
IsActive: true,
UserID: user.ID,
Hash: "secret-123-this-is-a-secret",
}
pipeline := &model.Pipeline{
ID: 123,
RepoID: repo.ID,
Event: model.EventPush,
}

repoToken := token.New(token.HookToken)
repoToken.Set("repo-id", fmt.Sprintf("%d", repo.ID))
signedToken, err := repoToken.Sign("secret-123-this-is-a-secret")
if err != nil {
g.Fail(err)
}

header := http.Header{}
header.Set("Authorization", fmt.Sprintf("Bearer %s", signedToken))
c.Request = &http.Request{
Header: header,
URL: &url.URL{
Scheme: "https",
},
}

_manager.On("ForgeFromRepo", repo).Return(_forge, nil)
_forge.On("Hook", mock.Anything, mock.Anything).Return(repo, pipeline, nil)
_store.On("GetRepo", repo.ID).Return(repo, nil)
_store.On("GetUser", user.ID).Return(user, nil)
_store.On("UpdateRepo", repo).Return(nil)
_store.On("CreatePipeline", mock.Anything).Return(nil)
_manager.On("ConfigServiceFromRepo", repo).Return(_configService)
_configService.On("Fetch", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
_forge.On("Netrc", mock.Anything, mock.Anything).Return(&model.Netrc{}, nil)
_store.On("GetPipelineLastBefore", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
_manager.On("SecretServiceFromRepo", repo).Return(_secretService)
_secretService.On("SecretListPipeline", repo, mock.Anything, mock.Anything).Return(nil, nil)
_manager.On("RegistryServiceFromRepo", repo).Return(_registryService)
_registryService.On("RegistryList", repo, mock.Anything).Return(nil, nil)
_manager.On("EnvironmentService").Return(nil)
_store.On("DeletePipeline", mock.Anything).Return(nil)

api.PostHook(c)

assert.Equal(g, http.StatusNoContent, c.Writer.Status())
assert.Equal(g, "true", w.Header().Get("Pipeline-Filtered"))
})
})
}
Loading