Vibhu Pandey 360e8309c8
feat(password): implement strong controls for password (#8983)
## 📄 Summary

implement strong controls for password. Now the password requirement is : 

password must be at least 12 characters long, should contain at least one uppercase letter [A-Z], one lowercase letter [a-z], one number [0-9], and one symbol
2025-09-04 17:22:28 +05:30

673 lines
20 KiB
Go

package impluser
import (
"context"
"fmt"
"net/url"
"slices"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
type Module struct {
store types.UserStore
jwt *authtypes.JWT
emailing emailing.Emailing
settings factory.ScopedProviderSettings
orgSetter organization.Setter
analytics analytics.Analytics
}
// This module is a WIP, don't take inspiration from this.
func NewModule(store types.UserStore, jwt *authtypes.JWT, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, analytics analytics.Analytics) root.Module {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser")
return &Module{
store: store,
jwt: jwt,
emailing: emailing,
settings: settings,
orgSetter: orgSetter,
analytics: analytics,
}
}
// CreateBulk implements invite.Module.
func (m *Module) CreateBulkInvite(ctx context.Context, orgID, userID string, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error) {
creator, err := m.GetUserByID(ctx, orgID, userID)
if err != nil {
return nil, err
}
invites := make([]*types.Invite, 0, len(bulkInvites.Invites))
for _, invite := range bulkInvites.Invites {
// check if user exists
existingUser, err := m.GetUserByEmailInOrg(ctx, orgID, invite.Email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingUser != nil {
return nil, errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "User already exists with the same email")
}
// Check if an invite already exists
existingInvite, err := m.GetInviteByEmailInOrg(ctx, orgID, invite.Email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingInvite != nil {
return nil, errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "An invite already exists for this email")
}
role, err := types.NewRole(invite.Role.String())
if err != nil {
return nil, err
}
newInvite, err := types.NewInvite(orgID, role.String(), invite.Name, invite.Email)
if err != nil {
return nil, err
}
newInvite.InviteLink = fmt.Sprintf("%s/signup?token=%s", invite.FrontendBaseUrl, newInvite.Token)
invites = append(invites, newInvite)
}
err = m.store.CreateBulkInvite(ctx, invites)
if err != nil {
return nil, err
}
for i := 0; i < len(invites); i++ {
m.analytics.TrackUser(ctx, orgID, creator.ID.String(), "Invite Sent", map[string]any{"invitee_email": invites[i].Email, "invitee_role": invites[i].Role})
// if the frontend base url is not provided, we don't send the email
if bulkInvites.Invites[i].FrontendBaseUrl == "" {
m.settings.Logger().InfoContext(ctx, "frontend base url is not provided, skipping email", "invitee_email", invites[i].Email)
continue
}
if err := m.emailing.SendHTML(ctx, invites[i].Email, "You are invited to join a team in SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
"CustomerName": invites[i].Name,
"InviterName": creator.DisplayName,
"InviterEmail": creator.Email,
"Link": fmt.Sprintf("%s/signup?token=%s", bulkInvites.Invites[i].FrontendBaseUrl, invites[i].Token),
}); err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to send email", "error", err)
}
}
return invites, nil
}
func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) {
return m.store.ListInvite(ctx, orgID)
}
func (m *Module) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error {
return m.store.DeleteInvite(ctx, orgID, id)
}
func (m *Module) GetInviteByToken(ctx context.Context, token string) (*types.GettableInvite, error) {
return m.store.GetInviteByToken(ctx, token)
}
func (m *Module) GetInviteByEmailInOrg(ctx context.Context, orgID string, email string) (*types.Invite, error) {
return m.store.GetInviteByEmailInOrg(ctx, orgID, email)
}
func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
createUserOpts := root.NewCreateUserOptions(opts...)
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.CreateUser(ctx, input); err != nil {
return err
}
if createUserOpts.FactorPassword != nil {
if err := module.store.CreatePassword(ctx, createUserOpts.FactorPassword); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
traitsOrProperties := types.NewTraitsFromUser(input)
module.analytics.IdentifyUser(ctx, input.OrgID, input.ID.String(), traitsOrProperties)
module.analytics.TrackUser(ctx, input.OrgID, input.ID.String(), "User Created", traitsOrProperties)
return nil
}
func (m *Module) GetUserByID(ctx context.Context, orgID string, id string) (*types.GettableUser, error) {
return m.store.GetUserByID(ctx, orgID, id)
}
func (m *Module) GetUserByEmailInOrg(ctx context.Context, orgID string, email string) (*types.GettableUser, error) {
return m.store.GetUserByEmailInOrg(ctx, orgID, email)
}
func (m *Module) GetUsersByEmail(ctx context.Context, email string) ([]*types.GettableUser, error) {
return m.store.GetUsersByEmail(ctx, email)
}
func (m *Module) GetUsersByRoleInOrg(ctx context.Context, orgID string, role types.Role) ([]*types.GettableUser, error) {
return m.store.GetUsersByRoleInOrg(ctx, orgID, role)
}
func (m *Module) ListUsers(ctx context.Context, orgID string) ([]*types.GettableUser, error) {
return m.store.ListUsers(ctx, orgID)
}
func (m *Module) UpdateUser(ctx context.Context, orgID string, id string, user *types.User, updatedBy string) (*types.User, error) {
existingUser, err := m.GetUserByID(ctx, orgID, id)
if err != nil {
return nil, err
}
requestor, err := m.GetUserByID(ctx, orgID, updatedBy)
if err != nil {
return nil, err
}
// only displayName, role can be updated
if user.DisplayName == "" {
user.DisplayName = existingUser.DisplayName
}
if user.Role == "" {
user.Role = existingUser.Role
}
if user.Role != existingUser.Role && requestor.Role != types.RoleAdmin.String() {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")
}
// Make sure that th e request is not demoting the last admin user.
// also an admin user can only change role of their own or other user
if user.Role != existingUser.Role && existingUser.Role == types.RoleAdmin.String() {
adminUsers, err := m.GetUsersByRoleInOrg(ctx, orgID, types.RoleAdmin)
if err != nil {
return nil, err
}
if len(adminUsers) == 1 {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot demote the last admin")
}
}
user.UpdatedAt = time.Now()
updatedUser, err := m.store.UpdateUser(ctx, orgID, id, user)
if err != nil {
return nil, err
}
traits := types.NewTraitsFromUser(updatedUser)
m.analytics.IdentifyUser(ctx, user.OrgID, user.ID.String(), traits)
traits["updated_by"] = updatedBy
m.analytics.TrackUser(ctx, user.OrgID, user.ID.String(), "User Updated", traits)
// if the role is updated then send an email
if existingUser.Role != updatedUser.Role {
if err := m.emailing.SendHTML(ctx, existingUser.Email, "Your Role Has Been Updated in SigNoz", emailtypes.TemplateNameUpdateRole, map[string]any{
"CustomerName": existingUser.DisplayName,
"UpdatedByEmail": requestor.Email,
"OldRole": cases.Title(language.English).String(strings.ToLower(existingUser.Role)),
"NewRole": cases.Title(language.English).String(strings.ToLower(updatedUser.Role)),
}); err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to send email", "error", err)
}
}
return updatedUser, nil
}
func (m *Module) DeleteUser(ctx context.Context, orgID string, id string, deletedBy string) error {
user, err := m.store.GetUserByID(ctx, orgID, id)
if err != nil {
return err
}
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(user.Email)) {
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "integration user cannot be deleted")
}
// don't allow to delete the last admin user
adminUsers, err := m.GetUsersByRoleInOrg(ctx, orgID, types.RoleAdmin)
if err != nil {
return err
}
if len(adminUsers) == 1 && user.Role == types.RoleAdmin.String() {
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot delete the last admin")
}
if err := m.store.DeleteUser(ctx, orgID, user.ID.StringValue()); err != nil {
return err
}
m.analytics.TrackUser(ctx, user.OrgID, user.ID.String(), "User Deleted", map[string]any{
"deleted_by": deletedBy,
})
return nil
}
func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, userID valuer.UUID) (*types.ResetPasswordToken, error) {
password, err := module.store.GetPasswordByUserID(ctx, userID)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
}
if password == nil {
// if the user does not have a password, we need to create a new one (common for SSO/SAML users)
password = types.MustGenerateFactorPassword(userID.String())
if err := module.store.CreatePassword(ctx, password); err != nil {
return nil, err
}
}
resetPasswordToken, err := types.NewResetPasswordToken(password.ID)
if err != nil {
return nil, err
}
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 resetPasswordToken, 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
}
password, err := module.store.GetPassword(ctx, resetPasswordToken.PasswordID)
if err != nil {
return err
}
if err := password.Update(passwd); err != nil {
return err
}
return module.store.UpdatePassword(ctx, password)
}
func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, oldpasswd string, passwd string) error {
password, err := module.store.GetPasswordByUserID(ctx, userID)
if err != nil {
return err
}
if !password.Equals(oldpasswd) {
return errors.New(errors.TypeInvalidInput, types.ErrCodeIncorrectPassword, "old password is incorrect")
}
if err := password.Update(passwd); err != nil {
return err
}
return module.store.UpdatePassword(ctx, password)
}
func (m *Module) GetAuthenticatedUser(ctx context.Context, orgID, email, password, refreshToken string) (*types.User, error) {
if refreshToken != "" {
// parse the refresh token
claims, err := m.jwt.Claims(refreshToken)
if err != nil {
return nil, err
}
user, err := m.store.GetUserByID(ctx, claims.OrgID, claims.UserID)
if err != nil {
return nil, err
}
return &user.User, nil
}
var dbUser *types.User
// when the orgID is not provided we login if the user exists in just one org
users, err := m.store.GetUsersByEmail(ctx, email)
if err != nil {
return nil, err
}
if len(users) == 0 {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "user with email: %s does not exist", email)
} else if len(users) == 1 {
dbUser = &users[0].User
} else {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "please provide an orgID")
}
existingPassword, err := m.store.GetPasswordByUserID(ctx, dbUser.ID)
if err != nil {
return nil, err
}
if !existingPassword.Equals(password) {
return nil, errors.New(errors.TypeInvalidInput, types.ErrCodeIncorrectPassword, "password is incorrect")
}
return dbUser, nil
}
func (m *Module) LoginPrecheck(ctx context.Context, orgID, email, sourceUrl string) (*types.GettableLoginPrecheck, error) {
// assume user is valid unless proven otherwise and assign default values for rest of the fields
resp := &types.GettableLoginPrecheck{IsUser: true, CanSelfRegister: false, SSO: false, SSOUrl: "", SSOError: ""}
// check if email is a valid user
users, err := m.GetUsersByEmail(ctx, email)
if err != nil {
return nil, err
}
if len(users) == 0 {
resp.IsUser = false
}
if len(users) > 1 {
resp.SelectOrg = true
resp.Orgs = make([]string, len(users))
for i, user := range users {
resp.Orgs[i] = user.OrgID
}
}
// TODO(Nitya): in multitenancy this should use orgId as well.
orgDomain, err := m.GetAuthDomainByEmail(ctx, email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if orgDomain != nil && orgDomain.SsoEnabled {
// this is to allow self registration
resp.IsUser = true
// saml is enabled for this domain, lets prepare sso url
if sourceUrl == "" {
sourceUrl = constants.GetDefaultSiteURL()
}
// parse source url that generated the login request
var err error
escapedUrl, _ := url.QueryUnescape(sourceUrl)
siteUrl, err := url.Parse(escapedUrl)
if err != nil {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse referer")
}
// build Idp URL that will authenticat the user
// the front-end will redirect user to this url
resp.SSOUrl, err = orgDomain.BuildSsoUrl(siteUrl)
if err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to prepare saml request for domain", "domain", orgDomain.Name, "error", err)
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "failed to prepare saml request for domain")
}
// set SSO to true, as the url is generated correctly
resp.SSO = true
}
return resp, nil
}
func (m *Module) GetJWTForUser(ctx context.Context, user *types.User) (types.GettableUserJwt, error) {
role, err := types.NewRole(user.Role)
if err != nil {
return types.GettableUserJwt{}, err
}
accessJwt, accessClaims, err := m.jwt.AccessToken(user.OrgID, user.ID.String(), user.Email, role)
if err != nil {
return types.GettableUserJwt{}, err
}
refreshJwt, refreshClaims, err := m.jwt.RefreshToken(user.OrgID, user.ID.String(), user.Email, role)
if err != nil {
return types.GettableUserJwt{}, err
}
return types.GettableUserJwt{
AccessJwt: accessJwt,
RefreshJwt: refreshJwt,
AccessJwtExpiry: accessClaims.ExpiresAt.Unix(),
RefreshJwtExpiry: refreshClaims.ExpiresAt.Unix(),
}, nil
}
func (m *Module) CreateUserForSAMLRequest(ctx context.Context, email string) (*types.User, error) {
// get auth domain from email domain
_, err := m.GetAuthDomainByEmail(ctx, email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
// get name from email
parts := strings.Split(email, "@")
if len(parts) < 2 {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid email format")
}
name := parts[0]
defaultOrgID, err := m.store.GetDefaultOrgID(ctx)
if err != nil {
return nil, err
}
user, err := types.NewUser(name, email, types.RoleViewer.String(), defaultOrgID)
if err != nil {
return nil, err
}
err = m.CreateUser(ctx, user)
if err != nil {
return nil, err
}
return user, nil
}
func (m *Module) PrepareSsoRedirect(ctx context.Context, redirectUri, email string) (string, error) {
users, err := m.GetUsersByEmail(ctx, email)
if err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to get user with email received from auth provider", "error", err)
return "", err
}
user := &types.User{}
if len(users) == 0 {
newUser, err := m.CreateUserForSAMLRequest(ctx, email)
user = newUser
if err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to create user with email received from auth provider", "error", err)
return "", err
}
} else {
user = &users[0].User
}
tokenStore, err := m.GetJWTForUser(ctx, user)
if err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to generate token for SSO login user", "error", err)
return "", err
}
return fmt.Sprintf("%s?jwt=%s&usr=%s&refreshjwt=%s",
redirectUri,
tokenStore.AccessJwt,
user.ID,
tokenStore.RefreshJwt), nil
}
func (m *Module) CanUsePassword(ctx context.Context, email string) (bool, error) {
domain, err := m.GetAuthDomainByEmail(ctx, email)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return false, err
}
if domain != nil && domain.SsoEnabled {
// sso is enabled, check if the user has admin role
users, err := m.GetUsersByEmail(ctx, email)
if err != nil {
return false, err
}
if len(users) == 0 {
return false, errors.New(errors.TypeNotFound, errors.CodeNotFound, "user not found")
}
if users[0].Role != types.RoleAdmin.String() {
return false, errors.New(errors.TypeForbidden, errors.CodeForbidden, "auth method not supported")
}
}
return true, nil
}
func (m *Module) GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error) {
if email == "" {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "email is required")
}
components := strings.Split(email, "@")
if len(components) < 2 {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid email format")
}
domain, err := m.store.GetDomainByName(ctx, components[1])
if err != nil {
return nil, err
}
gettableDomain := &types.GettableOrgDomain{StorableOrgDomain: *domain}
if err := gettableDomain.LoadConfig(domain.Data); err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to load domain config")
}
return gettableDomain, nil
}
func (m *Module) CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error {
return m.store.CreateAPIKey(ctx, apiKey)
}
func (m *Module) UpdateAPIKey(ctx context.Context, id valuer.UUID, apiKey *types.StorableAPIKey, updaterID valuer.UUID) error {
return m.store.UpdateAPIKey(ctx, id, apiKey, updaterID)
}
func (m *Module) ListAPIKeys(ctx context.Context, orgID valuer.UUID) ([]*types.StorableAPIKeyUser, error) {
return m.store.ListAPIKeys(ctx, orgID)
}
func (m *Module) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*types.StorableAPIKeyUser, error) {
return m.store.GetAPIKey(ctx, orgID, id)
}
func (m *Module) RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error {
return m.store.RevokeAPIKey(ctx, id, removedByUserID)
}
func (m *Module) GetDomainFromSsoResponse(ctx context.Context, url *url.URL) (*types.GettableOrgDomain, error) {
return m.store.GetDomainFromSsoResponse(ctx, url)
}
func (m *Module) CreateDomain(ctx context.Context, domain *types.GettableOrgDomain) error {
return m.store.CreateDomain(ctx, domain)
}
func (m *Module) DeleteDomain(ctx context.Context, id uuid.UUID) error {
return m.store.DeleteDomain(ctx, id)
}
func (m *Module) ListDomains(ctx context.Context, orgID valuer.UUID) ([]*types.GettableOrgDomain, error) {
return m.store.ListDomains(ctx, orgID)
}
func (m *Module) UpdateDomain(ctx context.Context, domain *types.GettableOrgDomain) error {
return m.store.UpdateDomain(ctx, domain)
}
func (module *Module) CreateFirstUser(ctx context.Context, organization *types.Organization, name string, email string, passwd string) (*types.User, error) {
user, err := types.NewUser(name, email, types.RoleAdmin.String(), organization.ID.StringValue())
if err != nil {
return nil, err
}
password, err := types.NewFactorPassword(passwd, user.ID.StringValue())
if err != nil {
return nil, err
}
if err = module.store.RunInTx(ctx, func(ctx context.Context) error {
err := module.orgSetter.Create(ctx, organization)
if err != nil {
return err
}
err = module.CreateUser(ctx, user, root.WithFactorPassword(password))
if err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return user, nil
}
func (m *Module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
stats := make(map[string]any)
count, err := m.store.CountByOrgID(ctx, orgID)
if err == nil {
stats["user.count"] = count
}
count, err = m.store.CountAPIKeyByOrgID(ctx, orgID)
if err == nil {
stats["factor.api_key.count"] = count
}
return stats, nil
}