Browse Source

finish user endpoints refactoring

Alexander Belanger 4 years ago
parent
commit
dea010f6d8

+ 2 - 33
api/client/user.go

@@ -19,7 +19,7 @@ type AuthCheckResponse models.UserExternal
 func (c *Client) AuthCheck(ctx context.Context) (*AuthCheckResponse, error) {
 	req, err := http.NewRequest(
 		"GET",
-		fmt.Sprintf("%s/auth/check", c.BaseURL),
+		fmt.Sprintf("%s/users/current", c.BaseURL),
 		nil,
 	)
 
@@ -154,37 +154,6 @@ func (c *Client) CreateUser(
 	return bodyResp, nil
 }
 
-// GetUserResponse is the user model response that is returned after successfully
-// getting a user
-type GetUserResponse models.UserExternal
-
-// GetUser retrieves a user given a user id
-func (c *Client) GetUser(ctx context.Context, userID uint) (*GetUserResponse, error) {
-	req, err := http.NewRequest(
-		"GET",
-		fmt.Sprintf("%s/users/%d", c.BaseURL, userID),
-		nil,
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	req = req.WithContext(ctx)
-
-	bodyResp := &GetUserResponse{}
-
-	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
-		if httpErr != nil {
-			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
-		}
-
-		return nil, err
-	}
-
-	return bodyResp, nil
-}
-
 // ListUserProjectsResponse is the list of projects returned
 type ListUserProjectsResponse []*types.Project
 
@@ -192,7 +161,7 @@ type ListUserProjectsResponse []*types.Project
 func (c *Client) ListUserProjects(ctx context.Context, userID uint) (ListUserProjectsResponse, error) {
 	req, err := http.NewRequest(
 		"GET",
-		fmt.Sprintf("%s/users/%d/projects", c.BaseURL, userID),
+		fmt.Sprintf("%s/projects", c.BaseURL),
 		nil,
 	)
 

+ 373 - 0
api/server/handlers/user/pw_reset.go

@@ -1 +1,374 @@
 package user
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/porter-dev/porter/internal/random"
+	"golang.org/x/crypto/bcrypt"
+	"gorm.io/gorm"
+)
+
+type UserPasswordInitiateResetHandler struct {
+	config           *shared.Config
+	decoderValidator shared.RequestDecoderValidator
+	writer           shared.ResultWriter
+}
+
+func NewUserPasswordInitiateResetHandler(
+	config *shared.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UserPasswordInitiateResetHandler {
+	return &UserPasswordInitiateResetHandler{config, decoderValidator, writer}
+}
+
+func (c *UserPasswordInitiateResetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.InitiateResetUserPasswordRequest{}
+
+	ok := c.decoderValidator.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	// check that the email exists; return 200 status code even if it doesn't
+	user, err := c.config.Repo.User().ReadUserByEmail(request.Email)
+
+	if err == gorm.ErrRecordNotFound {
+		w.WriteHeader(http.StatusOK)
+
+		return
+	} else if err != nil {
+		apierrors.HandleAPIError(
+			w,
+			c.config.Logger,
+			apierrors.NewErrInternal(
+				err,
+			),
+		)
+
+		return
+	}
+
+	// if the user is a Github user, send them a Github email
+	if user.GithubUserID != 0 {
+		err := c.config.UserNotifier.SendGithubRelinkEmail(
+			&notifier.SendGithubRelinkEmailOpts{
+				Email: user.Email,
+				URL:   fmt.Sprintf("%s/api/oauth/login/github", c.config.ServerConf.ServerURL),
+			},
+		)
+
+		if err != nil {
+			apierrors.HandleAPIError(
+				w,
+				c.config.Logger,
+				apierrors.NewErrInternal(
+					err,
+				),
+			)
+
+			return
+		}
+
+		w.WriteHeader(http.StatusOK)
+		return
+	}
+
+	// convert the form to a project model
+	expiry := time.Now().Add(30 * time.Minute)
+
+	rawToken, err := random.StringWithCharset(32, "")
+
+	if err != nil {
+		apierrors.HandleAPIError(
+			w,
+			c.config.Logger,
+			apierrors.NewErrInternal(
+				err,
+			),
+		)
+
+		return
+	}
+
+	hashedToken, err := bcrypt.GenerateFromPassword([]byte(rawToken), 8)
+
+	if err != nil {
+		apierrors.HandleAPIError(
+			w,
+			c.config.Logger,
+			apierrors.NewErrInternal(
+				err,
+			),
+		)
+
+		return
+	}
+
+	pwReset := &models.PWResetToken{
+		Email:   request.Email,
+		IsValid: true,
+		Expiry:  &expiry,
+		Token:   string(hashedToken),
+	}
+
+	// handle write to the database
+	pwReset, err = c.config.Repo.PWResetToken().CreatePWResetToken(pwReset)
+
+	if err != nil {
+		apierrors.HandleAPIError(
+			w,
+			c.config.Logger,
+			apierrors.NewErrInternal(
+				err,
+			),
+		)
+
+		return
+	}
+
+	queryVals := url.Values{
+		"token":    []string{rawToken},
+		"email":    []string{request.Email},
+		"token_id": []string{fmt.Sprintf("%d", pwReset.ID)},
+	}
+
+	err = c.config.UserNotifier.SendPasswordResetEmail(
+		&notifier.SendPasswordResetEmailOpts{
+			Email: user.Email,
+			URL:   fmt.Sprintf("%s/password/reset/finalize?%s", c.config.ServerConf.ServerURL, queryVals.Encode()),
+		},
+	)
+
+	if err != nil {
+		apierrors.HandleAPIError(
+			w,
+			c.config.Logger,
+			apierrors.NewErrInternal(
+				err,
+			),
+		)
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+type UserPasswordVerifyResetHandler struct {
+	config           *shared.Config
+	decoderValidator shared.RequestDecoderValidator
+	writer           shared.ResultWriter
+}
+
+func NewUserPasswordVerifyResetHandler(
+	config *shared.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UserPasswordVerifyResetHandler {
+	return &UserPasswordVerifyResetHandler{config, decoderValidator, writer}
+}
+
+func (c *UserPasswordVerifyResetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.VerifyResetUserPasswordRequest{}
+
+	ok := c.decoderValidator.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	ok, _ = VerifyPasswordResetToken(c.config, w, request)
+
+	if ok {
+		w.WriteHeader(http.StatusOK)
+	}
+
+	return
+}
+
+type UserPasswordFinalizeResetHandler struct {
+	config           *shared.Config
+	decoderValidator shared.RequestDecoderValidator
+	writer           shared.ResultWriter
+}
+
+func NewUserPasswordFinalizeResetHandler(
+	config *shared.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UserPasswordFinalizeResetHandler {
+	return &UserPasswordFinalizeResetHandler{config, decoderValidator, writer}
+}
+
+func (c *UserPasswordFinalizeResetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	request := &types.FinalizeResetUserPasswordRequest{}
+
+	ok := c.decoderValidator.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	ok, token := VerifyPasswordResetToken(c.config, w, &request.VerifyResetUserPasswordRequest)
+
+	if ok {
+		w.WriteHeader(http.StatusOK)
+	}
+
+	// check that the email exists
+	user, err := c.config.Repo.User().ReadUserByEmail(request.Email)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(
+				w,
+				c.config.Logger,
+				apierrors.NewErrForbidden(
+					fmt.Errorf("finalize password reset failed: email does not exist"),
+				),
+			)
+		} else {
+			apierrors.HandleAPIError(
+				w,
+				c.config.Logger,
+				apierrors.NewErrInternal(
+					err,
+				),
+			)
+		}
+
+		return
+	}
+
+	hashedPW, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), 8)
+
+	if err != nil {
+		apierrors.HandleAPIError(
+			w,
+			c.config.Logger,
+			apierrors.NewErrInternal(
+				err,
+			),
+		)
+
+		return
+	}
+
+	user.Password = string(hashedPW)
+
+	user, err = c.config.Repo.User().UpdateUser(user)
+
+	if err != nil {
+		apierrors.HandleAPIError(
+			w,
+			c.config.Logger,
+			apierrors.NewErrInternal(
+				err,
+			),
+		)
+
+		return
+	}
+
+	// invalidate the token
+	token.IsValid = false
+
+	_, err = c.config.Repo.PWResetToken().UpdatePWResetToken(token)
+
+	if err != nil {
+		apierrors.HandleAPIError(
+			w,
+			c.config.Logger,
+			apierrors.NewErrInternal(
+				err,
+			),
+		)
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+func VerifyPasswordResetToken(
+	config *shared.Config,
+	w http.ResponseWriter,
+	request *types.VerifyResetUserPasswordRequest,
+) (bool, *models.PWResetToken) {
+	token, err := config.Repo.PWResetToken().ReadPWResetToken(request.TokenID)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(
+				w,
+				config.Logger,
+				apierrors.NewErrForbidden(
+					fmt.Errorf("verify/finalize password reset failed: token does not exist"),
+				),
+			)
+		} else {
+			apierrors.HandleAPIError(
+				w,
+				config.Logger,
+				apierrors.NewErrInternal(
+					err,
+				),
+			)
+		}
+
+		return false, nil
+	}
+
+	// make sure the token is still valid and has not expired
+	if !token.IsValid || token.IsExpired() {
+		apierrors.HandleAPIError(
+			w,
+			config.Logger,
+			apierrors.NewErrForbidden(
+				fmt.Errorf("verify password reset failed: expired %t, valid %t", token.IsExpired(), token.IsValid),
+			),
+		)
+
+		return false, nil
+	}
+
+	// check that the email matches
+	if token.Email != request.Email {
+		apierrors.HandleAPIError(
+			w,
+			config.Logger,
+			apierrors.NewErrForbidden(
+				fmt.Errorf("verify password reset failed: token email does not match request email"),
+			),
+		)
+
+		return false, nil
+	}
+
+	// make sure the token is correct
+	if err := bcrypt.CompareHashAndPassword([]byte(token.Token), []byte(request.Token)); err != nil {
+		apierrors.HandleAPIError(
+			w,
+			config.Logger,
+			apierrors.NewErrForbidden(
+				fmt.Errorf("verify password reset failed: %s", err),
+			),
+		)
+
+		return false, nil
+	}
+
+	return true, token
+}

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

@@ -95,5 +95,77 @@ func GetBaseRoutes(
 		Router:   r,
 	})
 
+	// POST /api/password/reset/initiate -> user.NewUserPasswordInitiateResetHandler
+	passwordInitiateResetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/password/reset/initiate",
+			},
+		},
+	)
+
+	passwordInitiateResetHandler := user.NewUserPasswordInitiateResetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: passwordInitiateResetEndpoint,
+		Handler:  passwordInitiateResetHandler,
+		Router:   r,
+	})
+
+	// POST /api/password/reset/verify -> user.NewUserPasswordVerifyResetHandler
+	passwordVerifyResetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/password/reset/verify",
+			},
+		},
+	)
+
+	passwordVerifyResetHandler := user.NewUserPasswordVerifyResetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: passwordVerifyResetEndpoint,
+		Handler:  passwordVerifyResetHandler,
+		Router:   r,
+	})
+
+	// POST /api/password/reset/finalize -> user.NewUserPasswordFinalizeResetHandler
+	passwordFinalizeResetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/password/reset/finalize",
+			},
+		},
+	)
+
+	passwordFinalizeResetHandler := user.NewUserPasswordFinalizeResetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: passwordFinalizeResetEndpoint,
+		Handler:  passwordFinalizeResetHandler,
+		Router:   r,
+	})
+
 	return routes
 }

+ 16 - 5
api/server/shared/apitest/config.go

@@ -5,9 +5,9 @@ import (
 	"testing"
 
 	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/envloader"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
-	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository/test"
 )
@@ -24,15 +24,26 @@ func NewTestConfigLoader(canQuery bool, failingRepoMethods ...string) shared.Con
 func (t *TestConfigLoader) LoadConfig() (*shared.Config, error) {
 	l := logger.New(true, os.Stdout)
 	repo := test.NewRepository(t.canQuery, t.failingRepoMethods...)
-	configFromEnv := config.FromEnv()
-	store, err := sessionstore.NewStore(repo, configFromEnv.Server)
+
+	envConf, err := envloader.FromEnv()
+
+	if err != nil {
+		return nil, err
+	}
+
+	store, err := sessionstore.NewStore(
+		&sessionstore.NewStoreOpts{
+			SessionRepository: repo.Session(),
+			CookieSecrets:     envConf.ServerConf.CookieSecrets,
+		},
+	)
 
 	if err != nil {
 		return nil, err
 	}
 
 	tokenConf := &token.TokenGeneratorConf{
-		TokenSecret: configFromEnv.Server.TokenGeneratorSecret,
+		TokenSecret: envConf.ServerConf.TokenGeneratorSecret,
 	}
 
 	notifier := NewFakeUserNotifier()
@@ -41,7 +52,7 @@ func (t *TestConfigLoader) LoadConfig() (*shared.Config, error) {
 		Logger:       l,
 		Repo:         repo,
 		Store:        store,
-		ServerConf:   configFromEnv.Server,
+		ServerConf:   envConf.ServerConf,
 		TokenConf:    tokenConf,
 		UserNotifier: notifier,
 	}, nil

+ 31 - 7
api/server/shared/capabilities.go

@@ -1,11 +1,35 @@
 package shared
 
 type Capabilities struct {
-	Provisioning bool `json:"provisioner"`
-	Github       bool `json:"github"`
-	BasicLogin   bool `json:"basic_login"`
-	GithubLogin  bool `json:"github_login"`
-	GoogleLogin  bool `json:"google_login"`
-	Email        bool `json:"email"`
-	Analytics    bool `json:"analytics"`
+	Provisioning       bool `json:"provisioner"`
+	Github             bool `json:"github"`
+	BasicLogin         bool `json:"basic_login"`
+	GithubLogin        bool `json:"github_login"`
+	GoogleLogin        bool `json:"google_login"`
+	SlackNotifications bool `json:"slack_notifications"`
+	Email              bool `json:"email"`
+	Analytics          bool `json:"analytics"`
+}
+
+func CapabilitiesFromConf(sc *ServerConf) *Capabilities {
+	return &Capabilities{
+		// TODO: case provisioning on env variables
+		Provisioning:       false,
+		Github:             hasGithubAppVars(sc),
+		GithubLogin:        sc.GithubClientID != "" && sc.GithubClientSecret != "" && sc.GithubLoginEnabled,
+		BasicLogin:         sc.BasicLoginEnabled,
+		GoogleLogin:        sc.GoogleClientID != "" && sc.GoogleClientSecret != "",
+		SlackNotifications: sc.SlackClientID != "" && sc.SlackClientSecret != "",
+		Email:              sc.SendgridAPIKey != "",
+		Analytics:          sc.SegmentClientKey != "",
+	}
+}
+
+func hasGithubAppVars(sc *ServerConf) bool {
+	return sc.GithubAppClientID != "" &&
+		sc.GithubAppClientSecret != "" &&
+		sc.GithubAppName != "" &&
+		sc.GithubAppWebhookSecret != "" &&
+		sc.GithubAppSecretPath != "" &&
+		sc.GithubAppID != ""
 }

+ 90 - 2
api/server/shared/config.go

@@ -1,9 +1,10 @@
 package shared
 
 import (
+	"time"
+
 	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/internal/auth/token"
-	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/repository"
@@ -24,7 +25,7 @@ type Config struct {
 	Store sessions.Store
 
 	// ServerConf is the set of configuration variables for the Porter server
-	ServerConf config.ServerConf
+	ServerConf *ServerConf
 
 	// TokenConf contains the config for generating and validating JWT tokens
 	TokenConf *token.TokenGeneratorConf
@@ -37,3 +38,90 @@ type Config struct {
 type ConfigLoader interface {
 	LoadConfig() (*Config, error)
 }
+
+// ServerConf is the server configuration
+type ServerConf struct {
+	Debug bool `env:"DEBUG,default=false"`
+
+	ServerURL            string        `env:"SERVER_URL,default=http://localhost:8080"`
+	Port                 int           `env:"SERVER_PORT,default=8080"`
+	StaticFilePath       string        `env:"STATIC_FILE_PATH,default=/porter/static"`
+	CookieName           string        `env:"COOKIE_NAME,default=porter"`
+	CookieSecrets        []string      `env:"COOKIE_SECRETS,default=random_hash_key_;random_block_key"`
+	TokenGeneratorSecret string        `env:"TOKEN_GENERATOR_SECRET,default=secret"`
+	TimeoutRead          time.Duration `env:"SERVER_TIMEOUT_READ,default=5s"`
+	TimeoutWrite         time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
+	TimeoutIdle          time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
+	IsLocal              bool          `env:"IS_LOCAL,default=false"`
+	IsTesting            bool          `env:"IS_TESTING,default=false"`
+	AppRootDomain        string        `env:"APP_ROOT_DOMAIN,default=porter.run"`
+
+	DefaultApplicationHelmRepoURL string `env:"HELM_APP_REPO_URL,default=https://charts.dev.getporter.dev"`
+	DefaultAddonHelmRepoURL       string `env:"HELM_ADD_ON_REPO_URL,default=https://chart-addons.dev.getporter.dev"`
+
+	BasicLoginEnabled bool `env:"BASIC_LOGIN_ENABLED,default=true"`
+
+	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
+	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
+	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
+
+	GithubAppClientID      string `env:"GITHUB_APP_CLIENT_ID"`
+	GithubAppClientSecret  string `env:"GITHUB_APP_CLIENT_SECRET"`
+	GithubAppName          string `env:"GITHUB_APP_NAME"`
+	GithubAppWebhookSecret string `env:"GITHUB_APP_WEBHOOK_SECRET"`
+	GithubAppID            string `env:"GITHUB_APP_ID"`
+	GithubAppSecretPath    string `env:"GITHUB_APP_SECRET_PATH"`
+
+	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"`
+	SendgridVerifyEmailTemplateID   string `env:"SENDGRID_VERIFY_EMAIL_TEMPLATE_ID"`
+	SendgridProjectInviteTemplateID string `env:"SENDGRID_INVITE_TEMPLATE_ID"`
+	SendgridSenderEmail             string `env:"SENDGRID_SENDER_EMAIL"`
+
+	SlackClientID     string `env:"SLACK_CLIENT_ID"`
+	SlackClientSecret string `env:"SLACK_CLIENT_SECRET"`
+
+	DOClientID                 string `env:"DO_CLIENT_ID"`
+	DOClientSecret             string `env:"DO_CLIENT_SECRET"`
+	ProvisionerImageTag        string `env:"PROV_IMAGE_TAG,default=latest"`
+	ProvisionerImagePullSecret string `env:"PROV_IMAGE_PULL_SECRET"`
+	SegmentClientKey           string `env:"SEGMENT_CLIENT_KEY"`
+
+	ProvisionerCluster string `env:"PROVISIONER_CLUSTER"`
+	IngressCluster     string `env:"INGRESS_CLUSTER"`
+	SelfKubeconfig     string `env:"SELF_KUBECONFIG"`
+}
+
+// DBConf is the database configuration: if generated from environment variables,
+// it assumes the default docker-compose configuration is used
+type DBConf struct {
+	// EncryptionKey is the key to use for sensitive values that are encrypted at rest
+	EncryptionKey string `env:"ENCRYPTION_KEY,default=__random_strong_encryption_key__"`
+
+	Host     string `env:"DB_HOST,default=postgres"`
+	Port     int    `env:"DB_PORT,default=5432"`
+	Username string `env:"DB_USER,default=porter"`
+	Password string `env:"DB_PASS,default=porter"`
+	DbName   string `env:"DB_NAME,default=porter"`
+	ForceSSL bool   `env:"DB_FORCE_SSL,default=false"`
+
+	SQLLite     bool   `env:"SQL_LITE,default=false"`
+	SQLLitePath string `env:"SQL_LITE_PATH,default=/porter/porter.db"`
+}
+
+// RedisConf is the redis config required for the provisioner container
+type RedisConf struct {
+	// if redis should be used
+	Enabled bool `env:"REDIS_ENABLED,default=true"`
+
+	Host     string `env:"REDIS_HOST,default=redis"`
+	Port     string `env:"REDIS_PORT,default=6379"`
+	Username string `env:"REDIS_USER"`
+	Password string `env:"REDIS_PASS"`
+	DB       int    `env:"REDIS_DB,default=0"`
+}

+ 89 - 0
api/server/shared/configloader/configloader.go

@@ -0,0 +1,89 @@
+package configloader
+
+import (
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/envloader"
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/auth/sessionstore"
+	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/porter-dev/porter/internal/notifier/sendgrid"
+	"github.com/porter-dev/porter/internal/repository/gorm"
+
+	lr "github.com/porter-dev/porter/internal/logger"
+)
+
+type EnvConfigLoader struct{}
+
+func NewEnvLoader() shared.ConfigLoader {
+	return &EnvConfigLoader{}
+}
+
+func (e *EnvConfigLoader) LoadConfig() (*shared.Config, error) {
+	envConf, err := envloader.FromEnv()
+
+	if err != nil {
+		return nil, err
+	}
+
+	capabilities := shared.CapabilitiesFromConf(envConf.ServerConf)
+
+	db, err := adapter.New(envConf.DBConf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = gorm.AutoMigrate(db)
+
+	if err != nil {
+		return nil, err
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte(envConf.DBConf.EncryptionKey) {
+		key[i] = b
+	}
+
+	repo := gorm.NewRepository(db, &key)
+
+	// create the session store
+	store, err := sessionstore.NewStore(
+		&sessionstore.NewStoreOpts{
+			SessionRepository: repo.Session(),
+			CookieSecrets:     envConf.ServerConf.CookieSecrets,
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tokenConf := &token.TokenGeneratorConf{
+		TokenSecret: envConf.ServerConf.TokenGeneratorSecret,
+	}
+
+	var notif notifier.UserNotifier = &notifier.EmptyUserNotifier{}
+
+	if capabilities.Email {
+		notif = sendgrid.NewUserNotifier(&sendgrid.Client{
+			APIKey:                  envConf.ServerConf.SendgridAPIKey,
+			PWResetTemplateID:       envConf.ServerConf.SendgridPWResetTemplateID,
+			PWGHTemplateID:          envConf.ServerConf.SendgridPWGHTemplateID,
+			VerifyEmailTemplateID:   envConf.ServerConf.SendgridVerifyEmailTemplateID,
+			ProjectInviteTemplateID: envConf.ServerConf.SendgridProjectInviteTemplateID,
+			SenderEmail:             envConf.ServerConf.SendgridSenderEmail,
+		})
+	}
+
+	return &shared.Config{
+		Logger:       lr.NewConsole(envConf.ServerConf.Debug),
+		Repo:         repo,
+		Capabilities: capabilities,
+		Store:        store,
+		ServerConf:   envConf.ServerConf,
+		TokenConf:    tokenConf,
+		UserNotifier: notif,
+	}, nil
+}

+ 35 - 0
api/server/shared/envloader/envloader.go

@@ -0,0 +1,35 @@
+package envloader
+
+import (
+	"fmt"
+
+	"github.com/joeshaw/envdecode"
+	"github.com/porter-dev/porter/api/server/shared"
+)
+
+type EnvDecoderConf struct {
+	ServerConf shared.ServerConf
+	RedisConf  shared.RedisConf
+	DBConf     shared.DBConf
+}
+
+type EnvConf struct {
+	ServerConf *shared.ServerConf
+	RedisConf  *shared.RedisConf
+	DBConf     *shared.DBConf
+}
+
+// FromEnv generates a configuration from environment variables
+func FromEnv() (*EnvConf, error) {
+	var envDecoderConf EnvDecoderConf = EnvDecoderConf{}
+
+	if err := envdecode.StrictDecode(&envDecoderConf); err != nil {
+		return nil, fmt.Errorf("Failed to decode server conf: %s", err)
+	}
+
+	return &EnvConf{
+		ServerConf: &envDecoderConf.ServerConf,
+		RedisConf:  &envDecoderConf.RedisConf,
+		DBConf:     &envDecoderConf.DBConf,
+	}, nil
+}

+ 0 - 6
api/types/email_verify.go

@@ -1,6 +0,0 @@
-package types
-
-type VerifyEmailFinalizeRequest struct {
-	TokenID uint   `schema:"token_id" form:"required"`
-	Token   string `schema:"token" form:"required"`
-}

+ 21 - 0
api/types/user.go

@@ -22,6 +22,11 @@ type LoginUserRequest struct {
 
 type LoginUserResponse User
 
+type VerifyEmailFinalizeRequest struct {
+	TokenID uint   `schema:"token_id" form:"required"`
+	Token   string `schema:"token" form:"required"`
+}
+
 type CLILoginUserRequest struct {
 	Redirect string `schema:"redirect" form:"required"`
 }
@@ -33,3 +38,19 @@ type CLILoginExchangeRequest struct {
 type CLILoginExchangeResponse struct {
 	Token string `json:"token" form:"required"`
 }
+
+type InitiateResetUserPasswordRequest struct {
+	Email string `json:"email" form:"required"`
+}
+
+type VerifyResetUserPasswordRequest struct {
+	Email   string `json:"email" form:"required,max=255,email"`
+	TokenID uint   `json:"token_id" form:"required"`
+	Token   string `json:"token" form:"required"`
+}
+
+type FinalizeResetUserPasswordRequest struct {
+	VerifyResetUserPasswordRequest
+
+	NewPassword string `json:"new_password" form:"required,max=255"`
+}

+ 1 - 1
cli/cmd/login/server.go

@@ -86,7 +86,7 @@ type ExchangeResponse struct {
 
 func ExchangeToken(host, code string) (string, error) {
 	req, err := http.NewRequest(
-		"GET",
+		"POST",
 		fmt.Sprintf("%s/api/cli/login/exchange", host),
 		strings.NewReader(fmt.Sprintf(`{"authorization_code": "%s"}`, code)),
 	)

+ 11 - 64
cmd/app/main.go

@@ -7,16 +7,8 @@ import (
 	"net/http"
 	"os"
 
-	"github.com/porter-dev/porter/internal/repository/gorm"
-
-	"github.com/porter-dev/porter/server/api"
-
-	"github.com/porter-dev/porter/internal/adapter"
-	"github.com/porter-dev/porter/internal/config"
-	lr "github.com/porter-dev/porter/internal/logger"
-	"github.com/porter-dev/porter/server/router"
-
-	prov "github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/api/server/router"
+	"github.com/porter-dev/porter/api/server/shared/configloader"
 )
 
 // Version will be linked by an ldflag during build
@@ -33,71 +25,26 @@ func main() {
 		os.Exit(0)
 	}
 
-	appConf := config.FromEnv()
-
-	logger := lr.NewConsole(appConf.Debug)
-	db, err := adapter.New(&appConf.Db)
-
-	if err != nil {
-		logger.Fatal().Err(err).Msg("")
-		return
-	}
-
-	err = gorm.AutoMigrate(db)
-
-	if err != nil {
-		logger.Fatal().Err(err).Msg("")
-		return
-	}
-
-	var key [32]byte
-
-	for i, b := range []byte(appConf.Db.EncryptionKey) {
-		key[i] = b
-	}
-
-	repo := gorm.NewRepository(db, &key)
-
-	if appConf.Redis.Enabled {
-		redis, err := adapter.NewRedisClient(&appConf.Redis)
-
-		if err != nil {
-			logger.Fatal().Err(err).Msg("")
-			return
-		}
-
-		prov.InitGlobalStream(redis)
-
-		errorChan := make(chan error)
-
-		go prov.GlobalStreamListener(redis, repo, errorChan)
-	}
+	cl := configloader.NewEnvLoader()
 
-	a, err := api.New(&api.AppConfig{
-		Logger:     logger,
-		Repository: repo,
-		ServerConf: appConf.Server,
-		RedisConf:  &appConf.Redis,
-		CapConf:    appConf.Capabilities,
-		DBConf:     appConf.Db,
-	})
+	config, err := cl.LoadConfig()
 
 	if err != nil {
-		logger.Fatal().Err(err).Msg("")
+		log.Fatal("Config loading failed: ", err)
 	}
 
-	appRouter := router.New(a)
+	appRouter := router.NewAPIRouter(config)
 
-	address := fmt.Sprintf(":%d", appConf.Server.Port)
+	address := fmt.Sprintf(":%d", config.ServerConf.Port)
 
-	logger.Info().Msgf("Starting server %v", address)
+	config.Logger.Info().Msgf("Starting server %v", address)
 
 	s := &http.Server{
 		Addr:         address,
 		Handler:      appRouter,
-		ReadTimeout:  appConf.Server.TimeoutRead,
-		WriteTimeout: appConf.Server.TimeoutWrite,
-		IdleTimeout:  appConf.Server.TimeoutIdle,
+		ReadTimeout:  config.ServerConf.TimeoutRead,
+		WriteTimeout: config.ServerConf.TimeoutWrite,
+		IdleTimeout:  config.ServerConf.TimeoutIdle,
 	}
 
 	if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {

+ 2 - 2
cmd/migrate/keyrotate/helpers_test.go

@@ -5,9 +5,9 @@ import (
 	"testing"
 	"time"
 
+	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/adapter"
-	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/models"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
@@ -43,7 +43,7 @@ type tester struct {
 func setupTestEnv(tester *tester, t *testing.T) {
 	t.Helper()
 
-	db, err := adapter.New(&config.DBConf{
+	db, err := adapter.New(&shared.DBConf{
 		EncryptionKey: "__random_strong_encryption_key__",
 		SQLLite:       true,
 		SQLLitePath:   tester.dbFileName,

+ 9 - 4
cmd/migrate/main.go

@@ -4,10 +4,10 @@ import (
 	"fmt"
 	"log"
 
+	"github.com/porter-dev/porter/api/server/shared/envloader"
 	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
 
 	adapter "github.com/porter-dev/porter/internal/adapter"
-	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
@@ -15,12 +15,17 @@ import (
 )
 
 func main() {
+	logger := lr.NewConsole(true)
 	fmt.Println("running migrations...")
 
-	appConf := config.FromEnv()
+	envConf, err := envloader.FromEnv()
 
-	logger := lr.NewConsole(true)
-	db, err := adapter.New(&appConf.Db)
+	if err != nil {
+		logger.Fatal().Err(err).Msg("")
+		return
+	}
+
+	db, err := adapter.New(envConf.DBConf)
 
 	if err != nil {
 		logger.Fatal().Err(err).Msg("")

+ 2 - 9
dashboard/src/shared/api.tsx

@@ -10,7 +10,7 @@ import { FullActionConfigType, StorageType } from "./types";
  * @param {(err: Object, res: Object) => void} callback - Callback function.
  */
 
-const checkAuth = baseApi("GET", "/api/auth/check");
+const checkAuth = baseApi("GET", "/api/users/current");
 
 const connectECRRegistry = baseApi<
   {
@@ -652,9 +652,7 @@ const getProjectRepos = baseApi<{}, { id: number }>("GET", (pathParams) => {
   return `/api/projects/${pathParams.id}/repos`;
 });
 
-const getProjects = baseApi<{}, { id: number }>("GET", (pathParams) => {
-  return `/api/users/${pathParams.id}/projects`;
-});
+const getProjects = baseApi("GET", "/api/projects");
 
 const getPrometheusIsInstalled = baseApi<
   {
@@ -764,10 +762,6 @@ const getTemplates = baseApi<
   {}
 >("GET", "/api/templates");
 
-const getUser = baseApi<{}, { id: number }>("GET", (pathParams) => {
-  return `/api/users/${pathParams.id}`;
-});
-
 const getCapabilities = baseApi<{}, {}>("GET", () => {
   return `/api/capabilities`;
 });
@@ -1116,7 +1110,6 @@ export default {
   getTemplateInfo,
   getTemplateUpgradeNotes,
   getTemplates,
-  getUser,
   linkGithubProject,
   getGithubAccess,
   listConfigMaps,

+ 0 - 6
dashboard/src/shared/baseApi.tsx

@@ -20,15 +20,9 @@ export const baseApi = <T extends {}, S = {}>(
     // Handle request type (can refactor)
     if (requestType === "POST") {
       return axios.post(endpointString, params, {
-        headers: {
-          Authorization: `Bearer ${token}`,
-        },
       });
     } else if (requestType === "PUT") {
       return axios.put(endpointString, params, {
-        headers: {
-          Authorization: `Bearer ${token}`,
-        },
       });
     } else if (requestType === "DELETE") {
       return axios.delete(

+ 2 - 2
internal/adapter/gorm.go

@@ -4,14 +4,14 @@ import (
 	"fmt"
 	"time"
 
-	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/api/server/shared"
 	"gorm.io/driver/postgres"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"
 )
 
 // New returns a new gorm database instance
-func New(conf *config.DBConf) (*gorm.DB, error) {
+func New(conf *shared.DBConf) (*gorm.DB, error) {
 	if conf.SQLLite {
 		// we add DisableForeignKeyConstraintWhenMigrating since our sqlite does
 		// not support foreign key constraints

+ 13 - 8
internal/auth/sessionstore/sessionstore.go

@@ -26,7 +26,7 @@ type PGStore struct {
 	Codecs  []securecookie.Codec
 	Options *sessions.Options
 	Path    string
-	Repo    repository.Repository
+	Repo    repository.SessionRepository
 }
 
 // Helpers
@@ -60,7 +60,7 @@ func (store *PGStore) MaxAge(age int) {
 // load fetches a session by ID from the database and decodes its content
 // into session.Values.
 func (store *PGStore) load(session *sessions.Session) error {
-	res, err := store.Repo.Session().SelectSession(&models.Session{Key: session.ID})
+	res, err := store.Repo.SelectSession(&models.Session{Key: session.ID})
 
 	if err != nil {
 		return err
@@ -99,21 +99,26 @@ func (store *PGStore) save(session *sessions.Session) error {
 	repo := store.Repo
 
 	if session.IsNew {
-		_, createErr := repo.Session().CreateSession(s)
+		_, createErr := repo.CreateSession(s)
 		return createErr
 	}
 
-	_, updateErr := repo.Session().UpdateSession(s)
+	_, updateErr := repo.UpdateSession(s)
 	return updateErr
 }
 
 // Implementation of the interface (Get, New, Save)
 
+type NewStoreOpts struct {
+	SessionRepository repository.SessionRepository
+	CookieSecrets     []string
+}
+
 // NewStore takes an initialized db and session key pairs to create a session-store in postgres db.
-func NewStore(repo repository.Repository, conf config.ServerConf) (*PGStore, error) {
+func NewStore(opts *NewStoreOpts) (*PGStore, error) {
 	keyPairs := [][]byte{}
 
-	for _, key := range conf.CookieSecrets {
+	for _, key := range opts.CookieSecrets {
 		keyPairs = append(keyPairs, []byte(key))
 	}
 
@@ -126,7 +131,7 @@ func NewStore(repo repository.Repository, conf config.ServerConf) (*PGStore, err
 			HttpOnly: true,
 			SameSite: http.SameSiteLaxMode,
 		},
-		Repo: repo,
+		Repo: opts.SessionRepository,
 	}
 
 	return dbStore, nil
@@ -193,7 +198,7 @@ func (store *PGStore) Save(r *http.Request, w http.ResponseWriter, session *sess
 
 	// Set delete if max-age is < 0
 	if session.Options.MaxAge < 0 {
-		if _, err := repo.Session().DeleteSession(&models.Session{Key: session.ID}); err != nil {
+		if _, err := repo.DeleteSession(&models.Session{Key: session.ID}); err != nil {
 			return err
 		}
 		http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options))

+ 12 - 8
internal/auth/sessionstore/sessionstore_test.go

@@ -5,8 +5,6 @@ import (
 	"net/http"
 	"testing"
 
-	"github.com/porter-dev/porter/internal/config"
-
 	"github.com/gorilla/securecookie"
 	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/internal/repository/test"
@@ -33,9 +31,12 @@ var secret = "secret"
 func TestPGStore(t *testing.T) {
 	repo := test.NewRepository(true)
 
-	ss, err := sessionstore.NewStore(repo, config.ServerConf{
-		CookieSecrets: []string{"secret"},
-	})
+	ss, err := sessionstore.NewStore(
+		&sessionstore.NewStoreOpts{
+			SessionRepository: repo.Session(),
+			CookieSecrets:     []string{"secret"},
+		},
+	)
 
 	if err != nil {
 		t.Fatal("Failed to get store", err)
@@ -133,9 +134,12 @@ func TestPGStore(t *testing.T) {
 func TestSessionOptionsAreUniquePerSession(t *testing.T) {
 	repo := test.NewRepository(true)
 
-	ss, err := sessionstore.NewStore(repo, config.ServerConf{
-		CookieSecrets: []string{"secret"},
-	})
+	ss, err := sessionstore.NewStore(
+		&sessionstore.NewStoreOpts{
+			SessionRepository: repo.Session(),
+			CookieSecrets:     []string{"secret"},
+		},
+	)
 
 	if err != nil {
 		t.Fatal("Failed to get store", err)

+ 2 - 2
internal/forms/helper_test.go

@@ -4,9 +4,9 @@ import (
 	"os"
 	"testing"
 
+	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/adapter"
-	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/models"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
@@ -32,7 +32,7 @@ type tester struct {
 func setupTestEnv(tester *tester, t *testing.T) {
 	t.Helper()
 
-	db, err := adapter.New(&config.DBConf{
+	db, err := adapter.New(&shared.DBConf{
 		EncryptionKey: "__random_strong_encryption_key__",
 		SQLLite:       true,
 		SQLLitePath:   tester.dbFileName,

+ 18 - 0
internal/notifier/notifier.go

@@ -28,3 +28,21 @@ type UserNotifier interface {
 	SendEmailVerification(opts *SendEmailVerificationOpts) error
 	SendProjectInviteEmail(opts *SendProjectInviteEmailOpts) error
 }
+
+type EmptyUserNotifier struct{}
+
+func (e *EmptyUserNotifier) SendPasswordResetEmail(opts *SendPasswordResetEmailOpts) error {
+	return nil
+}
+
+func (e *EmptyUserNotifier) SendGithubRelinkEmail(opts *SendGithubRelinkEmailOpts) error {
+	return nil
+}
+
+func (e *EmptyUserNotifier) SendEmailVerification(opts *SendEmailVerificationOpts) error {
+	return nil
+}
+
+func (e *EmptyUserNotifier) SendProjectInviteEmail(opts *SendProjectInviteEmailOpts) error {
+	return nil
+}

+ 27 - 0
internal/random/string.go

@@ -0,0 +1,27 @@
+package random
+
+import (
+	"crypto/rand"
+	"math/big"
+)
+
+const randCharset string = "abcdefghijklmnopqrstuvwxyz1234567890"
+
+func StringWithCharset(length int, charset string) (string, error) {
+	letters := charset
+
+	if charset == "" {
+		letters = randCharset
+	}
+
+	ret := make([]byte, length)
+	for i := 0; i < length; i++ {
+		num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
+		if err != nil {
+			return "", err
+		}
+		ret[i] = letters[num.Int64()]
+	}
+
+	return string(ret), nil
+}

+ 2 - 2
internal/repository/gorm/helpers_test.go

@@ -5,9 +5,9 @@ import (
 	"testing"
 	"time"
 
+	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/adapter"
-	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/models"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
@@ -39,7 +39,7 @@ type tester struct {
 func setupTestEnv(tester *tester, t *testing.T) {
 	t.Helper()
 
-	db, err := adapter.New(&config.DBConf{
+	db, err := adapter.New(&shared.DBConf{
 		EncryptionKey: "__random_strong_encryption_key__",
 		SQLLite:       true,
 		SQLLitePath:   tester.dbFileName,

+ 6 - 1
server/api/api.go

@@ -147,7 +147,12 @@ func New(conf *AppConfig) (*App, error) {
 	// }
 
 	// create the session store
-	store, err := sessionstore.NewStore(app.Repo, app.ServerConf)
+	store, err := sessionstore.NewStore(
+		&sessionstore.NewStoreOpts{
+			SessionRepository: app.Repo.Session(),
+			CookieSecrets:     app.ServerConf.CookieSecrets,
+		},
+	)
 
 	if err != nil {
 		return nil, err

+ 8 - 2
server/api/helpers_test.go

@@ -7,6 +7,7 @@ import (
 	"time"
 
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/helm"
@@ -86,7 +87,7 @@ func newTester(canQuery bool) *tester {
 
 	logger := lr.NewConsole(appConf.Debug)
 
-	db, _ := adapter.New(&config.DBConf{
+	db, _ := adapter.New(&shared.DBConf{
 		EncryptionKey: "__random_strong_encryption_key__",
 		SQLLite:       true,
 		SQLLitePath:   "api_test.db",
@@ -126,7 +127,12 @@ func newTester(canQuery bool) *tester {
 	}
 
 	repo := gorm.NewRepository(db, &key)
-	store, _ := sessionstore.NewStore(repo, appConf.Server)
+	store, _ := sessionstore.NewStore(
+		&sessionstore.NewStoreOpts{
+			SessionRepository: repo.Session(),
+			CookieSecrets:     []string{"secret"},
+		},
+	)
 	k8sAgent := kubernetes.GetAgentTesting()
 
 	app, _ := api.New(&api.AppConfig{