From 9a19552f7236c724a9cf33e0e74c6845a0cfe7ee Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 15 Mar 2022 08:53:48 +0530 Subject: [PATCH] feat: add resolver for inviting members --- app/src/Root.tsx | 4 + app/src/pages/setup-password.tsx | 12 +++ server/email/invite_email.go | 113 ++++++++++++++++++++++ server/graph/generated/generated.go | 118 +++++++++++++++++++++++ server/graph/model/models_gen.go | 5 + server/graph/schema.graphqls | 6 ++ server/graph/schema.resolvers.go | 4 + server/resolvers/forgot_password.go | 2 +- server/resolvers/invite_members.go | 134 +++++++++++++++++++++++++++ server/resolvers/magic_link_login.go | 2 +- server/resolvers/signup.go | 2 +- server/resolvers/update_profile.go | 2 +- server/resolvers/update_user.go | 2 +- server/utils/urls.go | 11 +++ 14 files changed, 412 insertions(+), 5 deletions(-) create mode 100644 app/src/pages/setup-password.tsx create mode 100644 server/email/invite_email.go create mode 100644 server/resolvers/invite_members.go diff --git a/app/src/Root.tsx b/app/src/Root.tsx index d62ded8..8707a2b 100644 --- a/app/src/Root.tsx +++ b/app/src/Root.tsx @@ -1,6 +1,7 @@ import React, { useEffect, lazy, Suspense } from 'react'; import { Switch, Route } from 'react-router-dom'; import { useAuthorizer } from '@authorizerdev/authorizer-react'; +import SetupPassword from './pages/setup-password'; const ResetPassword = lazy(() => import('./pages/rest-password')); const Login = lazy(() => import('./pages/login')); @@ -60,6 +61,9 @@ export default function Root({ + + + ); diff --git a/app/src/pages/setup-password.tsx b/app/src/pages/setup-password.tsx new file mode 100644 index 0000000..1709797 --- /dev/null +++ b/app/src/pages/setup-password.tsx @@ -0,0 +1,12 @@ +import React, { Fragment } from 'react'; +import { AuthorizerResetPassword } from '@authorizerdev/authorizer-react'; + +export default function SetupPassword() { + return ( + +

Setup new Password

+
+ +
+ ); +} diff --git a/server/email/invite_email.go b/server/email/invite_email.go new file mode 100644 index 0000000..23a5cf3 --- /dev/null +++ b/server/email/invite_email.go @@ -0,0 +1,113 @@ +package email + +import ( + "log" + + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/envstore" +) + +// InviteEmail to send invite email +func InviteEmail(toEmail, token, url string) error { + // The receiver needs to be in slice as the receive supports multiple receiver + Receiver := []string{toEmail} + + Subject := "Please accept the invitation" + message := ` + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + +
+
+
+ + + ` + data := make(map[string]interface{}, 3) + data["org_logo"] = envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyOrganizationLogo) + data["org_name"] = envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyOrganizationName) + data["verification_url"] = url + "?token=" + token + message = addEmailTemplate(message, data, "verify_email.tmpl") + // bodyMessage := sender.WriteHTMLEmail(Receiver, Subject, message) + + err := SendMail(Receiver, Subject, message) + if err != nil { + log.Println("=> error sending email:", err) + } + return err +} diff --git a/server/graph/generated/generated.go b/server/graph/generated/generated.go index 817dd79..43d5c21 100644 --- a/server/graph/generated/generated.go +++ b/server/graph/generated/generated.go @@ -114,6 +114,7 @@ type ComplexityRoot struct { AdminSignup func(childComplexity int, params model.AdminSignupInput) int DeleteUser func(childComplexity int, params model.DeleteUserInput) int ForgotPassword func(childComplexity int, params model.ForgotPasswordInput) int + InviteMembers func(childComplexity int, params model.InviteMemberInput) int Login func(childComplexity int, params model.LoginInput) int Logout func(childComplexity int) int MagicLinkLogin func(childComplexity int, params model.MagicLinkLoginInput) int @@ -208,6 +209,7 @@ type MutationResolver interface { AdminLogin(ctx context.Context, params model.AdminLoginInput) (*model.Response, error) AdminLogout(ctx context.Context) (*model.Response, error) UpdateEnv(ctx context.Context, params model.UpdateEnvInput) (*model.Response, error) + InviteMembers(ctx context.Context, params model.InviteMemberInput) (*model.Response, error) } type QueryResolver interface { Meta(ctx context.Context) (*model.Meta, error) @@ -660,6 +662,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.ForgotPassword(childComplexity, args["params"].(model.ForgotPasswordInput)), true + case "Mutation._invite_members": + if e.complexity.Mutation.InviteMembers == nil { + break + } + + args, err := ec.field_Mutation__invite_members_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.InviteMembers(childComplexity, args["params"].(model.InviteMemberInput)), true + case "Mutation.login": if e.complexity.Mutation.Login == nil { break @@ -1434,6 +1448,11 @@ input OAuthRevokeInput { refresh_token: String! } +input InviteMemberInput { + emails: [String!]! + redirect_uri: String +} + type Mutation { signup(params: SignUpInput!): AuthResponse! login(params: LoginInput!): AuthResponse! @@ -1452,6 +1471,7 @@ type Mutation { _admin_login(params: AdminLoginInput!): Response! _admin_logout: Response! _update_env(params: UpdateEnvInput!): Response! + _invite_members(params: InviteMemberInput!): Response! } type Query { @@ -1517,6 +1537,21 @@ func (ec *executionContext) field_Mutation__delete_user_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation__invite_members_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 model.InviteMemberInput + if tmp, ok := rawArgs["params"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + arg0, err = ec.unmarshalNInviteMemberInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐInviteMemberInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["params"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation__update_env_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -4182,6 +4217,48 @@ func (ec *executionContext) _Mutation__update_env(ctx context.Context, field gra return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation__invite_members(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation__invite_members_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().InviteMembers(rctx, args["params"].(model.InviteMemberInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.Response) + fc.Result = res + return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) +} + func (ec *executionContext) _Pagination_limit(ctx context.Context, field graphql.CollectedField, obj *model.Pagination) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -6914,6 +6991,37 @@ func (ec *executionContext) unmarshalInputForgotPasswordInput(ctx context.Contex return it, nil } +func (ec *executionContext) unmarshalInputInviteMemberInput(ctx context.Context, obj interface{}) (model.InviteMemberInput, error) { + var it model.InviteMemberInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + for k, v := range asMap { + switch k { + case "emails": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("emails")) + it.Emails, err = ec.unmarshalNString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + case "redirect_uri": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("redirect_uri")) + it.RedirectURI, err = ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputLoginInput(ctx context.Context, obj interface{}) (model.LoginInput, error) { var it model.LoginInput asMap := map[string]interface{}{} @@ -8182,6 +8290,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "_invite_members": + out.Values[i] = ec._Mutation__invite_members(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -8911,6 +9024,11 @@ func (ec *executionContext) marshalNInt642int64(ctx context.Context, sel ast.Sel return res } +func (ec *executionContext) unmarshalNInviteMemberInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐInviteMemberInput(ctx context.Context, v interface{}) (model.InviteMemberInput, error) { + res, err := ec.unmarshalInputInviteMemberInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNLoginInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐLoginInput(ctx context.Context, v interface{}) (model.LoginInput, error) { res, err := ec.unmarshalInputLoginInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/server/graph/model/models_gen.go b/server/graph/model/models_gen.go index ea069e5..88be349 100644 --- a/server/graph/model/models_gen.go +++ b/server/graph/model/models_gen.go @@ -74,6 +74,11 @@ type ForgotPasswordInput struct { RedirectURI *string `json:"redirect_uri"` } +type InviteMemberInput struct { + Emails []string `json:"emails"` + RedirectURI *string `json:"redirect_uri"` +} + type LoginInput struct { Email string `json:"email"` Password string `json:"password"` diff --git a/server/graph/schema.graphqls b/server/graph/schema.graphqls index 18a727c..fcd2db0 100644 --- a/server/graph/schema.graphqls +++ b/server/graph/schema.graphqls @@ -272,6 +272,11 @@ input OAuthRevokeInput { refresh_token: String! } +input InviteMemberInput { + emails: [String!]! + redirect_uri: String +} + type Mutation { signup(params: SignUpInput!): AuthResponse! login(params: LoginInput!): AuthResponse! @@ -290,6 +295,7 @@ type Mutation { _admin_login(params: AdminLoginInput!): Response! _admin_logout: Response! _update_env(params: UpdateEnvInput!): Response! + _invite_members(params: InviteMemberInput!): Response! } type Query { diff --git a/server/graph/schema.resolvers.go b/server/graph/schema.resolvers.go index 245d7d8..e4f9275 100644 --- a/server/graph/schema.resolvers.go +++ b/server/graph/schema.resolvers.go @@ -75,6 +75,10 @@ func (r *mutationResolver) UpdateEnv(ctx context.Context, params model.UpdateEnv return resolvers.UpdateEnvResolver(ctx, params) } +func (r *mutationResolver) InviteMembers(ctx context.Context, params model.InviteMemberInput) (*model.Response, error) { + return resolvers.InviteMembersResolver(ctx, params) +} + func (r *queryResolver) Meta(ctx context.Context) (*model.Meta, error) { return resolvers.MetaResolver(ctx) } diff --git a/server/resolvers/forgot_password.go b/server/resolvers/forgot_password.go index 4be96b1..58edb08 100644 --- a/server/resolvers/forgot_password.go +++ b/server/resolvers/forgot_password.go @@ -43,7 +43,7 @@ func ForgotPasswordResolver(ctx context.Context, params model.ForgotPasswordInpu if err != nil { return res, err } - redirectURL := envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAppURL) + redirectURL := utils.GetAppURL(gc) + "/reset-password" if params.RedirectURI != nil { redirectURL = *params.RedirectURI } diff --git a/server/resolvers/invite_members.go b/server/resolvers/invite_members.go new file mode 100644 index 0000000..649e36a --- /dev/null +++ b/server/resolvers/invite_members.go @@ -0,0 +1,134 @@ +package resolvers + +import ( + "context" + "errors" + "log" + "strings" + "time" + + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/db" + "github.com/authorizerdev/authorizer/server/db/models" + emailservice "github.com/authorizerdev/authorizer/server/email" + "github.com/authorizerdev/authorizer/server/envstore" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/token" + "github.com/authorizerdev/authorizer/server/utils" +) + +// InviteMembersResolver resolver to invite members +func InviteMembersResolver(ctx context.Context, params model.InviteMemberInput) (*model.Response, error) { + gc, err := utils.GinContextFromContext(ctx) + var res *model.Response + if err != nil { + return res, err + } + + if !token.IsSuperAdmin(gc) { + return res, errors.New("unauthorized") + } + + // this feature is only allowed if email server is configured + if envstore.EnvStoreObj.GetBoolStoreEnvVariable(constants.EnvKeyDisableEmailVerification) { + return res, errors.New("email sending is disabled") + } + + if envstore.EnvStoreObj.GetBoolStoreEnvVariable(constants.EnvKeyDisableBasicAuthentication) && envstore.EnvStoreObj.GetBoolStoreEnvVariable(constants.EnvKeyDisableMagicLinkLogin) { + return res, errors.New("either basic authentication or magic link login is required") + } + + // filter valid emails + emails := []string{} + for _, email := range params.Emails { + if utils.IsValidEmail(email) { + emails = append(emails, email) + } + } + + if len(emails) == 0 { + res.Message = "No valid emails found" + return res, errors.New("no valid emails found") + } + + // TODO: optimise to use like query instead of looping through emails and getting user individually + // for each emails check if emails exists in db + newEmails := []string{} + for _, email := range emails { + _, err := db.Provider.GetUserByEmail(email) + if err != nil { + log.Printf("%s user not found. inviting user.", email) + newEmails = append(newEmails, email) + } else { + log.Println("%s user already exists. skipping.", email) + } + } + + if len(newEmails) == 0 { + res.Message = "All emails already exist" + return res, errors.New("all emails already exist") + } + + // invite new emails + for _, email := range newEmails { + + user := models.User{ + Email: email, + Roles: strings.Join(envstore.EnvStoreObj.GetSliceStoreEnvVariable(constants.EnvKeyDefaultRoles), ","), + } + redirectURL := utils.GetAppURL(gc) + "/verify_email" + if params.RedirectURI != nil { + redirectURL = *params.RedirectURI + } + + _, nonceHash, err := utils.GenerateNonce() + if err != nil { + return res, err + } + + verificationToken, err := token.CreateVerificationToken(email, constants.VerificationTypeForgotPassword, redirectURL, nonceHash, redirectURL) + if err != nil { + log.Println(`error generating token`, err) + } + + verificationRequest := models.VerificationRequest{ + Token: verificationToken, + ExpiresAt: time.Now().Add(time.Minute * 30).Unix(), + Email: email, + Nonce: nonceHash, + RedirectURI: redirectURL, + } + + // use magic link login if that option is on + if !envstore.EnvStoreObj.GetBoolStoreEnvVariable(constants.EnvKeyDisableMagicLinkLogin) { + user.SignupMethods = constants.SignupMethodMagicLinkLogin + verificationRequest.Identifier = constants.VerificationTypeMagicLinkLogin + } else { + // use basic authentication if that option is on + user.SignupMethods = constants.SignupMethodBasicAuth + verificationRequest.Identifier = constants.VerificationTypeForgotPassword + + redirectURL = utils.GetAppURL(gc) + "/setup-password" + if params.RedirectURI != nil { + redirectURL = *params.RedirectURI + } + + } + + user, err = db.Provider.AddUser(user) + if err != nil { + log.Printf("error inviting user: %s, err: %v", email, err) + return res, err + } + + _, err = db.Provider.AddVerificationRequest(verificationRequest) + if err != nil { + log.Printf("error inviting user: %s, err: %v", email, err) + return res, err + } + + go emailservice.InviteEmail(email, verificationToken, redirectURL) + } + + return res, nil +} diff --git a/server/resolvers/magic_link_login.go b/server/resolvers/magic_link_login.go index 7014ca1..05d0708 100644 --- a/server/resolvers/magic_link_login.go +++ b/server/resolvers/magic_link_login.go @@ -123,7 +123,7 @@ func MagicLinkLoginResolver(ctx context.Context, params model.MagicLinkLoginInpu if params.Scope != nil && len(params.Scope) > 0 { redirectURLParams = redirectURLParams + "&scope=" + strings.Join(params.Scope, " ") } - redirectURL := envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAppURL) + redirectURL := utils.GetAppURL(gc) if params.RedirectURI != nil { redirectURL = *params.RedirectURI } diff --git a/server/resolvers/signup.go b/server/resolvers/signup.go index 308d284..478f8b7 100644 --- a/server/resolvers/signup.go +++ b/server/resolvers/signup.go @@ -128,7 +128,7 @@ func SignupResolver(ctx context.Context, params model.SignUpInput) (*model.AuthR return res, err } verificationType := constants.VerificationTypeBasicAuthSignup - redirectURL := envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAppURL) + redirectURL := utils.GetAppURL(gc) verificationToken, err := token.CreateVerificationToken(params.Email, verificationType, hostname, nonceHash, redirectURL) if err != nil { return res, err diff --git a/server/resolvers/update_profile.go b/server/resolvers/update_profile.go index 5a3f328..73e87fe 100644 --- a/server/resolvers/update_profile.go +++ b/server/resolvers/update_profile.go @@ -134,7 +134,7 @@ func UpdateProfileResolver(ctx context.Context, params model.UpdateProfileInput) return res, err } verificationType := constants.VerificationTypeUpdateEmail - redirectURL := envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAppURL) + redirectURL := utils.GetAppURL(gc) verificationToken, err := token.CreateVerificationToken(newEmail, verificationType, hostname, nonceHash, redirectURL) if err != nil { log.Println(`error generating token`, err) diff --git a/server/resolvers/update_user.go b/server/resolvers/update_user.go index f9438d4..16871cf 100644 --- a/server/resolvers/update_user.go +++ b/server/resolvers/update_user.go @@ -106,7 +106,7 @@ func UpdateUserResolver(ctx context.Context, params model.UpdateUserInput) (*mod return res, err } verificationType := constants.VerificationTypeUpdateEmail - redirectURL := envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAppURL) + redirectURL := utils.GetAppURL(gc) verificationToken, err := token.CreateVerificationToken(newEmail, verificationType, hostname, nonceHash, redirectURL) if err != nil { log.Println(`error generating token`, err) diff --git a/server/utils/urls.go b/server/utils/urls.go index 64b8406..e4e6c06 100644 --- a/server/utils/urls.go +++ b/server/utils/urls.go @@ -4,6 +4,8 @@ import ( "net/url" "strings" + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/envstore" "github.com/gin-gonic/gin" ) @@ -71,3 +73,12 @@ func GetDomainName(uri string) string { return host } + +// GetAppURL to get /app/ url if not configured by user +func GetAppURL(gc *gin.Context) string { + envAppURL := envstore.EnvStoreObj.GetStringStoreEnvVariable(constants.EnvKeyAppURL) + if envAppURL == "" { + envAppURL = GetHost(gc) + "/app/" + } + return envAppURL +}