From e576c4134857d4e2bed1f1163e40960f067d0b78 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Fri, 12 Nov 2021 05:19:43 +0530 Subject: [PATCH] feat: add support for magic link login --- server/constants/constants.go | 1 + server/env.go | 5 + server/graph/generated/generated.go | 164 ++++++++++++++++++++++++++++ server/graph/model/models_gen.go | 6 + server/graph/schema.graphqls | 7 ++ server/graph/schema.resolvers.go | 4 + server/handlers/oauthCallback.go | 2 +- server/handlers/verifyEmail.go | 4 +- server/resolvers/magicLogin.go | 122 +++++++++++++++++++++ server/utils/email.go | 2 +- server/utils/meta.go | 1 + 11 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 server/resolvers/magicLogin.go diff --git a/server/constants/constants.go b/server/constants/constants.go index e8143b7..e7c9d13 100644 --- a/server/constants/constants.go +++ b/server/constants/constants.go @@ -21,6 +21,7 @@ var ( RESET_PASSWORD_URL = "" DISABLE_EMAIL_VERIFICATION = "false" DISABLE_BASIC_AUTHENTICATION = "false" + DISABLE_MAGIC_LOGIN = "false" // ROLES ROLES = []string{} diff --git a/server/env.go b/server/env.go index d41fc8e..47a415c 100644 --- a/server/env.go +++ b/server/env.go @@ -64,6 +64,7 @@ func InitEnv() { constants.RESET_PASSWORD_URL = strings.TrimPrefix(os.Getenv("RESET_PASSWORD_URL"), "/") constants.DISABLE_BASIC_AUTHENTICATION = os.Getenv("DISABLE_BASIC_AUTHENTICATION") constants.DISABLE_EMAIL_VERIFICATION = os.Getenv("DISABLE_EMAIL_VERIFICATION") + constants.DISABLE_MAGIC_LOGIN = os.Getenv("DISABLE_MAGIC_LOGIN") constants.JWT_ROLE_CLAIM = os.Getenv("JWT_ROLE_CLAIM") if constants.ADMIN_SECRET == "" { @@ -126,6 +127,10 @@ func InitEnv() { constants.DISABLE_BASIC_AUTHENTICATION = "false" } + if constants.DISABLE_MAGIC_LOGIN == "" { + constants.DISABLE_MAGIC_LOGIN = "false" + } + if constants.DISABLE_EMAIL_VERIFICATION == "" && constants.DISABLE_BASIC_AUTHENTICATION == "false" { if constants.SMTP_HOST == "" || constants.SENDER_EMAIL == "" || constants.SENDER_PASSWORD == "" { constants.DISABLE_EMAIL_VERIFICATION = "true" diff --git a/server/graph/generated/generated.go b/server/graph/generated/generated.go index 8e4284b..db8919b 100644 --- a/server/graph/generated/generated.go +++ b/server/graph/generated/generated.go @@ -61,6 +61,7 @@ type ComplexityRoot struct { IsFacebookLoginEnabled func(childComplexity int) int IsGithubLoginEnabled func(childComplexity int) int IsGoogleLoginEnabled func(childComplexity int) int + IsMagicLoginEnabled func(childComplexity int) int IsTwitterLoginEnabled func(childComplexity int) int Version func(childComplexity int) int } @@ -71,6 +72,7 @@ type ComplexityRoot struct { ForgotPassword func(childComplexity int, params model.ForgotPasswordInput) int Login func(childComplexity int, params model.LoginInput) int Logout func(childComplexity int) int + MagicLogin func(childComplexity int, params model.MagicLoginInput) int ResendVerifyEmail func(childComplexity int, params model.ResendVerifyEmailInput) int ResetPassword func(childComplexity int, params model.ResetPasswordInput) int Signup func(childComplexity int, params model.SignUpInput) int @@ -117,6 +119,7 @@ type ComplexityRoot struct { type MutationResolver interface { Signup(ctx context.Context, params model.SignUpInput) (*model.AuthResponse, error) Login(ctx context.Context, params model.LoginInput) (*model.AuthResponse, error) + MagicLogin(ctx context.Context, params model.MagicLoginInput) (*model.Response, error) Logout(ctx context.Context) (*model.Response, error) UpdateProfile(ctx context.Context, params model.UpdateProfileInput) (*model.Response, error) AdminUpdateUser(ctx context.Context, params model.AdminUpdateUserInput) (*model.User, error) @@ -226,6 +229,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Meta.IsGoogleLoginEnabled(childComplexity), true + case "Meta.isMagicLoginEnabled": + if e.complexity.Meta.IsMagicLoginEnabled == nil { + break + } + + return e.complexity.Meta.IsMagicLoginEnabled(childComplexity), true + case "Meta.isTwitterLoginEnabled": if e.complexity.Meta.IsTwitterLoginEnabled == nil { break @@ -295,6 +305,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.Logout(childComplexity), true + case "Mutation.magicLogin": + if e.complexity.Mutation.MagicLogin == nil { + break + } + + args, err := ec.field_Mutation_magicLogin_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.MagicLogin(childComplexity, args["params"].(model.MagicLoginInput)), true + case "Mutation.resendVerifyEmail": if e.complexity.Mutation.ResendVerifyEmail == nil { break @@ -600,6 +622,7 @@ type Meta { isGithubLoginEnabled: Boolean! isEmailVerificationEnabled: Boolean! isBasicAuthenticationEnabled: Boolean! + isMagicLoginEnabled: Boolean! } type User { @@ -699,9 +722,15 @@ input DeleteUserInput { email: String! } +input MagicLoginInput { + email: String! + roles: [String!] +} + type Mutation { signup(params: SignUpInput!): AuthResponse! login(params: LoginInput!): AuthResponse! + magicLogin(params: MagicLoginInput!): Response! logout: Response! updateProfile(params: UpdateProfileInput!): Response! adminUpdateUser(params: AdminUpdateUserInput!): User! @@ -787,6 +816,21 @@ func (ec *executionContext) field_Mutation_login_args(ctx context.Context, rawAr return args, nil } +func (ec *executionContext) field_Mutation_magicLogin_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 model.MagicLoginInput + if tmp, ok := rawArgs["params"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + arg0, err = ec.unmarshalNMagicLoginInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐMagicLoginInput(ctx, tmp) + if err != nil { + return nil, err + } + } + args["params"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_resendVerifyEmail_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -1376,6 +1420,41 @@ func (ec *executionContext) _Meta_isBasicAuthenticationEnabled(ctx context.Conte return ec.marshalNBoolean2bool(ctx, field.Selections, res) } +func (ec *executionContext) _Meta_isMagicLoginEnabled(ctx context.Context, field graphql.CollectedField, obj *model.Meta) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Meta", + Field: field, + Args: nil, + IsMethod: false, + IsResolver: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.IsMagicLoginEnabled, nil + }) + 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.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _Mutation_signup(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -1460,6 +1539,48 @@ func (ec *executionContext) _Mutation_login(ctx context.Context, field graphql.C return ec.marshalNAuthResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐAuthResponse(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_magicLogin(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_magicLogin_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().MagicLogin(rctx, args["params"].(model.MagicLoginInput)) + }) + 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) _Mutation_logout(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -3856,6 +3977,34 @@ func (ec *executionContext) unmarshalInputLoginInput(ctx context.Context, obj in return it, nil } +func (ec *executionContext) unmarshalInputMagicLoginInput(ctx context.Context, obj interface{}) (model.MagicLoginInput, error) { + var it model.MagicLoginInput + var asMap = obj.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "email": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) + it.Email, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + case "roles": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("roles")) + it.Roles, err = ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputResendVerifyEmailInput(ctx context.Context, obj interface{}) (model.ResendVerifyEmailInput, error) { var it model.ResendVerifyEmailInput var asMap = obj.(map[string]interface{}) @@ -4187,6 +4336,11 @@ func (ec *executionContext) _Meta(ctx context.Context, sel ast.SelectionSet, obj if out.Values[i] == graphql.Null { invalids++ } + case "isMagicLoginEnabled": + out.Values[i] = ec._Meta_isMagicLoginEnabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -4223,6 +4377,11 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "magicLogin": + out.Values[i] = ec._Mutation_magicLogin(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } case "logout": out.Values[i] = ec._Mutation_logout(ctx, field) if out.Values[i] == graphql.Null { @@ -4800,6 +4959,11 @@ func (ec *executionContext) unmarshalNLoginInput2githubᚗcomᚋauthorizerdevᚋ return res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalNMagicLoginInput2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐMagicLoginInput(ctx context.Context, v interface{}) (model.MagicLoginInput, error) { + res, err := ec.unmarshalInputMagicLoginInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalNMeta2githubᚗcomᚋauthorizerdevᚋauthorizerᚋserverᚋgraphᚋmodelᚐMeta(ctx context.Context, sel ast.SelectionSet, v model.Meta) graphql.Marshaler { return ec._Meta(ctx, sel, &v) } diff --git a/server/graph/model/models_gen.go b/server/graph/model/models_gen.go index 8ffcd8a..ea0cadd 100644 --- a/server/graph/model/models_gen.go +++ b/server/graph/model/models_gen.go @@ -37,6 +37,11 @@ type LoginInput struct { Roles []string `json:"roles"` } +type MagicLoginInput struct { + Email string `json:"email"` + Roles []string `json:"roles"` +} + type Meta struct { Version string `json:"version"` IsGoogleLoginEnabled bool `json:"isGoogleLoginEnabled"` @@ -45,6 +50,7 @@ type Meta struct { IsGithubLoginEnabled bool `json:"isGithubLoginEnabled"` IsEmailVerificationEnabled bool `json:"isEmailVerificationEnabled"` IsBasicAuthenticationEnabled bool `json:"isBasicAuthenticationEnabled"` + IsMagicLoginEnabled bool `json:"isMagicLoginEnabled"` } type ResendVerifyEmailInput struct { diff --git a/server/graph/schema.graphqls b/server/graph/schema.graphqls index 1793ff5..aa94b55 100644 --- a/server/graph/schema.graphqls +++ b/server/graph/schema.graphqls @@ -13,6 +13,7 @@ type Meta { isGithubLoginEnabled: Boolean! isEmailVerificationEnabled: Boolean! isBasicAuthenticationEnabled: Boolean! + isMagicLoginEnabled: Boolean! } type User { @@ -112,9 +113,15 @@ input DeleteUserInput { email: String! } +input MagicLoginInput { + email: String! + roles: [String!] +} + type Mutation { signup(params: SignUpInput!): AuthResponse! login(params: LoginInput!): AuthResponse! + magicLogin(params: MagicLoginInput!): Response! logout: Response! updateProfile(params: UpdateProfileInput!): Response! adminUpdateUser(params: AdminUpdateUserInput!): User! diff --git a/server/graph/schema.resolvers.go b/server/graph/schema.resolvers.go index ae1f687..ea1a1fc 100644 --- a/server/graph/schema.resolvers.go +++ b/server/graph/schema.resolvers.go @@ -19,6 +19,10 @@ func (r *mutationResolver) Login(ctx context.Context, params model.LoginInput) ( return resolvers.Login(ctx, params) } +func (r *mutationResolver) MagicLogin(ctx context.Context, params model.MagicLoginInput) (*model.Response, error) { + return resolvers.MagicLogin(ctx, params) +} + func (r *mutationResolver) Logout(ctx context.Context) (*model.Response, error) { return resolvers.Logout(ctx) } diff --git a/server/handlers/oauthCallback.go b/server/handlers/oauthCallback.go index 948bdbf..8455bbf 100644 --- a/server/handlers/oauthCallback.go +++ b/server/handlers/oauthCallback.go @@ -208,7 +208,7 @@ func OAuthCallbackHandler() gin.HandlerFunc { signupMethod := existingUser.SignupMethod if !strings.Contains(signupMethod, provider) { - signupMethod = signupMethod + "," + enum.Github.String() + signupMethod = signupMethod + "," + provider } user.SignupMethod = signupMethod user.Password = existingUser.Password diff --git a/server/handlers/verifyEmail.go b/server/handlers/verifyEmail.go index 0e1f9d9..00ccff3 100644 --- a/server/handlers/verifyEmail.go +++ b/server/handlers/verifyEmail.go @@ -46,7 +46,9 @@ func VerifyEmailHandler() gin.HandlerFunc { } // update email_verified_at in users table - db.Mgr.UpdateVerificationTime(time.Now().Unix(), user.ID) + if user.EmailVerifiedAt <= 0 { + db.Mgr.UpdateVerificationTime(time.Now().Unix(), user.ID) + } // delete from verification table db.Mgr.DeleteToken(claim.Email) diff --git a/server/resolvers/magicLogin.go b/server/resolvers/magicLogin.go new file mode 100644 index 0000000..e17bde3 --- /dev/null +++ b/server/resolvers/magicLogin.go @@ -0,0 +1,122 @@ +package resolvers + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/authorizerdev/authorizer/server/constants" + "github.com/authorizerdev/authorizer/server/db" + "github.com/authorizerdev/authorizer/server/enum" + "github.com/authorizerdev/authorizer/server/graph/model" + "github.com/authorizerdev/authorizer/server/utils" +) + +func MagicLogin(ctx context.Context, params model.MagicLoginInput) (*model.Response, error) { + var res *model.Response + + if constants.DISABLE_MAGIC_LOGIN == "true" { + return res, fmt.Errorf(`magic link login is disabled for this instance`) + } + + params.Email = strings.ToLower(params.Email) + + if !utils.IsValidEmail(params.Email) { + return res, fmt.Errorf(`invalid email address`) + } + + inputRoles := []string{} + + user := db.User{ + Email: params.Email, + } + + // find user with email + existingUser, err := db.Mgr.GetUserByEmail(params.Email) + if err != nil { + user.SignupMethod = enum.MagicLink.String() + // define roles for new user + if len(params.Roles) > 0 { + // check if roles exists + if !utils.IsValidRoles(constants.ROLES, params.Roles) { + return res, fmt.Errorf(`invalid roles`) + } else { + inputRoles = params.Roles + } + } else { + inputRoles = constants.DEFAULT_ROLES + } + + user.Roles = strings.Join(inputRoles, ",") + } else { + user = existingUser + // There multiple scenarios with roles here in magic link login + // 1. user has access to protected roles + roles and trying to login + // 2. user has not signed up for one of the available role but trying to signup. + // Need to modify roles in this case + + // find the unassigned roles + existingRoles := strings.Split(existingUser.Roles, ",") + unasignedRoles := []string{} + for _, ir := range inputRoles { + if !utils.StringSliceContains(existingRoles, ir) { + unasignedRoles = append(unasignedRoles, ir) + } + } + + if len(unasignedRoles) > 0 { + // check if it contains protected unassigned role + hasProtectedRole := false + for _, ur := range unasignedRoles { + if utils.StringSliceContains(constants.PROTECTED_ROLES, ur) { + hasProtectedRole = true + } + } + + if hasProtectedRole { + return res, fmt.Errorf(`invalid roles`) + } else { + user.Roles = existingUser.Roles + "," + strings.Join(unasignedRoles, ",") + } + } else { + user.Roles = existingUser.Roles + } + + signupMethod := existingUser.SignupMethod + if !strings.Contains(signupMethod, enum.MagicLink.String()) { + signupMethod = signupMethod + "," + enum.MagicLink.String() + } + + user.SignupMethod = signupMethod + } + + user, _ = db.Mgr.SaveUser(user) + + if constants.DISABLE_EMAIL_VERIFICATION != "true" { + // insert verification request + verificationType := enum.MagicLink.String() + token, err := utils.CreateVerificationToken(params.Email, verificationType) + if err != nil { + log.Println(`Error generating token`, err) + } + db.Mgr.AddVerification(db.VerificationRequest{ + Token: token, + Identifier: verificationType, + ExpiresAt: time.Now().Add(time.Minute * 30).Unix(), + Email: params.Email, + }) + + // exec it as go routin so that we can reduce the api latency + go func() { + utils.SendVerificationMail(params.Email, token) + }() + } + + res = &model.Response{ + Message: `Verification request has been sent. Please check your inbox!`, + } + + return res, nil +} diff --git a/server/utils/email.go b/server/utils/email.go index a8f5b58..29c1ebc 100644 --- a/server/utils/email.go +++ b/server/utils/email.go @@ -74,7 +74,7 @@ func SendVerificationMail(toEmail, token string) error {

Hey there 👋

-

We received a request to sign-up for %s. If this is correct, please confirm your email address by clicking the button below.


+

We received a request to sign-up / login for %s. If this is correct, please confirm your email address by clicking the button below.


Confirm Email diff --git a/server/utils/meta.go b/server/utils/meta.go index d1c6753..3edb24f 100644 --- a/server/utils/meta.go +++ b/server/utils/meta.go @@ -16,5 +16,6 @@ func GetMetaInfo() model.Meta { IsTwitterLoginEnabled: constants.TWITTER_CLIENT_ID != "" && constants.TWITTER_CLIENT_SECRET != "", IsBasicAuthenticationEnabled: constants.DISABLE_BASIC_AUTHENTICATION != "true", IsEmailVerificationEnabled: constants.DISABLE_EMAIL_VERIFICATION != "true", + IsMagicLoginEnabled: constants.DISABLE_MAGIC_LOGIN != "true", } }