mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 18:30:26 -06:00
d5d8db7fa8
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.
1509 lines
48 KiB
Ruby
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
|