discourse/app/controllers/users_controller.rb
Sam Saffron d5d8db7fa8 FEATURE: improve honeypot and challenge logic
This feature amends it so instead of using one challenge and honeypot
statically per site we have a rotating honeypot and challenge value which
changes every hour.

This means you must grab a fresh copy of honeypot and challenge value once
an hour or account registration will be rejected.

We also now cycle the value of the challenge when after successful account
registration forcing an extra call to hp.json between account registrations

Client has been made aware of these changes.

Additionally this contains a JavaScript workaround for:
https://bugs.chromium.org/p/chromium/issues/detail?id=987293

This is client side code that is specific to Chrome user agent and swaps
a PASSWORD type honeypot with a TEXT type honeypot.
2019-10-16 16:53:44 +11:00

1509 lines
48 KiB
Ruby

# frozen_string_literal: true
class UsersController < ApplicationController
skip_before_action :authorize_mini_profiler, only: [:avatar]
requires_login only: [
:username, :update, :user_preferences_redirect, :upload_user_image,
:pick_avatar, :destroy_user_image, :destroy, :check_emails,
:topic_tracking_state, :preferences, :create_second_factor_totp,
:enable_second_factor_totp, :disable_second_factor, :list_second_factors,
:update_second_factor, :create_second_factor_backup, :select_avatar,
:notification_level, :revoke_auth_token, :register_second_factor_security_key,
:create_second_factor_security_key
]
skip_before_action :check_xhr, only: [
:show, :badges, :password_reset, :update, :account_created,
:activate_account, :perform_account_activation, :user_preferences_redirect, :avatar,
:my_redirect, :toggle_anon, :admin_login, :confirm_admin, :email_login, :summary
]
before_action :second_factor_check_confirmed_password, only: [
:create_second_factor_totp, :enable_second_factor_totp,
:disable_second_factor, :update_second_factor, :create_second_factor_backup,
:register_second_factor_security_key, :create_second_factor_security_key
]
before_action :respond_to_suspicious_request, only: [:create]
# we need to allow account creation with bad CSRF tokens, if people are caching, the CSRF token on the
# page is going to be empty, this means that server will see an invalid CSRF and blow the session
# once that happens you can't log in with social
skip_before_action :verify_authenticity_token, only: [:create]
skip_before_action :redirect_to_login_if_required, only: [:check_username,
:create,
:get_honeypot_value,
:account_created,
:activate_account,
:perform_account_activation,
:send_activation_email,
:update_activation_email,
:password_reset,
:confirm_email_token,
:email_login,
:admin_login,
:confirm_admin]
def index
end
def show
return redirect_to path('/login') if SiteSetting.hide_user_profiles_from_public && !current_user
@user = fetch_user_from_params(
include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts)
)
user_serializer = nil
if guardian.can_see_profile?(@user)
user_serializer = UserSerializer.new(@user, scope: guardian, root: 'user')
# TODO remove this options from serializer
user_serializer.omit_stats = true
topic_id = params[:include_post_count_for].to_i
if topic_id != 0
user_serializer.topic_post_count = { topic_id => Post.secured(guardian).where(topic_id: topic_id, user_id: @user.id).count }
end
else
user_serializer = HiddenProfileSerializer.new(@user, scope: guardian, root: 'user')
end
if !params[:skip_track_visit] && (@user != current_user)
track_visit_to_user_profile
end
# This is a hack to get around a Rails issue where values with periods aren't handled correctly
# when used as part of a route.
if params[:external_id] && params[:external_id].ends_with?('.json')
return render_json_dump(user_serializer)
end
respond_to do |format|
format.html do
@restrict_fields = guardian.restrict_user_fields?(@user)
store_preloaded("user_#{@user.username}", MultiJson.dump(user_serializer))
render :show
end
format.json do
render_json_dump(user_serializer)
end
end
end
def badges
raise Discourse::NotFound unless SiteSetting.enable_badges?
show
end
def user_preferences_redirect
redirect_to email_preferences_path(current_user.username_lower)
end
def update
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
attributes = user_params
# We can't update the username via this route. Use the username route
attributes.delete(:username)
if params[:user_fields].present?
attributes[:custom_fields] ||= {}
fields = UserField.all
fields = fields.where(editable: true) unless current_user.staff?
fields.each do |f|
field_id = f.id.to_s
next unless params[:user_fields].has_key?(field_id)
val = params[:user_fields][field_id]
val = nil if val === "false"
val = val[0...UserField.max_length] if val
return render_json_error(I18n.t("login.missing_user_field")) if val.blank? && f.required?
attributes[:custom_fields]["#{User::USER_FIELD_PREFIX}#{f.id}"] = val
end
end
json_result(user, serializer: UserSerializer, additional_errors: [:user_profile]) do |u|
updater = UserUpdater.new(current_user, user)
updater.update(attributes.permit!)
end
end
def username
params.require(:new_username)
if clashing_with_existing_route?(params[:new_username]) || User.reserved_username?(params[:new_username])
return render_json_error(I18n.t("login.reserved_username"))
end
user = fetch_user_from_params
guardian.ensure_can_edit_username!(user)
result = UsernameChanger.change(user, params[:new_username], current_user)
if result
render json: { id: user.id, username: user.username }
else
render_json_error(user.errors.full_messages.join(','))
end
rescue Discourse::InvalidAccess
if current_user&.staff?
render_json_error(I18n.t('errors.messages.sso_overrides_username'))
else
render json: failed_json, status: 403
end
end
def check_emails
user = fetch_user_from_params(include_inactive: true)
unless user == current_user
guardian.ensure_can_check_emails!(user)
StaffActionLogger.new(current_user).log_check_email(user, context: params[:context])
end
email, *secondary_emails = user.emails
render json: {
email: email,
secondary_emails: secondary_emails,
associated_accounts: user.associated_accounts
}
rescue Discourse::InvalidAccess
render json: failed_json, status: 403
end
def topic_tracking_state
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
report = TopicTrackingState.report(user)
serializer = ActiveModel::ArraySerializer.new(report, each_serializer: TopicTrackingStateSerializer)
render json: MultiJson.dump(serializer)
end
def badge_title
params.require(:user_badge_id)
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
user_badge = UserBadge.find_by(id: params[:user_badge_id])
if user_badge && user_badge.user == user && user_badge.badge.allow_title?
user.title = user_badge.badge.display_name
user.user_profile.badge_granted_title = true
user.save!
user.user_profile.save!
else
user.title = ''
user.save!
end
render body: nil
end
def preferences
render body: nil
end
def my_redirect
raise Discourse::NotFound if params[:path] !~ /^[a-z_\-\/]+$/
if current_user.blank?
cookies[:destination_url] = "/my/#{params[:path]}"
redirect_to "/login-preferences"
else
redirect_to(path("/u/#{current_user.username}/#{params[:path]}"))
end
end
def profile_hidden
render nothing: true
end
def summary
@user = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts))
raise Discourse::NotFound unless guardian.can_see_profile?(@user)
summary = UserSummary.new(@user, guardian)
serializer = UserSummarySerializer.new(summary, scope: guardian)
respond_to do |format|
format.html do
@restrict_fields = guardian.restrict_user_fields?(@user)
render :show
end
format.json do
render_json_dump(serializer)
end
end
end
def invited
inviter = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts))
offset = params[:offset].to_i || 0
filter_by = params[:filter]
invites = if guardian.can_see_invite_details?(inviter) && filter_by == "pending"
Invite.find_pending_invites_from(inviter, offset)
else
Invite.find_redeemed_invites_from(inviter, offset)
end
invites = invites.filter_by(params[:search])
render_json_dump invites: serialize_data(invites.to_a, InviteSerializer),
can_see_invite_details: guardian.can_see_invite_details?(inviter)
end
def invited_count
inviter = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts))
pending_count = Invite.find_pending_invites_count(inviter)
redeemed_count = Invite.find_redeemed_invites_count(inviter)
render json: { counts: { pending: pending_count, redeemed: redeemed_count,
total: (pending_count.to_i + redeemed_count.to_i) } }
end
def is_local_username
usernames = params[:usernames]
usernames = [params[:username]] if usernames.blank?
groups = Group.where(name: usernames).pluck(:name)
mentionable_groups =
if current_user
Group.mentionable(current_user)
.where(name: usernames)
.pluck(:name, :user_count)
.map do |name, user_count|
{
name: name,
user_count: user_count
}
end
end
usernames -= groups
usernames.each(&:downcase!)
cannot_see = []
topic_id = params[:topic_id]
unless topic_id.blank?
topic = Topic.find_by(id: topic_id)
usernames.each { |username| cannot_see.push(username) unless Guardian.new(User.find_by_username(username)).can_see?(topic) }
end
result = User.where(staged: false)
.where(username_lower: usernames)
.pluck(:username_lower)
render json: {
valid: result,
valid_groups: groups,
mentionable_groups: mentionable_groups,
cannot_see: cannot_see,
max_users_notified_per_group_mention: SiteSetting.max_users_notified_per_group_mention
}
end
def render_available_true
render(json: { available: true })
end
def changing_case_of_own_username(target_user, username)
target_user && username.downcase == (target_user.username.downcase)
end
# Used for checking availability of a username and will return suggestions
# if the username is not available.
def check_username
if !params[:username].present?
params.require(:username) if !params[:email].present?
return render(json: success_json)
end
username = params[:username]&.unicode_normalize
target_user = user_from_params_or_current_user
# The special case where someone is changing the case of their own username
return render_available_true if changing_case_of_own_username(target_user, username)
checker = UsernameCheckerService.new
email = params[:email] || target_user.try(:email)
render json: checker.check_username(username, email)
end
def user_from_params_or_current_user
params[:for_user_id] ? User.find(params[:for_user_id]) : current_user
end
def create
params.require(:email)
params.require(:username)
params.permit(:user_fields)
unless SiteSetting.allow_new_registrations
return fail_with("login.new_registrations_disabled")
end
if params[:password] && params[:password].length > User.max_password_length
return fail_with("login.password_too_long")
end
if params[:email].length > 254 + 1 + 253
return fail_with("login.email_too_long")
end
if clashing_with_existing_route?(params[:username]) || User.reserved_username?(params[:username])
return fail_with("login.reserved_username")
end
params[:locale] ||= I18n.locale unless current_user
new_user_params = user_params
user = User.unstage(new_user_params)
user = User.new(new_user_params) if user.nil?
# Handle API approval
ReviewableUser.set_approved_fields!(user, current_user) if user.approved?
# Handle custom fields
user_fields = UserField.all
if user_fields.present?
field_params = params[:user_fields] || {}
fields = user.custom_fields
user_fields.each do |f|
field_val = field_params[f.id.to_s]
if field_val.blank?
return fail_with("login.missing_user_field") if f.required?
else
fields["#{User::USER_FIELD_PREFIX}#{f.id}"] = field_val[0...UserField.max_length]
end
end
user.custom_fields = fields
end
authentication = UserAuthenticator.new(user, session)
if !authentication.has_authenticator? && !SiteSetting.enable_local_logins
return render body: nil, status: :forbidden
end
authentication.start
if authentication.email_valid? && !authentication.authenticated?
# posted email is different that the already validated one?
return fail_with('login.incorrect_username_email_or_password')
end
activation = UserActivator.new(user, request, session, cookies)
activation.start
# just assign a password if we have an authenticator and no password
# this is the case for Twitter
user.password = SecureRandom.hex if user.password.blank? && authentication.has_authenticator?
if user.save
authentication.finish
activation.finish
secure_session[HONEYPOT_KEY] = nil
secure_session[CHALLENGE_KEY] = nil
# save user email in session, to show on account-created page
session["user_created_message"] = activation.message
session[SessionController::ACTIVATE_USER_KEY] = user.id
# If the user was created as active, they might need to be approved
user.create_reviewable if user.active?
render json: {
success: true,
active: user.active?,
message: activation.message,
user_id: user.id
}
elsif SiteSetting.hide_email_address_taken && user.errors[:primary_email]&.include?(I18n.t('errors.messages.taken'))
session["user_created_message"] = activation.success_message
if existing_user = User.find_by_email(user.primary_email&.email)
Jobs.enqueue(:critical_user_email, type: :account_exists, user_id: existing_user.id)
end
render json: {
success: true,
active: user.active?,
message: activation.success_message,
user_id: user.id
}
else
errors = user.errors.to_hash
errors[:email] = errors.delete(:primary_email) if errors[:primary_email]
render json: {
success: false,
message: I18n.t(
'login.errors',
errors: user.errors.full_messages.join("\n")
),
errors: errors,
values: {
name: user.name,
username: user.username,
email: user.primary_email&.email
},
is_developer: UsernameCheckerService.is_developer?(user.email)
}
end
rescue ActiveRecord::StatementInvalid
render json: {
success: false,
message: I18n.t("login.something_already_taken")
}
end
def get_honeypot_value
secure_session.set(HONEYPOT_KEY, honeypot_value, expires: 1.hour)
secure_session.set(CHALLENGE_KEY, challenge_value, expires: 1.hour)
render json: {
value: honeypot_value,
challenge: challenge_value,
expires_in: SecureSession.expiry
}
end
def password_reset
expires_now
token = params[:token]
if EmailToken.valid_token_format?(token)
@user = if request.put?
EmailToken.confirm(token)
else
EmailToken.confirmable(token)&.user
end
if @user
secure_session["password-#{token}"] = @user.id
else
user_id = secure_session["password-#{token}"].to_i
@user = User.find(user_id) if user_id > 0
end
end
second_factor_token = params[:second_factor_token]
second_factor_method = params[:second_factor_method].to_i
security_key_credential = params[:security_key_credential]
if second_factor_token.present? && UserSecondFactor.methods[second_factor_method]
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
second_factor_authenticated = @user&.authenticate_second_factor(second_factor_token, second_factor_method)
elsif security_key_credential.present?
security_key_authenticated = ::Webauthn::SecurityKeyAuthenticationService.new(
@user,
security_key_credential,
challenge: secure_session["staged-webauthn-challenge-#{@user.id}"],
rp_id: secure_session["staged-webauthn-rp-id-#{@user.id}"],
origin: Discourse.base_url
).authenticate_security_key
end
second_factor_totp_disabled = !@user&.totp_enabled?
if second_factor_authenticated || second_factor_totp_disabled || security_key_authenticated
secure_session["second-factor-#{token}"] = "true"
end
security_key_disabled = !@user&.security_keys_enabled?
if security_key_authenticated || security_key_disabled
secure_session["security-key-#{token}"] = "true"
end
valid_second_factor = secure_session["second-factor-#{token}"] == "true"
valid_security_key = secure_session["security-key-#{token}"] == "true"
if !@user
@error = I18n.t('password_reset.no_token')
elsif request.put?
if !valid_second_factor
@user.errors.add(:user_second_factors, :invalid)
@error = I18n.t('login.invalid_second_factor_code')
elsif @invalid_password = params[:password].blank? || params[:password].size > User.max_password_length
@user.errors.add(:password, :invalid)
else
@user.password = params[:password]
@user.password_required!
@user.user_auth_tokens.destroy_all
if @user.save
Invite.invalidate_for_email(@user.email) # invite link can't be used to log in anymore
secure_session["password-#{token}"] = nil
secure_session["second-factor-#{token}"] = nil
UserHistory.create!(
target_user: @user,
acting_user: @user,
action: UserHistory.actions[:change_password]
)
logon_after_password_reset
end
end
end
respond_to do |format|
format.html do
if @error
render layout: 'no_ember'
else
stage_webauthn_security_key_challenge(@user)
store_preloaded(
"password_reset",
MultiJson.dump(
{
is_developer: UsernameCheckerService.is_developer?(@user.email),
admin: @user.admin?,
second_factor_required: !valid_second_factor,
security_key_required: !valid_security_key,
backup_enabled: @user.backup_codes_enabled?
}.merge(webauthn_security_key_challenge_and_allowed_credentials(@user))
)
)
end
return redirect_to(wizard_path) if request.put? && Wizard.user_requires_completion?(@user)
end
format.json do
if request.put?
if @error || @user&.errors&.any?
render json: {
success: false,
message: @error,
errors: @user&.errors&.to_hash,
is_developer: UsernameCheckerService.is_developer?(@user&.email),
admin: @user&.admin?
}
else
render json: {
success: true,
message: @success,
requires_approval: !Guardian.new(@user).can_access_forum?,
redirect_to: Wizard.user_requires_completion?(@user) ? wizard_path : nil
}
end
else
if @error || @user&.errors&.any?
render json: {
message: @error,
errors: @user&.errors&.to_hash
}
else
stage_webauthn_security_key_challenge(@user) if !valid_security_key && !security_key_credential.present?
render json: {
is_developer: UsernameCheckerService.is_developer?(@user.email),
admin: @user.admin?,
second_factor_required: !valid_second_factor,
security_key_required: !valid_security_key,
backup_enabled: @user.backup_codes_enabled?
}.merge(webauthn_security_key_challenge_and_allowed_credentials(@user))
end
end
end
end
rescue ::Webauthn::SecurityKeyError => err
render json: {
message: err.message,
errors: [err.message]
}
end
def confirm_email_token
expires_now
EmailToken.confirm(params[:token])
render json: success_json
end
def logon_after_password_reset
message =
if Guardian.new(@user).can_access_forum?
# Log in the user
log_on_user(@user)
'password_reset.success'
else
@requires_approval = true
'password_reset.success_unapproved'
end
@success = I18n.t(message)
end
def admin_login
return redirect_to(path("/")) if current_user
if request.put? && params[:email].present?
RateLimiter.new(nil, "admin-login-hr-#{request.remote_ip}", 6, 1.hour).performed!
RateLimiter.new(nil, "admin-login-min-#{request.remote_ip}", 3, 1.minute).performed!
if user = User.with_email(params[:email]).admins.human_users.first
email_token = user.email_tokens.create(email: user.email)
Jobs.enqueue(:critical_user_email, type: :admin_login, user_id: user.id, email_token: email_token.token)
@message = I18n.t("admin_login.success")
else
@message = I18n.t("admin_login.errors.unknown_email_address")
end
elsif (token = params[:token]).present?
valid_token = EmailToken.valid_token_format?(token)
if valid_token
if params[:second_factor_token].present?
RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed!
end
email_token_user = EmailToken.confirmable(token)&.user
totp_enabled = email_token_user&.totp_enabled?
security_keys_enabled = email_token_user&.security_keys_enabled?
second_factor_token = params[:second_factor_token]
second_factor_method = params[:second_factor_method].to_i
confirm_email = false
@security_key_required = security_keys_enabled
if security_keys_enabled && params[:security_key_credential].blank?
stage_webauthn_security_key_challenge(email_token_user)
challenge_and_credentials = webauthn_security_key_challenge_and_allowed_credentials(email_token_user)
@security_key_challenge = challenge_and_credentials[:challenge]
@security_key_allowed_credential_ids = challenge_and_credentials[:allowed_credential_ids].join(",")
end
if security_keys_enabled && params[:security_key_credential].present?
credential = JSON.parse(params[:security_key_credential]).with_indifferent_access
confirm_email = ::Webauthn::SecurityKeyAuthenticationService.new(email_token_user, credential,
challenge: secure_session["staged-webauthn-challenge-#{email_token_user&.id}"],
rp_id: secure_session["staged-webauthn-rp-id-#{email_token_user&.id}"],
origin: Discourse.base_url
).authenticate_security_key
@message = I18n.t('login.security_key_invalid') if !confirm_email
elsif security_keys_enabled && second_factor_token.blank?
confirm_email = false
@message = I18n.t("login.second_factor_title")
if totp_enabled
@second_factor_required = true
@backup_codes_enabled = true
end
else
confirm_email =
if totp_enabled
@second_factor_required = true
@backup_codes_enabled = true
@message = I18n.t("login.second_factor_title")
if second_factor_token.present?
if email_token_user.authenticate_second_factor(second_factor_token, second_factor_method)
true
else
@error = I18n.t("login.invalid_second_factor_code")
false
end
end
else
true
end
end
if confirm_email
@user = EmailToken.confirm(token)
if @user && @user.admin?
log_on_user(@user)
return redirect_to path("/")
else
@message = I18n.t("admin_login.errors.unknown_email_address")
end
end
else
@message = I18n.t("admin_login.errors.invalid_token")
end
end
render layout: 'no_ember'
rescue RateLimiter::LimitExceeded
@message = I18n.t("rate_limiter.slow_down")
render layout: 'no_ember'
rescue ::Webauthn::SecurityKeyError => err
@message = err.message
render layout: 'no_ember'
end
def email_login
raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email
return redirect_to path("/") if current_user
expires_now
params.require(:login)
RateLimiter.new(nil, "email-login-hour-#{request.remote_ip}", 6, 1.hour).performed!
RateLimiter.new(nil, "email-login-min-#{request.remote_ip}", 3, 1.minute).performed!
user = User.human_users.find_by_username_or_email(params[:login])
user_presence = user.present? && !user.staged
if user
RateLimiter.new(nil, "email-login-hour-#{user.id}", 6, 1.hour).performed!
RateLimiter.new(nil, "email-login-min-#{user.id}", 3, 1.minute).performed!
if user_presence
email_token = user.email_tokens.create!(email: user.email)
Jobs.enqueue(:critical_user_email,
type: :email_login,
user_id: user.id,
email_token: email_token.token
)
end
end
json = success_json
json[:user_found] = user_presence unless SiteSetting.hide_email_address_taken
render json: json
rescue RateLimiter::LimitExceeded
render_json_error(I18n.t("rate_limiter.slow_down"))
end
def toggle_anon
user = AnonymousShadowCreator.get_master(current_user) ||
AnonymousShadowCreator.get(current_user)
if user
log_on_user(user)
render json: success_json
else
render json: failed_json, status: 403
end
end
def account_created
if current_user.present?
if SiteSetting.enable_sso_provider && payload = cookies.delete(:sso_payload)
return redirect_to(session_sso_provider_url + "?" + payload)
elsif destination_url = cookies.delete(:destination_url)
return redirect_to(destination_url)
else
return redirect_to(path('/'))
end
end
@custom_body_class = "static-account-created"
@message = session['user_created_message'] || I18n.t('activation.missing_session')
@account_created = { message: @message, show_controls: false }
if session_user_id = session[SessionController::ACTIVATE_USER_KEY]
if user = User.where(id: session_user_id.to_i).first
@account_created[:username] = user.username
@account_created[:email] = user.email
@account_created[:show_controls] = !user.from_staged?
end
end
store_preloaded("accountCreated", MultiJson.dump(@account_created))
expires_now
respond_to do |format|
format.html { render "default/empty" }
format.json { render json: success_json }
end
end
def activate_account
expires_now
render layout: 'no_ember'
end
def perform_account_activation
raise Discourse::InvalidAccess.new if honeypot_or_challenge_fails?(params)
if @user = EmailToken.confirm(params[:token])
# Log in the user unless they need to be approved
if Guardian.new(@user).can_access_forum?
@user.enqueue_welcome_message('welcome_user') if @user.send_welcome_message
log_on_user(@user)
if Wizard.user_requires_completion?(@user)
return redirect_to(wizard_path)
elsif destination_url = cookies[:destination_url]
cookies[:destination_url] = nil
return redirect_to(destination_url)
elsif SiteSetting.enable_sso_provider && payload = cookies.delete(:sso_payload)
return redirect_to(session_sso_provider_url + "?" + payload)
end
else
@needs_approval = true
end
else
flash.now[:error] = I18n.t('activation.already_done')
end
render layout: 'no_ember'
end
def update_activation_email
RateLimiter.new(nil, "activate-edit-email-hr-#{request.remote_ip}", 5, 1.hour).performed!
if params[:username].present?
@user = User.find_by_username_or_email(params[:username])
raise Discourse::InvalidAccess.new unless @user.present?
raise Discourse::InvalidAccess.new unless @user.confirm_password?(params[:password])
elsif user_key = session[SessionController::ACTIVATE_USER_KEY]
@user = User.where(id: user_key.to_i).first
end
if @user.blank? || @user.active? || current_user.present? || @user.from_staged?
raise Discourse::InvalidAccess.new
end
User.transaction do
primary_email = @user.primary_email
primary_email.email = params[:email]
primary_email.skip_validate_email = false
if primary_email.save
@user.email_tokens.create!(email: @user.email)
enqueue_activation_email
render json: success_json
else
render_json_error(primary_email)
end
end
end
def send_activation_email
if current_user.blank? || !current_user.staff?
RateLimiter.new(nil, "activate-hr-#{request.remote_ip}", 30, 1.hour).performed!
RateLimiter.new(nil, "activate-min-#{request.remote_ip}", 6, 1.minute).performed!
end
raise Discourse::InvalidAccess.new if SiteSetting.must_approve_users?
if params[:username].present?
@user = User.find_by_username_or_email(params[:username].to_s)
end
raise Discourse::NotFound unless @user
if !current_user&.staff? &&
@user.id != session[SessionController::ACTIVATE_USER_KEY]
raise Discourse::InvalidAccess.new
end
session.delete(SessionController::ACTIVATE_USER_KEY)
if @user.active && @user.email_confirmed?
render_json_error(I18n.t('activation.activated'), status: 409)
else
@email_token = @user.email_tokens.unconfirmed.active.first
enqueue_activation_email
render body: nil
end
end
def enqueue_activation_email
@email_token ||= @user.email_tokens.create!(email: @user.email)
Jobs.enqueue(:critical_user_email, type: :signup, user_id: @user.id, email_token: @email_token.token, to_address: @user.email)
end
def search_users
term = params[:term].to_s.strip
topic_id = params[:topic_id]
topic_id = topic_id.to_i if topic_id
category_id = params[:category_id]
category_id = category_id.to_i if category_id
topic_allowed_users = params[:topic_allowed_users] || false
group_names = params[:groups] || []
group_names << params[:group] if params[:group]
if group_names.present?
@groups = Group.where(name: group_names)
end
options = {
topic_allowed_users: topic_allowed_users,
searching_user: current_user,
groups: @groups
}
if topic_id
options[:topic_id] = topic_id
end
if category_id
options[:category_id] = category_id
end
results = UserSearch.new(term, options).search
user_fields = [:username, :upload_avatar_template]
user_fields << :name if SiteSetting.enable_names?
to_render = { users: results.as_json(only: user_fields, methods: [:avatar_template]) }
groups =
if current_user
if params[:include_mentionable_groups] == 'true'
Group.mentionable(current_user)
elsif params[:include_messageable_groups] == 'true'
Group.messageable(current_user)
end
end
include_groups = params[:include_groups] == "true"
# blank term is only handy for in-topic search of users after @
# we do not want group results ever if term is blank
include_groups = groups = nil if term.blank?
if include_groups || groups
groups = Group.search_groups(term, groups: groups)
groups = groups.where(visibility_level: Group.visibility_levels[:public]) if include_groups
groups = groups.order('groups.name asc')
to_render[:groups] = groups.map do |m|
{ name: m.name, full_name: m.full_name }
end
end
render json: to_render
end
AVATAR_TYPES_WITH_UPLOAD ||= %w{uploaded custom gravatar}
def pick_avatar
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
type = params[:type]
upload_id = params[:upload_id]
if SiteSetting.sso_overrides_avatar
return render json: failed_json, status: 422
end
if !SiteSetting.allow_uploaded_avatars
if type == "uploaded" || type == "custom"
return render json: failed_json, status: 422
end
end
upload = Upload.find_by(id: upload_id)
# old safeguard
user.create_user_avatar unless user.user_avatar
guardian.ensure_can_pick_avatar!(user.user_avatar, upload)
if AVATAR_TYPES_WITH_UPLOAD.include?(type)
if !upload
return render_json_error I18n.t("avatar.missing")
end
if type == "gravatar"
user.user_avatar.gravatar_upload_id = upload_id
else
user.user_avatar.custom_upload_id = upload_id
end
end
user.uploaded_avatar_id = upload_id
user.save!
user.user_avatar.save!
render json: success_json
end
def select_avatar
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
url = params[:url]
if url.blank?
return render json: failed_json, status: 422
end
unless SiteSetting.selectable_avatars_enabled
return render json: failed_json, status: 422
end
if SiteSetting.selectable_avatars.blank?
return render json: failed_json, status: 422
end
unless SiteSetting.selectable_avatars[url]
return render json: failed_json, status: 422
end
unless upload = Upload.find_by(url: url)
return render json: failed_json, status: 422
end
user.uploaded_avatar_id = upload.id
user.save!
avatar = user.user_avatar || user.create_user_avatar
avatar.custom_upload_id = upload.id
avatar.save!
render json: {
avatar_template: user.avatar_template,
custom_avatar_template: user.avatar_template,
uploaded_avatar_id: upload.id,
}
end
def destroy_user_image
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
case params.require(:type)
when "profile_background"
user.user_profile.clear_profile_background
when "card_background"
user.user_profile.clear_card_background
else
raise Discourse::InvalidParameters.new(:type)
end
render json: success_json
end
def destroy
@user = fetch_user_from_params
guardian.ensure_can_delete_user!(@user)
UserDestroyer.new(current_user).destroy(@user, delete_posts: true, context: params[:context])
render json: success_json
end
def notification_level
user = fetch_user_from_params
if params[:notification_level] == "ignore"
guardian.ensure_can_ignore_user!(user.id)
MutedUser.where(user: current_user, muted_user: user).delete_all
ignored_user = IgnoredUser.find_by(user: current_user, ignored_user: user)
if ignored_user.present?
ignored_user.update(expiring_at: DateTime.parse(params[:expiring_at]))
else
IgnoredUser.create!(user: current_user, ignored_user: user, expiring_at: Time.parse(params[:expiring_at]))
end
elsif params[:notification_level] == "mute"
guardian.ensure_can_mute_user!(user.id)
IgnoredUser.where(user: current_user, ignored_user: user).delete_all
MutedUser.find_or_create_by!(user: current_user, muted_user: user)
elsif params[:notification_level] == "normal"
MutedUser.where(user: current_user, muted_user: user).delete_all
IgnoredUser.where(user: current_user, ignored_user: user).delete_all
end
render json: success_json
end
def read_faq
if user = current_user
user.user_stat.read_faq = 1.second.ago
user.user_stat.save
end
render json: success_json
end
def staff_info
@user = fetch_user_from_params(include_inactive: true)
guardian.ensure_can_see_staff_info!(@user)
result = {}
%W{number_of_deleted_posts number_of_flagged_posts number_of_flags_given number_of_suspensions warnings_received_count}.each do |info|
result[info] = @user.public_send(info)
end
render json: result
end
def confirm_admin
@confirmation = AdminConfirmation.find_by_code(params[:token])
raise Discourse::NotFound unless @confirmation
raise Discourse::InvalidAccess.new unless
@confirmation.performed_by.id == (current_user&.id || @confirmation.performed_by.id)
if request.post?
@confirmation.email_confirmed!
@confirmed = true
end
respond_to do |format|
format.json { render json: success_json }
format.html { render layout: 'no_ember' }
end
end
def list_second_factors
raise Discourse::NotFound if SiteSetting.enable_sso || !SiteSetting.enable_local_logins
unless params[:password].empty?
RateLimiter.new(nil, "login-hr-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_hour, 1.hour).performed!
RateLimiter.new(nil, "login-min-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_minute, 1.minute).performed!
unless current_user.confirm_password?(params[:password])
return render json: failed_json.merge(
error: I18n.t("login.incorrect_password")
)
end
secure_session["confirmed-password-#{current_user.id}"] = "true"
end
if secure_session["confirmed-password-#{current_user.id}"] == "true"
totp_second_factors = current_user.totps
.select(:id, :name, :last_used, :created_at, :method)
.where(enabled: true).order(:created_at)
security_keys = current_user.security_keys.where(factor_type: UserSecurityKey.factor_types[:second_factor]).order(:created_at)
render json: success_json.merge(
totps: totp_second_factors,
security_keys: security_keys
)
else
render json: success_json.merge(
password_required: true
)
end
end
def create_second_factor_backup
backup_codes = current_user.generate_backup_codes
render json: success_json.merge(
backup_codes: backup_codes
)
end
def create_second_factor_totp
totp_data = ROTP::Base32.random_base32
secure_session["staged-totp-#{current_user.id}"] = totp_data
qrcode_svg = RQRCode::QRCode.new(current_user.totp_provisioning_uri(totp_data)).as_svg(
offset: 0,
color: '000',
shape_rendering: 'crispEdges',
module_size: 4
)
render json: success_json.merge(
key: totp_data.scan(/.{4}/).join(" "),
qr: qrcode_svg
)
end
def create_second_factor_security_key
challenge = SecureRandom.hex(30)
secure_session["staged-webauthn-challenge-#{current_user.id}"] = challenge
secure_session["staged-webauthn-rp-id-#{current_user.id}"] = Discourse.current_hostname
secure_session["staged-webauthn-rp-name-#{current_user.id}"] = SiteSetting.title
render json: success_json.merge(
challenge: challenge,
rp_id: Discourse.current_hostname,
rp_name: SiteSetting.title,
supported_algoriths: ::Webauthn::SUPPORTED_ALGORITHMS,
user_secure_id: current_user.create_or_fetch_secure_identifier,
existing_active_credential_ids: current_user.second_factor_security_key_credential_ids
)
end
def register_second_factor_security_key
params.require(:name)
params.require(:attestation)
params.require(:clientData)
::Webauthn::SecurityKeyRegistrationService.new(
current_user,
params,
challenge: secure_session["staged-webauthn-challenge-#{current_user.id}"],
rp_id: secure_session["staged-webauthn-rp-id-#{current_user.id}"],
origin: Discourse.base_url
).register_second_factor_security_key
render json: success_json
rescue ::Webauthn::SecurityKeyError => err
render json: failed_json.merge(
error: err.message
)
end
def update_security_key
user_security_key = current_user.security_keys.find_by(id: params[:id].to_i)
raise Discourse::InvalidParameters unless user_security_key
if params[:name] && !params[:name].blank?
user_security_key.update!(name: params[:name])
end
if params[:disable] == "true"
user_security_key.update!(enabled: false)
end
render json: success_json
end
def enable_second_factor_totp
params.require(:second_factor_token)
params.require(:name)
auth_token = params[:second_factor_token]
totp_data = secure_session["staged-totp-#{current_user.id}"]
totp_object = current_user.get_totp_object(totp_data)
[request.remote_ip, current_user.id].each do |key|
RateLimiter.new(nil, "second-factor-min-#{key}", 3, 1.minute).performed!
end
authenticated = !auth_token.blank? && totp_object.verify_with_drift(auth_token, 30)
unless authenticated
return render json: failed_json.merge(
error: I18n.t("login.invalid_second_factor_code")
)
end
current_user.create_totp(data: totp_data, name: params[:name], enabled: true)
render json: success_json
end
def disable_second_factor
# delete all second factors for a user
current_user.user_second_factors.destroy_all
Jobs.enqueue(
:critical_user_email,
type: :account_second_factor_disabled,
user_id: current_user.id
)
render json: success_json
end
def update_second_factor
params.require(:second_factor_target)
update_second_factor_method = params[:second_factor_target].to_i
if update_second_factor_method == UserSecondFactor.methods[:totp]
params.require(:id)
second_factor_id = params[:id].to_i
user_second_factor = current_user.user_second_factors.totps.find_by(id: second_factor_id)
elsif update_second_factor_method == UserSecondFactor.methods[:backup_codes]
user_second_factor = current_user.user_second_factors.backup_codes
end
raise Discourse::InvalidParameters unless user_second_factor
if params[:name] && !params[:name].blank?
user_second_factor.update!(name: params[:name])
end
if params[:disable] == "true"
# Disabling backup codes deletes *all* backup codes
if update_second_factor_method == UserSecondFactor.methods[:backup_codes]
current_user.user_second_factors.where(method: UserSecondFactor.methods[:backup_codes]).destroy_all
else
user_second_factor.update!(enabled: false)
end
end
render json: success_json
end
def second_factor_check_confirmed_password
raise Discourse::NotFound if SiteSetting.enable_sso || !SiteSetting.enable_local_logins
raise Discourse::InvalidAccess.new unless current_user && secure_session["confirmed-password-#{current_user.id}"] == "true"
end
def revoke_account
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
provider_name = params.require(:provider_name)
# Using Discourse.authenticators rather than Discourse.enabled_authenticators so users can
# revoke permissions even if the admin has temporarily disabled that type of login
authenticator = Discourse.authenticators.find { |a| a.name == provider_name }
raise Discourse::NotFound if authenticator.nil? || !authenticator.can_revoke?
skip_remote = params.permit(:skip_remote)
# We're likely going to contact the remote auth provider, so hijack request
hijack do
DiscourseEvent.trigger(:before_auth_revoke, authenticator, user)
result = authenticator.revoke(user, skip_remote: skip_remote)
if result
render json: success_json
else
render json: {
success: false,
message: I18n.t("associated_accounts.revoke_failed", provider_name: provider_name)
}
end
end
end
def revoke_auth_token
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
if params[:token_id]
token = UserAuthToken.find_by(id: params[:token_id], user_id: user.id)
# The user should not be able to revoke the auth token of current session.
raise Discourse::InvalidParameters.new(:token_id) if guardian.auth_token == token.auth_token
UserAuthToken.where(id: params[:token_id], user_id: user.id).each(&:destroy!)
MessageBus.publish "/file-change", ["refresh"], user_ids: [user.id]
else
UserAuthToken.where(user_id: user.id).each(&:destroy!)
end
render json: success_json
end
HONEYPOT_KEY ||= 'HONEYPOT_KEY'
CHALLENGE_KEY ||= 'CHALLENGE_KEY'
protected
def honeypot_value
secure_session[HONEYPOT_KEY] ||= SecureRandom.hex
end
def challenge_value
secure_session[CHALLENGE_KEY] ||= SecureRandom.hex
end
private
def respond_to_suspicious_request
if suspicious?(params)
render json: {
success: true,
active: false,
message: I18n.t("login.activate_email", email: params[:email])
}
end
end
def suspicious?(params)
return false if current_user && is_api? && current_user.admin?
honeypot_or_challenge_fails?(params) || SiteSetting.invite_only?
end
def honeypot_or_challenge_fails?(params)
return false if is_api?
params[:password_confirmation] != honeypot_value ||
params[:challenge] != challenge_value.try(:reverse)
end
def user_params
permitted = [
:name,
:email,
:password,
:username,
:title,
:date_of_birth,
:muted_usernames,
:ignored_usernames,
:theme_ids,
:locale,
:bio_raw,
:location,
:website,
:dismissed_banner_key,
:profile_background_upload_url,
:card_background_upload_url
]
editable_custom_fields = User.editable_user_custom_fields(by_staff: current_user.try(:staff?))
permitted << { custom_fields: editable_custom_fields } unless editable_custom_fields.blank?
permitted.concat UserUpdater::OPTION_ATTR
permitted.concat UserUpdater::CATEGORY_IDS.keys.map { |k| { k => [] } }
permitted.concat UserUpdater::TAG_NAMES.keys
result = params
.permit(permitted, theme_ids: [])
.reverse_merge(
ip_address: request.remote_ip,
registration_ip_address: request.remote_ip
)
if !UsernameCheckerService.is_developer?(result['email']) &&
is_api? &&
current_user.present? &&
current_user.admin?
result.merge!(params.permit(:active, :staged, :approved))
end
modify_user_params(result)
end
# Plugins can use this to modify user parameters
def modify_user_params(attrs)
attrs
end
def fail_with(key)
render json: { success: false, message: I18n.t(key) }
end
def track_visit_to_user_profile
user_profile_id = @user.user_profile.id
ip = request.remote_ip
user_id = (current_user.id if current_user)
Scheduler::Defer.later 'Track profile view visit' do
UserProfileView.add(user_profile_id, ip, user_id)
end
end
def clashing_with_existing_route?(username)
normalized_username = User.normalize_username(username)
http_verbs = %w[GET POST PUT DELETE PATCH]
allowed_actions = %w[show update destroy]
http_verbs.any? do |verb|
begin
path = Rails.application.routes.recognize_path("/u/#{normalized_username}", method: verb)
allowed_actions.exclude?(path[:action])
rescue ActionController::RoutingError
false
end
end
end
def stage_webauthn_security_key_challenge(user)
challenge = SecureRandom.hex(30)
secure_session["staged-webauthn-challenge-#{user.id}"] = challenge
secure_session["staged-webauthn-rp-id-#{user.id}"] = Discourse.current_hostname
end
def webauthn_security_key_challenge_and_allowed_credentials(user)
return {} if !user.security_keys_enabled?
credential_ids = user.second_factor_security_key_credential_ids
{
allowed_credential_ids: credential_ids,
challenge: secure_session["staged-webauthn-challenge-#{user.id}"]
}
end
end