Class: Webhookdb::Customer

Inherits:
Object
  • Object
show all
Extended by:
MethodUtilities
Includes:
Appydays::Configurable
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

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:



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

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:



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

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.



131
132
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
# File 'lib/webhookdb/customer.rb', line 131

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>.



98
99
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
# File 'lib/webhookdb/customer.rb', line 98

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



62
63
64
# File 'lib/webhookdb/customer.rb', line 62

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

Instance Method Details

#add_membership(opts = {}) ⇒ Object

:section: Memberships



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

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)


199
200
201
# File 'lib/webhookdb/customer.rb', line 199

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.



255
256
257
258
259
# File 'lib/webhookdb/customer.rb', line 255

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

#before_createObject

:section: Sequel Hooks



293
294
295
# File 'lib/webhookdb/customer.rb', line 293

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

#before_soft_deleteObject

Soft-delete hook – prep the user for deletion.



298
299
300
301
302
# File 'lib/webhookdb/customer.rb', line 298

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

#default_organizationObject



223
224
225
# File 'lib/webhookdb/customer.rb', line 223

def default_organization
  return self.default_membership&.organization
end

#encrypted_passwordObject

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



238
239
240
241
# File 'lib/webhookdb/customer.rb', line 238

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

#ensure_role(role_or_name) ⇒ Object



193
194
195
196
197
# File 'lib/webhookdb/customer.rb', line 193

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



203
204
205
# File 'lib/webhookdb/customer.rb', line 203

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

#password=(unencrypted) ⇒ Object

Set the password to the given unencrypted String.



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

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



227
228
229
230
231
# File 'lib/webhookdb/customer.rb', line 227

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)


187
188
189
190
191
# File 'lib/webhookdb/customer.rb', line 187

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)


285
286
287
# File 'lib/webhookdb/customer.rb', line 285

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

#us_phoneObject

:section: Phone



277
278
279
# File 'lib/webhookdb/customer.rb', line 277

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

#us_phone=(s) ⇒ Object



281
282
283
# File 'lib/webhookdb/customer.rb', line 281

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

#validateObject

:section: Sequel Validation



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

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)


219
220
221
# File 'lib/webhookdb/customer.rb', line 219

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