docbrown/app/services/authentication/authenticator.rb
Jeremy Friesen 21abe8cb34
Guarding against spam from OAuth Sources (#15404)
* Guarding against spam from OAuth Sources

Prior to this commit, when an administrator had indicated blocked email
domains, those blocks were not applied to identities created via the
OAuth sources (e.g., Twitter, Facebook, etc).  With this change, we're
hooking into the similar logic flow as suspended email accounts.

Related to #15403, #15397, and forem/rfcs#281

* Adding class documentation to exception

* Update app/services/authentication/authenticator.rb

Co-authored-by: Ridhwana <Ridhwana.Khan16@gmail.com>

Co-authored-by: Ridhwana <Ridhwana.Khan16@gmail.com>
2021-11-18 16:26:39 -05:00

191 lines
6.1 KiB
Ruby

module Authentication
# TODO: [@forem/oss] use strategy pattern for the three cases
# described below.
# Make the decision early which one of the 3 cases we're dealing with
# and then call either NewUserStrategy, UpdateUserStrategy or
# LoggedInUserStrategy. I think the resulting three classes would be much
# easier to understand and they can still share methods by inheriting
# from a basic AuthStrategy.
# Authenticator will perform one of these tree operations:
# 1. create a new user and match it to its authentication identity
# 2. update an existing user and align it to its authentication identity
# 3. return the current user if a user is given (already logged in scenario)
class Authenticator
# auth_payload is the payload schema, see https://github.com/omniauth/omniauth/wiki/Auth-Hash-Schema
def initialize(auth_payload, current_user: nil, cta_variant: nil)
@provider = load_authentication_provider(auth_payload)
@current_user = current_user
@cta_variant = cta_variant
end
# @api public
#
# @see #initialize method for parameters
#
# @return user [User] when the given provider is valid
#
# @raises [Authentication::Errors::PreviouslySuspended] when the user was already suspended
# @raises [Authentication::Errors::SpammyEmailDomain] when the associated email is spammy
def self.call(...)
new(...).call
end
# @api private
def call
identity = Identity.build_from_omniauth(provider)
guard_against_spam_from!(identity: identity)
return current_user if current_user_identity_exists?
# These variables need to be set outside of the scope of the
# transaction in order to be used after the transaction is completed.
log_to_datadog = false
id_provider, authed_user = nil
ActiveRecord::Base.transaction do
user = proper_user(identity)
user = if user.nil?
find_or_create_user!
else
update_user(user)
end
identity.user = user if identity.user_id.blank?
new_identity = identity.new_record?
successful_save = identity.save!
log_to_datadog = new_identity && successful_save
id_provider = identity.provider
user.skip_confirmation!
flag_spam_user(user) if account_less_than_a_week_old?(user, identity)
user.save!
authed_user = user
end
if log_to_datadog
# Notify DataDog if a new identity was successfully created.
ForemStatsClient.increment("identity.created", tags: ["provider:#{id_provider}"])
end
# Return the successfully-authed used from the transaction.
authed_user
rescue StandardError => e
# Notify DataDog if something goes wrong in the transaction,
# and then ensure that we re-raise and bubble up the error.
ForemStatsClient.increment("identity.errors", tags: ["error:#{e.class}", "message:#{e.message}"])
raise e
end
private
def guard_against_spam_from!(identity:)
domain = identity.email.split("@")[-1]
return unless domain
return if Settings::Authentication.acceptable_domain?(domain: domain)
message = "This #{identity.provider} email address #{identity.email} has been marked as spam"
raise Authentication::Errors::SpammyEmailDomain, message
end
attr_reader :provider, :current_user, :cta_variant
# Loads the proper authentication provider from the available ones
def load_authentication_provider(auth_payload)
provider_class = Authentication::Providers.get!(auth_payload.provider)
provider_class.new(auth_payload)
end
def current_user_identity_exists?
current_user&.identities&.exists?(provider: provider.name)
end
def proper_user(identity)
if current_user
current_user
elsif identity.user
identity.user
elsif provider.user_email.present?
User.find_by(email: provider.user_email)
end
end
def find_or_create_user!
username = provider.user_nickname
suspended_user = Users::SuspendedUsername.previously_suspended?(username)
raise ::Authentication::Errors::PreviouslySuspended if suspended_user
existing_user = User.where(
provider.user_username_field => username,
).take
return existing_user if existing_user
User.new.tap do |user|
user.assign_attributes(provider.new_user_data)
user.assign_attributes(default_user_fields)
user.set_remember_fields
# The user must be saved in the database before
# we assign the user to a new identity.
user.save!
end
end
def default_user_fields
password = Devise.friendly_token(20)
{
password: password,
password_confirmation: password,
signup_cta_variant: cta_variant,
registered: true,
registered_at: Time.current
}
end
def update_user(user)
user.tap do |model|
model.unlock_access! if model.access_locked?
model.assign_attributes(provider.existing_user_data)
update_profile_updated_at(model)
model.set_remember_fields
end
end
def update_profile_updated_at(user)
field_name = "#{provider.user_username_field}_changed?"
user.profile_updated_at = Time.current if user.public_send(field_name)
end
def account_less_than_a_week_old?(_user, logged_in_identity)
user_identity_age = extract_created_at_from_payload(logged_in_identity)
# last one is a fallback in case both are nil
range = 1.week.ago.beginning_of_day..Time.current
range.cover?(user_identity_age)
end
def extract_created_at_from_payload(logged_in_identity)
raw_info = logged_in_identity.auth_data_dump.extra.raw_info
if raw_info.created_at.present?
Time.zone.parse(raw_info.created_at)
elsif raw_info.auth_time.present?
Time.zone.at(raw_info.auth_time)
else
Time.current
end
end
def flag_spam_user(user)
Slack::Messengers::PotentialSpammer.call(user: user)
end
end
end