diff --git a/.vscode/settings.json b/.vscode/settings.json index 19022660d3..b132be53a3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,7 @@ { - "eslint.workingDirectories": ["./frontend"], + "eslint.workingDirectories": [ + "./frontend" + ], "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.codeActionsOnSave": { diff --git a/conf/example.yaml b/conf/example.yaml index 7813e96c01..bf37e03698 100644 --- a/conf/example.yaml +++ b/conf/example.yaml @@ -291,3 +291,12 @@ flagger: float: integer: object: + +##################### User ##################### +user: + password: + reset: + # Whether to allow users to reset their password themselves. + allow_self: true + # The duration within which a user can reset their password. + max_token_lifetime: 6h diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index 48bad98eb2..53d5fe8f25 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -1985,6 +1985,35 @@ paths: summary: Update user preference tags: - preferences + /api/v2/factor_password/forgot: + post: + deprecated: false + description: This endpoint initiates the forgot password flow by sending a reset + password email + operationId: ForgotPassword + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TypesPostableForgotPassword' + responses: + "204": + description: No Content + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Bad Request + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/RenderErrorResponse' + description: Internal Server Error + summary: Forgot password + tags: + - users /api/v2/features: get: deprecated: false @@ -3979,6 +4008,15 @@ components: token: type: string type: object + TypesPostableForgotPassword: + properties: + email: + type: string + frontendBaseURL: + type: string + orgId: + type: string + type: object TypesPostableInvite: properties: email: @@ -3999,6 +4037,9 @@ components: type: object TypesResetPasswordToken: properties: + expiresAt: + format: date-time + type: string id: type: string passwordId: diff --git a/pkg/apiserver/signozapiserver/user.go b/pkg/apiserver/signozapiserver/user.go index 6d0a3d33e3..ddb4268e40 100644 --- a/pkg/apiserver/signozapiserver/user.go +++ b/pkg/apiserver/signozapiserver/user.go @@ -315,5 +315,22 @@ func (provider *provider) addUserRoutes(router *mux.Router) error { return err } + if err := router.Handle("/api/v2/factor_password/forgot", handler.New(provider.authZ.OpenAccess(provider.userHandler.ForgotPassword), handler.OpenAPIDef{ + ID: "ForgotPassword", + Tags: []string{"users"}, + Summary: "Forgot password", + Description: "This endpoint initiates the forgot password flow by sending a reset password email", + Request: new(types.PostableForgotPassword), + RequestContentType: "application/json", + Response: nil, + ResponseContentType: "", + SuccessStatusCode: http.StatusNoContent, + ErrorStatusCodes: []int{http.StatusBadRequest}, + Deprecated: false, + SecuritySchemes: []handler.OpenAPISecurityScheme{}, + })).Methods(http.MethodPost).GetError(); err != nil { + return err + } + return nil } diff --git a/pkg/modules/user/config.go b/pkg/modules/user/config.go new file mode 100644 index 0000000000..6a57782195 --- /dev/null +++ b/pkg/modules/user/config.go @@ -0,0 +1,43 @@ +package user + +import ( + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/factory" +) + +type Config struct { + Password PasswordConfig `mapstructure:"password"` +} +type PasswordConfig struct { + Reset ResetConfig `mapstructure:"reset"` +} + +type ResetConfig struct { + AllowSelf bool `mapstructure:"allow_self"` + MaxTokenLifetime time.Duration `mapstructure:"max_token_lifetime"` +} + +func NewConfigFactory() factory.ConfigFactory { + return factory.NewConfigFactory(factory.MustNewName("user"), newConfig) +} + +func newConfig() factory.Config { + return &Config{ + Password: PasswordConfig{ + Reset: ResetConfig{ + AllowSelf: false, + MaxTokenLifetime: 6 * time.Hour, + }, + }, + } +} + +func (c Config) Validate() error { + if c.Password.Reset.MaxTokenLifetime <= 0 { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::password::reset::max_token_lifetime must be positive") + } + + return nil +} diff --git a/pkg/modules/user/impluser/handler.go b/pkg/modules/user/impluser/handler.go index 63e9d44baf..e572f6ba27 100644 --- a/pkg/modules/user/impluser/handler.go +++ b/pkg/modules/user/impluser/handler.go @@ -332,6 +332,25 @@ func (handler *handler) ChangePassword(w http.ResponseWriter, r *http.Request) { render.Success(w, http.StatusNoContent, nil) } +func (h *handler) ForgotPassword(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + req := new(types.PostableForgotPassword) + if err := binding.JSON.BindBody(r.Body, req); err != nil { + render.Error(w, err) + return + } + + err := h.module.ForgotPassword(ctx, req.OrgID, req.Email, req.FrontendBaseURL) + if err != nil { + render.Error(w, err) + return + } + + render.Success(w, http.StatusNoContent, nil) +} + func (h *handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() diff --git a/pkg/modules/user/impluser/module.go b/pkg/modules/user/impluser/module.go index ed503df9f1..d75e5c395c 100644 --- a/pkg/modules/user/impluser/module.go +++ b/pkg/modules/user/impluser/module.go @@ -12,11 +12,13 @@ import ( "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/modules/organization" + "github.com/SigNoz/signoz/pkg/modules/user" root "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/tokenizer" "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/emailtypes" "github.com/SigNoz/signoz/pkg/valuer" + "github.com/dustin/go-humanize" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -28,10 +30,11 @@ type Module struct { settings factory.ScopedProviderSettings orgSetter organization.Setter analytics analytics.Analytics + config user.Config } // This module is a WIP, don't take inspiration from this. -func NewModule(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, analytics analytics.Analytics) root.Module { +func NewModule(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, analytics analytics.Analytics, config user.Config) root.Module { settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser") return &Module{ store: store, @@ -40,6 +43,7 @@ func NewModule(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em settings: settings, orgSetter: orgSetter, analytics: analytics, + config: config, } } @@ -302,33 +306,91 @@ func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, userID } } - resetPasswordToken, err := types.NewResetPasswordToken(password.ID) + // check if a token already exists for this password id + existingResetPasswordToken, err := module.store.GetResetPasswordTokenByPasswordID(ctx, password.ID) + if err != nil && !errors.Ast(err, errors.TypeNotFound) { + return nil, err // return the error if it is not a not found error + } + + // return the existing token if it is not expired + if existingResetPasswordToken != nil && !existingResetPasswordToken.IsExpired() { + return existingResetPasswordToken, nil // return the existing token if it is not expired + } + + // delete the existing token entry + if existingResetPasswordToken != nil { + if err := module.store.DeleteResetPasswordTokenByPasswordID(ctx, password.ID); err != nil { + return nil, err + } + } + + // create a new token + resetPasswordToken, err := types.NewResetPasswordToken(password.ID, time.Now().Add(module.config.Password.Reset.MaxTokenLifetime)) if err != nil { return nil, err } + // create a new token err = module.store.CreateResetPasswordToken(ctx, resetPasswordToken) if err != nil { - if !errors.Ast(err, errors.TypeAlreadyExists) { - return nil, err - } - - // if the token already exists, we return the existing token - resetPasswordToken, err = module.store.GetResetPasswordTokenByPasswordID(ctx, password.ID) - if err != nil { - return nil, err - } + return nil, err } return resetPasswordToken, nil } +func (module *Module) ForgotPassword(ctx context.Context, orgID valuer.UUID, email valuer.Email, frontendBaseURL string) error { + if !module.config.Password.Reset.AllowSelf { + return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "users are not allowed to reset their password themselves, please contact an admin to reset your password") + } + + user, err := module.store.GetUserByEmailAndOrgID(ctx, email, orgID) + if err != nil { + if errors.Ast(err, errors.TypeNotFound) { + return nil // for security reasons + } + return err + } + + token, err := module.GetOrCreateResetPasswordToken(ctx, user.ID) + if err != nil { + module.settings.Logger().ErrorContext(ctx, "failed to create reset password token", "error", err) + return err + } + + resetLink := fmt.Sprintf("%s/password-reset?token=%s", frontendBaseURL, token.Token) + + tokenLifetime := module.config.Password.Reset.MaxTokenLifetime + humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", "")) + + if err := module.emailing.SendHTML( + ctx, + user.Email.String(), + "Reset your SigNoz password", + emailtypes.TemplateNameResetPassword, + map[string]any{ + "Name": user.DisplayName, + "Link": resetLink, + "Expiry": humanizedTokenLifetime, + }, + ); err != nil { + module.settings.Logger().ErrorContext(ctx, "failed to send reset password email", "error", err) + return nil + } + + return nil +} + func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, token string, passwd string) error { resetPasswordToken, err := module.store.GetResetPasswordToken(ctx, token) if err != nil { return err } + if resetPasswordToken.IsExpired() { + return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "reset password token has expired") + } + password, err := module.store.GetPassword(ctx, resetPasswordToken.PasswordID) if err != nil { return err diff --git a/pkg/modules/user/impluser/store.go b/pkg/modules/user/impluser/store.go index c09b6bc4d0..ab1ff24b3e 100644 --- a/pkg/modules/user/impluser/store.go +++ b/pkg/modules/user/impluser/store.go @@ -391,6 +391,18 @@ func (store *store) GetResetPasswordTokenByPasswordID(ctx context.Context, passw return resetPasswordToken, nil } +func (store *store) DeleteResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) error { + _, err := store.sqlstore.BunDB().NewDelete(). + Model(&types.ResetPasswordToken{}). + Where("password_id = ?", passwordID). + Exec(ctx) + if err != nil { + return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete reset password token") + } + + return nil +} + func (store *store) GetResetPasswordToken(ctx context.Context, token string) (*types.ResetPasswordToken, error) { resetPasswordRequest := new(types.ResetPasswordToken) diff --git a/pkg/modules/user/user.go b/pkg/modules/user/user.go index 29bea96ed5..6ff595448c 100644 --- a/pkg/modules/user/user.go +++ b/pkg/modules/user/user.go @@ -30,6 +30,9 @@ type Module interface { // Updates password of user to the new password. It also deletes all reset password tokens for the user. UpdatePassword(ctx context.Context, userID valuer.UUID, oldPassword string, password string) error + // Initiate forgot password flow for a user + ForgotPassword(ctx context.Context, orgID valuer.UUID, email valuer.Email, frontendBaseURL string) error + UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.User, updatedBy string) (*types.User, error) DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error @@ -92,6 +95,7 @@ type Handler interface { GetResetPasswordToken(http.ResponseWriter, *http.Request) ResetPassword(http.ResponseWriter, *http.Request) ChangePassword(http.ResponseWriter, *http.Request) + ForgotPassword(http.ResponseWriter, *http.Request) // API KEY CreateAPIKey(http.ResponseWriter, *http.Request) diff --git a/pkg/signoz/config.go b/pkg/signoz/config.go index 9cf1158e7b..6d9e9dd33c 100644 --- a/pkg/signoz/config.go +++ b/pkg/signoz/config.go @@ -22,6 +22,7 @@ import ( "github.com/SigNoz/signoz/pkg/global" "github.com/SigNoz/signoz/pkg/instrumentation" "github.com/SigNoz/signoz/pkg/modules/metricsexplorer" + "github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/prometheus" "github.com/SigNoz/signoz/pkg/querier" "github.com/SigNoz/signoz/pkg/ruler" @@ -109,6 +110,9 @@ type Config struct { // Flagger config Flagger flagger.Config `mapstructure:"flagger"` + + // User config + User user.Config `mapstructure:"user"` } // DeprecatedFlags are the flags that are deprecated and scheduled for removal. @@ -171,6 +175,7 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R tokenizer.NewConfigFactory(), metricsexplorer.NewConfigFactory(), flagger.NewConfigFactory(), + user.NewConfigFactory(), } conf, err := config.New(ctx, resolverConfig, configFactories) diff --git a/pkg/signoz/module.go b/pkg/signoz/module.go index c33205393b..408d2f8d10 100644 --- a/pkg/signoz/module.go +++ b/pkg/signoz/module.go @@ -88,7 +88,7 @@ func NewModules( ) Modules { quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore)) orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter) - user := impluser.NewModule(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, analytics) + user := impluser.NewModule(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, analytics, config.User) userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings)) ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings) diff --git a/pkg/signoz/provider.go b/pkg/signoz/provider.go index 93e21565dd..753fff70f5 100644 --- a/pkg/signoz/provider.go +++ b/pkg/signoz/provider.go @@ -161,6 +161,7 @@ func NewSQLMigrationProviderFactories( sqlmigration.NewUpdateUserPreferenceFactory(sqlstore, sqlschema), sqlmigration.NewUpdateOrgPreferenceFactory(sqlstore, sqlschema), sqlmigration.NewRenameOrgDomainsFactory(sqlstore, sqlschema), + sqlmigration.NewAddResetPasswordTokenExpiryFactory(sqlstore, sqlschema), ) } diff --git a/pkg/sqlmigration/058_add_reset_password_token_expiry.go b/pkg/sqlmigration/058_add_reset_password_token_expiry.go new file mode 100644 index 0000000000..586187d908 --- /dev/null +++ b/pkg/sqlmigration/058_add_reset_password_token_expiry.go @@ -0,0 +1,83 @@ +package sqlmigration + +import ( + "context" + "time" + + "github.com/SigNoz/signoz/pkg/factory" + "github.com/SigNoz/signoz/pkg/sqlschema" + "github.com/SigNoz/signoz/pkg/sqlstore" + "github.com/uptrace/bun" + "github.com/uptrace/bun/migrate" +) + +type addResetPasswordTokenExpiry struct { + sqlstore sqlstore.SQLStore + sqlschema sqlschema.SQLSchema +} + +func NewAddResetPasswordTokenExpiryFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] { + return factory.NewProviderFactory(factory.MustNewName("add_reset_password_token_expiry"), func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) { + return newAddResetPasswordTokenExpiry(ctx, providerSettings, config, sqlstore, sqlschema) + }) +} + +func newAddResetPasswordTokenExpiry(_ context.Context, _ factory.ProviderSettings, _ Config, sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) (SQLMigration, error) { + return &addResetPasswordTokenExpiry{ + sqlstore: sqlstore, + sqlschema: sqlschema, + }, nil +} + +func (migration *addResetPasswordTokenExpiry) Register(migrations *migrate.Migrations) error { + if err := migrations.Register(migration.Up, migration.Down); err != nil { + return err + } + + return nil +} + +func (migration *addResetPasswordTokenExpiry) Up(ctx context.Context, db *bun.DB) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + + defer func() { + _ = tx.Rollback() + }() + + // get the reset_password_token table + table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("reset_password_token")) + if err != nil { + return err + } + + // add a new column `expires_at` + column := &sqlschema.Column{ + Name: sqlschema.ColumnName("expires_at"), + DataType: sqlschema.DataTypeTimestamp, + Nullable: true, + } + + // for existing rows set + defaultValueForExistingRows := time.Now() + + sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, column, defaultValueForExistingRows) + + for _, sql := range sqls { + if _, err := tx.ExecContext(ctx, string(sql)); err != nil { + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + + return nil +} + +func (migration *addResetPasswordTokenExpiry) Down(ctx context.Context, db *bun.DB) error { + return nil +} diff --git a/pkg/types/emailtypes/template.go b/pkg/types/emailtypes/template.go index 73f542a89c..942bf07d72 100644 --- a/pkg/types/emailtypes/template.go +++ b/pkg/types/emailtypes/template.go @@ -12,12 +12,13 @@ import ( var ( // Templates is a list of all the templates that are supported by the emailing service. // This list should be updated whenever a new template is added. - Templates = []TemplateName{TemplateNameInvitationEmail, TemplateNameUpdateRole} + Templates = []TemplateName{TemplateNameInvitationEmail, TemplateNameUpdateRole, TemplateNameResetPassword} ) var ( TemplateNameInvitationEmail = TemplateName{valuer.NewString("invitation_email")} TemplateNameUpdateRole = TemplateName{valuer.NewString("update_role")} + TemplateNameResetPassword = TemplateName{valuer.NewString("reset_password_email")} ) type TemplateName struct{ valuer.String } @@ -28,6 +29,8 @@ func NewTemplateName(name string) (TemplateName, error) { return TemplateNameInvitationEmail, nil case TemplateNameUpdateRole.StringValue(): return TemplateNameUpdateRole, nil + case TemplateNameResetPassword.StringValue(): + return TemplateNameResetPassword, nil default: return TemplateName{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid template name: %s", name) } diff --git a/pkg/types/factor_password.go b/pkg/types/factor_password.go index 4a5d45652d..acb49424b6 100644 --- a/pkg/types/factor_password.go +++ b/pkg/types/factor_password.go @@ -35,12 +35,19 @@ type ChangePasswordRequest struct { NewPassword string `json:"newPassword"` } +type PostableForgotPassword struct { + OrgID valuer.UUID `json:"orgId"` + Email valuer.Email `json:"email"` + FrontendBaseURL string `json:"frontendBaseURL"` +} + type ResetPasswordToken struct { bun.BaseModel `bun:"table:reset_password_token"` Identifiable Token string `bun:"token,type:text,notnull" json:"token"` PasswordID valuer.UUID `bun:"password_id,type:text,notnull,unique" json:"passwordId"` + ExpiresAt time.Time `bun:"expires_at,type:timestamptz,nullzero" json:"expiresAt"` } type FactorPassword struct { @@ -136,13 +143,14 @@ func NewHashedPassword(password string) (string, error) { return string(hashedPassword), nil } -func NewResetPasswordToken(passwordID valuer.UUID) (*ResetPasswordToken, error) { +func NewResetPasswordToken(passwordID valuer.UUID, expiresAt time.Time) (*ResetPasswordToken, error) { return &ResetPasswordToken{ Identifiable: Identifiable{ ID: valuer.GenerateUUID(), }, Token: valuer.GenerateUUID().String(), PasswordID: passwordID, + ExpiresAt: expiresAt, }, nil } @@ -208,3 +216,7 @@ func (f *FactorPassword) Equals(password string) bool { func comparePassword(hashedPassword string, password string) bool { return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil } + +func (r *ResetPasswordToken) IsExpired() bool { + return r.ExpiresAt.Before(time.Now()) +} diff --git a/pkg/types/user.go b/pkg/types/user.go index a1e29896de..5886324155 100644 --- a/pkg/types/user.go +++ b/pkg/types/user.go @@ -143,6 +143,7 @@ type UserStore interface { GetPasswordByUserID(ctx context.Context, userID valuer.UUID) (*FactorPassword, error) GetResetPasswordToken(ctx context.Context, token string) (*ResetPasswordToken, error) GetResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) (*ResetPasswordToken, error) + DeleteResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) error UpdatePassword(ctx context.Context, password *FactorPassword) error // API KEY diff --git a/templates/email/reset_password_email.gotmpl b/templates/email/reset_password_email.gotmpl new file mode 100644 index 0000000000..cdaaeceab8 --- /dev/null +++ b/templates/email/reset_password_email.gotmpl @@ -0,0 +1,13 @@ + + +
+Hello {{.Name}},
+You requested a password reset for your SigNoz account.
+Click the link below to reset your password:
+ Reset Password +This link will expire in {{.Expiry}}.
+If you didn't request this, please ignore this email. Your password will remain unchanged.
+Best regards,
The SigNoz Team