Class: Webhookdb::Customer
- Inherits:
-
Object
- Object
- Webhookdb::Customer
- 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
-
.find_or_create_default_organization(customer) ⇒ Array<TrueClass,FalseClass,Webhookdb::OrganizationMembership>
Make sure the customer has a default organization.
- .find_or_create_for_email(email) ⇒ Object
-
.finish_otp(me, token:) ⇒ Object
Tuple of <Step, Customer>.
-
.register_or_login(email:) ⇒ Object
Tuple of <Step, Customer>.
- .with_email(e) ⇒ Object
Instance Method Summary collapse
-
#add_membership(opts = {}) ⇒ Object
:section: Memberships.
- #admin? ⇒ Boolean
-
#authenticate(unencrypted) ⇒ Object
Attempt to authenticate the user with the specified
unencrypted
password. -
#before_create ⇒ Object
:section: Sequel Hooks.
-
#before_soft_delete ⇒ Object
Soft-delete hook – prep the user for deletion.
- #default_organization ⇒ Object
-
#encrypted_password ⇒ Object
Fetch the user’s password as an BCrypt::Password object.
- #ensure_role(role_or_name) ⇒ Object
- #greeting ⇒ Object
-
#password=(unencrypted) ⇒ Object
Set the password to the given
unencrypted
String. - #replace_default_membership(new_mem) ⇒ Object
-
#should_skip_authentication? ⇒ Boolean
If the SKIP_PHONE|EMAIL_VERIFICATION are set, verify the phone/email.
- #unverified? ⇒ Boolean
-
#us_phone ⇒ Object
:section: Phone.
- #us_phone=(s) ⇒ Object
-
#validate ⇒ Object
:section: Sequel Validation.
- #verified_member_of?(org) ⇒ Boolean
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
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.
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
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 signup_allowed = self.signup_email_allowlist.any? { |pattern| File.fnmatch(pattern, email) } raise SignupDisabled unless signup_allowed 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.
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>.
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.register_or_login(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
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_create ⇒ Object
: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_delete ⇒ Object
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_organization ⇒ Object
225 226 227 |
# File 'lib/webhookdb/customer.rb', line 225 def default_organization return self.default_membership&.organization end |
#encrypted_password ⇒ Object
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 |
#greeting ⇒ Object
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.
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
287 288 289 |
# File 'lib/webhookdb/customer.rb', line 287 def unverified? return !self.email_verified? && !self.phone_verified? end |
#us_phone ⇒ Object
: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 |
#validate ⇒ Object
: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
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 |