package user import ( "context" "database/sql" "encoding/json" "strconv" "strings" "time" "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "github.com/jmoiron/sqlx" "github.com/lib/pq" "github.com/pkg/errors" "github.com/sudo-suhas/symcrypto" ) // Repository defines the required dependencies for User. type Repository struct { DbConn *sqlx.DB ResetUrl func(string) string Notify notify.Email secretKey string } // NewRepository creates a new Repository that defines dependencies for User. func NewRepository(db *sqlx.DB, resetUrl func(string) string, notify notify.Email, secretKey string) *Repository { return &Repository{ DbConn: db, ResetUrl: resetUrl, Notify: notify, secretKey: secretKey, } } // User represents someone with access to our system. type User struct { ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` FirstName string `json:"first_name" validate:"required" example:"Gabi"` LastName string `json:"last_name" validate:"required" example:"May"` Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"` PasswordSalt string `json:"-" validate:"required"` PasswordHash []byte `json:"-" validate:"required"` PasswordReset *sql.NullString `json:"-"` Timezone *string `json:"timezone" validate:"omitempty" example:"America/Anchorage"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` ArchivedAt *pq.NullTime `json:"archived_at,omitempty"` } // UserResponse represents someone with access to our system that is returned for display. type UserResponse struct { ID string `json:"id" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` Name string `json:"name" example:"Gabi"` FirstName string `json:"first_name" example:"Gabi"` LastName string `json:"last_name" example:"May"` Email string `json:"email" example:"gabi@geeksinthewoods.com"` Timezone string `json:"timezone" example:"America/Anchorage"` CreatedAt web.TimeResponse `json:"created_at"` // CreatedAt contains multiple format options for display. UpdatedAt web.TimeResponse `json:"updated_at"` // UpdatedAt contains multiple format options for display. ArchivedAt *web.TimeResponse `json:"archived_at,omitempty"` // ArchivedAt contains multiple format options for display. Gravatar web.GravatarResponse `json:"gravatar"` } // Response transforms User and UserResponse that is used for display. // Additional filtering by context values or translations could be applied. func (m *User) Response(ctx context.Context) *UserResponse { if m == nil { return nil } r := &UserResponse{ ID: m.ID, Name: m.FirstName + " " + m.LastName, FirstName: m.FirstName, LastName: m.LastName, Email: m.Email, CreatedAt: web.NewTimeResponse(ctx, m.CreatedAt), UpdatedAt: web.NewTimeResponse(ctx, m.UpdatedAt), Gravatar: web.NewGravatarResponse(ctx, m.Email), } if m.Timezone != nil { r.Timezone = *m.Timezone } if m.ArchivedAt != nil && !m.ArchivedAt.Time.IsZero() { at := web.NewTimeResponse(ctx, m.ArchivedAt.Time) r.ArchivedAt = &at } return r } func (m *UserResponse) UnmarshalBinary(data []byte) error { if data == nil || len(data) == 0 { return nil } // convert data to yours, let's assume its json data return json.Unmarshal(data, m) } func (m *UserResponse) MarshalBinary() ([]byte, error) { return json.Marshal(m) } // Users a list of Users. type Users []*User // Response transforms a list of Users to a list of UserResponses. func (m *Users) Response(ctx context.Context) []*UserResponse { var l []*UserResponse if m != nil && len(*m) > 0 { for _, n := range *m { l = append(l, n.Response(ctx)) } } return l } // UserCreateRequest contains information needed to create a new User. type UserCreateRequest struct { FirstName string `json:"first_name" validate:"required" example:"Gabi"` LastName string `json:"last_name" validate:"required" example:"May"` Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"` Password string `json:"password" validate:"required" example:"SecretString"` PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"SecretString"` Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"` } // UserCreateInviteRequest contains information needed to create a new User. type UserCreateInviteRequest struct { Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"` } // UserReadRequest defines the information needed to read an user. type UserReadRequest struct { ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` IncludeArchived bool `json:"include-archived" example:"false"` } // UserUpdateRequest defines what information may be provided to modify an existing // User. All fields are optional so clients can send just the fields they want // changed. It uses pointer fields so we can differentiate between a field that // was not provided and a field that was provided as explicitly blank. Normally // we do not want to use pointers to basic types but we make exceptions around // marshalling/unmarshalling. type UserUpdateRequest struct { ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` FirstName *string `json:"first_name,omitempty" validate:"omitempty" example:"Gabi May Not"` LastName *string `json:"last_name,omitempty" validate:"omitempty" example:"Gabi May Not"` Email *string `json:"email,omitempty" validate:"omitempty,email,unique" example:"gabi.may@geeksinthewoods.com"` Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"` } // UserUpdatePasswordRequest defines what information is required to update a user password. type UserUpdatePasswordRequest struct { ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` Password string `json:"password" validate:"required" example:"NeverTellSecret"` PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"NeverTellSecret"` } // UserArchiveRequest defines the information needed to archive an user. This will archive (soft-delete) the // existing database entry. type UserArchiveRequest struct { ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` force bool } // UserRestoreRequest defines the information needed to restore an user. type UserRestoreRequest struct { ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` } // UserDeleteRequest defines the information needed to delete a user. type UserDeleteRequest struct { ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` force bool } // UserFindRequest defines the possible options to search for users. By default // archived users will be excluded from response. type UserFindRequest struct { Where string `json:"where" example:"name = ? and email = ?"` Args []interface{} `json:"args" swaggertype:"array,string" example:"Company Name,gabi.may@geeksinthewoods.com"` Order []string `json:"order" example:"created_at desc"` Limit *uint `json:"limit" example:"10"` Offset *uint `json:"offset" example:"20"` IncludeArchived bool `json:"include-archived" example:"false"` } // UserResetPasswordRequest defines the fields need to reset a user password. type UserResetPasswordRequest struct { Email string `json:"email" validate:"required,email" example:"gabi.may@geeksinthewoods.com"` TTL time.Duration `json:"ttl,omitempty" ` } // ResetHash type ResetHash struct { ResetID string `json:"reset_id" validate:"required" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` CreatedAt int `json:"created_at" validate:"required"` ExpiresAt int `json:"expires_at" validate:"required"` RequestIP string `json:"request_ip" validate:"required,ip" example:"69.56.104.36"` } // UserResetConfirmRequest defines the fields need to reset a user password. type UserResetConfirmRequest struct { ResetHash string `json:"reset_hash" validate:"required" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` Password string `json:"password" validate:"required" example:"SecretString"` PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"SecretString"` } // NewResetHash generates a new encrypt reset hash that is web safe for use in URLs. func NewResetHash(ctx context.Context, secretKey, resetId, requestIp string, ttl time.Duration, now time.Time) (string, error) { // Generate a string that embeds additional information. hashPts := []string{ resetId, strconv.Itoa(int(now.UTC().Unix())), strconv.Itoa(int(now.UTC().Add(ttl).Unix())), requestIp, } hashStr := strings.Join(hashPts, "|") // This returns the nonce appended with the encrypted string. crypto, err := symcrypto.New(secretKey) if err != nil { return "", errors.WithStack(err) } encrypted, err := crypto.Encrypt(hashStr) if err != nil { return "", errors.WithStack(err) } return encrypted, nil } // ParseResetHash extracts the details encrypted in the hash string. func ParseResetHash(ctx context.Context, secretKey string, str string, now time.Time) (*ResetHash, error) { crypto, err := symcrypto.New(secretKey) if err != nil { return nil, errors.WithStack(err) } hashStr, err := crypto.Decrypt(str) if err != nil { return nil, errors.WithStack(err) } hashPts := strings.Split(hashStr, "|") var hash ResetHash if len(hashPts) == 4 { hash.ResetID = hashPts[0] hash.CreatedAt, _ = strconv.Atoi(hashPts[1]) hash.ExpiresAt, _ = strconv.Atoi(hashPts[2]) hash.RequestIP = hashPts[3] } // Validate the hash. err = webcontext.Validator().StructCtx(ctx, hash) if err != nil { return nil, err } if int64(hash.ExpiresAt) < now.UTC().Unix() { err = errors.WithMessage(ErrResetExpired, "Password reset has expired.") return nil, err } return &hash, nil } // ParseResetHash extracts the details encrypted in the hash string. func (repo *Repository) ParseResetHash(ctx context.Context, str string, now time.Time) (*ResetHash, error) { return ParseResetHash(ctx, repo.secretKey, str, now) }