Kaynağa Gözat

email verification backend

Alexander Belanger 5 yıl önce
ebeveyn
işleme
1dcd877ccf

+ 16 - 0
dashboard/src/main/Main.tsx

@@ -9,6 +9,7 @@ import ResetPasswordInit from "./auth/ResetPasswordInit";
 import ResetPasswordFinalize from "./auth/ResetPasswordFinalize";
 import Login from "./auth/Login";
 import Register from "./auth/Register";
+import VerifyEmail from "./auth/VerifyEmail";
 import CurrentError from "./CurrentError";
 import Home from "./home/Home";
 import Loading from "components/Loading";
@@ -19,6 +20,7 @@ type PropsType = {};
 type StateType = {
   loading: boolean;
   isLoggedIn: boolean;
+  isEmailVerified: boolean;
   initialized: boolean;
 };
 
@@ -26,6 +28,7 @@ export default class Main extends Component<PropsType, StateType> {
   state = {
     loading: true,
     isLoggedIn: false,
+    isEmailVerified: false,
     initialized: localStorage.getItem("init") === "true",
   };
 
@@ -41,6 +44,7 @@ export default class Main extends Component<PropsType, StateType> {
           setUser(res?.data?.id, res?.data?.email);
           this.setState({
             isLoggedIn: true,
+            isEmailVerified: res?.data?.email_verified,
             initialized: true,
             loading: false,
           });
@@ -73,6 +77,18 @@ export default class Main extends Component<PropsType, StateType> {
       return <Loading />;
     }
 
+    // if logged in but not verified, block until email verification
+    if (this.state.isLoggedIn && !this.state.isEmailVerified) {
+      return <Switch>
+        <Route
+          path="/"
+          render={() => {
+            return <VerifyEmail />
+          }}
+        />
+      </Switch>
+    }
+
     return (
       <Switch>
         <Route

+ 312 - 0
dashboard/src/main/auth/VerifyEmail.tsx

@@ -0,0 +1,312 @@
+import React, { ChangeEvent, Component } from "react";
+import styled from "styled-components";
+import logo from "assets/logo.png";
+
+import api from "shared/api";
+import { emailRegex } from "shared/regex";
+import { Context } from "shared/Context";
+
+type PropsType = {};
+
+type StateType = {
+  submitted: boolean;
+};
+
+export default class VerifyEmail extends Component<PropsType, StateType> {
+  state = {
+    submitted: false,
+  };
+
+  handleSendEmail = (): void => {
+      api
+        .createEmailVerification("", {}, {})
+        .then((res) => {
+          this.setState({ submitted: true })
+        })
+        .catch((err) =>
+          this.context.setCurrentError(err.response.data.errors[0])
+        );
+  };
+
+  render() {
+    let { submitted } = this.state;
+
+    let formSection = <div>
+        <InputWrapper>
+            <StatusText>
+                Please verify your email to continue onto Porter.
+            </StatusText>
+            </InputWrapper>
+            <Button onClick={this.handleSendEmail}>Send Verification Email</Button>
+    </div>
+
+    if (submitted) {
+        formSection = <StatusText>
+            Please check your inbox for a verification email! Remember to check your spam folder.
+        </StatusText>
+    }
+
+    return (
+      <StyledLogin>
+        <LoginPanel>
+          <OverflowWrapper>
+            <GradientBg />
+          </OverflowWrapper>
+          <FormWrapper>
+            <Logo src={logo} />
+            <Prompt>Verify Email</Prompt>
+            <DarkMatter />
+            {formSection}
+          </FormWrapper>
+        </LoginPanel>
+
+        <Footer>
+          © 2021 Porter Technologies Inc. •
+          <Link
+            href="https://docs.getporter.dev/docs/terms-of-service"
+            target="_blank"
+          >
+            Terms & Privacy
+          </Link>
+        </Footer>
+      </StyledLogin>
+    );
+  }
+}
+
+VerifyEmail.contextType = Context;
+
+const Footer = styled.div`
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  margin-bottom: 30px;
+  width: 100vw;
+  text-align: center;
+  color: #aaaabb;
+  font-size: 13px;
+  padding-right: 8px;
+  font: Work Sans, sans-serif;
+`;
+
+const DarkMatter = styled.div`
+  margin-top: -10px;
+`;
+
+const Or = styled.div`
+  position: absolute;
+  width: 30px;
+  text-align: center;
+  background: #111114;
+  z-index: 999;
+  left: calc(50% - 15px);
+  margin-top: -1px;
+`;
+
+const OrWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+  position: relative;
+`;
+
+const IconWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0 10px;
+  height: 100%;
+`;
+
+const Icon = styled.img`
+  height: 18px;
+  margin-right: 20px;
+`;
+
+const OAuthButton = styled.div`
+  width: 200px;
+  height: 30px;
+  display: flex;
+  background: #ffffff;
+  align-items: center;
+  border-radius: 3px;
+  color: #000000;
+  cursor: pointer;
+  user-select: none;
+  font-weight: 500;
+  font-size: 13px;
+  :hover {
+    background: #ffffffdd;
+  }
+`;
+
+const Link = styled.a`
+  margin-left: 5px;
+  color: #819bfd;
+`;
+
+const Helper = styled.div`
+  position: absolute;
+  bottom: 30px;
+  width: 100%;
+  text-align: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+  color: #ffffff44;
+`;
+
+const OverflowWrapper = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  border-radius: 10px;
+`;
+
+const ErrorHelper = styled.div`
+  position: absolute;
+  right: -185px;
+  top: 8px;
+  height: 30px;
+  width: 170px;
+  user-select: none;
+  background: #272731;
+  font-family: "Work Sans", sans-serif;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ff3b62;
+  border-radius: 3px;
+
+  > div {
+    background: #272731;
+    height: 15px;
+    width: 15px;
+    position: absolute;
+    left: -3px;
+    top: 7px;
+    transform: rotate(45deg);
+    z-index: -1;
+  }
+`;
+
+const Line = styled.div`
+  min-height: 3px;
+  width: 100px;
+  z-index: 999;
+  background: #ffffff22;
+  margin: 30px 0px 30px;
+`;
+
+const Button = styled.button`
+  width: 200px;
+  min-height: 30px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  cursor: pointer;
+  margin: 9px auto;
+  border-radius: 2px;
+  border: 0;
+  background: #819bfd;
+  color: white;
+  font-weight: 500;
+  font-size: 14px;
+`;
+
+const InputWrapper = styled.div`
+  position: relative;
+`;
+
+const Input = styled.input`
+  width: 200px;
+  font-family: "Work Sans", sans-serif;
+  margin: 8px 0px;
+  height: 30px;
+  padding: 8px;
+  background: #ffffff12;
+  color: #ffffff;
+  border: ${(props: { valid?: boolean }) =>
+    props.valid ? "0" : "1px solid #ff3b62"};
+  border-radius: 2px;
+  font-size: 14px;
+`;
+
+const Prompt = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  font-size: 15px;
+  margin-bottom: 18px;
+`;
+
+const Logo = styled.img`
+  width: 140px;
+  margin-top: 50px;
+  margin-bottom: 75px;
+  user-select: none;
+`;
+
+const StatusText = styled.div`
+padding: 18px 30px; 
+font-family: "Work Sans", sans-serif;
+font-size: 14px;
+line-height: 160%;
+`;
+
+const FormWrapper = styled.div`
+  width: calc(100% - 8px);
+  height: calc(100% - 8px);
+  background: #111114;
+  z-index: 1;
+  border-radius: 10px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+const GradientBg = styled.div`
+  background: linear-gradient(#8ce1ff, #a59eff, #fba8ff);
+  width: 180%;
+  height: 180%;
+  position: absolute;
+  top: -40%;
+  left: -40%;
+  animation: flip 6s infinite linear;
+  @keyframes flip {
+    from {
+      transform: rotate(0deg);
+    }
+    to {
+      transform: rotate(360deg);
+    }
+  }
+`;
+
+const LoginPanel = styled.div`
+  width: 330px;
+  height: 470px;
+  background: white;
+  margin-top: -20px;
+  border-radius: 10px;
+  display: flex;
+  justify-content: center;
+  position: relative;
+  align-items: center;
+`;
+
+const StyledLogin = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100vw;
+  height: 100vh;
+  position: fixed;
+  top: 0;
+  left: 0;
+  background: #111114;
+`;

+ 5 - 0
dashboard/src/shared/api.tsx

@@ -71,6 +71,10 @@ const createDOKS = baseApi<
   return `/api/projects/${pathParams.project_id}/provision/doks`;
 });
 
+const createEmailVerification = baseApi<{}, {}>("POST", (pathParams) => {
+  return `/api/email/verify/initiate`;
+});
+
 const createGCPIntegration = baseApi<
   {
     gcp_region: string;
@@ -648,6 +652,7 @@ export default {
   createAWSIntegration,
   createDOCR,
   createDOKS,
+  createEmailVerification,
   createGCPIntegration,
   createGCR,
   createGHAction,

+ 4 - 3
internal/config/config.go

@@ -36,9 +36,10 @@ type ServerConf struct {
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 
-	SendgridAPIKey            string `env:"SENDGRID_API_KEY"`
-	SendgridPWResetTemplateID string `env:"SENDGRID_PW_RESET_TEMPLATE_ID"`
-	SendgridSenderEmail       string `env:"SENDGRID_SENDER_EMAIL"`
+	SendgridAPIKey                string `env:"SENDGRID_API_KEY"`
+	SendgridPWResetTemplateID     string `env:"SENDGRID_PW_RESET_TEMPLATE_ID"`
+	SendgridVerifyEmailTemplateID string `env:"SENDGRID_VERIFY_EMAIL_TEMPLATE_ID"`
+	SendgridSenderEmail           string `env:"SENDGRID_SENDER_EMAIL"`
 
 	DOClientID          string `env:"DO_CLIENT_ID"`
 	DOClientSecret      string `env:"DO_CLIENT_SECRET"`

+ 5 - 0
internal/forms/user.go

@@ -109,3 +109,8 @@ type FinalizeResetUserPasswordForm struct {
 	Token          string `json:"token" form:"required"`
 	NewPassword    string `json:"new_password" form:"required,max=255"`
 }
+
+type FinalizeVerifyEmailForm struct {
+	TokenID uint   `json:"token_id" form:"required"`
+	Token   string `json:"token" form:"required"`
+}

+ 36 - 3
internal/integrations/email/sendgrid.go

@@ -8,9 +8,10 @@ import (
 )
 
 type SendgridClient struct {
-	APIKey            string
-	PWResetTemplateID string
-	SenderEmail       string
+	APIKey                string
+	PWResetTemplateID     string
+	VerifyEmailTemplateID string
+	SenderEmail           string
 }
 
 func (client *SendgridClient) SendPWResetEmail(url, email string) error {
@@ -44,3 +45,35 @@ func (client *SendgridClient) SendPWResetEmail(url, email string) error {
 
 	return err
 }
+
+func (client *SendgridClient) SendEmailVerification(url, email string) error {
+	request := sendgrid.GetRequest(os.Getenv("SENDGRID_API_KEY"), "/v3/mail/send", "https://api.sendgrid.com")
+	request.Method = "POST"
+
+	sgMail := &mail.SGMailV3{
+		Personalizations: []*mail.Personalization{
+			{
+				To: []*mail.Email{
+					{
+						Address: email,
+					},
+				},
+				DynamicTemplateData: map[string]interface{}{
+					"url":   url,
+					"email": email,
+				},
+			},
+		},
+		From: &mail.Email{
+			Address: client.SenderEmail,
+			Name:    "Porter",
+		},
+		TemplateID: client.VerifyEmailTemplateID,
+	}
+
+	request.Body = mail.GetRequestBody(sgMail)
+
+	_, err := sendgrid.API(request)
+
+	return err
+}

+ 9 - 6
internal/models/user.go

@@ -8,8 +8,9 @@ import (
 type User struct {
 	gorm.Model
 
-	Email    string `json:"email" gorm:"unique"`
-	Password string `json:"password"`
+	Email         string `json:"email" gorm:"unique"`
+	Password      string `json:"password"`
+	EmailVerified bool   `json:"email_verified"`
 
 	// The github user id used for login (optional)
 	GithubUserID int64
@@ -17,14 +18,16 @@ type User struct {
 
 // UserExternal represents the User type that is sent over REST
 type UserExternal struct {
-	ID    uint   `json:"id"`
-	Email string `json:"email"`
+	ID            uint   `json:"id"`
+	Email         string `json:"email"`
+	EmailVerified bool   `json:"email_verified"`
 }
 
 // Externalize generates an external User to be shared over REST
 func (u *User) Externalize() *UserExternal {
 	return &UserExternal{
-		ID:    u.ID,
-		Email: u.Email,
+		ID:            u.ID,
+		Email:         u.Email,
+		EmailVerified: u.EmailVerified,
 	}
 }

+ 5 - 2
server/api/oauth_github_handler.go

@@ -196,11 +196,13 @@ func (app *App) upsertUserFromToken(tok *oauth2.Token) (*models.User, error) {
 		}
 
 		primary := ""
+		verified := false
 
 		// get the primary email
 		for _, email := range emails {
 			if email.GetPrimary() {
 				primary = email.GetEmail()
+				verified = email.GetVerified()
 				break
 			}
 		}
@@ -214,8 +216,9 @@ func (app *App) upsertUserFromToken(tok *oauth2.Token) (*models.User, error) {
 
 		if err == gorm.ErrRecordNotFound {
 			user = &models.User{
-				Email:        primary,
-				GithubUserID: githubUser.GetID(),
+				Email:         primary,
+				EmailVerified: verified,
+				GithubUserID:  githubUser.GetID(),
 			}
 
 			user, err = app.Repo.User.CreateUser(user)

+ 181 - 11
server/api/user_handler.go

@@ -65,7 +65,7 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 		w.WriteHeader(http.StatusCreated)
 
-		if err := app.sendUser(w, user.ID, user.Email, redirect); err != nil {
+		if err := app.sendUser(w, user.ID, user.Email, false, redirect); err != nil {
 			app.handleErrorFormDecoding(err, ErrUserDecode, w)
 			return
 		}
@@ -86,7 +86,7 @@ func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		if err := app.sendUser(w, tok.IBy, user.Email, ""); err != nil {
+		if err := app.sendUser(w, tok.IBy, user.Email, user.EmailVerified, ""); err != nil {
 			app.handleErrorFormDecoding(err, ErrUserDecode, w)
 			return
 		}
@@ -103,9 +103,19 @@ func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 
 	userID, _ := session.Values["user_id"].(uint)
 	email, _ := session.Values["email"].(string)
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	fmt.Println("EMAIL VERIFIED IS", user.EmailVerified)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
 	w.WriteHeader(http.StatusOK)
 
-	if err := app.sendUser(w, userID, email, ""); err != nil {
+	if err := app.sendUser(w, userID, email, user.EmailVerified, ""); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
@@ -262,7 +272,7 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 
 	w.WriteHeader(http.StatusOK)
 
-	if err := app.sendUser(w, storedUser.ID, storedUser.Email, redirect); err != nil {
+	if err := app.sendUser(w, storedUser.ID, storedUser.Email, storedUser.EmailVerified, redirect); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
@@ -354,6 +364,164 @@ func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// InitiateEmailVerifyUser initiates the email verification flow for a logged-in user
+func (app *App) InitiateEmailVerifyUser(w http.ResponseWriter, r *http.Request) {
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// error already handled by helper
+	if err != nil {
+		return
+	}
+
+	form := &forms.InitiateResetUserPasswordForm{
+		Email: user.Email,
+	}
+
+	// convert the form to a pw reset token model
+	pwReset, rawToken, err := form.ToPWResetToken()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	pwReset, err = app.Repo.PWResetToken.CreatePWResetToken(pwReset)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	queryVals := url.Values{
+		"token":    []string{rawToken},
+		"token_id": []string{fmt.Sprintf("%d", pwReset.ID)},
+	}
+
+	sgClient := email.SendgridClient{
+		APIKey:                app.ServerConf.SendgridAPIKey,
+		VerifyEmailTemplateID: app.ServerConf.SendgridVerifyEmailTemplateID,
+		SenderEmail:           app.ServerConf.SendgridSenderEmail,
+	}
+
+	err = sgClient.SendEmailVerification(
+		fmt.Sprintf("%s/api/email/verify/finalize?%s", app.ServerConf.ServerURL, queryVals.Encode()),
+		form.Email,
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// FinalizEmailVerifyUser completes the email verification flow for a user.
+func (app *App) FinalizEmailVerifyUser(w http.ResponseWriter, r *http.Request) {
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Invalid email verification URL"), 302)
+		return
+	}
+
+	var tokenStr string
+	var tokenID uint
+
+	if tokenArr, ok := vals["token"]; ok && len(tokenArr) == 1 {
+		tokenStr = tokenArr[0]
+	} else {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Invalid email verification URL: token required"), 302)
+		return
+	}
+
+	if tokenIDArr, ok := vals["token_id"]; ok && len(tokenIDArr) == 1 {
+		id, err := strconv.ParseUint(tokenIDArr[0], 10, 64)
+
+		if err != nil {
+			http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Invalid email verification URL: valid token id required"), 302)
+			return
+		}
+
+		tokenID = uint(id)
+	} else {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Invalid email verification URL: valid token id required"), 302)
+		return
+	}
+
+	// verify the token is valid
+	token, err := app.Repo.PWResetToken.ReadPWResetToken(tokenID)
+
+	if err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Email verification error: valid token required"), 302)
+		return
+	}
+
+	// make sure the token is still valid and has not expired
+	if !token.IsValid || token.IsExpired() {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Email verification error: valid token required"), 302)
+		return
+	}
+
+	// make sure the token is correct
+	if err := bcrypt.CompareHashAndPassword([]byte(token.Token), []byte(tokenStr)); err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Email verification error: valid token required"), 302)
+		return
+	}
+
+	user.EmailVerified = true
+
+	user, err = app.Repo.User.UpdateUser(user)
+
+	fmt.Println("UPDATED USER WITH VERIFIED EMAIL", user)
+
+	if err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Could not verify email address"), 302)
+		return
+	}
+
+	// invalidate the token
+	token.IsValid = false
+
+	_, err = app.Repo.PWResetToken.UpdatePWResetToken(token)
+
+	if err != nil {
+		http.Redirect(w, r, "/dashboard?error="+url.QueryEscape("Could not verify email address"), 302)
+		return
+	}
+
+	http.Redirect(w, r, "/dashboard", 302)
+	return
+}
+
 // InitiatePWResetUser initiates the password reset flow based on an email. The endpoint
 // checks if the email exists, but returns a 200 status code regardless, since we don't
 // want to leak in-use emails
@@ -660,16 +828,18 @@ func doesUserExist(repo *repository.Repository, user *models.User) *HTTPError {
 }
 
 type SendUserExt struct {
-	ID       uint   `json:"id"`
-	Email    string `json:"email"`
-	Redirect string `json:"redirect,omitempty"`
+	ID            uint   `json:"id"`
+	Email         string `json:"email"`
+	EmailVerified bool   `json:"email_verified"`
+	Redirect      string `json:"redirect,omitempty"`
 }
 
-func (app *App) sendUser(w http.ResponseWriter, userID uint, email, redirect string) error {
+func (app *App) sendUser(w http.ResponseWriter, userID uint, email string, emailVerified bool, redirect string) error {
 	resUser := &SendUserExt{
-		ID:       userID,
-		Email:    email,
-		Redirect: redirect,
+		ID:            userID,
+		Email:         email,
+		EmailVerified: emailVerified,
+		Redirect:      redirect,
 	}
 
 	if err := json.NewEncoder(w).Encode(resUser); err != nil {

+ 16 - 0
server/router/router.go

@@ -98,6 +98,22 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"POST",
+			"/email/verify/initiate",
+			auth.BasicAuthenticate(
+				requestlog.NewHandler(a.InitiateEmailVerifyUser, l),
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/email/verify/finalize",
+			auth.BasicAuthenticateWithRedirect(
+				requestlog.NewHandler(a.FinalizEmailVerifyUser, l),
+			),
+		)
+
 		r.Method(
 			"POST",
 			"/password/reset/initiate",