Class: Webhookdb::Customer

Inherits:
Object
  • Object
show all
Extended by:
MethodUtilities
Includes:
Appydays::Configurable, Admin::Linked
Defined in:
lib/webhookdb/customer.rb

Defined Under Namespace

Classes: InvalidPassword, ResetCode, SignupDisabled

Constant Summary collapse

MIN_PASSWORD_LENGTH =
8
PLACEHOLDER_PASSWORD_DIGEST =

A bcrypt digest that’s valid, but not a real digest. Used as a placeholder for accounts with no passwords, which makes them impossible to authenticate. Or at least much less likely than with a random string.

"$2a$11$....................................................."
DELETED_EMAIL_PATTERN =

Regex that matches the prefix of a deleted user’s email

/^(?<prefix>\d+(?:\.\d+)?)\+(?<rest>.*)$/

Class Method Summary collapse

Instance Method Summary collapse

Methods included from MethodUtilities

attr_predicate, attr_predicate_accessor, singleton_attr_accessor, singleton_attr_reader, singleton_attr_writer, singleton_method_alias, singleton_predicate_accessor, singleton_predicate_reader

Methods included from Admin::Linked

#admin_link

Class Method Details

.find_or_create_default_organization(customer) ⇒ Array<TrueClass,FalseClass,Webhookdb::OrganizationMembership>

Make sure the customer has a default organization. New registrants, or users who have been invited (so have an existing customer and invited org) get an org created. Default orgs must already be verified as per a DB constraint.

Returns:



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/webhookdb/customer.rb', line 82

def self.find_or_create_default_organization(customer)
  mem = customer.default_membership
  return [false, mem] if mem
  email = customer.email
  # We could have no default, but already be in an organization, like if the default was deleted.
  mem = customer.verified_memberships.first
  return [false, mem] if mem
  # We have no verified orgs, so create one.
  # TODO: this will fail if not unique. We need to make sure we pick a unique name/key.
  self_org = Webhookdb::Organization.create(name: "#{email} Org", billing_email: email.to_s)
  mem = customer.add_membership(
    organization: self_org, membership_role: Webhookdb::Role.admin_role, verified: true, is_default: true,
  )
  self_org.publish_deferred("syncdemodata", self_org.id) if Webhookdb::DemoMode.example_datasets_enabled
  return [true, mem]
end

.find_or_create_for_email(email) ⇒ Object

Raises:



68
69
70
71
72
73
74
75
76
# File 'lib/webhookdb/customer.rb', line 68

def self.find_or_create_for_email(email)
  email = email.strip.downcase
  # If there is no Customer object associated with the email, create one
  me = Webhookdb::Customer[email:]
  return [false, me] if me
   = self..any? { |pattern| File.fnmatch(pattern, email) }
  raise SignupDisabled unless 
  return [true, Webhookdb::Customer.create(email:, password: SecureRandom.hex(32))]
end

.finish_otp(me, token:) ⇒ Object

Returns Tuple of <Step, Customer>. Customer is nil if token was invalid.

Returns:

  • Tuple of <Step, Customer>. Customer is nil if token was invalid.



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/webhookdb/customer.rb', line 133

def self.finish_otp(me, token:)
  if me.nil?
    step = Webhookdb::Replicator::StateMachineStep.new
    step.output = %(Sorry, no one with that email exists. Try running:

webhookdb auth login [email]
    )
    step.needs_input = false
    step.complete = true
    step.error_code = "email_not_exist"
    return [step, nil]
  end

  unless me.should_skip_authentication?
    begin
      Webhookdb::Customer::ResetCode.use_code_with_token(token) do |code|
        raise Webhookdb::Customer::ResetCode::Unusable unless code.customer === me
        code.customer.save_changes
        me.refresh
      end
    rescue Webhookdb::Customer::ResetCode::Unusable
      step = Webhookdb::Replicator::StateMachineStep.new
      step.output = %(Sorry, that token is invalid. Please try again.
If you have not gotten a code, use Ctrl+C to close this prompt and request a new code:

webhookdb auth login #{me.email}
)
      step.error_code = "invalid_otp"
      step.prompt_is_secret = true
      step.prompt = "Enter the token from your email:"
      step.needs_input = true
      step.post_to_url = "/v1/auth"
      step.post_params = {email: me.email}
      step.post_params_value_key = "token"
      return [step, nil]
    end
  end

  welcome_tutorial = "Quick tip: Use `webhookdb services list` to see what services are available."
  if me.invited_memberships.present?
    welcome_tutorial = "You have the following pending invites:\n\n" +
      me.invited_memberships.map { |om| "  #{om.organization.display_string}: #{om.invitation_code}" }.join("\n") +
      "\n\nUse `webhookdb org join [code]` to accept an invitation."
  end
  step = Webhookdb::Replicator::StateMachineStep.new
  step.output = %(Welcome! For help getting started, please check out
our docs at https://docs.webhookdb.com.

#{welcome_tutorial})
  step.needs_input = false
  step.complete = true
  return [step, me]
end

.register_or_login(email:) ⇒ Object

Returns Tuple of <Step, Customer>.

Returns:

  • Tuple of <Step, Customer>.



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/webhookdb/customer.rb', line 100

def self.(email:)
  self.db.transaction do
    customer_created, me = self.find_or_create_for_email(email)
    org_created, _membership = self.find_or_create_default_organization(me)
    me.reset_codes_dataset.usable.each(&:expire!)
    me.add_reset_code(transport: "email")
    step = Webhookdb::Replicator::StateMachineStep.new
    step.output = if customer_created || org_created
                    %(To finish registering, please look for an email we just sent to #{email}.
It contains a One Time Password code to validate your email.
)
    else
      %(Hello again!

To finish logging in, please look for an email we just sent to #{email}.
It contains a One Time Password used to log in.
)
                  end
    step.output += %(You can enter it here, or if you want to finish up from a new prompt, use:

webhookdb auth login --username=#{email} --token=<#{Webhookdb::Customer::ResetCode::TOKEN_LENGTH} digit token>
)
    step.prompt = "Enter the token from your email:"
    step.prompt_is_secret = true
    step.needs_input = true
    step.post_to_url = "/v1/auth"
    step.post_params = {email:}
    step.post_params_value_key = "token"
    return [step, me]
  end
end

.with_email(e) ⇒ Object



64
65
66
# File 'lib/webhookdb/customer.rb', line 64

def self.with_email(e)
  return self.dataset.with_email(e).first
end

Instance Method Details

#add_membership(opts = {}) ⇒ Object

:section: Memberships



213
214
215
216
217
218
219
# File 'lib/webhookdb/customer.rb', line 213

def add_membership(opts={})
  if !opts.is_a?(Webhookdb::OrganizationMembership) && !opts.key?(:verified)
    raise ArgumentError, "must pass :verified or a model into add_membership, it is ambiguous otherwise"
  end
  self.associations.delete(opts[:verified] ? :verified_memberships : :invited_memberships)
  return self.add_all_membership(opts)
end

#admin?Boolean

Returns:

  • (Boolean)


201
202
203
# File 'lib/webhookdb/customer.rb', line 201

def admin?
  return self.roles.include?(Webhookdb::Role.admin_role)
end

#authenticate(unencrypted) ⇒ Object

Attempt to authenticate the user with the specified unencrypted password. Returns true if the password matched.



257
258
259
260
261
# File 'lib/webhookdb/customer.rb', line 257

def authenticate(unencrypted)
  return false unless unencrypted
  return false if self.soft_deleted?
  return self.encrypted_password == unencrypted
end

#before_createObject

:section: Sequel Hooks



295
296
297
# File 'lib/webhookdb/customer.rb', line 295

def before_create
  self[:opaque_id] ||= Webhookdb::Id.new_opaque_id("cus")
end

#before_soft_deleteObject

Soft-delete hook – prep the user for deletion.



300
301
302
303
304
# File 'lib/webhookdb/customer.rb', line 300

def before_soft_delete
  self.email = "#{Time.now.to_f}+#{self[:email]}"
  self.password = "aA1!#{SecureRandom.hex(8)}"
  super
end

#default_organizationObject



225
226
227
# File 'lib/webhookdb/customer.rb', line 225

def default_organization
  return self.default_membership&.organization
end

#encrypted_passwordObject

Fetch the user’s password as an BCrypt::Password object.



240
241
242
243
# File 'lib/webhookdb/customer.rb', line 240

def encrypted_password
  digest = self.password_digest or return nil
  return BCrypt::Password.new(digest)
end

#ensure_role(role_or_name) ⇒ Object



195
196
197
198
199
# File 'lib/webhookdb/customer.rb', line 195

def ensure_role(role_or_name)
  role = role_or_name.is_a?(Webhookdb::Role) ? role_or_name : Webhookdb::Role[name: role_or_name]
  raise "No role for #{role_or_name}" unless role.present?
  self.add_role(role) unless self.roles_dataset[role.id]
end

#greetingObject



205
206
207
# File 'lib/webhookdb/customer.rb', line 205

def greeting
  return self.name.present? ? self.name : "there"
end

#password=(unencrypted) ⇒ Object

Set the password to the given unencrypted String.



246
247
248
249
250
251
252
253
# File 'lib/webhookdb/customer.rb', line 246

def password=(unencrypted)
  if unencrypted
    self.check_password_complexity(unencrypted)
    self.password_digest = BCrypt::Password.create(unencrypted, cost: self.class.password_hash_cost)
  else
    self.password_digest = BCrypt::Password.new(PLACEHOLDER_PASSWORD_DIGEST)
  end
end

#replace_default_membership(new_mem) ⇒ Object



229
230
231
232
233
# File 'lib/webhookdb/customer.rb', line 229

def replace_default_membership(new_mem)
  self.verified_memberships_dataset.update(is_default: false)
  self.associations.delete(:verified_memberships)
  new_mem.update(is_default: true)
end

#should_skip_authentication?Boolean

If the SKIP_PHONE|EMAIL_VERIFICATION are set, verify the phone/email. Also verify phone and email if the customer email matches the allowlist.

Returns:

  • (Boolean)


189
190
191
192
193
# File 'lib/webhookdb/customer.rb', line 189

def should_skip_authentication?
  return true if self.class.skip_authentication
  return true if self.class.skip_authentication_allowlist.any? { |pattern| File.fnmatch(pattern, self.email) }
  return false
end

#unverified?Boolean

Returns:

  • (Boolean)


287
288
289
# File 'lib/webhookdb/customer.rb', line 287

def unverified?
  return !self.email_verified? && !self.phone_verified?
end

#us_phoneObject

:section: Phone



279
280
281
# File 'lib/webhookdb/customer.rb', line 279

def us_phone
  return Phony.format(self.phone, format: :national)
end

#us_phone=(s) ⇒ Object



283
284
285
# File 'lib/webhookdb/customer.rb', line 283

def us_phone=(s)
  self.phone = Webhookdb::PhoneNumber::US.normalize(s)
end

#validateObject

:section: Sequel Validation



313
314
315
316
317
318
319
# File 'lib/webhookdb/customer.rb', line 313

def validate
  super
  self.validates_presence(:email)
  self.validates_format(/[[:graph:]]+@[[:graph:]]+\.[a-zA-Z]{2,}/, :email)
  self.validates_unique(:email)
  self.validates_operator(:==, self.email&.downcase&.strip, :email)
end

#verified_member_of?(org) ⇒ Boolean

Returns:

  • (Boolean)


221
222
223
# File 'lib/webhookdb/customer.rb', line 221

def verified_member_of?(org)
  return !org.verified_memberships_dataset.where(customer_id: self.id).empty?
end