Nityananda Gohain 0a2b7ca1d8
chore(auth): refactor the auth modules and handler in preparation for multi tenant login (#7778)
* chore: update auth

* chore: password changes

* chore: make changes in oss code

* chore: login

* chore: get to a running state

* fix: migration inital commit

* fix: signoz cloud intgtn tests

* fix: minor fixes

* chore: sso code fixed with org domain

* fix: tests

* fix: ee auth api's

* fix: changes in name

* fix: return user in login api

* fix: address comments

* fix: validate password

* fix: handle get domain by email properly

* fix: move authomain to usermodule

* fix: use displayname instead of hname

* fix: rename back endpoints

* fix: update telemetry

* fix: correct errors

* fix: test and fix the invite endpoints

* fix: delete all things related to user in store

* fix: address issues

* fix: ee delete invite

* fix: rename func

* fix: update user and update role

* fix: update role

* fix: login and invite changes

* fix: return org name in users response

* fix: update user role

* fix: nil check

* fix: getinvite and update role

* fix: sso

* fix: getinvite use sso ctx

* fix: use correct sourceurl

* fix: getsourceurl from req payload

* fix: update created_at

* fix: fix reset password

* fix: sso signup and token password change

* fix: don't delete last admin

* fix: reset password and migration

* fix: migration

* fix: reset password for sso users

* fix: clean up invite

* fix: migration

* fix: update claims and store code

* fix: use correct error

* fix: proper nil checks

* fix: make migration multitenant

* fix: address comments

* fix: minor fixes

* fix: test

* fix: rename reset password

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2025-05-14 23:12:55 +05:30

395 lines
12 KiB
Go

package impluser
import (
"bytes"
"context"
"fmt"
"os"
"slices"
"text/template"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/telemetry"
smtpservice "github.com/SigNoz/signoz/pkg/query-service/utils/smtpService"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"go.uber.org/zap"
)
type Module struct {
store types.UserStore
JWT *authtypes.JWT
}
// This module is a WIP, don't take inspiration from this.
func NewModule(store types.UserStore) user.Module {
jwtSecret := os.Getenv("SIGNOZ_JWT_SECRET")
jwt := authtypes.NewJWT(jwtSecret, 30*time.Minute, 30*24*time.Hour)
return &Module{store: store, JWT: jwt}
}
// 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
}
// send telemetry event
for i := 0; i < len(invites); i++ {
telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_EVENT_USER_INVITATION_SENT, map[string]interface{}{
"invited user email": invites[i].Email,
}, creator.Email, true, false)
// send email if SMTP is enabled
if os.Getenv("SMTP_ENABLED") == "true" && bulkInvites.Invites[i].FrontendBaseUrl != "" {
m.inviteEmail(&bulkInvites.Invites[i], creator.Email, creator.DisplayName, invites[i].Token)
}
}
return invites, nil
}
func (m *Module) inviteEmail(req *types.PostableInvite, creatorEmail, creatorName, token string) {
smtp := smtpservice.GetInstance()
data := types.InviteEmailData{
CustomerName: req.Name,
InviterName: creatorName,
InviterEmail: creatorEmail,
Link: fmt.Sprintf("%s/signup?token=%s", req.FrontendBaseUrl, token),
}
tmpl, err := template.ParseFiles(constants.InviteEmailTemplate)
if err != nil {
zap.L().Error("failed to send email", zap.Error(err))
return
}
var body bytes.Buffer
if err := tmpl.Execute(&body, data); err != nil {
zap.L().Error("failed to send email", zap.Error(err))
return
}
err = smtp.SendEmail(
req.Email,
creatorName+" has invited you to their team in SigNoz",
body.String(),
)
if err != nil {
zap.L().Error("failed to send email", zap.Error(err))
return
}
}
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 (m *Module) CreateUserWithPassword(ctx context.Context, user *types.User, password *types.FactorPassword) (*types.User, error) {
user, err := m.store.CreateUserWithPassword(ctx, user, password)
if err != nil {
return nil, err
}
return user, nil
}
func (m *Module) CreateUser(ctx context.Context, user *types.User) error {
return m.store.CreateUser(ctx, user)
}
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) (*types.User, error) {
return m.store.UpdateUser(ctx, orgID, id, user)
}
func (m *Module) DeleteUser(ctx context.Context, orgID string, id 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")
}
return m.store.DeleteUser(ctx, orgID, user.ID.StringValue())
}
func (m *Module) CreateResetPasswordToken(ctx context.Context, userID string) (*types.ResetPasswordRequest, error) {
password, err := m.store.GetPasswordByUserID(ctx, userID)
if err != nil {
// if the user does not have a password, we need to create a new one
// this will happen for SSO users
if errors.Ast(err, errors.TypeNotFound) {
password, err = m.store.CreatePassword(ctx, &types.FactorPassword{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
},
Password: valuer.GenerateUUID().String(),
UserID: userID,
})
if err != nil {
return nil, err
}
} else {
return nil, err
}
}
resetPasswordRequest, err := types.NewResetPasswordRequest(password.ID.StringValue())
if err != nil {
return nil, err
}
// check if a reset password token already exists for this user
existingRequest, err := m.store.GetResetPasswordByPasswordID(ctx, resetPasswordRequest.PasswordID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingRequest != nil {
return existingRequest, nil
}
err = m.store.CreateResetPasswordToken(ctx, resetPasswordRequest)
if err != nil {
return nil, err
}
return resetPasswordRequest, nil
}
func (m *Module) GetPasswordByUserID(ctx context.Context, id string) (*types.FactorPassword, error) {
return m.store.GetPasswordByUserID(ctx, id)
}
func (m *Module) GetResetPassword(ctx context.Context, token string) (*types.ResetPasswordRequest, error) {
return m.store.GetResetPassword(ctx, token)
}
func (m *Module) UpdatePasswordAndDeleteResetPasswordEntry(ctx context.Context, passwordID string, password string) error {
hashedPassword, err := types.HashPassword(password)
if err != nil {
return err
}
existingPassword, err := m.store.GetPasswordByID(ctx, passwordID)
if err != nil {
return err
}
return m.store.UpdatePasswordAndDeleteResetPasswordEntry(ctx, existingPassword.UserID, hashedPassword)
}
func (m *Module) UpdatePassword(ctx context.Context, userID string, password string) error {
hashedPassword, err := types.HashPassword(password)
if err != nil {
return err
}
return m.store.UpdatePassword(ctx, userID, hashedPassword)
}
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 provided
if orgID != "" {
user, err := m.store.GetUserByEmailInOrg(ctx, orgID, email)
if err != nil {
return nil, err
}
dbUser = &user.User
}
// when the orgID is not provided we login if the user exists in just one org
user, err := m.store.GetUsersByEmail(ctx, email)
if err != nil {
return nil, err
}
if len(user) == 1 {
dbUser = &user[0].User
} else {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "please provide an orgID")
}
existingPassword, err := m.store.GetPasswordByUserID(ctx, dbUser.ID.StringValue())
if err != nil {
return nil, err
}
if !types.ComparePassword(existingPassword.Password, password) {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid password")
}
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
}
}
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) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SAML login is not supported")
}
func (m *Module) PrepareSsoRedirect(ctx context.Context, redirectUri, email string, jwt *authtypes.JWT) (string, error) {
return "", errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SSO is not supported")
}
func (m *Module) CanUsePassword(ctx context.Context, email string) (bool, error) {
return false, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SSO is not supported")
}
func (m *Module) GetAuthDomainByEmail(ctx context.Context, email string) (*types.GettableOrgDomain, error) {
return nil, errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "SSO is not supported")
}