diff --git a/BUILD.bazel b/BUILD.bazel index 79fc3654c7..f709c90ae7 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -55,3 +55,4 @@ genrule( # gazelle:exclude pkg/model/piped_stats.pb.validate.go # gazelle:exclude pkg/model/project.pb.validate.go # gazelle:exclude pkg/model/role.pb.validate.go +# gazelle:exclude pkg/model/user.pb.validate.go diff --git a/go.mod b/go.mod index f1652c1c95..0f088db489 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/golang/protobuf v1.4.0 github.com/gomodule/redigo v2.0.0+incompatible github.com/google/go-cmp v0.4.0 + github.com/google/go-github/v29 v29.0.3 github.com/google/uuid v1.1.1 github.com/hashicorp/golang-lru v0.5.1 github.com/prometheus/client_golang v1.6.0 diff --git a/go.sum b/go.sum index a3295e3241..4b20f4dfc3 100644 --- a/go.sum +++ b/go.sum @@ -161,6 +161,10 @@ github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-github/v29 v29.0.3 h1:IktKCTwU//aFHnpA+2SLIi7Oo9uhAzgsdZNbcAqhgdc= +github.com/google/go-github/v29 v29.0.3/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= diff --git a/pkg/app/api/authhandler/BUILD.bazel b/pkg/app/api/authhandler/BUILD.bazel index 62b1132e8d..a1828483e3 100644 --- a/pkg/app/api/authhandler/BUILD.bazel +++ b/pkg/app/api/authhandler/BUILD.bazel @@ -3,6 +3,7 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library") go_library( name = "go_default_library", srcs = [ + "callback_handler.go", "handler.go", "login_handler.go", ], @@ -11,6 +12,7 @@ go_library( deps = [ "//pkg/jwt:go_default_library", "//pkg/model:go_default_library", + "//pkg/oauth/github:go_default_library", "@org_golang_x_net//xsrftoken:go_default_library", "@org_uber_go_zap//:go_default_library", ], diff --git a/pkg/app/api/authhandler/callback_handler.go b/pkg/app/api/authhandler/callback_handler.go new file mode 100644 index 0000000000..a162c4589a --- /dev/null +++ b/pkg/app/api/authhandler/callback_handler.go @@ -0,0 +1,121 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package authhandler + +import ( + "context" + "crypto/subtle" + "encoding/hex" + "fmt" + "net/http" + "time" + + "go.uber.org/zap" + "golang.org/x/net/xsrftoken" + + "github.com/pipe-cd/pipe/pkg/jwt" + "github.com/pipe-cd/pipe/pkg/model" + "github.com/pipe-cd/pipe/pkg/oauth/github" +) + +func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + + err := checkState(r, h.stateKey) + if err != nil { + handleError(w, r, rootPath, "unauthorized access", h.logger, err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + proj, err := h.getProject(ctx, r) + if err != nil { + handleError(w, r, rootPath, "wrong project", h.logger, err) + return + } + + user, err := getUser(ctx, proj.Sso, proj.Id, r.FormValue(authCodeFormKey)) + if err != nil { + handleError(w, r, rootPath, "internal error", h.logger, err) + return + } + claims := jwt.NewClaims( + user.Username, + user.AvatarUrl, + defaultTokenTTL, + *user.Role, + ) + signedToken, err := h.signer.Sign(claims) + if err != nil { + handleError(w, r, rootPath, "internal error", h.logger, err) + return + } + http.SetCookie(w, makeTokenCookie(signedToken)) + http.SetCookie(w, makeExpiredStateCookie()) + + h.logger.Info("user logged in", + zap.String("user", user.Username), + zap.String("project-id", proj.Id), + zap.String("project-role", user.Role.String()), + ) + + http.Redirect(w, r, rootPath, http.StatusFound) +} + +func checkState(r *http.Request, key string) error { + state := r.FormValue(stateFormKey) + rawStateToken, err := hex.DecodeString(state) + if err != nil { + return err + } + + stateToken := string(rawStateToken) + if !xsrftoken.Valid(stateToken, key, "", "") { + return fmt.Errorf("invalid state") + } + + c, err := r.Cookie(stateCookieKey) + if err != nil { + return err + } + + secretState := c.Value + if state == "" || subtle.ConstantTimeCompare([]byte(state), []byte(secretState)) != 1 { + return fmt.Errorf("wrong state") + } + + return nil +} + +func getUser(ctx context.Context, sso *model.ProjectSingleSignOn, projectID, code string) (*model.User, error) { + if sso == nil { + return nil, fmt.Errorf("missing SSO configuration") + } + switch sso.Provider { + case model.ProjectSingleSignOnProvider_GITHUB: + if sso.Github == nil { + return nil, fmt.Errorf("missing GitHub oauth in the SSO configuration") + } + cli, err := github.NewOAuthClient(ctx, sso.Github, projectID, code) + if err != nil { + return nil, err + } + return cli.GetUser(ctx) + default: + return nil, fmt.Errorf("not implemented") + } +} diff --git a/pkg/app/api/authhandler/handler.go b/pkg/app/api/authhandler/handler.go index f769af015e..181f64c1fc 100644 --- a/pkg/app/api/authhandler/handler.go +++ b/pkg/app/api/authhandler/handler.go @@ -41,6 +41,8 @@ const ( projectFormKey = "project" usernameFormKey = "username" passwordFormKey = "password" + authCodeFormKey = "code" + stateFormKey = "state" stateCookieKey = "state" errorCookieKey = "error" @@ -59,7 +61,7 @@ type projectGetter interface { type Handler struct { signer jwt.Signer apiURL string - stateSeed string + stateKey string projectGetter projectGetter logger *zap.Logger } @@ -68,14 +70,14 @@ type Handler struct { func NewHandler( signer jwt.Signer, apiURL string, - stateSeed string, + stateKey string, projectGetter projectGetter, logger *zap.Logger, ) *Handler { return &Handler{ signer: signer, apiURL: apiURL, - stateSeed: stateSeed, + stateKey: stateKey, projectGetter: projectGetter, logger: logger, } @@ -85,6 +87,7 @@ func NewHandler( func (h *Handler) Register(reg func(string, func(http.ResponseWriter, *http.Request))) { reg(loginPath, h.handleLogin) reg(staticLoginPath, h.handleStaticLogin) + reg(callbackPath, h.handleCallback) reg(logoutPath, h.handleLogout) } diff --git a/pkg/app/api/authhandler/login_handler.go b/pkg/app/api/authhandler/login_handler.go index 617d5a1ff7..f74859fde9 100644 --- a/pkg/app/api/authhandler/login_handler.go +++ b/pkg/app/api/authhandler/login_handler.go @@ -46,7 +46,7 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { return } - stateToken := xsrftoken.Generate(h.stateSeed, "", "") + stateToken := xsrftoken.Generate(h.stateKey, "", "") state := hex.EncodeToString([]byte(stateToken)) authURL, err := proj.Sso.GenerateAuthCodeURL(proj.Id, h.apiURL, callbackPath, state) if err != nil { diff --git a/pkg/app/api/cmd/server/server.go b/pkg/app/api/cmd/server/server.go index bf4d8a75a4..5cf2484e67 100644 --- a/pkg/app/api/cmd/server/server.go +++ b/pkg/app/api/cmd/server/server.go @@ -234,7 +234,7 @@ func (s *server) run(ctx context.Context, t cli.Telemetry) error { Handler: mux, } handlers := []httpHandler{ - authhandler.NewHandler(signer, cfg.APIURL, cfg.StateSeed, datastore.NewProjectStore(ds), t.Logger), + authhandler.NewHandler(signer, cfg.APIURL, cfg.StateKey, datastore.NewProjectStore(ds), t.Logger), } for _, h := range handlers { h.Register(mux.HandleFunc) diff --git a/pkg/config/control_plane.go b/pkg/config/control_plane.go index bdb0ef31b9..a4e11c97d6 100644 --- a/pkg/config/control_plane.go +++ b/pkg/config/control_plane.go @@ -32,8 +32,8 @@ type ControlPlaneSpec struct { Projects []ControlPlaneProject `json:"projects"` // The address to the API of PipeCD control plane. APIURL string `json:"apiUrl"` - // The seed to generate oauth state paramater. - StateSeed string `json:"stateSeed"` + // The key to generate oauth state paramater. + StateKey string `json:"stateKey"` } func (s *ControlPlaneSpec) Validate() error { diff --git a/pkg/model/BUILD.bazel b/pkg/model/BUILD.bazel index 2c72c2e0e8..c5c7a1a083 100644 --- a/pkg/model/BUILD.bazel +++ b/pkg/model/BUILD.bazel @@ -16,6 +16,7 @@ proto_library( "piped_stats.proto", "project.proto", "role.proto", + "user.proto", ], visibility = ["//visibility:public"], #keep diff --git a/pkg/model/project.proto b/pkg/model/project.proto index dd3c7181f4..78b1bd0918 100644 --- a/pkg/model/project.proto +++ b/pkg/model/project.proto @@ -58,14 +58,16 @@ enum ProjectSingleSignOnProvider { message ProjectSingleSignOn { ProjectSingleSignOnProvider provider = 1 [(validate.rules).enum.defined_only = true]; - string admin_team = 2 [(validate.rules).string.min_len = 1]; - string editor_team = 3 [(validate.rules).string.min_len = 1]; - string viewer_team = 4 [(validate.rules).string.min_len = 1]; message GitHub { string client_id = 1 [(validate.rules).string.min_len = 1]; string client_secret = 2 [(validate.rules).string.min_len = 1]; string base_url = 3; + string upload_url = 4; + string org = 5 [(validate.rules).string.min_len = 1]; + string admin_team = 6 [(validate.rules).string.min_len = 1]; + string editor_team = 7 [(validate.rules).string.min_len = 1]; + string viewer_team = 8 [(validate.rules).string.min_len = 1]; } message Google { diff --git a/pkg/model/user.proto b/pkg/model/user.proto new file mode 100644 index 0000000000..de1be53f34 --- /dev/null +++ b/pkg/model/user.proto @@ -0,0 +1,28 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package pipe.model; +option go_package = "github.com/pipe-cd/pipe/pkg/model"; + +import "validate/validate.proto"; +import "pkg/model/role.proto"; + +// User represents a logged in user. +message User { + string username = 1 [(validate.rules).string.min_len = 1]; + string avatar_url = 2; + Role role = 3 [(validate.rules).message.required = true]; +} diff --git a/pkg/oauth/github/BUILD.bazel b/pkg/oauth/github/BUILD.bazel new file mode 100644 index 0000000000..3e4222b454 --- /dev/null +++ b/pkg/oauth/github/BUILD.bazel @@ -0,0 +1,26 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["github.go"], + importpath = "github.com/pipe-cd/pipe/pkg/oauth/github", + visibility = ["//visibility:public"], + deps = [ + "//pkg/model:go_default_library", + "@com_github_google_go_github_v29//github:go_default_library", + "@org_golang_x_oauth2//:go_default_library", + "@org_golang_x_oauth2//github:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["github_test.go"], + embed = [":go_default_library"], + deps = [ + "//pkg/model:go_default_library", + "@com_github_google_go_github_v29//github:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + ], +) diff --git a/pkg/oauth/github/github.go b/pkg/oauth/github/github.go new file mode 100644 index 0000000000..9f39271099 --- /dev/null +++ b/pkg/oauth/github/github.go @@ -0,0 +1,146 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package github + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/google/go-github/v29/github" + "golang.org/x/oauth2" + oauth2github "golang.org/x/oauth2/github" + + "github.com/pipe-cd/pipe/pkg/model" +) + +// OAuthClient is a oauth client for github. +type OAuthClient struct { + *github.Client + + projectID string + org string + adminTeam string + editorTeam string + viewerTeam string +} + +// NewOAuthClient creates a new oauth client for GitHub. +func NewOAuthClient(ctx context.Context, p *model.ProjectSingleSignOn_GitHub, projectID, code string) (*OAuthClient, error) { + c := &OAuthClient{ + projectID: projectID, + org: p.Org, + adminTeam: p.AdminTeam, + editorTeam: p.EditorTeam, + viewerTeam: p.ViewerTeam, + } + var ( + tokenURL = oauth2github.Endpoint.TokenURL + baseURL *url.URL + err error + ) + if p.BaseUrl != "" { + baseURL, err = url.Parse(p.BaseUrl) + if err != nil { + return nil, err + } + tokenURL = fmt.Sprintf("%s://%s%s", baseURL.Scheme, baseURL.Host, "/login/oauth/access_token") + } + + cfg := oauth2.Config{ + ClientID: p.ClientId, + ClientSecret: p.ClientSecret, + Endpoint: oauth2.Endpoint{TokenURL: tokenURL}, + } + token, err := cfg.Exchange(ctx, code) + if err != nil { + return nil, err + } + + cli := github.NewClient(cfg.Client(ctx, token)) + if p.BaseUrl != "" { + if !strings.HasSuffix(baseURL.Path, "/") { + baseURL.Path += "/" + } + cli.BaseURL = baseURL + } + if p.UploadUrl != "" { + uploadURL, err := url.Parse(p.UploadUrl) + if err != nil { + return nil, err + } + if !strings.HasSuffix(uploadURL.Path, "/") { + uploadURL.Path += "/" + } + cli.UploadURL = uploadURL + } + + c.Client = cli + return c, nil +} + +// GetUser returns a user model. +func (c *OAuthClient) GetUser(ctx context.Context) (*model.User, error) { + user, _, err := c.Users.Get(ctx, "") + if err != nil { + return nil, err + } + teams, _, err := c.Teams.ListUserTeams(ctx, nil) + if err != nil { + return nil, err + } + role, err := c.decideRole(user.GetLogin(), teams) + if err != nil { + return nil, err + } + + return &model.User{ + Username: user.GetLogin(), + AvatarUrl: user.GetAvatarURL(), + Role: &model.Role{ + ProjectId: c.projectID, + ProjectRole: *role, + }, + }, nil +} + +func (c *OAuthClient) decideRole(user string, teams []*github.Team) (*model.Role_ProjectRole, error) { + var viewer, editor bool + for _, team := range teams { + slug := team.GetSlug() + if c.org != team.Organization.GetLogin() || slug == "" { + continue + } + switch slug { + case c.adminTeam: + r := model.Role_ADMIN + return &r, nil + case c.editorTeam: + editor = true + case c.viewerTeam: + viewer = true + } + } + if editor { + r := model.Role_EDITOR + return &r, nil + } + if viewer { + r := model.Role_VIEWER + return &r, nil + } + return nil, fmt.Errorf("user (%s) not found in any of the %d project teams", user, len(teams)) +} diff --git a/pkg/oauth/github/github_test.go b/pkg/oauth/github/github_test.go new file mode 100644 index 0000000000..06357d104f --- /dev/null +++ b/pkg/oauth/github/github_test.go @@ -0,0 +1,110 @@ +// Copyright 2020 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package github + +import ( + "testing" + + "github.com/google/go-github/v29/github" + "github.com/stretchr/testify/assert" + + "github.com/pipe-cd/pipe/pkg/model" +) + +func stringPointer(s string) *string { return &s } + +func TestDecideRole(t *testing.T) { + cases := []struct { + name string + username string + teams []*github.Team + role model.Role_ProjectRole + wantErr bool + }{ + { + name: "nothing", + username: "foo", + teams: []*github.Team{ + { + Slug: stringPointer("team1"), + }, + }, + wantErr: true, + }, + { + name: "admin", + username: "foo", + teams: []*github.Team{ + { + Slug: stringPointer("team-admin"), + }, + { + Slug: stringPointer("team-editor"), + }, + { + Slug: stringPointer("team-viewer"), + }, + }, + role: model.Role_ADMIN, + }, + { + name: "editor", + username: "foo", + teams: []*github.Team{ + { + Slug: stringPointer("team1"), + }, + { + Slug: stringPointer("team-editor"), + }, + { + Slug: stringPointer("team-viewer"), + }, + }, + role: model.Role_EDITOR, + }, + { + name: "viewer", + username: "foo", + teams: []*github.Team{ + { + Slug: stringPointer("team1"), + }, + { + Slug: stringPointer("team2"), + }, + { + Slug: stringPointer("team-viewer"), + }, + }, + role: model.Role_VIEWER, + }, + } + + oc := &OAuthClient{ + adminTeam: "team-admin", + editorTeam: "team-editor", + viewerTeam: "team-viewer", + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + role, err := oc.decideRole(tc.username, tc.teams) + assert.Equal(t, tc.wantErr, err != nil) + if err == nil { + assert.Equal(t, tc.role, *role) + } + }) + } +}