From e1ac992e5a65b49678187303840e79b568feea87 Mon Sep 17 00:00:00 2001 From: Karan Balani <29383381+balanikaran@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:31:15 +0530 Subject: [PATCH] feat: forgot password api and token expiry (#10073) --- .vscode/settings.json | 4 +- conf/example.yaml | 9 + docs/api/openapi.yml | 41 +++ pkg/apiserver/signozapiserver/user.go | 17 ++ pkg/modules/user/config.go | 43 +++ pkg/modules/user/impluser/handler.go | 19 ++ pkg/modules/user/impluser/module.go | 84 +++++- pkg/modules/user/impluser/store.go | 12 + pkg/modules/user/user.go | 4 + pkg/signoz/config.go | 5 + pkg/signoz/module.go | 2 +- pkg/signoz/provider.go | 1 + .../058_add_reset_password_token_expiry.go | 83 ++++++ pkg/types/emailtypes/template.go | 5 +- pkg/types/factor_password.go | 14 +- pkg/types/user.go | 1 + templates/email/reset_password_email.gotmpl | 13 + tests/integration/fixtures/signoz.py | 2 + .../src/passwordauthn/04_password.py | 260 ++++++++++++++++++ 19 files changed, 604 insertions(+), 15 deletions(-) create mode 100644 pkg/modules/user/config.go create mode 100644 pkg/sqlmigration/058_add_reset_password_token_expiry.go create mode 100644 templates/email/reset_password_email.gotmpl 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

+ + diff --git a/tests/integration/fixtures/signoz.py b/tests/integration/fixtures/signoz.py index fd99e40846..be3b0662af 100644 --- a/tests/integration/fixtures/signoz.py +++ b/tests/integration/fixtures/signoz.py @@ -67,6 +67,8 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments "SIGNOZ_GATEWAY_URL": gateway.container_configs["8080"].base(), "SIGNOZ_TOKENIZER_JWT_SECRET": "secret", "SIGNOZ_GLOBAL_INGESTION__URL": "https://ingest.test.signoz.cloud", + "SIGNOZ_USER_PASSWORD_RESET_ALLOW__SELF": True, + "SIGNOZ_USER_PASSWORD_RESET_MAX__TOKEN__LIFETIME": "6h", } | sqlstore.env | clickhouse.env diff --git a/tests/integration/src/passwordauthn/04_password.py b/tests/integration/src/passwordauthn/04_password.py index 1b87b235ad..9739e9dfa4 100644 --- a/tests/integration/src/passwordauthn/04_password.py +++ b/tests/integration/src/passwordauthn/04_password.py @@ -7,6 +7,8 @@ from sqlalchemy import sql from fixtures import types from fixtures.logger import setup_logger +from datetime import datetime, timedelta, timezone + logger = setup_logger(__name__) @@ -240,3 +242,261 @@ def test_reset_password_with_no_password( token = get_token("admin+password@integration.test", "FINALPASSword123!#[") assert token is not None + +def test_forgot_password_returns_204_for_nonexistent_email( + signoz: types.SigNoz, +) -> None: + """ + Test that forgotPassword returns 204 even for non-existent emails + (for security reasons - doesn't reveal if user exists). + """ + # Get org ID first (needed for the forgot password request) + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/sessions/context"), + params={ + "email": "admin@integration.test", + "ref": f"{signoz.self.host_configs['8080'].base()}", + }, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK + org_id = response.json()["data"]["orgs"][0]["id"] + + # Call forgot password with a non-existent email + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/factor_password/forgot"), + json={ + "email": "nonexistent@integration.test", + "orgId": org_id, + "frontendBaseURL": signoz.self.host_configs["8080"].base(), + }, + timeout=5, + ) + + # Should return 204 even for non-existent email (security) + assert response.status_code == HTTPStatus.NO_CONTENT + + +def test_forgot_password_creates_reset_token( + signoz: types.SigNoz, get_token: Callable[[str, str], str] +) -> None: + """ + Test the full forgot password flow: + 1. Call forgotPassword endpoint for existing user + 2. Verify reset password token is created in database + 3. Use the token to reset password + 4. Verify user can login with new password + """ + admin_token = get_token("admin@integration.test", "password123Z$") + + # Create a user specifically for testing forgot password + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v1/invite"), + json={"email": "forgot@integration.test", "role": "EDITOR", "name": "forgotpassword user"}, + timeout=2, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == HTTPStatus.CREATED + + # Get the invite token + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v1/invite"), + timeout=2, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + invite_response = response.json()["data"] + found_invite = next( + ( + invite + for invite in invite_response + if invite["email"] == "forgot@integration.test" + ), + None, + ) + + # Accept the invite to create the user + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v1/invite/accept"), + json={ + "password": "originalPassword123Z$", + "displayName": "forgotpassword user", + "token": f"{found_invite['token']}", + }, + timeout=2, + ) + assert response.status_code == HTTPStatus.CREATED + + # Get org ID + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/sessions/context"), + params={ + "email": "forgot@integration.test", + "ref": f"{signoz.self.host_configs['8080'].base()}", + }, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK + org_id = response.json()["data"]["orgs"][0]["id"] + + # Call forgot password endpoint + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/factor_password/forgot"), + json={ + "email": "forgot@integration.test", + "orgId": org_id, + "frontendBaseURL": signoz.self.host_configs["8080"].base(), + }, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT + + # Verify reset password token was created by querying the database + # First, get the user ID + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v1/user"), + timeout=2, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == HTTPStatus.OK + user_response = response.json()["data"] + found_user = next( + ( + user + for user in user_response + if user["email"] == "forgot@integration.test" + ), + None, + ) + assert found_user is not None + + reset_token = None + # Query the database directly to get the reset password token + # First get the password_id from factor_password, then get the token + with signoz.sqlstore.conn.connect() as conn: + result = conn.execute( + sql.text(""" + SELECT rpt.token + FROM reset_password_token rpt + JOIN factor_password fp ON rpt.password_id = fp.id + WHERE fp.user_id = :user_id + """), + {"user_id": found_user["id"]}, + ) + row = result.fetchone() + assert row is not None, "Reset password token should exist after calling forgotPassword" + reset_token = row[0] + + assert reset_token is not None + assert reset_token != "" + + # Reset password with a valid strong password + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v1/resetPassword"), + json={"password": "newSecurePassword123Z$!", "token": reset_token}, + timeout=2, + ) + assert response.status_code == HTTPStatus.NO_CONTENT + + # Verify user can login with the new password + user_token = get_token("forgot@integration.test", "newSecurePassword123Z$!") + assert user_token is not None + + # Verify old password no longer works + try: + get_token("forgot@integration.test", "originalPassword123Z$") + assert False, "Old password should not work after reset" + except AssertionError: + pass # Expected - old password should fail + + +def test_reset_password_with_expired_token( + signoz: types.SigNoz, get_token: Callable[[str, str], str] +) -> None: + """ + Test that resetting password with an expired token fails. + """ + admin_token = get_token("admin@integration.test", "password123Z$") + + # Get user ID for the forgot@integration.test user + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v1/user"), + timeout=2, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == HTTPStatus.OK + user_response = response.json()["data"] + found_user = next( + ( + user + for user in user_response + if user["email"] == "forgot@integration.test" + ), + None, + ) + assert found_user is not None + + # Get org ID + response = requests.get( + signoz.self.host_configs["8080"].get("/api/v2/sessions/context"), + params={ + "email": "forgot@integration.test", + "ref": f"{signoz.self.host_configs['8080'].base()}", + }, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK + org_id = response.json()["data"]["orgs"][0]["id"] + + # Call forgot password to generate a new token + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v2/factor_password/forgot"), + json={ + "email": "forgot@integration.test", + "orgId": org_id, + "frontendBaseURL": signoz.self.host_configs["8080"].base(), + }, + timeout=5, + ) + assert response.status_code == HTTPStatus.NO_CONTENT + + # Query the database to get the token and then expire it + reset_token = None + with signoz.sqlstore.conn.connect() as conn: + # First get the token + result = conn.execute( + sql.text(""" + SELECT rpt.token, rpt.id + FROM reset_password_token rpt + JOIN factor_password fp ON rpt.password_id = fp.id + WHERE fp.user_id = :user_id + """), + {"user_id": found_user["id"]}, + ) + row = result.fetchone() + assert row is not None, "Reset password token should exist" + reset_token = row[0] + token_id = row[1] + + # Now expire the token by setting expires_at to a past time + conn.execute( + sql.text(""" + UPDATE reset_password_token + SET expires_at = :expired_time + WHERE id = :token_id + """), + { + "expired_time": "2020-01-01 00:00:00", + "token_id": token_id, + }, + ) + conn.commit() + + assert reset_token is not None + + # Try to use the expired token - should fail with 401 Unauthorized + response = requests.post( + signoz.self.host_configs["8080"].get("/api/v1/resetPassword"), + json={"password": "expiredTokenPassword123Z$!", "token": reset_token}, + timeout=2, + ) + assert response.status_code == HTTPStatus.UNAUTHORIZED