mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-03 08:33:26 +00:00
feat: forgot password api and token expiry (#10073)
This commit is contained in:
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
43
pkg/modules/user/config.go
Normal file
43
pkg/modules/user/config.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
83
pkg/sqlmigration/058_add_reset_password_token_expiry.go
Normal file
83
pkg/sqlmigration/058_add_reset_password_token_expiry.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
13
templates/email/reset_password_email.gotmpl
Normal file
13
templates/email/reset_password_email.gotmpl
Normal 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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user