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:



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

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:



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

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.



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

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



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

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



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

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

Instance Method Details

#add_membership(opts = {}) ⇒ Object

:section: Memberships



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

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)


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

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.



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

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

#before_createObject

:section: Sequel Hooks



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

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

#before_soft_deleteObject

Soft-delete hook – prep the user for deletion.



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

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

#default_organizationObject



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

def default_organization
  return self.default_membership&.organization
end

#encrypted_passwordObject

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



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

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

#ensure_role(role_or_name) ⇒ Object



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

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



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

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

#password=(unencrypted) ⇒ Object

Set the password to the given unencrypted String.



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

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



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

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)


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

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)


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

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

#us_phoneObject

:section: Phone



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

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

#us_phone=(s) ⇒ Object



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

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

#validateObject

:section: Sequel Validation



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

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)


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

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