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

Use proper oauth state #3847

Merged
merged 20 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"go.lintTool": "golangci-lint",
"go.lintFlags": ["--fast"],
"go.buildTags": "test",
6543 marked this conversation as resolved.
Show resolved Hide resolved
"eslint.workingDirectories": ["./web"],
"prettier.ignorePath": "./web/.prettierignore",
// Enable the ESlint flat config support
Expand Down
7 changes: 7 additions & 0 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,13 @@
}

func setupEvilGlobals(c *cli.Context, s store.Store) error {
// secrets
var err error
server.Config.Server.JWTSecret, err = setupJWTSecret(s)
if err != nil {
return fmt.Errorf("could not setup jwt secret: %w", err)
}

Check warning on line 273 in cmd/server/server.go

View check run for this annotation

Codecov / codecov/patch

cmd/server/server.go#L268-L273

Added lines #L268 - L273 were not covered by tests

// services
server.Config.Services.Queue = setupQueue(c, s)
server.Config.Services.Logs = logging.New()
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 @@

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 @@
"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 @@
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

Check warning on line 186 in cmd/server/setup.go

View check run for this annotation

Codecov / codecov/patch

cmd/server/setup.go#L175-L186

Added lines #L175 - L186 were not covered by tests
}

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

Check warning on line 191 in cmd/server/setup.go

View check run for this annotation

Codecov / codecov/patch

cmd/server/setup.go#L189-L191

Added lines #L189 - L191 were not covered by tests

return jwtSecret, nil

Check warning on line 193 in cmd/server/setup.go

View check run for this annotation

Codecov / codecov/patch

cmd/server/setup.go#L193

Added line #L193 was not covered by tests
}
2 changes: 1 addition & 1 deletion server/api/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
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
_forge, err := server.Config.Services.Manager.ForgeByID(1) // TODO: get the forge for the specific repo somehow

Check warning on line 107 in server/api/hook.go

View check run for this annotation

Codecov / codecov/patch

server/api/hook.go#L107

Added line #L107 was not covered by tests
if err != nil {
log.Error().Err(err).Msg("Cannot get main forge")
c.AbortWithStatus(http.StatusInternalServerError)
Expand Down
38 changes: 34 additions & 4 deletions server/api/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
"go.woodpecker-ci.org/woodpecker/v2/shared/token"
)

const stateTokenDuration = time.Minute * 5

func HandleAuth(c *gin.Context) {
// TODO: check if this is really needed
c.Writer.Header().Del("Content-Type")
Expand All @@ -56,17 +58,45 @@
}

_store := store.FromContext(c)

code := c.Request.FormValue("code")
state := c.Request.FormValue("state")
isCallback := code != "" && state != ""
forgeID := int64(1) // TODO: replace with forge id when multiple forges are supported
_forge, err := server.Config.Services.Manager.ForgeMain()

if isCallback { // validate the state token
_, err := token.Parse([]token.Type{token.OAuthStateToken}, state, func(_ *token.Token) (string, error) {
return server.Config.Server.JWTSecret, nil
})
if err != nil {
log.Error().Err(err).Msg("cannot verify state token")
c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=invalid_state")
return
}
} else { // only generate a state token if not a callback
var err error
jwtSecret := server.Config.Server.JWTSecret
exp := time.Now().Add(stateTokenDuration).Unix()
stateToken := token.New(token.OAuthStateToken)
stateToken.Set("forge-id", strconv.FormatInt(forgeID, 10))
state, err = stateToken.SignExpires(jwtSecret, exp)
if err != nil {
log.Error().Err(err).Msg("cannot create state token")
c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error")
return
}

Check warning on line 87 in server/api/login.go

View check run for this annotation

Codecov / codecov/patch

server/api/login.go#L84-L87

Added lines #L84 - L87 were not covered by tests
}

_forge, err := server.Config.Services.Manager.ForgeByID(forgeID)
if err != nil {
log.Error().Err(err).Msg("Cannot get main forge")
log.Error().Err(err).Msgf("Cannot get forge by id %d", forgeID)

Check warning on line 92 in server/api/login.go

View check run for this annotation

Codecov / codecov/patch

server/api/login.go#L92

Added line #L92 was not covered by tests
c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error")
return
}

userFromForge, redirectURL, err := _forge.Login(c, &forge_types.OAuthRequest{
Code: c.Request.FormValue("code"),
State: "woodpecker", // TODO: use proper state
State: state,
})
if err != nil {
log.Error().Err(err).Msg("cannot authenticate user")
Expand Down Expand Up @@ -250,7 +280,7 @@
func DeprecatedGetLoginToken(c *gin.Context) {
_store := store.FromContext(c)

_forge, err := server.Config.Services.Manager.ForgeMain() // TODO: get selected forge from auth request
_forge, err := server.Config.Services.Manager.ForgeByID(1) // TODO: get selected forge from auth request

Check warning on line 283 in server/api/login.go

View check run for this annotation

Codecov / codecov/patch

server/api/login.go#L283

Added line #L283 was not covered by tests
if err != nil {
log.Error().Err(err).Msg("Cannot get main forge")
c.AbortWithStatus(http.StatusInternalServerError)
Expand Down
65 changes: 50 additions & 15 deletions server/api/login_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api_test

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
Expand All @@ -16,11 +17,13 @@ import (
"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"
forge_types "go.woodpecker-ci.org/woodpecker/v2/server/forge/types"
"go.woodpecker-ci.org/woodpecker/v2/server/model"
mocks_services "go.woodpecker-ci.org/woodpecker/v2/server/services/mocks"
"go.woodpecker-ci.org/woodpecker/v2/server/services/permissions"
mocks_store "go.woodpecker-ci.org/woodpecker/v2/server/store/mocks"
"go.woodpecker-ci.org/woodpecker/v2/server/store/types"
"go.woodpecker-ci.org/woodpecker/v2/shared/token"
)

func TestHandleAuth(t *testing.T) {
Expand Down Expand Up @@ -69,12 +72,37 @@ func TestHandleAuth(t *testing.T) {
assert.Equal(g, fmt.Sprintf("/login?%s", query.Encode()), c.Writer.Header().Get("Location"))
})

g.It("should fail if a code was provided and no state", func() {
// TODO: implement
})

g.It("should fail if the state is wrong", func() {
// TODO: implement
_manager := mocks_services.NewManager(t)
_store := mocks_store.NewStore(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)

query := url.Values{}
query.Set("code", "assumed_to_be_valid_code")

wrongToken := token.New(token.OAuthStateToken)
wrongToken.Set("forge_id", "1")
signedWrongToken, _ := wrongToken.Sign("wrong_secret")
query.Set("state", signedWrongToken)

c.Request = &http.Request{
Header: make(http.Header),
URL: &url.URL{
Scheme: "https",
RawQuery: query.Encode(),
},
}

api.HandleAuth(c)

assert.Equal(g, http.StatusSeeOther, c.Writer.Status())
assert.Equal(g, "/login?error=invalid_state", c.Writer.Header().Get("Location"))
})

g.It("should redirect to forge login page", func() {
Expand All @@ -95,10 +123,17 @@ func TestHandleAuth(t *testing.T) {
},
}

forgeRedirectURL := "https://my-awesome-forge.com/oauth/authorize?client_id=client-id"
_manager.On("ForgeByID", int64(1)).Return(_forge, nil)

_manager.On("ForgeMain").Return(_forge, nil)
_forge.On("Login", mock.Anything, mock.Anything).Return(nil, forgeRedirectURL, nil)
forgeRedirectURL := ""
_forge.On("Login", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
state, ok := args.Get(1).(*forge_types.OAuthRequest)
if ok {
forgeRedirectURL = fmt.Sprintf("https://my-awesome-forge.com/oauth/authorize?client_id=client-id&state=%s", state.State)
}
}).Return(nil, func(context.Context, *forge_types.OAuthRequest) string {
return forgeRedirectURL
}, nil)

api.HandleAuth(c)

Expand All @@ -124,7 +159,7 @@ func TestHandleAuth(t *testing.T) {
},
}

_manager.On("ForgeMain").Return(_forge, nil)
_manager.On("ForgeByID", int64(1)).Return(_forge, nil)
_forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(nil, types.RecordNotExist)
_store.On("CreateUser", mock.Anything).Return(nil)
Expand Down Expand Up @@ -158,7 +193,7 @@ func TestHandleAuth(t *testing.T) {
},
}

_manager.On("ForgeMain").Return(_forge, nil)
_manager.On("ForgeByID", int64(1)).Return(_forge, nil)
_forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(user, nil)
_store.On("OrgGet", org.ID).Return(org, nil)
Expand Down Expand Up @@ -190,7 +225,7 @@ func TestHandleAuth(t *testing.T) {
},
}

_manager.On("ForgeMain").Return(_forge, nil)
_manager.On("ForgeByID", int64(1)).Return(_forge, nil)
_forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(nil, types.RecordNotExist)

Expand Down Expand Up @@ -218,7 +253,7 @@ func TestHandleAuth(t *testing.T) {
},
}

_manager.On("ForgeMain").Return(_forge, nil)
_manager.On("ForgeByID", int64(1)).Return(_forge, nil)
_forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_forge.On("Teams", mock.Anything, user).Return([]*model.Team{
{
Expand Down Expand Up @@ -252,7 +287,7 @@ func TestHandleAuth(t *testing.T) {
}
user.OrgID = 0

_manager.On("ForgeMain").Return(_forge, nil)
_manager.On("ForgeByID", int64(1)).Return(_forge, nil)
_forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(user, nil)
_store.On("OrgFindByName", user.Login).Return(nil, types.RecordNotExist)
Expand Down Expand Up @@ -286,7 +321,7 @@ func TestHandleAuth(t *testing.T) {
}
user.OrgID = 0

_manager.On("ForgeMain").Return(_forge, nil)
_manager.On("ForgeByID", int64(1)).Return(_forge, nil)
_forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(user, nil)
_store.On("OrgFindByName", user.Login).Return(org, nil)
Expand Down Expand Up @@ -320,7 +355,7 @@ func TestHandleAuth(t *testing.T) {
}
org.Name = "not-the-user-name"

_manager.On("ForgeMain").Return(_forge, nil)
_manager.On("ForgeByID", int64(1)).Return(_forge, nil)
_forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(user, nil)
_store.On("OrgGet", user.OrgID).Return(org, nil)
Expand Down
1 change: 1 addition & 0 deletions server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ var Config = struct {
LogStore log.Service
}
Server struct {
JWTSecret string
Key string
Cert string
OAuthHost string
Expand Down
12 changes: 4 additions & 8 deletions server/services/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
EnvironmentService() environment.Service
ForgeFromRepo(repo *model.Repo) (forge.Forge, error)
ForgeFromUser(user *model.User) (forge.Forge, error)
ForgeMain() (forge.Forge, error)
ForgeByID(id int64) (forge.Forge, error)
}

type manager struct {
Expand Down Expand Up @@ -120,18 +120,14 @@
}

func (m *manager) ForgeFromRepo(repo *model.Repo) (forge.Forge, error) {
return m.getForgeByID(repo.ForgeID)
return m.ForgeByID(repo.ForgeID)

Check warning on line 123 in server/services/manager.go

View check run for this annotation

Codecov / codecov/patch

server/services/manager.go#L123

Added line #L123 was not covered by tests
}

func (m *manager) ForgeFromUser(user *model.User) (forge.Forge, error) {
return m.getForgeByID(user.ForgeID)
return m.ForgeByID(user.ForgeID)

Check warning on line 127 in server/services/manager.go

View check run for this annotation

Codecov / codecov/patch

server/services/manager.go#L127

Added line #L127 was not covered by tests
}

func (m *manager) ForgeMain() (forge.Forge, error) {
return m.getForgeByID(1) // main forge is always 1 and is configured via environment variables
}

func (m *manager) getForgeByID(id int64) (forge.Forge, error) {
func (m *manager) ForgeByID(id int64) (forge.Forge, error) {

Check warning on line 130 in server/services/manager.go

View check run for this annotation

Codecov / codecov/patch

server/services/manager.go#L130

Added line #L130 was not covered by tests
item := m.forgeCache.Get(id)
if item != nil && !item.IsExpired() {
return item.Value(), nil
Expand Down
Loading