Pārlūkot izejas kodu

on-prem fixes + g suite

Alexander Belanger 5 gadi atpakaļ
vecāks
revīzija
54fd6c8bf0

+ 6 - 2
cmd/app/main.go

@@ -102,15 +102,19 @@ func main() {
 		go prov.GlobalStreamListener(redis, *repo, errorChan)
 	}
 
-	a, _ := api.New(&api.AppConfig{
+	a, err := api.New(&api.AppConfig{
 		Logger:     logger,
 		Repository: repo,
 		ServerConf: appConf.Server,
 		RedisConf:  &appConf.Redis,
-		CapConf: 	appConf.Capabilities,
+		CapConf:    appConf.Capabilities,
 		DBConf:     appConf.Db,
 	})
 
+	if err != nil {
+		logger.Fatal().Err(err).Msg("")
+	}
+
 	appRouter := router.New(a)
 
 	address := fmt.Sprintf(":%d", appConf.Server.Port)

+ 56 - 0
dashboard/src/assets/GoogleIcon.tsx

@@ -0,0 +1,56 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+type PropsType = {};
+
+type StateType = {};
+
+export default class GHIcon extends Component<PropsType, StateType> {
+  render() {
+    return (
+      <Svg width="46px" height="46px" viewBox="0 0 46 46">
+        <title>btn_google_light_normal_ios</title>
+        <desc>Created with Sketch.</desc>
+        <defs>
+            <filter x="-50%" y="-50%" width="200%" height="200%" filterUnits="objectBoundingBox" id="filter-1">
+                <feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
+                <feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter1" result="shadowBlurOuter1"></feGaussianBlur>
+                <feColorMatrix values="0 0 0 0 0   0 0 0 0 0   0 0 0 0 0  0 0 0 0.168 0" in="shadowBlurOuter1" type="matrix" result="shadowMatrixOuter1"></feColorMatrix>
+                <feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2"></feOffset>
+                <feGaussianBlur stdDeviation="0.5" in="shadowOffsetOuter2" result="shadowBlurOuter2"></feGaussianBlur>
+                <feColorMatrix values="0 0 0 0 0   0 0 0 0 0   0 0 0 0 0  0 0 0 0.084 0" in="shadowBlurOuter2" type="matrix" result="shadowMatrixOuter2"></feColorMatrix>
+                <feMerge>
+                    <feMergeNode in="shadowMatrixOuter1"></feMergeNode>
+                    <feMergeNode in="shadowMatrixOuter2"></feMergeNode>
+                    <feMergeNode in="SourceGraphic"></feMergeNode>
+                </feMerge>
+            </filter>
+            <rect id="path-2" x="0" y="0" width="40" height="40" rx="2"></rect>
+        </defs>
+        <g id="Google-Button" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+            <g id="9-PATCH" transform="translate(-608.000000, -160.000000)"></g>
+            <g id="btn_google_light_normal" transform="translate(-1.000000, -1.000000)">
+                <g id="button" transform="translate(4.000000, 4.000000)" filter="url(#filter-1)">
+                    <g id="button-bg">
+                        <use fill="#FFFFFF" fill-rule="evenodd"></use>
+                        <use fill="none"></use>
+                        <use fill="none"></use>
+                        <use fill="none"></use>
+                    </g>
+                </g>
+                <g id="logo_googleg_48dp" transform="translate(15.000000, 15.000000)">
+                    <path d="M17.64,9.20454545 C17.64,8.56636364 17.5827273,7.95272727 17.4763636,7.36363636 L9,7.36363636 L9,10.845 L13.8436364,10.845 C13.635,11.97 13.0009091,12.9231818 12.0477273,13.5613636 L12.0477273,15.8195455 L14.9563636,15.8195455 C16.6581818,14.2527273 17.64,11.9454545 17.64,9.20454545 L17.64,9.20454545 Z" id="Shape" fill="#4285F4"></path>
+                    <path d="M9,18 C11.43,18 13.4672727,17.1940909 14.9563636,15.8195455 L12.0477273,13.5613636 C11.2418182,14.1013636 10.2109091,14.4204545 9,14.4204545 C6.65590909,14.4204545 4.67181818,12.8372727 3.96409091,10.71 L0.957272727,10.71 L0.957272727,13.0418182 C2.43818182,15.9831818 5.48181818,18 9,18 L9,18 Z" id="Shape" fill="#34A853" ></path>
+                    <path d="M3.96409091,10.71 C3.78409091,10.17 3.68181818,9.59318182 3.68181818,9 C3.68181818,8.40681818 3.78409091,7.83 3.96409091,7.29 L3.96409091,4.95818182 L0.957272727,4.95818182 C0.347727273,6.17318182 0,7.54772727 0,9 C0,10.4522727 0.347727273,11.8268182 0.957272727,13.0418182 L3.96409091,10.71 L3.96409091,10.71 Z" id="Shape" fill="#FBBC05"></path>
+                    <path d="M9,3.57954545 C10.3213636,3.57954545 11.5077273,4.03363636 12.4404545,4.92545455 L15.0218182,2.34409091 C13.4631818,0.891818182 11.4259091,0 9,0 C5.48181818,0 2.43818182,2.01681818 0.957272727,4.95818182 L3.96409091,7.29 C4.67181818,5.16272727 6.65590909,3.57954545 9,3.57954545 L9,3.57954545 Z" id="Shape" fill="#EA4335"></path>
+                    <path d="M0,0 L18,0 L18,18 L0,18 L0,0 Z" id="Shape"></path>
+                </g>
+                <g id="handles_square" ></g>
+            </g>
+        </g>
+      </Svg>
+    );
+  }
+}
+
+const Svg = styled.svg``;

+ 48 - 14
dashboard/src/main/auth/Login.tsx

@@ -2,6 +2,7 @@ import React, { ChangeEvent, Component } from "react";
 import styled from "styled-components";
 import logo from "assets/logo.png";
 import github from "assets/github-icon.png";
+import GoogleIcon from "assets/GoogleIcon";
 
 import api from "shared/api";
 import { emailRegex } from "shared/regex";
@@ -17,6 +18,7 @@ type StateType = {
   emailError: boolean;
   credentialError: boolean;
   hasGithub: boolean;
+  hasGoogle: boolean;
 };
 
 export default class Login extends Component<PropsType, StateType> {
@@ -26,6 +28,7 @@ export default class Login extends Component<PropsType, StateType> {
     emailError: false,
     credentialError: false,
     hasGithub: true,
+    hasGoogle: false,
   };
 
   handleKeyDown = (e: any) => {
@@ -43,7 +46,10 @@ export default class Login extends Component<PropsType, StateType> {
     api
       .getCapabilities("", {}, {})
       .then((res) => {
-        this.setState({ hasGithub: res.data?.github });
+        this.setState({ 
+          hasGithub: false,
+          hasGoogle: res.data?.google,
+        });
       })
       .catch((err) => console.log(err));
   }
@@ -115,21 +121,33 @@ export default class Login extends Component<PropsType, StateType> {
     window.location.href = redirectUrl;
   };
 
+  googleRedirect = () => {
+    let redirectUrl = `/api/oauth/login/google`;
+    window.location.href = redirectUrl;
+  };
+
   renderGithubSection = () => {
     if (this.state.hasGithub) {
       return (
-        <>
           <OAuthButton onClick={this.githubRedirect}>
             <IconWrapper>
               <Icon src={github} />
               Log in with GitHub
             </IconWrapper>
           </OAuthButton>
-          <OrWrapper>
-            <Line />
-            <Or>or</Or>
-          </OrWrapper>
-        </>
+      );
+    }
+  };
+
+  renderGoogleSection = () => {
+    if (this.state.hasGoogle) {
+      return (
+          <OAuthButton onClick={this.googleRedirect}>
+            <IconWrapper>
+              <StyledGoogleIcon />
+              Log in with Google
+            </IconWrapper>
+          </OAuthButton>
       );
     }
   };
@@ -139,7 +157,7 @@ export default class Login extends Component<PropsType, StateType> {
 
     return (
       <StyledLogin>
-        <LoginPanel>
+        <LoginPanel numOAuth={+this.state.hasGithub + +this.state.hasGoogle}>
           <OverflowWrapper>
             <GradientBg />
           </OverflowWrapper>
@@ -147,6 +165,14 @@ export default class Login extends Component<PropsType, StateType> {
             <Logo src={logo} />
             <Prompt>Log in to Porter</Prompt>
             {this.renderGithubSection()}
+            {this.renderGoogleSection()}
+            {(this.state.hasGithub || this.state.hasGoogle) ? 
+              <OrWrapper>
+                <Line />
+                <Or>or</Or>
+              </OrWrapper> :
+              null
+            }
             <DarkMatter />
             <InputWrapper>
               <Input
@@ -249,9 +275,14 @@ const IconWrapper = styled.div`
 
 const Icon = styled.img`
   height: 18px;
-  margin-right: 20px;
+  margin: 14px;
 `;
 
+const StyledGoogleIcon = styled(GoogleIcon)`
+  width: 38px;
+  height: 38px;
+`
+
 const OAuthButton = styled.div`
   width: 200px;
   height: 30px;
@@ -264,6 +295,8 @@ const OAuthButton = styled.div`
   user-select: none;
   font-weight: 500;
   font-size: 13px;
+  margin: 10px 0; 
+  overflow: hidden;
   :hover {
     background: #ffffffdd;
   }
@@ -392,11 +425,11 @@ const FormWrapper = styled.div`
 
 const GradientBg = styled.div`
   background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
-  width: 180%;
-  height: 180%;
+  width: 200%;
+  height: 200%;
   position: absolute;
-  top: -40%;
-  left: -40%;
+  top: -50%;
+  left: -50%;
   animation: flip 6s infinite linear;
   @keyframes flip {
     from {
@@ -410,7 +443,8 @@ const GradientBg = styled.div`
 
 const LoginPanel = styled.div`
   width: 330px;
-  height: 470px;
+  height: ${(props: { numOAuth: number }) =>
+    430 + props.numOAuth * 50}px;
   background: white;
   margin-top: -20px;
   border-radius: 10px;

+ 3 - 2
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -108,9 +108,10 @@ export default class ChartList extends Component<PropsType, StateType> {
 
   setupWebsocket = (kind: string) => {
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+    
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -152,9 +152,9 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
   setupWebsocket = (kind: string, chart: ChartType) => {
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");

+ 4 - 4
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -137,9 +137,9 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     let chartVersion = `${chart.chart.metadata.name}-${chart.chart.metadata.version}`;
 
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/job/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/job/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");
@@ -185,9 +185,9 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     let releaseNamespace = chart.namespace
 
     let { currentCluster, currentProject } = this.context;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     let ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/cronjob/status?cluster_id=${currentCluster.id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/cronjob/status?cluster_id=${currentCluster.id}`
     );
     ws.onopen = () => {
       console.log("connected to websocket");

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -131,9 +131,9 @@ export default class Logs extends Component<PropsType, StateType> {
     let { currentCluster, currentProject } = this.context;
     let { selectedPod } = this.props;
     if (!selectedPod?.metadata?.name) return;
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     this.ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`
     );
 
     this.ws.onopen = () => {};

+ 3 - 3
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -28,7 +28,7 @@ export default class InviteList extends Component<PropsType, StateType> {
     invites: [] as InviteType[],
     email: "",
     invalidEmail: false,
-    isHTTPS: process.env.API_SERVER === "dashboard.getporter.dev",
+    isHTTPS: window.location.protocol === "https:",
   };
 
   componentDidMount() {
@@ -118,7 +118,7 @@ export default class InviteList extends Component<PropsType, StateType> {
     navigator.clipboard
       .writeText(
         `${this.state.isHTTPS ? "https://" : ""}${
-          process.env.API_SERVER
+          window.location.host
         }/api/projects/${currentProject.id}/invites/${
           this.state.invites[index].token
         }`
@@ -182,7 +182,7 @@ export default class InviteList extends Component<PropsType, StateType> {
                     disabled={true}
                     type="string"
                     value={`${this.state.isHTTPS ? "https://" : ""}${
-                      process.env.API_SERVER
+                      window.location.host
                     }/api/projects/${currentProject.id}/invites/${
                       this.state.invites[i].token
                     }`}

+ 2 - 2
dashboard/src/main/home/provisioner/ProvisionerLogs.tsx

@@ -192,9 +192,9 @@ class ProvisionerLogs extends Component<PropsType, StateType> {
 
     if (!selectedInfra) return;
 
-    let protocol = process.env.NODE_ENV == "production" ? "wss" : "ws";
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
     this.ws = new WebSocket(
-      `${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provision/${selectedInfra.kind}/${selectedInfra.id}/logs`
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/provision/${selectedInfra.kind}/${selectedInfra.id}/logs`
     );
 
     this.setupWebsocket();

+ 15 - 6
internal/config/config.go

@@ -9,11 +9,11 @@ import (
 
 // Conf is the configuration for the Go server
 type Conf struct {
-	Debug  bool `env:"DEBUG,default=false"`
-	Server ServerConf
-	Db     DBConf
-	K8s    K8sConf
-	Redis  RedisConf
+	Debug        bool `env:"DEBUG,default=false"`
+	Server       ServerConf
+	Db           DBConf
+	K8s          K8sConf
+	Redis        RedisConf
 	Capabilities CapConf
 }
 
@@ -38,6 +38,10 @@ type ServerConf struct {
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 
+	GoogleClientID         string `env:"GOOGLE_CLIENT_ID"`
+	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`
+	GoogleRestrictedDomain string `env:"GOOGLE_RESTRICTED_DOMAIN"`
+
 	SendgridAPIKey                  string `env:"SENDGRID_API_KEY"`
 	SendgridPWResetTemplateID       string `env:"SENDGRID_PW_RESET_TEMPLATE_ID"`
 	SendgridPWGHTemplateID          string `env:"SENDGRID_PW_GH_TEMPLATE_ID"`
@@ -74,7 +78,8 @@ type K8sConf struct {
 
 type CapConf struct {
 	Provisioner bool `env:"PROVISIONER_ENABLED,default=true"`
-	Github bool `env:"GITHUB_ENABLED,default=true"`
+	Github      bool `env:"GITHUB_ENABLED,default=true"`
+	Google      bool
 }
 
 // FromEnv generates a configuration from environment variables
@@ -85,5 +90,9 @@ func FromEnv() *Conf {
 		log.Fatalf("Failed to decode server conf: %s", err)
 	}
 
+	if c.Server.GoogleClientID != "" && c.Server.GoogleClientSecret != "" {
+		c.Capabilities.Google = true
+	}
+
 	return &c
 }

+ 10 - 0
internal/kubernetes/config.go

@@ -66,6 +66,16 @@ func GetAgentOutOfClusterConfig(conf *OutOfClusterConfig) (*Agent, error) {
 	return &Agent{conf, clientset}, nil
 }
 
+// IsInCluster returns true if the process is running in a Kubernetes cluster,
+// false otherwise
+func IsInCluster() bool {
+	_, err := rest.InClusterConfig()
+
+	// If the error is not nil, it is either rest.ErrNotInCluster or the in-cluster
+	// config cannot be read. In either case, in-cluster operations are not supported.
+	return err == nil
+}
+
 // GetAgentInClusterConfig uses the service account that kubernetes
 // gives to pods to connect
 func GetAgentInClusterConfig() (*Agent, error) {

+ 1 - 0
internal/models/integrations/oauth.go

@@ -11,6 +11,7 @@ type OAuthIntegrationClient string
 const (
 	OAuthGithub       OAuthIntegrationClient = "github"
 	OAuthDigitalOcean OAuthIntegrationClient = "do"
+	OAuthGoogle       OAuthIntegrationClient = "google"
 )
 
 // OAuthIntegration is an auth mechanism that uses oauth

+ 1 - 0
internal/models/user.go

@@ -14,6 +14,7 @@ type User struct {
 
 	// The github user id used for login (optional)
 	GithubUserID int64
+	GoogleUserID string
 }
 
 // UserExternal represents the User type that is sent over REST

+ 13 - 0
internal/oauth/config.go

@@ -44,6 +44,19 @@ func NewDigitalOceanClient(cfg *Config) *oauth2.Config {
 	}
 }
 
+func NewGoogleClient(cfg *Config) *oauth2.Config {
+	return &oauth2.Config{
+		ClientID:     cfg.ClientID,
+		ClientSecret: cfg.ClientSecret,
+		Endpoint: oauth2.Endpoint{
+			AuthURL:  "https://accounts.google.com/o/oauth2/v2/auth",
+			TokenURL: "https://oauth2.googleapis.com/token",
+		},
+		RedirectURL: cfg.BaseURL + "/api/oauth/google/callback",
+		Scopes:      cfg.Scopes,
+	}
+}
+
 func CreateRandomState() string {
 	b := make([]byte, 16)
 	rand.Read(b)

+ 9 - 0
internal/repository/gorm/user.go

@@ -53,6 +53,15 @@ func (repo *UserRepository) ReadUserByGithubUserID(id int64) (*models.User, erro
 	return user, nil
 }
 
+// ReadUserByGoogleUserID finds a single user based on their google user id
+func (repo *UserRepository) ReadUserByGoogleUserID(id string) (*models.User, error) {
+	user := &models.User{}
+	if err := repo.db.Where("google_user_id = ?", id).First(&user).Error; err != nil {
+		return nil, err
+	}
+	return user, nil
+}
+
 // UpdateUser modifies an existing User in the database
 func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
 	if err := repo.db.Save(user).Error; err != nil {

+ 32 - 0
internal/repository/gorm/user_test.go

@@ -38,3 +38,35 @@ func TestReadUserByGithubUserID(t *testing.T) {
 		t.Error(diff)
 	}
 }
+
+func TestReadUserByGoogleUserID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_read_user_google.db",
+	}
+
+	setupTestEnv(tester, t)
+	defer cleanup(tester, t)
+
+	user := &models.User{
+		Email:        "test@test.it",
+		Password:     "fake",
+		GoogleUserID: "alsdkfjsldaf",
+	}
+
+	user, err := tester.repo.User.CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	readUser, err := tester.repo.User.ReadUserByGoogleUserID("alsdkfjsldaf")
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if diff := deep.Equal(user, readUser); diff != nil {
+		t.Errorf("users not equal:")
+		t.Error(diff)
+	}
+}

+ 15 - 0
internal/repository/memory/user.go

@@ -86,6 +86,21 @@ func (repo *UserRepository) ReadUserByGithubUserID(id int64) (*models.User, erro
 	return nil, gorm.ErrRecordNotFound
 }
 
+// ReadUserByGoogleUserID finds a single user based on their github id field
+func (repo *UserRepository) ReadUserByGoogleUserID(id string) (*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	for _, u := range repo.users {
+		if u.GoogleUserID == id && id != "" {
+			return u, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
 // UpdateUser modifies an existing User in the database
 func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
 	if !repo.canQuery {

+ 1 - 0
internal/repository/user.go

@@ -14,6 +14,7 @@ type UserRepository interface {
 	ReadUser(id uint) (*models.User, error)
 	ReadUserByEmail(email string) (*models.User, error)
 	ReadUserByGithubUserID(id int64) (*models.User, error)
+	ReadUserByGoogleUserID(id string) (*models.User, error)
 	UpdateUser(user *models.User) (*models.User, error)
 	DeleteUser(user *models.User) (*models.User, error)
 }

+ 67 - 19
server/api/api.go

@@ -21,8 +21,8 @@ import (
 	"github.com/porter-dev/porter/internal/repository"
 	memory "github.com/porter-dev/porter/internal/repository/memory"
 	"github.com/porter-dev/porter/internal/validator"
-	"helm.sh/helm/v3/pkg/storage"
 	segment "gopkg.in/segmentio/analytics-go.v3"
+	"helm.sh/helm/v3/pkg/storage"
 
 	"github.com/porter-dev/porter/internal/config"
 )
@@ -42,7 +42,7 @@ type AppConfig struct {
 	ServerConf config.ServerConf
 	RedisConf  *config.RedisConf
 	DBConf     config.DBConf
-	CapConf config.CapConf
+	CapConf    config.CapConf
 
 	// TestAgents if API is in testing mode
 	TestAgents *TestAgents
@@ -66,6 +66,9 @@ type App struct {
 	// agents exposed for testing
 	TestAgents *TestAgents
 
+	// An in-cluster agent if service is running in cluster
+	InClusterAgent *kubernetes.Agent
+
 	// redis client for redis connection
 	RedisConf *config.RedisConf
 
@@ -73,20 +76,30 @@ type App struct {
 	DBConf config.DBConf
 
 	// config for capabilities
-	CapConf config.CapConf
+	Capabilities *AppCapabilities
 
 	// oauth-specific clients
 	GithubUserConf    *oauth2.Config
 	GithubProjectConf *oauth2.Config
 	DOConf            *oauth2.Config
+	GoogleUserConf    *oauth2.Config
 
-	db         *gorm.DB
-	validator  *vr.Validate
-	translator *ut.Translator
-	tokenConf  *token.TokenGeneratorConf
+	db            *gorm.DB
+	validator     *vr.Validate
+	translator    *ut.Translator
+	tokenConf     *token.TokenGeneratorConf
 	segmentClient *segment.Client
 }
 
+type AppCapabilities struct {
+	Provisioning bool `json:"provisioner"`
+	Subdomains   bool `json:"subdomains"`
+	Github       bool `json:"github"`
+	GoogleLogin  bool `json:"google"`
+	Email        bool `json:"email"`
+	Analytics    bool `json:"analytics"`
+}
+
 // New returns a new App instance
 func New(conf *AppConfig) (*App, error) {
 	// create a new validator and translator
@@ -101,16 +114,16 @@ func New(conf *AppConfig) (*App, error) {
 	}
 
 	app := &App{
-		Logger:     conf.Logger,
-		Repo:       conf.Repository,
-		ServerConf: conf.ServerConf,
-		RedisConf:  conf.RedisConf,
-		DBConf:     conf.DBConf,
-		CapConf: 	conf.CapConf,
-		TestAgents: conf.TestAgents,
-		db:         conf.DB,
-		validator:  validator,
-		translator: &translator,
+		Logger:       conf.Logger,
+		Repo:         conf.Repository,
+		ServerConf:   conf.ServerConf,
+		RedisConf:    conf.RedisConf,
+		DBConf:       conf.DBConf,
+		TestAgents:   conf.TestAgents,
+		Capabilities: &AppCapabilities{},
+		db:           conf.DB,
+		validator:    validator,
+		translator:   &translator,
 	}
 
 	// if repository not specified, default to in-memory
@@ -127,8 +140,25 @@ func New(conf *AppConfig) (*App, error) {
 
 	app.Store = store
 
+	// if application is running in-cluster, set provisioning capabilities
+	if kubernetes.IsInCluster() {
+		app.Capabilities.Provisioning = true
+
+		agent, err := kubernetes.GetAgentInClusterConfig()
+
+		if err != nil {
+			return nil, fmt.Errorf("could not get in-cluster agent: %v", err)
+		}
+
+		app.InClusterAgent = agent
+	}
+
+	sc := conf.ServerConf
+
 	// if server config contains OAuth client info, create clients
-	if sc := conf.ServerConf; sc.GithubClientID != "" && sc.GithubClientSecret != "" {
+	if sc.GithubClientID != "" && sc.GithubClientSecret != "" {
+		app.Capabilities.Github = true
+
 		app.GithubUserConf = oauth.NewGithubClient(&oauth.Config{
 			ClientID:     sc.GithubClientID,
 			ClientSecret: sc.GithubClientSecret,
@@ -144,7 +174,22 @@ func New(conf *AppConfig) (*App, error) {
 		})
 	}
 
-	if sc := conf.ServerConf; sc.DOClientID != "" && sc.DOClientSecret != "" {
+	if sc.GoogleClientID != "" && sc.GoogleClientSecret != "" {
+		app.Capabilities.GoogleLogin = true
+
+		app.GoogleUserConf = oauth.NewGoogleClient(&oauth.Config{
+			ClientID:     sc.GoogleClientID,
+			ClientSecret: sc.GoogleClientSecret,
+			Scopes: []string{
+				"openid",
+				"profile",
+				"email",
+			},
+			BaseURL: sc.ServerURL,
+		})
+	}
+
+	if sc.DOClientID != "" && sc.DOClientSecret != "" {
 		app.DOConf = oauth.NewDigitalOceanClient(&oauth.Config{
 			ClientID:     sc.DOClientID,
 			ClientSecret: sc.DOClientSecret,
@@ -153,6 +198,9 @@ func New(conf *AppConfig) (*App, error) {
 		})
 	}
 
+	app.Capabilities.Email = sc.SendgridAPIKey != ""
+	app.Capabilities.Analytics = sc.SegmentClientKey != ""
+
 	app.tokenConf = &token.TokenGeneratorConf{
 		TokenSecret: conf.ServerConf.TokenGeneratorSecret,
 	}

+ 1 - 15
server/api/capability_handler.go

@@ -5,23 +5,9 @@ import (
 	"net/http"
 )
 
-// CapabilitiesExternal represents the Capabilities struct that will be sent over REST
-type CapabilitiesExternal struct {
-	Provisioner bool `json:"provisioner"`
-	GitHub bool	`json:"github"`
-}
-
 // HandleGetCapabilities gets the capabilities of the server
 func (app *App) HandleGetCapabilities(w http.ResponseWriter, r *http.Request) {
-
-	cap := app.CapConf
-
-	capExternal := &CapabilitiesExternal{
-		Provisioner: cap.Provisioner,
-		GitHub: cap.Github,
-	}
-
-	if err := json.NewEncoder(w).Encode(capExternal); err != nil {
+	if err := json.NewEncoder(w).Encode(app.Capabilities); err != nil {
 		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
 		return
 	}

+ 1 - 9
server/api/dns_record_handler.go

@@ -74,17 +74,9 @@ func (app *App) HandleCreateDNSRecord(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// launch provisioning destruction pod
-	inClusterAgent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	_record := domain.DNSRecord(*record)
 
-	err = _record.CreateDomain(inClusterAgent.Clientset)
+	err = _record.CreateDomain(app.InClusterAgent.Clientset)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)

+ 213 - 0
server/api/oauth_google_handler.go

@@ -0,0 +1,213 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+
+	segment "gopkg.in/segmentio/analytics-go.v3"
+)
+
+// HandleGoogleStartUser starts the oauth2 flow for a user login request.
+func (app *App) HandleGoogleStartUser(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	err := app.populateOAuthSession(w, r, state, false)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := app.GoogleUserConf.AuthCodeURL(state, oauth2.AccessTypeOnline)
+
+	http.Redirect(w, r, url, 302)
+}
+
+// HandleGithubOAuthCallback verifies the callback request by checking that the
+// state parameter has not been modified, and validates the token.
+//
+// When logging a user in, the access token gets stored in the session, and no refresh
+// token is requested. We store the access token in the session because a user can be
+// logged in multiple times with a single access token.
+func (app *App) HandleGoogleOAuthCallback(w http.ResponseWriter, r *http.Request) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Could not read cookie: are cookies enabled?",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	token, err := app.GoogleUserConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	if !token.Valid() {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	// create the user if not exists
+	user, err := app.upsertGoogleUserFromToken(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
+	}
+
+	// send to segment
+	if app.segmentClient != nil {
+		client := *app.segmentClient
+		client.Enqueue(segment.Identify{
+			UserId: fmt.Sprintf("%v", user.ID),
+			Traits: segment.NewTraits().
+				SetEmail(user.Email).
+				Set("github", "true"),
+		})
+
+		client.Enqueue(segment.Track{
+			UserId: fmt.Sprintf("%v", user.ID),
+			Event:  "New User",
+			Properties: segment.NewProperties().
+				Set("email", user.Email),
+		})
+	}
+
+	// log the user in
+	app.Logger.Info().Msgf("New user created: %d", user.ID)
+
+	session.Values["authenticated"] = true
+	session.Values["user_id"] = user.ID
+	session.Values["email"] = user.Email
+	session.Values["redirect"] = ""
+	session.Save(r, w)
+
+	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)
+	}
+}
+
+type googleUserInfo struct {
+	Email         string `json:"email"`
+	EmailVerified bool   `json:"email_verified"`
+	HD            string `json:"hd"`
+	Sub           string `json:"sub"`
+}
+
+func (app *App) upsertGoogleUserFromToken(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 app.ServerConf.GoogleRestrictedDomain != "" {
+		if gInfo.HD != "bloomchat.app" {
+			return nil, fmt.Errorf("Email is not in the restricted domain group.")
+		}
+	}
+
+	user, err := app.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 = app.Repo.User.ReadUserByEmail(gInfo.Email)
+
+		if err == gorm.ErrRecordNotFound {
+			user = &models.User{
+				Email:         gInfo.Email,
+				EmailVerified: gInfo.EmailVerified,
+				GoogleUserID:  gInfo.Sub,
+			}
+
+			user, err = app.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
+}
+
+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
+}

+ 12 - 130
server/api/provision_handler.go

@@ -25,14 +25,6 @@ func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	// create a new agent
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	form := &forms.CreateTestInfra{
 		ProjectID: uint(projID),
 	}
@@ -59,7 +51,7 @@ func (app *App) HandleProvisionTestInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = agent.ProvisionTest(
+	_, err = app.InClusterAgent.ProvisionTest(
 		uint(projID),
 		infra,
 		*app.Repo,
@@ -199,17 +191,7 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	_, err = agent.ProvisionECR(
+	_, err = app.InClusterAgent.ProvisionECR(
 		uint(projID),
 		awsInt,
 		form.ECRName,
@@ -282,16 +264,6 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	// launch provisioning destruction pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -301,7 +273,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = agent.ProvisionECR(
+	_, err = app.InClusterAgent.ProvisionECR(
 		infra.ProjectID,
 		awsInt,
 		form.ECRName,
@@ -375,17 +347,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	_, err = agent.ProvisionEKS(
+	_, err = app.InClusterAgent.ProvisionEKS(
 		uint(projID),
 		awsInt,
 		form.EKSName,
@@ -459,16 +421,6 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	// launch provisioning destruction pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -478,7 +430,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = agent.ProvisionEKS(
+	_, err = app.InClusterAgent.ProvisionEKS(
 		infra.ProjectID,
 		awsInt,
 		form.EKSName,
@@ -553,17 +505,7 @@ func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	_, err = agent.ProvisionGCR(
+	_, err = app.InClusterAgent.ProvisionGCR(
 		uint(projID),
 		gcpInt,
 		*app.Repo,
@@ -646,17 +588,7 @@ func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	_, err = agent.ProvisionGKE(
+	_, err = app.InClusterAgent.ProvisionGKE(
 		uint(projID),
 		gcpInt,
 		form.GKEName,
@@ -729,16 +661,6 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	// launch provisioning destruction pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -748,7 +670,7 @@ func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = agent.ProvisionGKE(
+	_, err = app.InClusterAgent.ProvisionGKE(
 		infra.ProjectID,
 		gcpInt,
 		form.GKEName,
@@ -866,17 +788,7 @@ func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	_, err = agent.ProvisionDOCR(
+	_, err = app.InClusterAgent.ProvisionDOCR(
 		uint(projID),
 		oauthInt,
 		app.DOConf,
@@ -951,16 +863,6 @@ func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	// launch provisioning destruction pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -970,7 +872,7 @@ func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = agent.ProvisionDOCR(
+	_, err = app.InClusterAgent.ProvisionDOCR(
 		infra.ProjectID,
 		oauthInt,
 		app.DOConf,
@@ -1046,17 +948,7 @@ func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// launch provisioning pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	_, err = agent.ProvisionDOKS(
+	_, err = app.InClusterAgent.ProvisionDOKS(
 		uint(projID),
 		oauthInt,
 		app.DOConf,
@@ -1131,16 +1023,6 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	// launch provisioning destruction pod
-	agent, err := kubernetes.GetAgentInClusterConfig()
-
-	if err != nil {
-		infra.Status = models.StatusError
-		infra, _ = app.Repo.Infra.UpdateInfra(infra)
-
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
 	infra, err = app.Repo.Infra.UpdateInfra(infra)
@@ -1150,7 +1032,7 @@ func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	_, err = agent.ProvisionDOKS(
+	_, err = app.InClusterAgent.ProvisionDOKS(
 		infra.ProjectID,
 		oauthInt,
 		app.DOConf,

+ 12 - 0
server/router/router.go

@@ -212,6 +212,18 @@ func New(a *api.App) *chi.Mux {
 				requestlog.NewHandler(a.HandleGithubOAuthCallback, l),
 			)
 
+			r.Method(
+				"GET",
+				"/oauth/login/google",
+				requestlog.NewHandler(a.HandleGoogleStartUser, l),
+			)
+
+			r.Method(
+				"GET",
+				"/oauth/google/callback",
+				requestlog.NewHandler(a.HandleGoogleOAuthCallback, l),
+			)
+
 			r.Method(
 				"GET",
 				"/oauth/projects/{project_id}/digitalocean",