feat: forgot password api and token expiry (#10073)

This commit is contained in:
Karan Balani
2026-01-27 15:31:15 +05:30
committed by GitHub
parent 15161c09e8
commit e1ac992e5a
19 changed files with 604 additions and 15 deletions

View File

@@ -1,5 +1,7 @@
{ {
"eslint.workingDirectories": ["./frontend"], "eslint.workingDirectories": [
"./frontend"
],
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {

View File

@@ -291,3 +291,12 @@ flagger:
float: float:
integer: integer:
object: 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

View File

@@ -1985,6 +1985,35 @@ paths:
summary: Update user preference summary: Update user preference
tags: tags:
- preferences - 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: /api/v2/features:
get: get:
deprecated: false deprecated: false
@@ -3979,6 +4008,15 @@ components:
token: token:
type: string type: string
type: object type: object
TypesPostableForgotPassword:
properties:
email:
type: string
frontendBaseURL:
type: string
orgId:
type: string
type: object
TypesPostableInvite: TypesPostableInvite:
properties: properties:
email: email:
@@ -3999,6 +4037,9 @@ components:
type: object type: object
TypesResetPasswordToken: TypesResetPasswordToken:
properties: properties:
expiresAt:
format: date-time
type: string
id: id:
type: string type: string
passwordId: passwordId:

View File

@@ -315,5 +315,22 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err 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 return nil
} }

View File

@@ -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
}

View File

@@ -332,6 +332,25 @@ func (handler *handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
render.Success(w, http.StatusNoContent, nil) 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) { func (h *handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel() defer cancel()

View File

@@ -12,11 +12,13 @@ import (
"github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization" "github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/user"
root "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/tokenizer"
"github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/emailtypes" "github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/valuer" "github.com/SigNoz/signoz/pkg/valuer"
"github.com/dustin/go-humanize"
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "golang.org/x/text/language"
) )
@@ -28,10 +30,11 @@ type Module struct {
settings factory.ScopedProviderSettings settings factory.ScopedProviderSettings
orgSetter organization.Setter orgSetter organization.Setter
analytics analytics.Analytics analytics analytics.Analytics
config user.Config
} }
// This module is a WIP, don't take inspiration from this. // 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") settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser")
return &Module{ return &Module{
store: store, store: store,
@@ -40,6 +43,7 @@ func NewModule(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
settings: settings, settings: settings,
orgSetter: orgSetter, orgSetter: orgSetter,
analytics: analytics, 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 { if err != nil {
return nil, err return nil, err
} }
// create a new token
err = module.store.CreateResetPasswordToken(ctx, resetPasswordToken) err = module.store.CreateResetPasswordToken(ctx, resetPasswordToken)
if err != nil { if err != nil {
if !errors.Ast(err, errors.TypeAlreadyExists) { return nil, err
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 resetPasswordToken, nil 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 { func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, token string, passwd string) error {
resetPasswordToken, err := module.store.GetResetPasswordToken(ctx, token) resetPasswordToken, err := module.store.GetResetPasswordToken(ctx, token)
if err != nil { if err != nil {
return err 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) password, err := module.store.GetPassword(ctx, resetPasswordToken.PasswordID)
if err != nil { if err != nil {
return err return err

View File

@@ -391,6 +391,18 @@ func (store *store) GetResetPasswordTokenByPasswordID(ctx context.Context, passw
return resetPasswordToken, nil 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) { func (store *store) GetResetPasswordToken(ctx context.Context, token string) (*types.ResetPasswordToken, error) {
resetPasswordRequest := new(types.ResetPasswordToken) resetPasswordRequest := new(types.ResetPasswordToken)

View File

@@ -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. // 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 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) 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 DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error
@@ -92,6 +95,7 @@ type Handler interface {
GetResetPasswordToken(http.ResponseWriter, *http.Request) GetResetPasswordToken(http.ResponseWriter, *http.Request)
ResetPassword(http.ResponseWriter, *http.Request) ResetPassword(http.ResponseWriter, *http.Request)
ChangePassword(http.ResponseWriter, *http.Request) ChangePassword(http.ResponseWriter, *http.Request)
ForgotPassword(http.ResponseWriter, *http.Request)
// API KEY // API KEY
CreateAPIKey(http.ResponseWriter, *http.Request) CreateAPIKey(http.ResponseWriter, *http.Request)

View File

@@ -22,6 +22,7 @@ import (
"github.com/SigNoz/signoz/pkg/global" "github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/instrumentation" "github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer" "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/prometheus"
"github.com/SigNoz/signoz/pkg/querier" "github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler" "github.com/SigNoz/signoz/pkg/ruler"
@@ -109,6 +110,9 @@ type Config struct {
// Flagger config // Flagger config
Flagger flagger.Config `mapstructure:"flagger"` Flagger flagger.Config `mapstructure:"flagger"`
// User config
User user.Config `mapstructure:"user"`
} }
// DeprecatedFlags are the flags that are deprecated and scheduled for removal. // 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(), tokenizer.NewConfigFactory(),
metricsexplorer.NewConfigFactory(), metricsexplorer.NewConfigFactory(),
flagger.NewConfigFactory(), flagger.NewConfigFactory(),
user.NewConfigFactory(),
} }
conf, err := config.New(ctx, resolverConfig, configFactories) conf, err := config.New(ctx, resolverConfig, configFactories)

View File

@@ -88,7 +88,7 @@ func NewModules(
) Modules { ) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore)) quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter) 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)) userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings))
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings) ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)

View File

@@ -161,6 +161,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewUpdateUserPreferenceFactory(sqlstore, sqlschema), sqlmigration.NewUpdateUserPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewUpdateOrgPreferenceFactory(sqlstore, sqlschema), sqlmigration.NewUpdateOrgPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewRenameOrgDomainsFactory(sqlstore, sqlschema), sqlmigration.NewRenameOrgDomainsFactory(sqlstore, sqlschema),
sqlmigration.NewAddResetPasswordTokenExpiryFactory(sqlstore, sqlschema),
) )
} }

View File

@@ -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
}

View File

@@ -12,12 +12,13 @@ import (
var ( var (
// Templates is a list of all the templates that are supported by the emailing service. // 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. // This list should be updated whenever a new template is added.
Templates = []TemplateName{TemplateNameInvitationEmail, TemplateNameUpdateRole} Templates = []TemplateName{TemplateNameInvitationEmail, TemplateNameUpdateRole, TemplateNameResetPassword}
) )
var ( var (
TemplateNameInvitationEmail = TemplateName{valuer.NewString("invitation_email")} TemplateNameInvitationEmail = TemplateName{valuer.NewString("invitation_email")}
TemplateNameUpdateRole = TemplateName{valuer.NewString("update_role")} TemplateNameUpdateRole = TemplateName{valuer.NewString("update_role")}
TemplateNameResetPassword = TemplateName{valuer.NewString("reset_password_email")}
) )
type TemplateName struct{ valuer.String } type TemplateName struct{ valuer.String }
@@ -28,6 +29,8 @@ func NewTemplateName(name string) (TemplateName, error) {
return TemplateNameInvitationEmail, nil return TemplateNameInvitationEmail, nil
case TemplateNameUpdateRole.StringValue(): case TemplateNameUpdateRole.StringValue():
return TemplateNameUpdateRole, nil return TemplateNameUpdateRole, nil
case TemplateNameResetPassword.StringValue():
return TemplateNameResetPassword, nil
default: default:
return TemplateName{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid template name: %s", name) return TemplateName{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid template name: %s", name)
} }

View File

@@ -35,12 +35,19 @@ type ChangePasswordRequest struct {
NewPassword string `json:"newPassword"` NewPassword string `json:"newPassword"`
} }
type PostableForgotPassword struct {
OrgID valuer.UUID `json:"orgId"`
Email valuer.Email `json:"email"`
FrontendBaseURL string `json:"frontendBaseURL"`
}
type ResetPasswordToken struct { type ResetPasswordToken struct {
bun.BaseModel `bun:"table:reset_password_token"` bun.BaseModel `bun:"table:reset_password_token"`
Identifiable Identifiable
Token string `bun:"token,type:text,notnull" json:"token"` Token string `bun:"token,type:text,notnull" json:"token"`
PasswordID valuer.UUID `bun:"password_id,type:text,notnull,unique" json:"passwordId"` 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 { type FactorPassword struct {
@@ -136,13 +143,14 @@ func NewHashedPassword(password string) (string, error) {
return string(hashedPassword), nil return string(hashedPassword), nil
} }
func NewResetPasswordToken(passwordID valuer.UUID) (*ResetPasswordToken, error) { func NewResetPasswordToken(passwordID valuer.UUID, expiresAt time.Time) (*ResetPasswordToken, error) {
return &ResetPasswordToken{ return &ResetPasswordToken{
Identifiable: Identifiable{ Identifiable: Identifiable{
ID: valuer.GenerateUUID(), ID: valuer.GenerateUUID(),
}, },
Token: valuer.GenerateUUID().String(), Token: valuer.GenerateUUID().String(),
PasswordID: passwordID, PasswordID: passwordID,
ExpiresAt: expiresAt,
}, nil }, nil
} }
@@ -208,3 +216,7 @@ func (f *FactorPassword) Equals(password string) bool {
func comparePassword(hashedPassword string, password string) bool { func comparePassword(hashedPassword string, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) == nil
} }
func (r *ResetPasswordToken) IsExpired() bool {
return r.ExpiresAt.Before(time.Now())
}

View File

@@ -143,6 +143,7 @@ type UserStore interface {
GetPasswordByUserID(ctx context.Context, userID valuer.UUID) (*FactorPassword, error) GetPasswordByUserID(ctx context.Context, userID valuer.UUID) (*FactorPassword, error)
GetResetPasswordToken(ctx context.Context, token string) (*ResetPasswordToken, error) GetResetPasswordToken(ctx context.Context, token string) (*ResetPasswordToken, error)
GetResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) (*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 UpdatePassword(ctx context.Context, password *FactorPassword) error
// API KEY // API KEY

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<body>
<p>Hello {{.Name}},</p>
<p>You requested a password reset for your SigNoz account.</p>
<p>Click the link below to reset your password:</p>
<a href="{{.Link}}">Reset Password</a>
<p>This link will expire in {{.Expiry}}.</p>
<p>If you didn't request this, please ignore this email. Your password will remain unchanged.</p>
<br>
<p>Best regards,<br>The SigNoz Team</p>
</body>
</html>

View File

@@ -67,6 +67,8 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
"SIGNOZ_GATEWAY_URL": gateway.container_configs["8080"].base(), "SIGNOZ_GATEWAY_URL": gateway.container_configs["8080"].base(),
"SIGNOZ_TOKENIZER_JWT_SECRET": "secret", "SIGNOZ_TOKENIZER_JWT_SECRET": "secret",
"SIGNOZ_GLOBAL_INGESTION__URL": "https://ingest.test.signoz.cloud", "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 | sqlstore.env
| clickhouse.env | clickhouse.env

View File

@@ -7,6 +7,8 @@ from sqlalchemy import sql
from fixtures import types from fixtures import types
from fixtures.logger import setup_logger from fixtures.logger import setup_logger
from datetime import datetime, timedelta, timezone
logger = setup_logger(__name__) logger = setup_logger(__name__)
@@ -240,3 +242,261 @@ def test_reset_password_with_no_password(
token = get_token("admin+password@integration.test", "FINALPASSword123!#[") token = get_token("admin+password@integration.test", "FINALPASSword123!#[")
assert token is not None 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