Просмотр исходного кода

add invite flow apis and magic links

abhishek9686 1 год назад
Родитель
Сommit
0fc9a181fd
10 измененных файлов с 346 добавлено и 7 удалено
  1. 201 1
      controllers/user.go
  2. 2 0
      database/database.go
  3. 2 0
      go.mod
  4. 4 0
      go.sum
  5. 48 0
      logic/notification.go
  6. 4 4
      logic/user_mgmt.go
  7. 47 0
      logic/users.go
  8. 13 1
      models/user_mgmt.go
  9. 9 0
      scripts/netmaker.default.env
  10. 16 1
      servercfg/serverconf.go

+ 201 - 1
controllers/user.go

@@ -51,6 +51,14 @@ func userHandlers(r *mux.Router) {
 	r.HandleFunc("/api/v1/users/group", logic.SecurityCheck(true, http.HandlerFunc(updateUserGroup))).Methods(http.MethodPut)
 	r.HandleFunc("/api/v1/users/group", logic.SecurityCheck(true, http.HandlerFunc(deleteUserGroup))).Methods(http.MethodDelete)
 
+	// User Invite Handlers
+	r.HandleFunc("/api/v1/users/invite", userInviteVerify).Methods(http.MethodGet)
+	r.HandleFunc("/api/v1/users/invite-signup", userInviteSignUp).Methods(http.MethodPost)
+	r.HandleFunc("/api/v1/users/invite", logic.SecurityCheck(true, http.HandlerFunc(inviteUsers))).Methods(http.MethodPost)
+	r.HandleFunc("/api/v1/users/invites", logic.SecurityCheck(true, http.HandlerFunc(listUserInvites))).Methods(http.MethodGet)
+	r.HandleFunc("/api/v1/users/invite", logic.SecurityCheck(true, http.HandlerFunc(deleteUserInvite))).Methods(http.MethodDelete)
+	r.HandleFunc("/api/v1/users/invites", logic.SecurityCheck(true, http.HandlerFunc(deleteAllUserInvites))).Methods(http.MethodDelete)
+
 }
 
 // swagger:route GET /api/v1/user/groups user listUserGroups
@@ -1024,7 +1032,6 @@ func deletePendingUser(w http.ResponseWriter, r *http.Request) {
 //				200: userBodyResponse
 func deleteAllPendingUsers(w http.ResponseWriter, r *http.Request) {
 	// set header.
-	w.Header().Set("Content-Type", "application/json")
 	err := database.DeleteAllRecords(database.PENDING_USERS_TABLE_NAME)
 	if err != nil {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("failed to delete all pending users "+err.Error()), "internal"))
@@ -1032,3 +1039,196 @@ func deleteAllPendingUsers(w http.ResponseWriter, r *http.Request) {
 	}
 	logic.ReturnSuccessResponse(w, r, "cleared all pending users")
 }
+
+// swagger:route POST /api/v1/users/invite-signup user userInviteSignUp
+//
+// user signup via invite.
+//
+//	Schemes: https
+//
+//	Responses:
+//		200: ReturnSuccessResponse
+func userInviteSignUp(w http.ResponseWriter, r *http.Request) {
+	var params = mux.Vars(r)
+	email := params["email"]
+	code := params["code"]
+	in, err := logic.GetUserInvite(email)
+	if err != nil {
+		logger.Log(0, "failed to fetch users: ", err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+	if code != in.InviteCode {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("invalid invite code"), "badrequest"))
+		return
+	}
+	// check if user already exists
+	_, err = logic.GetUser(email)
+	if err == nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("user already exists"), "badrequest"))
+		return
+	}
+	var user models.User
+	err = json.NewDecoder(r.Body).Decode(&user)
+	if err != nil {
+		logger.Log(0, user.UserName, "error decoding request body: ",
+			err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+	if user.UserName != email {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("username not matching with invite"), "badrequest"))
+		return
+	}
+	if user.Password == "" {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("password cannot be empty"), "badrequest"))
+		return
+	}
+	for _, inviteGroupID := range in.Groups {
+		userG, err := logic.GetUserGroup(inviteGroupID)
+		if err != nil {
+			logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("error fetching group id "+inviteGroupID.String()), "badrequest"))
+			return
+		}
+		user.PlatformRoleID = userG.PlatformRole
+		user.UserGroups[inviteGroupID] = struct{}{}
+	}
+	user.NetworkRoles = make(map[models.NetworkID]map[models.UserRole]struct{})
+	user.IsSuperAdmin = false
+	err = logic.CreateUser(&user)
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+	// delete invite
+	logic.DeleteUserInvite(email)
+	logic.DeletePendingUser(email)
+	logic.ReturnSuccessResponse(w, r, "created user successfully "+email)
+}
+
+// swagger:route GET /api/v1/users/invite user userInviteVerify
+//
+// verfies user invite.
+//
+//	Schemes: https
+//
+//	Responses:
+//		200: ReturnSuccessResponse
+func userInviteVerify(w http.ResponseWriter, r *http.Request) {
+	var params = mux.Vars(r)
+	email := params["email"]
+	code := params["code"]
+	err := logic.ValidateAndApproveUserInvite(email, code)
+	if err != nil {
+		logger.Log(0, "failed to fetch users: ", err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+	logic.ReturnSuccessResponse(w, r, "invite is valid")
+}
+
+// swagger:route POST /api/v1/users/invite user inviteUsers
+//
+// invite users.
+//
+//			Schemes: https
+//
+//			Security:
+//	  		oauth
+//
+//			Responses:
+//				200: userBodyResponse
+func inviteUsers(w http.ResponseWriter, r *http.Request) {
+	var inviteReq models.InviteUsersReq
+	err := json.NewDecoder(r.Body).Decode(&inviteReq)
+	if err != nil {
+		slog.Error("error decoding request body", "error",
+			err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+	for _, inviteeEmail := range inviteReq.UserEmails {
+		// check if user with email exists, then ignore
+		_, err := logic.GetUser(inviteeEmail)
+		if err == nil {
+			// user exists already, so ignore
+			continue
+		}
+		invite := models.UserInvite{
+			Email:      inviteeEmail,
+			Groups:     inviteReq.Groups,
+			InviteCode: logic.RandomString(8),
+		}
+		err = logic.InsertUserInvite(invite)
+		if err != nil {
+			slog.Error("failed to insert invite for user", "email", invite.Email, "error", err)
+		}
+		// notify user with magic link
+		go logic.SendInviteEmail(invite)
+	}
+
+}
+
+// swagger:route GET /api/v1/users/invites user listUserInvites
+//
+// lists all pending invited users.
+//
+//			Schemes: https
+//
+//			Security:
+//	  		oauth
+//
+//			Responses:
+//				200: ReturnSuccessResponseWithJson
+func listUserInvites(w http.ResponseWriter, r *http.Request) {
+	usersInvites, err := logic.ListUserInvites()
+	if err != nil {
+		logger.Log(0, "failed to fetch users: ", err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+	logic.ReturnSuccessResponseWithJson(w, r, usersInvites, "fetched pending user invites")
+}
+
+// swagger:route DELETE /api/v1/users/invite user deleteUserInvite
+//
+// delete pending invite.
+//
+//			Schemes: https
+//
+//			Security:
+//	  		oauth
+//
+//			Responses:
+//				200: ReturnSuccessResponse
+func deleteUserInvite(w http.ResponseWriter, r *http.Request) {
+	var params = mux.Vars(r)
+	username := params["invitee_email"]
+	err := logic.DeleteUserInvite(username)
+	if err != nil {
+		logger.Log(0, "failed to delete user invite: ", username, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+	logic.ReturnSuccessResponse(w, r, "deleted user invite")
+}
+
+// swagger:route DELETE /api/v1/users/invites user deleteAllUserInvites
+//
+// deletes all pending invites.
+//
+//			Schemes: https
+//
+//			Security:
+//	  		oauth
+//
+//			Responses:
+//				200: ReturnSuccessResponse
+func deleteAllUserInvites(w http.ResponseWriter, r *http.Request) {
+	err := database.DeleteAllRecords(database.USER_INVITES_TABLE_NAME)
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("failed to delete all pending user invites "+err.Error()), "internal"))
+		return
+	}
+	logic.ReturnSuccessResponse(w, r, "cleared all pending user invites")
+}

+ 2 - 0
database/database.go

@@ -65,6 +65,8 @@ const (
 	HOST_ACTIONS_TABLE_NAME = "hostactions"
 	// PENDING_USERS_TABLE_NAME - table name for pending users
 	PENDING_USERS_TABLE_NAME = "pending_users"
+	// USER_INVITES - table for user invites
+	USER_INVITES_TABLE_NAME = "user_invites"
 	// == ERROR CONSTS ==
 	// NO_RECORD - no singular result found
 	NO_RECORD = "no result found"

+ 2 - 0
go.mod

@@ -42,6 +42,7 @@ require (
 	github.com/matryer/is v1.4.1
 	github.com/olekukonko/tablewriter v0.0.5
 	github.com/spf13/cobra v1.8.1
+	gopkg.in/mail.v2 v2.3.1
 )
 
 require (
@@ -50,6 +51,7 @@ require (
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
+	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
 )
 
 require (

+ 4 - 0
go.sum

@@ -140,8 +140,12 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb h1:9aqVcYEDHmSNb0uOWukxV5lHV09WqiSiCuhEgWNETLY=
 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb/go.mod h1:mQqgjkW8GQQcJQsbBvK890TKqUK1DfKWkuBGbOkuMHQ=
+gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
+gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
+gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

+ 48 - 0
logic/notification.go

@@ -0,0 +1,48 @@
+package logic
+
+import (
+	"crypto/tls"
+	"fmt"
+
+	gomail "gopkg.in/mail.v2"
+
+	"github.com/gravitl/netmaker/models"
+	"github.com/gravitl/netmaker/servercfg"
+)
+
+var (
+	smtpHost       = servercfg.GetSmtpHost()
+	smtpPort       = servercfg.GetSmtpPort()
+	senderEmail    = servercfg.GetSenderEmail()
+	senderPassword = servercfg.GetSenderEmailPassWord()
+)
+
+func SendInviteEmail(invite models.UserInvite) error {
+	m := gomail.NewMessage()
+
+	// Set E-Mail sender
+	m.SetHeader("From", senderEmail)
+
+	// Set E-Mail receivers
+	m.SetHeader("To", invite.Email)
+
+	// Set E-Mail subject
+	m.SetHeader("Subject", "Netmaker Invite")
+
+	// Set E-Mail body. You can set plain text or html with text/html
+	m.SetBody("text/html", "Click Here to Signup! <a>"+fmt.Sprintf("https://api.%s/invitesignup?code=%v", servercfg.GetServer(), invite.InviteCode))
+
+	// Settings for SMTP server
+	d := gomail.NewDialer(smtpHost, smtpPort, senderEmail, senderPassword)
+
+	// This is only needed when SSL/TLS certificate is not valid on server.
+	// In production this should be set to false.
+	d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
+
+	// Now send E-Mail
+	if err := d.DialAndSend(m); err != nil {
+		return err
+	}
+
+	return nil
+}

+ 4 - 4
logic/user_mgmt.go

@@ -218,7 +218,7 @@ func CreateUserGroup(g models.UserGroup) error {
 	if g.ID == "" {
 		return errors.New("group id cannot be empty")
 	}
-	_, err := database.FetchRecord(database.USER_GROUPS_TABLE_NAME, g.ID)
+	_, err := database.FetchRecord(database.USER_GROUPS_TABLE_NAME, g.ID.String())
 	if err == nil {
 		return errors.New("group already exists")
 	}
@@ -226,7 +226,7 @@ func CreateUserGroup(g models.UserGroup) error {
 	if err != nil {
 		return err
 	}
-	return database.Insert(g.ID, string(d), database.USER_GROUPS_TABLE_NAME)
+	return database.Insert(g.ID.String(), string(d), database.USER_GROUPS_TABLE_NAME)
 }
 
 // GetUserGroup - fetches user group
@@ -267,7 +267,7 @@ func UpdateUserGroup(g models.UserGroup) error {
 	if g.ID == "" {
 		return errors.New("group id cannot be empty")
 	}
-	_, err := database.FetchRecord(database.USER_GROUPS_TABLE_NAME, g.ID)
+	_, err := database.FetchRecord(database.USER_GROUPS_TABLE_NAME, g.ID.String())
 	if err != nil {
 		return err
 	}
@@ -275,7 +275,7 @@ func UpdateUserGroup(g models.UserGroup) error {
 	if err != nil {
 		return err
 	}
-	return database.Insert(g.ID, string(d), database.USER_GROUPS_TABLE_NAME)
+	return database.Insert(g.ID.String(), string(d), database.USER_GROUPS_TABLE_NAME)
 }
 
 // DeleteUserGroup - deletes user group

+ 47 - 0
logic/users.go

@@ -119,3 +119,50 @@ func ListPendingUsers() ([]models.ReturnUser, error) {
 	}
 	return pendingUsers, nil
 }
+
+func InsertUserInvite(invite models.UserInvite) error {
+	data, err := json.Marshal(invite)
+	if err != nil {
+		return err
+	}
+	return database.Insert(invite.Email, string(data), database.USER_INVITES_TABLE_NAME)
+}
+
+func GetUserInvite(email string) (in models.UserInvite, err error) {
+	d, err := database.FetchRecord(database.USER_INVITES_TABLE_NAME, email)
+	if err != nil {
+		return
+	}
+	err = json.Unmarshal([]byte(d), &in)
+	return
+}
+
+func ListUserInvites() ([]models.UserInvite, error) {
+	invites := []models.UserInvite{}
+	records, err := database.FetchRecords(database.USER_INVITES_TABLE_NAME)
+	if err != nil && !database.IsEmptyRecord(err) {
+		return invites, err
+	}
+	for _, record := range records {
+		in := models.UserInvite{}
+		err = json.Unmarshal([]byte(record), &in)
+		if err == nil {
+			invites = append(invites, in)
+		}
+	}
+	return invites, nil
+}
+
+func DeleteUserInvite(email string) error {
+	return database.DeleteRecord(database.USER_INVITES_TABLE_NAME, email)
+}
+func ValidateAndApproveUserInvite(email, code string) error {
+	in, err := GetUserInvite(email)
+	if err != nil {
+		return err
+	}
+	if code != in.InviteCode {
+		return errors.New("invalid code")
+	}
+	return nil
+}

+ 13 - 1
models/user_mgmt.go

@@ -87,7 +87,7 @@ type UserRolePermissionTemplate struct {
 }
 
 type UserGroup struct {
-	ID           string                              `json:"id"`
+	ID           UserGroupID                         `json:"id"`
 	PlatformRole UserRole                            `json:"platform_role"`
 	NetworkRoles map[NetworkID]map[UserRole]struct{} `json:"network_roles"`
 	MetaData     string                              `json:"meta_data"`
@@ -131,3 +131,15 @@ type UserClaims struct {
 	UserName     string
 	jwt.RegisteredClaims
 }
+
+type InviteUsersReq struct {
+	UserEmails []string `json:"user_emails"`
+	Groups     []UserGroupID
+}
+
+// UserInvite - model for user invite
+type UserInvite struct {
+	Email      string        `json:"email"`
+	Groups     []UserGroupID `json:"groups"`
+	InviteCode string        `json:"invite_code"`
+}

+ 9 - 0
scripts/netmaker.default.env

@@ -75,3 +75,12 @@ RAC_AUTO_DISABLE=false
 CACHING_ENABLED=true
 # if turned on netclient checks if peers are reachable over private/LAN address, and choose that as peer endpoint
 ENDPOINT_DETECTION=true
+# config for sending emails
+# mail server host
+SMTP_HOST=smtp.gmail.com
+# mail server port
+SMTP_PORT=587
+# sender email
+SENDER_EMAIL=
+# sender email password
+SENDER_PASSWORD=

+ 16 - 1
servercfg/serverconf.go

@@ -241,6 +241,21 @@ func GetPublicBrokerEndpoint() string {
 	}
 }
 
+func GetSmtpHost() string {
+	return os.Getenv("SMTP_HOST")
+}
+
+func GetSmtpPort() int {
+	port, _ := strconv.Atoi(os.Getenv("SMTP_PORT"))
+	return port
+}
+func GetSenderEmail() string {
+	return os.Getenv("SENDER_EMAIL")
+}
+func GetSenderEmailPassWord() string {
+	return os.Getenv("SENDER_PASSWORD")
+}
+
 // GetOwnerEmail - gets the owner email (saas)
 func GetOwnerEmail() string {
 	return os.Getenv("SAAS_OWNER_EMAIL")
@@ -471,7 +486,7 @@ func GetPublicIP() (string, error) {
 			break
 		}
 	}
-	if err == nil && endpoint == "" {
+	if endpoint == "" {
 		err = errors.New("public address not found")
 	}
 	return endpoint, err