Jelajahi Sumber

add google and github oauth handlers

Alexander Belanger 4 tahun lalu
induk
melakukan
f24f007fa1

+ 151 - 0
api/server/handlers/user/github_callback.go

@@ -0,0 +1,151 @@
+package user
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/api/server/authn"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type UserOAuthGithubCallbackHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewUserOAuthGithubCallbackHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UserOAuthGithubCallbackHandler {
+	return &UserOAuthGithubCallbackHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *UserOAuthGithubCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	session, err := p.Config().Store.Get(r, p.Config().ServerConf.CookieName)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	token, err := p.Config().GithubConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	if !token.Valid() {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("invalid token")))
+		return
+	}
+
+	// otherwise, create the user if not exists
+	user, err := upsertUserFromToken(p.Config(), token)
+
+	if err != nil && strings.Contains(err.Error(), "already registered") {
+		http.Redirect(w, r, "/login?error="+url.QueryEscape(err.Error()), 302)
+		return
+	} else if err != nil {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	// save the user as authenticated in the session
+	if err := authn.SaveUserAuthenticated(w, r, p.Config(), user); err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if session.Values["query_params"] != "" {
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	} else {
+		http.Redirect(w, r, "/dashboard", 302)
+	}
+}
+
+func upsertUserFromToken(config *config.Config, tok *oauth2.Token) (*models.User, error) {
+	// determine if the user already exists
+	client := github.NewClient(config.GithubConf.Client(oauth2.NoContext, tok))
+
+	githubUser, _, err := client.Users.Get(context.Background(), "")
+
+	if err != nil {
+		return nil, err
+	}
+
+	user, err := config.Repo.User().ReadUserByGithubUserID(*githubUser.ID)
+
+	// if the user does not exist, create new user
+	if err != nil && err == gorm.ErrRecordNotFound {
+		emails, _, err := client.Users.ListEmails(context.Background(), &github.ListOptions{})
+
+		if err != nil {
+			return nil, err
+		}
+
+		primary := ""
+		verified := false
+
+		// get the primary email
+		for _, email := range emails {
+			if email.GetPrimary() {
+				primary = email.GetEmail()
+				verified = email.GetVerified()
+				break
+			}
+		}
+
+		if primary == "" {
+			return nil, fmt.Errorf("github user must have an email")
+		}
+
+		// check if a user with that email address already exists
+		_, err = config.Repo.User().ReadUserByEmail(primary)
+
+		if err == gorm.ErrRecordNotFound {
+			user = &models.User{
+				Email:         primary,
+				EmailVerified: !config.Metadata.Email || verified,
+				GithubUserID:  githubUser.GetID(),
+			}
+
+			user, err = config.Repo.User().CreateUser(user)
+
+			if err != nil {
+				return nil, err
+			}
+		} else if err == nil {
+			return nil, fmt.Errorf("email already registered")
+		} else if err != nil {
+			return nil, err
+		}
+	} else if err != nil {
+		return nil, fmt.Errorf("unexpected error occurred:%s", err.Error())
+	}
+
+	return user, nil
+}

+ 41 - 0
api/server/handlers/user/github_start.go

@@ -0,0 +1,41 @@
+package user
+
+import (
+	"net/http"
+
+	"golang.org/x/oauth2"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/internal/oauth"
+)
+
+type UserOAuthGithubHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewUserOAuthGithubHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UserOAuthGithubHandler {
+	return &UserOAuthGithubHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *UserOAuthGithubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	if err := p.PopulateOAuthSession(w, r, state, false); err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := p.Config().GithubConf.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+	http.Redirect(w, r, url, 302)
+}

+ 174 - 0
api/server/handlers/user/google_callback.go

@@ -0,0 +1,174 @@
+package user
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+
+	"github.com/porter-dev/porter/api/server/authn"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type UserOAuthGoogleCallbackHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewUserOAuthGoogleCallbackHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UserOAuthGoogleCallbackHandler {
+	return &UserOAuthGoogleCallbackHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *UserOAuthGoogleCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	session, err := p.Config().Store.Get(r, p.Config().ServerConf.CookieName)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	token, err := p.Config().GoogleConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	if !token.Valid() {
+		p.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("invalid token")))
+		return
+	}
+
+	// otherwise, create the user if not exists
+	user, err := upsertGoogleUserFromToken(p.Config(), token)
+
+	if err != nil && strings.Contains(err.Error(), "already registered") {
+		http.Redirect(w, r, "/login?error="+url.QueryEscape(err.Error()), 302)
+		return
+	} else if err != nil && strings.Contains(err.Error(), "restricted domain group") {
+		http.Redirect(w, r, "/login?error="+url.QueryEscape(err.Error()), 302)
+		return
+	} else if err != nil {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	// save the user as authenticated in the session
+	if err := authn.SaveUserAuthenticated(w, r, p.Config(), user); err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if session.Values["query_params"] != "" {
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	} else {
+		http.Redirect(w, r, "/dashboard", 302)
+	}
+}
+
+func upsertGoogleUserFromToken(config *config.Config, tok *oauth2.Token) (*models.User, error) {
+	gInfo, err := getGoogleUserInfoFromToken(tok)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// if the app has a restricted domain, check the `hd` query param
+	if config.ServerConf.GoogleRestrictedDomain != "" {
+		if gInfo.HD != config.ServerConf.GoogleRestrictedDomain {
+			return nil, fmt.Errorf("Email is not in the restricted domain group.")
+		}
+	}
+
+	user, err := config.Repo.User().ReadUserByGoogleUserID(gInfo.Sub)
+
+	// if the user does not exist, create new user
+	if err != nil && err == gorm.ErrRecordNotFound {
+		// check if a user with that email address already exists
+		_, err = config.Repo.User().ReadUserByEmail(gInfo.Email)
+
+		if err == gorm.ErrRecordNotFound {
+			user = &models.User{
+				Email:         gInfo.Email,
+				EmailVerified: !config.Metadata.Email || gInfo.EmailVerified,
+				GoogleUserID:  gInfo.Sub,
+			}
+
+			user, err = config.Repo.User().CreateUser(user)
+
+			if err != nil {
+				return nil, err
+			}
+		} else if err == nil {
+			return nil, fmt.Errorf("email already registered")
+		} else if err != nil {
+			return nil, err
+		}
+	} else if err != nil {
+		return nil, fmt.Errorf("unexpected error occurred:%s", err.Error())
+	}
+
+	return user, nil
+}
+
+type googleUserInfo struct {
+	Email         string `json:"email"`
+	EmailVerified bool   `json:"email_verified"`
+	HD            string `json:"hd"`
+	Sub           string `json:"sub"`
+}
+
+func getGoogleUserInfoFromToken(tok *oauth2.Token) (*googleUserInfo, error) {
+	// use userinfo endpoint for Google OIDC to get claims
+	url := "https://openidconnect.googleapis.com/v1/userinfo"
+
+	req, err := http.NewRequest("GET", url, nil)
+
+	req.Header.Add("Authorization", "Bearer "+tok.AccessToken)
+
+	client := &http.Client{}
+
+	response, err := client.Do(req)
+
+	if err != nil {
+		return nil, fmt.Errorf("failed getting user info: %s", err.Error())
+	}
+
+	defer response.Body.Close()
+
+	contents, err := ioutil.ReadAll(response.Body)
+
+	if err != nil {
+		return nil, fmt.Errorf("failed reading response body: %s", err.Error())
+	}
+
+	// parse contents into Google userinfo claims
+	gInfo := &googleUserInfo{}
+	err = json.Unmarshal(contents, &gInfo)
+
+	return gInfo, nil
+}

+ 41 - 0
api/server/handlers/user/google_start.go

@@ -0,0 +1,41 @@
+package user
+
+import (
+	"net/http"
+
+	"golang.org/x/oauth2"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/internal/oauth"
+)
+
+type UserOAuthGoogleHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewUserOAuthGoogleHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UserOAuthGoogleHandler {
+	return &UserOAuthGoogleHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *UserOAuthGoogleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	if err := p.PopulateOAuthSession(w, r, state, false); err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := p.Config().GoogleConf.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+	http.Redirect(w, r, url, 302)
+}

+ 100 - 0
api/server/router/base.go

@@ -359,5 +359,105 @@ func GetBaseRoutes(
 		Router:   r,
 	})
 
+	// GET /api/oauth/login/github
+	githubLoginStartEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/oauth/login/github",
+			},
+			Scopes: []types.PermissionScope{},
+		},
+	)
+
+	githubLoginStartHandler := user.NewUserOAuthGithubHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: githubLoginStartEndpoint,
+		Handler:  githubLoginStartHandler,
+		Router:   r,
+	})
+
+	// GET /api/oauth/github/callback
+	githubLoginCallbackEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/oauth/github/callback",
+			},
+			Scopes: []types.PermissionScope{},
+		},
+	)
+
+	githubLoginCallbackHandler := user.NewUserOAuthGithubCallbackHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: githubLoginCallbackEndpoint,
+		Handler:  githubLoginCallbackHandler,
+		Router:   r,
+	})
+
+	// GET /api/oauth/login/google
+	googleLoginStartEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/oauth/login/google",
+			},
+			Scopes: []types.PermissionScope{},
+		},
+	)
+
+	googleLoginStartHandler := user.NewUserOAuthGoogleHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: googleLoginStartEndpoint,
+		Handler:  googleLoginStartHandler,
+		Router:   r,
+	})
+
+	// GET /api/oauth/google/callback
+	googleLoginCallbackEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/oauth/google/callback",
+			},
+			Scopes: []types.PermissionScope{},
+		},
+	)
+
+	googleLoginCallbackHandler := user.NewUserOAuthGoogleCallbackHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: googleLoginCallbackEndpoint,
+		Handler:  googleLoginCallbackHandler,
+		Router:   r,
+	})
+
 	return routes
 }

+ 3 - 0
api/server/shared/config/config.go

@@ -58,6 +58,9 @@ type Config struct {
 	// GithubAppConf is the configuration for a Github App OAuth client
 	GithubAppConf *oauth.GithubAppConf
 
+	// GoogleConf is the configuration for a Google OAuth client
+	GoogleConf *oauth2.Config
+
 	// SlackConf is the configuration for a Slack OAuth client
 	SlackConf *oauth2.Config
 

+ 13 - 0
api/server/shared/config/loader/loader.go

@@ -111,6 +111,19 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		})
 	}
 
+	if sc.GoogleClientID != "" && sc.GoogleClientSecret != "" {
+		res.GoogleConf = oauth.NewGoogleClient(&oauth.Config{
+			ClientID:     sc.GoogleClientID,
+			ClientSecret: sc.GoogleClientSecret,
+			Scopes: []string{
+				"openid",
+				"profile",
+				"email",
+			},
+			BaseURL: sc.ServerURL,
+		})
+	}
+
 	if sc.GithubClientID != "" && sc.GithubClientSecret != "" {
 		res.GithubConf = oauth.NewGithubClient(&oauth.Config{
 			ClientID:     sc.GithubClientID,

+ 6 - 6
docs/developing/backend-refactor-status.md

@@ -19,13 +19,13 @@
 | <li>- [x] `POST /api/login`                                                                                                 | AB          |                 |             | yes              |
 | <li>- [x] `POST /api/logout`                                                                                                | AB          |                 |             | yes              |
 | <li>- [X] `GET /api/oauth/digitalocean/callback`                                                                            | AB          |                 |             |                  |
-| <li>- [ ] `GET /api/oauth/github-app/callback`                                                                              | AB          |                 |             |                  |
-| <li>- [ ] `GET /api/oauth/github/callback`                                                                                  |             |                 |             |                  |
-| <li>- [ ] `GET /api/oauth/google/callback`                                                                                  |             |                 |             |                  |
-| <li>- [ ] `GET /api/oauth/login/github`                                                                                     |             |                 |             |                  |
-| <li>- [ ] `GET /api/oauth/login/google`                                                                                     |             |                 |             |                  |
+| <li>- [X] `GET /api/oauth/github-app/callback`                                                                              | AB          |                 |             |                  |
+| <li>- [X] `GET /api/oauth/github/callback`                                                                                  | AB          |                 |             |                  |
+| <li>- [X] `GET /api/oauth/google/callback`                                                                                  | AB          |                 |             |                  |
+| <li>- [X] `GET /api/oauth/login/github`                                                                                     | AB          |                 |             |                  |
+| <li>- [X] `GET /api/oauth/login/google`                                                                                     | AB          |                 |             |                  |
 | <li>- [X] `GET /api/oauth/projects/{project_id}/digitalocean`                                                               | AB          |                 |             |                  |
-| <li>- [ ] `GET /api/oauth/projects/{project_id}/github`                                                                     |             |                 |             |                  |
+| <li>- [X] `GET /api/oauth/projects/{project_id}/github`                                                                     | N/A         |                 |             |                  |
 | <li>- [x] `GET /api/oauth/projects/{project_id}/slack`                                                                      | AS          | yes             |             | yes              |
 | <li>- [x] `GET /api/oauth/slack/callback`                                                                                   | AS          |                 |             | yes              |
 | <li>- [x] `POST /api/password/reset/finalize`                                                                               | AB          |                 |             | yes              |