Use proper oauth state (#3847)

This commit is contained in:
Anbraten 2024-06-27 16:52:09 +02:00 committed by GitHub
parent 92cd0d04a3
commit 2bda19024e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 164 additions and 64 deletions

View file

@ -8,6 +8,7 @@
}, },
"go.lintTool": "golangci-lint", "go.lintTool": "golangci-lint",
"go.lintFlags": ["--fast"], "go.lintFlags": ["--fast"],
"go.buildTags": "test",
"eslint.workingDirectories": ["./web"], "eslint.workingDirectories": ["./web"],
"prettier.ignorePath": "./web/.prettierignore", "prettier.ignorePath": "./web/.prettierignore",
// Enable the ESlint flat config support // Enable the ESlint flat config support

View file

@ -265,6 +265,13 @@ func run(c *cli.Context) error {
} }
func setupEvilGlobals(c *cli.Context, s store.Store) error { 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)
}
// services // services
server.Config.Services.Queue = setupQueue(c, s) server.Config.Services.Queue = setupQueue(c, s)
server.Config.Services.Logs = logging.New() server.Config.Services.Logs = logging.New()

View file

@ -17,10 +17,13 @@ package main
import ( import (
"context" "context"
"encoding/base32"
"errors"
"fmt" "fmt"
"os" "os"
"time" "time"
"github.com/gorilla/securecookie"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
prometheus_auto "github.com/prometheus/client_golang/prometheus/promauto" prometheus_auto "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -34,6 +37,7 @@ import (
"go.woodpecker-ci.org/woodpecker/v2/server/services/log/file" "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"
"go.woodpecker-ci.org/woodpecker/v2/server/store/datastore" "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) { func setupStore(c *cli.Context) (store.Store, error) {
@ -165,3 +169,26 @@ func setupLogStore(c *cli.Context, s store.Store) (logService.Service, error) {
return s, nil 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
}

View file

@ -104,7 +104,7 @@ func BlockTilQueueHasRunningItem(c *gin.Context) {
func PostHook(c *gin.Context) { func PostHook(c *gin.Context) {
_store := store.FromContext(c) _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
if err != nil { if err != nil {
log.Error().Err(err).Msg("Cannot get main forge") log.Error().Err(err).Msg("Cannot get main forge")
c.AbortWithStatus(http.StatusInternalServerError) c.AbortWithStatus(http.StatusInternalServerError)

View file

@ -37,6 +37,8 @@ import (
"go.woodpecker-ci.org/woodpecker/v2/shared/token" "go.woodpecker-ci.org/woodpecker/v2/shared/token"
) )
const stateTokenDuration = time.Minute * 5
func HandleAuth(c *gin.Context) { func HandleAuth(c *gin.Context) {
// TODO: check if this is really needed // TODO: check if this is really needed
c.Writer.Header().Del("Content-Type") c.Writer.Header().Del("Content-Type")
@ -56,17 +58,45 @@ func HandleAuth(c *gin.Context) {
} }
_store := store.FromContext(c) _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 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
}
}
_forge, err := server.Config.Services.Manager.ForgeByID(forgeID)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Cannot get main forge") log.Error().Err(err).Msgf("Cannot get forge by id %d", forgeID)
c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error") c.Redirect(http.StatusSeeOther, server.Config.Server.RootPath+"/login?error=internal_error")
return return
} }
userFromForge, redirectURL, err := _forge.Login(c, &forge_types.OAuthRequest{ userFromForge, redirectURL, err := _forge.Login(c, &forge_types.OAuthRequest{
Code: c.Request.FormValue("code"), Code: c.Request.FormValue("code"),
State: "woodpecker", // TODO: use proper state State: state,
}) })
if err != nil { if err != nil {
log.Error().Err(err).Msg("cannot authenticate user") log.Error().Err(err).Msg("cannot authenticate user")
@ -250,7 +280,7 @@ func GetLogout(c *gin.Context) {
func DeprecatedGetLoginToken(c *gin.Context) { func DeprecatedGetLoginToken(c *gin.Context) {
_store := store.FromContext(c) _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
if err != nil { if err != nil {
log.Error().Err(err).Msg("Cannot get main forge") log.Error().Err(err).Msg("Cannot get main forge")
c.AbortWithStatus(http.StatusInternalServerError) c.AbortWithStatus(http.StatusInternalServerError)

View file

@ -1,6 +1,7 @@
package api_test package api_test
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -16,11 +17,13 @@ import (
"go.woodpecker-ci.org/woodpecker/v2/server" "go.woodpecker-ci.org/woodpecker/v2/server"
"go.woodpecker-ci.org/woodpecker/v2/server/api" "go.woodpecker-ci.org/woodpecker/v2/server/api"
mocks_forge "go.woodpecker-ci.org/woodpecker/v2/server/forge/mocks" 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" "go.woodpecker-ci.org/woodpecker/v2/server/model"
mocks_services "go.woodpecker-ci.org/woodpecker/v2/server/services/mocks" mocks_services "go.woodpecker-ci.org/woodpecker/v2/server/services/mocks"
"go.woodpecker-ci.org/woodpecker/v2/server/services/permissions" "go.woodpecker-ci.org/woodpecker/v2/server/services/permissions"
mocks_store "go.woodpecker-ci.org/woodpecker/v2/server/store/mocks" 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/server/store/types"
"go.woodpecker-ci.org/woodpecker/v2/shared/token"
) )
func TestHandleAuth(t *testing.T) { func TestHandleAuth(t *testing.T) {
@ -69,12 +72,37 @@ func TestHandleAuth(t *testing.T) {
assert.Equal(g, fmt.Sprintf("/login?%s", query.Encode()), c.Writer.Header().Get("Location")) 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() { 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() { g.It("should redirect to forge login page", func() {
@ -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) forgeRedirectURL := ""
_forge.On("Login", mock.Anything, mock.Anything).Return(nil, forgeRedirectURL, nil) _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) api.HandleAuth(c)
@ -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) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(nil, types.RecordNotExist) _store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(nil, types.RecordNotExist)
_store.On("CreateUser", mock.Anything).Return(nil) _store.On("CreateUser", mock.Anything).Return(nil)
@ -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) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(user, nil) _store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(user, nil)
_store.On("OrgGet", org.ID).Return(org, nil) _store.On("OrgGet", org.ID).Return(org, nil)
@ -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) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(nil, types.RecordNotExist) _store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(nil, types.RecordNotExist)
@ -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("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_forge.On("Teams", mock.Anything, user).Return([]*model.Team{ _forge.On("Teams", mock.Anything, user).Return([]*model.Team{
{ {
@ -252,7 +287,7 @@ func TestHandleAuth(t *testing.T) {
} }
user.OrgID = 0 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) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(user, nil) _store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(user, nil)
_store.On("OrgFindByName", user.Login).Return(nil, types.RecordNotExist) _store.On("OrgFindByName", user.Login).Return(nil, types.RecordNotExist)
@ -286,7 +321,7 @@ func TestHandleAuth(t *testing.T) {
} }
user.OrgID = 0 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) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(user, nil) _store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(user, nil)
_store.On("OrgFindByName", user.Login).Return(org, nil) _store.On("OrgFindByName", user.Login).Return(org, nil)
@ -320,7 +355,7 @@ func TestHandleAuth(t *testing.T) {
} }
org.Name = "not-the-user-name" 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) _forge.On("Login", mock.Anything, mock.Anything).Return(user, "", nil)
_store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(user, nil) _store.On("GetUserRemoteID", user.ForgeRemoteID, user.Login).Return(user, nil)
_store.On("OrgGet", user.OrgID).Return(org, nil) _store.On("OrgGet", user.OrgID).Return(org, nil)

View file

@ -38,6 +38,7 @@ var Config = struct {
LogStore log.Service LogStore log.Service
} }
Server struct { Server struct {
JWTSecret string
Key string Key string
Cert string Cert string
OAuthHost string OAuthHost string

View file

@ -46,7 +46,7 @@ type Manager interface {
EnvironmentService() environment.Service EnvironmentService() environment.Service
ForgeFromRepo(repo *model.Repo) (forge.Forge, error) ForgeFromRepo(repo *model.Repo) (forge.Forge, error)
ForgeFromUser(user *model.User) (forge.Forge, error) ForgeFromUser(user *model.User) (forge.Forge, error)
ForgeMain() (forge.Forge, error) ForgeByID(id int64) (forge.Forge, error)
} }
type manager struct { type manager struct {
@ -120,18 +120,14 @@ func (m *manager) EnvironmentService() environment.Service {
} }
func (m *manager) ForgeFromRepo(repo *model.Repo) (forge.Forge, error) { func (m *manager) ForgeFromRepo(repo *model.Repo) (forge.Forge, error) {
return m.getForgeByID(repo.ForgeID) return m.ForgeByID(repo.ForgeID)
} }
func (m *manager) ForgeFromUser(user *model.User) (forge.Forge, error) { func (m *manager) ForgeFromUser(user *model.User) (forge.Forge, error) {
return m.getForgeByID(user.ForgeID) return m.ForgeByID(user.ForgeID)
} }
func (m *manager) ForgeMain() (forge.Forge, error) { func (m *manager) ForgeByID(id int64) (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) {
item := m.forgeCache.Get(id) item := m.forgeCache.Get(id)
if item != nil && !item.IsExpired() { if item != nil && !item.IsExpired() {
return item.Value(), nil return item.Value(), nil

View file

@ -68,6 +68,36 @@ func (_m *Manager) EnvironmentService() environment.Service {
return r0 return r0
} }
// ForgeByID provides a mock function with given fields: id
func (_m *Manager) ForgeByID(id int64) (forge.Forge, error) {
ret := _m.Called(id)
if len(ret) == 0 {
panic("no return value specified for ForgeByID")
}
var r0 forge.Forge
var r1 error
if rf, ok := ret.Get(0).(func(int64) (forge.Forge, error)); ok {
return rf(id)
}
if rf, ok := ret.Get(0).(func(int64) forge.Forge); ok {
r0 = rf(id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(forge.Forge)
}
}
if rf, ok := ret.Get(1).(func(int64) error); ok {
r1 = rf(id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ForgeFromRepo provides a mock function with given fields: repo // ForgeFromRepo provides a mock function with given fields: repo
func (_m *Manager) ForgeFromRepo(repo *model.Repo) (forge.Forge, error) { func (_m *Manager) ForgeFromRepo(repo *model.Repo) (forge.Forge, error) {
ret := _m.Called(repo) ret := _m.Called(repo)
@ -128,36 +158,6 @@ func (_m *Manager) ForgeFromUser(user *model.User) (forge.Forge, error) {
return r0, r1 return r0, r1
} }
// ForgeMain provides a mock function with given fields:
func (_m *Manager) ForgeMain() (forge.Forge, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for ForgeMain")
}
var r0 forge.Forge
var r1 error
if rf, ok := ret.Get(0).(func() (forge.Forge, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() forge.Forge); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(forge.Forge)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RegistryService provides a mock function with given fields: // RegistryService provides a mock function with given fields:
func (_m *Manager) RegistryService() registry.Service { func (_m *Manager) RegistryService() registry.Service {
ret := _m.Called() ret := _m.Called()

View file

@ -27,11 +27,12 @@ type SecretFunc func(*Token) (string, error)
type Type string type Type string
const ( const (
UserToken Type = "user" // user token (exp cli) UserToken Type = "user" // user token (exp cli)
SessToken Type = "sess" // session token (ui token requires csrf check) SessToken Type = "sess" // session token (ui token requires csrf check)
HookToken Type = "hook" // repo hook token HookToken Type = "hook" // repo hook token
CsrfToken Type = "csrf" CsrfToken Type = "csrf"
AgentToken Type = "agent" AgentToken Type = "agent"
OAuthStateToken Type = "oauth-state"
) )
// SignerAlgo id default algorithm used to sign JWT tokens. // SignerAlgo id default algorithm used to sign JWT tokens.

View file

@ -452,5 +452,6 @@
"oauth_error": "Error while authenticating against OAuth provider", "oauth_error": "Error while authenticating against OAuth provider",
"internal_error": "Some internal error occurred", "internal_error": "Some internal error occurred",
"registration_closed": "The registration is closed", "registration_closed": "The registration is closed",
"access_denied": "You are not allowed to access this instance" "access_denied": "You are not allowed to access this instance",
"invalid_state": "The OAuth state is invalid"
} }

View file

@ -52,6 +52,7 @@ const authErrorMessages = {
internal_error: i18n.t('internal_error'), internal_error: i18n.t('internal_error'),
registration_closed: i18n.t('registration_closed'), registration_closed: i18n.t('registration_closed'),
access_denied: i18n.t('access_denied'), access_denied: i18n.t('access_denied'),
invalid_state: i18n.t('invalid_state'),
}; };
const errorMessage = ref<string>(); const errorMessage = ref<string>();