forked from cybrespace/mastodon
		
	Add e-mail-based sign in challenge for users with disabled 2FA (#14013)
This commit is contained in:
		
							parent
							
								
									8b6d97fb7c
								
							
						
					
					
						commit
						72a7cfaa39
					
				
					 14 changed files with 368 additions and 51 deletions
				
			
		| 
						 | 
				
			
			@ -8,7 +8,8 @@ class Auth::SessionsController < Devise::SessionsController
 | 
			
		|||
  skip_before_action :require_no_authentication, only: [:create]
 | 
			
		||||
  skip_before_action :require_functional!
 | 
			
		||||
 | 
			
		||||
  prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
 | 
			
		||||
  include TwoFactorAuthenticationConcern
 | 
			
		||||
  include SignInTokenAuthenticationConcern
 | 
			
		||||
 | 
			
		||||
  before_action :set_instance_presenter, only: [:new]
 | 
			
		||||
  before_action :set_body_classes
 | 
			
		||||
| 
						 | 
				
			
			@ -39,8 +40,8 @@ class Auth::SessionsController < Devise::SessionsController
 | 
			
		|||
  protected
 | 
			
		||||
 | 
			
		||||
  def find_user
 | 
			
		||||
    if session[:otp_user_id]
 | 
			
		||||
      User.find(session[:otp_user_id])
 | 
			
		||||
    if session[:attempt_user_id]
 | 
			
		||||
      User.find(session[:attempt_user_id])
 | 
			
		||||
    else
 | 
			
		||||
      user   = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
 | 
			
		||||
      user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
 | 
			
		||||
| 
						 | 
				
			
			@ -49,7 +50,7 @@ class Auth::SessionsController < Devise::SessionsController
 | 
			
		|||
  end
 | 
			
		||||
 | 
			
		||||
  def user_params
 | 
			
		||||
    params.require(:user).permit(:email, :password, :otp_attempt)
 | 
			
		||||
    params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def after_sign_in_path_for(resource)
 | 
			
		||||
| 
						 | 
				
			
			@ -70,47 +71,6 @@ class Auth::SessionsController < Devise::SessionsController
 | 
			
		|||
    super
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_enabled?
 | 
			
		||||
    find_user&.otp_required_for_login?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def valid_otp_attempt?(user)
 | 
			
		||||
    user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
 | 
			
		||||
      user.invalidate_otp_backup_code!(user_params[:otp_attempt])
 | 
			
		||||
  rescue OpenSSL::Cipher::CipherError
 | 
			
		||||
    false
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def authenticate_with_two_factor
 | 
			
		||||
    user = self.resource = find_user
 | 
			
		||||
 | 
			
		||||
    if user_params[:otp_attempt].present? && session[:otp_user_id]
 | 
			
		||||
      authenticate_with_two_factor_via_otp(user)
 | 
			
		||||
    elsif user.present? && (user.encrypted_password.blank? || user.valid_password?(user_params[:password]))
 | 
			
		||||
      # If encrypted_password is blank, we got the user from LDAP or PAM,
 | 
			
		||||
      # so credentials are already valid
 | 
			
		||||
 | 
			
		||||
      prompt_for_two_factor(user)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def authenticate_with_two_factor_via_otp(user)
 | 
			
		||||
    if valid_otp_attempt?(user)
 | 
			
		||||
      session.delete(:otp_user_id)
 | 
			
		||||
      remember_me(user)
 | 
			
		||||
      sign_in(user)
 | 
			
		||||
    else
 | 
			
		||||
      flash.now[:alert] = I18n.t('users.invalid_otp_token')
 | 
			
		||||
      prompt_for_two_factor(user)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def prompt_for_two_factor(user)
 | 
			
		||||
    session[:otp_user_id] = user.id
 | 
			
		||||
    @body_classes = 'lighter'
 | 
			
		||||
    render :two_factor
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def require_no_authentication
 | 
			
		||||
    super
 | 
			
		||||
    # Delete flash message that isn't entirely useful and may be confusing in
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,49 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module SignInTokenAuthenticationConcern
 | 
			
		||||
  extend ActiveSupport::Concern
 | 
			
		||||
 | 
			
		||||
  included do
 | 
			
		||||
    prepend_before_action :authenticate_with_sign_in_token, if: :sign_in_token_required?, only: [:create]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def sign_in_token_required?
 | 
			
		||||
    find_user&.suspicious_sign_in?(request.remote_ip)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def valid_sign_in_token_attempt?(user)
 | 
			
		||||
    Devise.secure_compare(user.sign_in_token, user_params[:sign_in_token_attempt])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def authenticate_with_sign_in_token
 | 
			
		||||
    user = self.resource = find_user
 | 
			
		||||
 | 
			
		||||
    if user_params[:sign_in_token_attempt].present? && session[:attempt_user_id]
 | 
			
		||||
      authenticate_with_sign_in_token_attempt(user)
 | 
			
		||||
    elsif user.present? && user.external_or_valid_password?(user_params[:password])
 | 
			
		||||
      prompt_for_sign_in_token(user)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def authenticate_with_sign_in_token_attempt(user)
 | 
			
		||||
    if valid_sign_in_token_attempt?(user)
 | 
			
		||||
      session.delete(:attempt_user_id)
 | 
			
		||||
      remember_me(user)
 | 
			
		||||
      sign_in(user)
 | 
			
		||||
    else
 | 
			
		||||
      flash.now[:alert] = I18n.t('users.invalid_sign_in_token')
 | 
			
		||||
      prompt_for_sign_in_token(user)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def prompt_for_sign_in_token(user)
 | 
			
		||||
    if user.sign_in_token_expired?
 | 
			
		||||
      user.generate_sign_in_token && user.save
 | 
			
		||||
      UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later!
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    session[:attempt_user_id] = user.id
 | 
			
		||||
    @body_classes = 'lighter'
 | 
			
		||||
    render :sign_in_token
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,47 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module TwoFactorAuthenticationConcern
 | 
			
		||||
  extend ActiveSupport::Concern
 | 
			
		||||
 | 
			
		||||
  included do
 | 
			
		||||
    prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def two_factor_enabled?
 | 
			
		||||
    find_user&.otp_required_for_login?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def valid_otp_attempt?(user)
 | 
			
		||||
    user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
 | 
			
		||||
      user.invalidate_otp_backup_code!(user_params[:otp_attempt])
 | 
			
		||||
  rescue OpenSSL::Cipher::CipherError
 | 
			
		||||
    false
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def authenticate_with_two_factor
 | 
			
		||||
    user = self.resource = find_user
 | 
			
		||||
 | 
			
		||||
    if user_params[:otp_attempt].present? && session[:attempt_user_id]
 | 
			
		||||
      authenticate_with_two_factor_attempt(user)
 | 
			
		||||
    elsif user.present? && user.external_or_valid_password?(user_params[:password])
 | 
			
		||||
      prompt_for_two_factor(user)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def authenticate_with_two_factor_attempt(user)
 | 
			
		||||
    if valid_otp_attempt?(user)
 | 
			
		||||
      session.delete(:attempt_user_id)
 | 
			
		||||
      remember_me(user)
 | 
			
		||||
      sign_in(user)
 | 
			
		||||
    else
 | 
			
		||||
      flash.now[:alert] = I18n.t('users.invalid_otp_token')
 | 
			
		||||
      prompt_for_two_factor(user)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def prompt_for_two_factor(user)
 | 
			
		||||
    session[:attempt_user_id] = user.id
 | 
			
		||||
    @body_classes = 'lighter'
 | 
			
		||||
    render :two_factor
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -126,4 +126,21 @@ class UserMailer < Devise::Mailer
 | 
			
		|||
           reply_to: Setting.site_contact_email
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def sign_in_token(user, remote_ip, user_agent, timestamp)
 | 
			
		||||
    @resource   = user
 | 
			
		||||
    @instance   = Rails.configuration.x.local_domain
 | 
			
		||||
    @remote_ip  = remote_ip
 | 
			
		||||
    @user_agent = user_agent
 | 
			
		||||
    @detection  = Browser.new(user_agent)
 | 
			
		||||
    @timestamp  = timestamp.to_time.utc
 | 
			
		||||
 | 
			
		||||
    return if @resource.disabled?
 | 
			
		||||
 | 
			
		||||
    I18n.with_locale(@resource.locale || I18n.default_locale) do
 | 
			
		||||
      mail to: @resource.email,
 | 
			
		||||
           subject: I18n.t('user_mailer.sign_in_token.subject'),
 | 
			
		||||
           reply_to: Setting.site_contact_email
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,6 +38,8 @@
 | 
			
		|||
#  chosen_languages          :string           is an Array
 | 
			
		||||
#  created_by_application_id :bigint(8)
 | 
			
		||||
#  approved                  :boolean          default(TRUE), not null
 | 
			
		||||
#  sign_in_token             :string
 | 
			
		||||
#  sign_in_token_sent_at     :datetime
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
class User < ApplicationRecord
 | 
			
		||||
| 
						 | 
				
			
			@ -113,7 +115,7 @@ class User < ApplicationRecord
 | 
			
		|||
           :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
 | 
			
		||||
           to: :settings, prefix: :setting, allow_nil: false
 | 
			
		||||
 | 
			
		||||
  attr_reader :invite_code
 | 
			
		||||
  attr_reader :invite_code, :sign_in_token_attempt
 | 
			
		||||
  attr_writer :external
 | 
			
		||||
 | 
			
		||||
  def confirmed?
 | 
			
		||||
| 
						 | 
				
			
			@ -167,6 +169,10 @@ class User < ApplicationRecord
 | 
			
		|||
    true
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def suspicious_sign_in?(ip)
 | 
			
		||||
    !otp_required_for_login? && current_sign_in_at.present? && current_sign_in_at < 2.weeks.ago && !recent_ip?(ip)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def functional?
 | 
			
		||||
    confirmed? && approved? && !disabled? && !account.suspended? && account.moved_to_account_id.nil?
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -269,6 +275,13 @@ class User < ApplicationRecord
 | 
			
		|||
    super
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def external_or_valid_password?(compare_password)
 | 
			
		||||
    # If encrypted_password is blank, we got the user from LDAP or PAM,
 | 
			
		||||
    # so credentials are already valid
 | 
			
		||||
 | 
			
		||||
    encrypted_password.blank? || valid_password?(compare_password)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def send_reset_password_instructions
 | 
			
		||||
    return false if encrypted_password.blank?
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -304,6 +317,15 @@ class User < ApplicationRecord
 | 
			
		|||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def sign_in_token_expired?
 | 
			
		||||
    sign_in_token_sent_at.nil? || sign_in_token_sent_at < 5.minutes.ago
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def generate_sign_in_token
 | 
			
		||||
    self.sign_in_token         = Devise.friendly_token(6)
 | 
			
		||||
    self.sign_in_token_sent_at = Time.now.utc
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  protected
 | 
			
		||||
 | 
			
		||||
  def send_devise_notification(notification, *args)
 | 
			
		||||
| 
						 | 
				
			
			@ -320,6 +342,10 @@ class User < ApplicationRecord
 | 
			
		|||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def recent_ip?(ip)
 | 
			
		||||
    recent_ips.any? { |(_, recent_ip)| recent_ip == ip }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def send_pending_devise_notifications
 | 
			
		||||
    pending_devise_notifications.each do |notification, args|
 | 
			
		||||
      render_and_send_devise_message(notification, *args)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										14
									
								
								app/views/auth/sessions/sign_in_token.html.haml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								app/views/auth/sessions/sign_in_token.html.haml
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
- content_for :page_title do
 | 
			
		||||
  = t('auth.login')
 | 
			
		||||
 | 
			
		||||
= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
 | 
			
		||||
  %p.hint.otp-hint= t('users.suspicious_sign_in_confirmation')
 | 
			
		||||
 | 
			
		||||
  .fields-group
 | 
			
		||||
    = f.input :sign_in_token_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.sign_in_token_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.sign_in_token_attempt'), :autocomplete => 'off' }, autofocus: true
 | 
			
		||||
 | 
			
		||||
  .actions
 | 
			
		||||
    = f.button :button, t('auth.login'), type: :submit
 | 
			
		||||
 | 
			
		||||
  - if Setting.site_contact_email.present?
 | 
			
		||||
    %p.hint.subtle-hint= t('users.generic_access_help_html', email: mail_to(Setting.site_contact_email, nil))
 | 
			
		||||
							
								
								
									
										105
									
								
								app/views/user_mailer/sign_in_token.html.haml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								app/views/user_mailer/sign_in_token.html.haml
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,105 @@
 | 
			
		|||
%table.email-table{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
  %tbody
 | 
			
		||||
    %tr
 | 
			
		||||
      %td.email-body
 | 
			
		||||
        .email-container
 | 
			
		||||
          %table.content-section{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
            %tbody
 | 
			
		||||
              %tr
 | 
			
		||||
                %td.content-cell.hero
 | 
			
		||||
                  .email-row
 | 
			
		||||
                    .col-6
 | 
			
		||||
                      %table.column{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
                        %tbody
 | 
			
		||||
                          %tr
 | 
			
		||||
                            %td.column-cell.text-center.padded
 | 
			
		||||
                              %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
                                %tbody
 | 
			
		||||
                                  %tr
 | 
			
		||||
                                    %td
 | 
			
		||||
                                      = image_tag full_pack_url('media/images/mailer/icon_email.png'), alt: ''
 | 
			
		||||
 | 
			
		||||
                              %h1= t 'user_mailer.sign_in_token.title'
 | 
			
		||||
                              %p.lead= t 'user_mailer.sign_in_token.explanation'
 | 
			
		||||
 | 
			
		||||
%table.email-table{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
  %tbody
 | 
			
		||||
    %tr
 | 
			
		||||
      %td.email-body
 | 
			
		||||
        .email-container
 | 
			
		||||
          %table.content-section{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
            %tbody
 | 
			
		||||
              %tr
 | 
			
		||||
                %td.content-cell.content-start
 | 
			
		||||
                  %table.column{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
                    %tbody
 | 
			
		||||
                      %tr
 | 
			
		||||
                        %td.column-cell.input-cell
 | 
			
		||||
                          %table.input{ align: 'center', cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
                            %tbody
 | 
			
		||||
                              %tr
 | 
			
		||||
                                %td= @resource.sign_in_token
 | 
			
		||||
 | 
			
		||||
%table.email-table{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
  %tbody
 | 
			
		||||
    %tr
 | 
			
		||||
      %td.email-body
 | 
			
		||||
        .email-container
 | 
			
		||||
          %table.content-section{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
            %tbody
 | 
			
		||||
              %tr
 | 
			
		||||
                %td.content-cell
 | 
			
		||||
                  .email-row
 | 
			
		||||
                    .col-6
 | 
			
		||||
                      %table.column{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
                        %tbody
 | 
			
		||||
                          %tr
 | 
			
		||||
                            %td.column-cell.text-center
 | 
			
		||||
                              %p= t 'user_mailer.sign_in_token.details'
 | 
			
		||||
                          %tr
 | 
			
		||||
                            %td.column-cell.text-center
 | 
			
		||||
                              %p
 | 
			
		||||
                                %strong= "#{t('sessions.ip')}:"
 | 
			
		||||
                                = @remote_ip
 | 
			
		||||
                                %br/
 | 
			
		||||
                                %strong= "#{t('sessions.browser')}:"
 | 
			
		||||
                                %span{ title: @user_agent }= t 'sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: "#{@detection.id}"), platform: t("sessions.platforms.#{@detection.platform.id}", default: "#{@detection.platform.id}")
 | 
			
		||||
                                %br/
 | 
			
		||||
                                = l(@timestamp)
 | 
			
		||||
 | 
			
		||||
%table.email-table{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
  %tbody
 | 
			
		||||
    %tr
 | 
			
		||||
      %td.email-body
 | 
			
		||||
        .email-container
 | 
			
		||||
          %table.content-section{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
            %tbody
 | 
			
		||||
              %tr
 | 
			
		||||
                %td.content-cell
 | 
			
		||||
                  .email-row
 | 
			
		||||
                    .col-6
 | 
			
		||||
                      %table.column{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
                        %tbody
 | 
			
		||||
                          %tr
 | 
			
		||||
                            %td.column-cell.text-center
 | 
			
		||||
                              %p= t 'user_mailer.sign_in_token.further_actions'
 | 
			
		||||
 | 
			
		||||
%table.email-table{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
  %tbody
 | 
			
		||||
    %tr
 | 
			
		||||
      %td.email-body
 | 
			
		||||
        .email-container
 | 
			
		||||
          %table.content-section{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
            %tbody
 | 
			
		||||
              %tr
 | 
			
		||||
                %td.content-cell
 | 
			
		||||
                  %table.column{ cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
                    %tbody
 | 
			
		||||
                      %tr
 | 
			
		||||
                        %td.column-cell.button-cell
 | 
			
		||||
                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
 | 
			
		||||
                            %tbody
 | 
			
		||||
                              %tr
 | 
			
		||||
                                %td.button-primary
 | 
			
		||||
                                  = link_to edit_user_registration_url do
 | 
			
		||||
                                    %span= t 'settings.account_settings'
 | 
			
		||||
							
								
								
									
										17
									
								
								app/views/user_mailer/sign_in_token.text.erb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/views/user_mailer/sign_in_token.text.erb
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
<%= t 'user_mailer.sign_in_token.title' %>
 | 
			
		||||
 | 
			
		||||
===
 | 
			
		||||
 | 
			
		||||
<%= t 'user_mailer.sign_in_token.explanation' %>
 | 
			
		||||
 | 
			
		||||
=> <%= @resource.sign_in_token %>
 | 
			
		||||
 | 
			
		||||
<%= t 'user_mailer.sign_in_token.details' %>
 | 
			
		||||
 | 
			
		||||
<%= t('sessions.ip') %>: <%= @remote_ip %>
 | 
			
		||||
<%= t('sessions.browser') %>: <%= t('sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: "#{@detection.id}"), platform: t("sessions.platforms.#{@detection.platform.id}", default: "#{@detection.platform.id}")) %>
 | 
			
		||||
<%= l(@timestamp) %>
 | 
			
		||||
 | 
			
		||||
<%= t 'user_mailer.sign_in_token.further_actions' %>
 | 
			
		||||
 | 
			
		||||
=> <%= edit_user_registration_url %>
 | 
			
		||||
| 
						 | 
				
			
			@ -1273,6 +1273,12 @@ en:
 | 
			
		|||
      explanation: You requested a full backup of your Mastodon account. It's now ready for download!
 | 
			
		||||
      subject: Your archive is ready for download
 | 
			
		||||
      title: Archive takeout
 | 
			
		||||
    sign_in_token:
 | 
			
		||||
      details: 'Here are details of the attempt:'
 | 
			
		||||
      explanation: 'We detected an attempt to sign in to your account from an unrecognized IP address. If this is you, please enter the security code below on the sign in challenge page:'
 | 
			
		||||
      further_actions: 'If this wasn''t you, please change your password and enable two-factor authentication on your account. You can do so here:'
 | 
			
		||||
      subject: Please confirm attempted sign in
 | 
			
		||||
      title: Sign in attempt
 | 
			
		||||
    warning:
 | 
			
		||||
      explanation:
 | 
			
		||||
        disable: While your account is frozen, your account data remains intact, but you cannot perform any actions until it is unlocked.
 | 
			
		||||
| 
						 | 
				
			
			@ -1310,11 +1316,14 @@ en:
 | 
			
		|||
      title: Welcome aboard, %{name}!
 | 
			
		||||
  users:
 | 
			
		||||
    follow_limit_reached: You cannot follow more than %{limit} people
 | 
			
		||||
    generic_access_help_html: Trouble accessing your account? You may get in touch with %{email} for assistance
 | 
			
		||||
    invalid_email: The e-mail address is invalid
 | 
			
		||||
    invalid_otp_token: Invalid two-factor code
 | 
			
		||||
    invalid_sign_in_token: Invalid security code
 | 
			
		||||
    otp_lost_help_html: If you lost access to both, you may get in touch with %{email}
 | 
			
		||||
    seamless_external_login: You are logged in via an external service, so password and e-mail settings are not available.
 | 
			
		||||
    signed_in_as: 'Signed in as:'
 | 
			
		||||
    suspicious_sign_in_confirmation: You appear to not have logged in from this device before, and you haven't logged in for a while, so we're sending a security code to your e-mail address to confirm that it's you.
 | 
			
		||||
  verification:
 | 
			
		||||
    explanation_html: 'You can <strong>verify yourself as the owner of the links in your profile metadata</strong>. For that, the linked website must contain a link back to your Mastodon profile. The link back <strong>must</strong> have a <code>rel="me"</code> attribute. The text content of the link does not matter. Here is an example:'
 | 
			
		||||
    verification: Verification
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -151,6 +151,7 @@ en:
 | 
			
		|||
        setting_use_blurhash: Show colorful gradients for hidden media
 | 
			
		||||
        setting_use_pending_items: Slow mode
 | 
			
		||||
        severity: Severity
 | 
			
		||||
        sign_in_token_attempt: Security code
 | 
			
		||||
        type: Import type
 | 
			
		||||
        username: Username
 | 
			
		||||
        username_or_email: Username or Email
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										6
									
								
								db/migrate/20200608113046_add_sign_in_token_to_users.rb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								db/migrate/20200608113046_add_sign_in_token_to_users.rb
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
class AddSignInTokenToUsers < ActiveRecord::Migration[5.2]
 | 
			
		||||
  def change
 | 
			
		||||
    add_column :users, :sign_in_token, :string
 | 
			
		||||
    add_column :users, :sign_in_token_sent_at, :datetime
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -10,7 +10,8 @@
 | 
			
		|||
#
 | 
			
		||||
# It's strongly recommended that you check this file into your version control system.
 | 
			
		||||
 | 
			
		||||
ActiveRecord::Schema.define(version: 2020_06_05_155027) do
 | 
			
		||||
ActiveRecord::Schema.define(version: 2020_06_08_113046) do
 | 
			
		||||
 | 
			
		||||
  # These are extensions that must be enabled in order to support this database
 | 
			
		||||
  enable_extension "plpgsql"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -869,6 +870,8 @@ ActiveRecord::Schema.define(version: 2020_06_05_155027) do
 | 
			
		|||
    t.string "chosen_languages", array: true
 | 
			
		||||
    t.bigint "created_by_application_id"
 | 
			
		||||
    t.boolean "approved", default: true, null: false
 | 
			
		||||
    t.string "sign_in_token"
 | 
			
		||||
    t.datetime "sign_in_token_sent_at"
 | 
			
		||||
    t.index ["account_id"], name: "index_users_on_account_id"
 | 
			
		||||
    t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
 | 
			
		||||
    t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -215,7 +215,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
 | 
			
		|||
 | 
			
		||||
      context 'using a valid OTP' do
 | 
			
		||||
        before do
 | 
			
		||||
          post :create, params: { user: { otp_attempt: user.current_otp } }, session: { otp_user_id: user.id }
 | 
			
		||||
          post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id }
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'redirects to home' do
 | 
			
		||||
| 
						 | 
				
			
			@ -230,7 +230,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
 | 
			
		|||
      context 'when the server has an decryption error' do
 | 
			
		||||
        before do
 | 
			
		||||
          allow_any_instance_of(User).to receive(:validate_and_consume_otp!).and_raise(OpenSSL::Cipher::CipherError)
 | 
			
		||||
          post :create, params: { user: { otp_attempt: user.current_otp } }, session: { otp_user_id: user.id }
 | 
			
		||||
          post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id }
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'shows a login error' do
 | 
			
		||||
| 
						 | 
				
			
			@ -244,7 +244,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
 | 
			
		|||
 | 
			
		||||
      context 'using a valid recovery code' do
 | 
			
		||||
        before do
 | 
			
		||||
          post :create, params: { user: { otp_attempt: recovery_codes.first } }, session: { otp_user_id: user.id }
 | 
			
		||||
          post :create, params: { user: { otp_attempt: recovery_codes.first } }, session: { attempt_user_id: user.id }
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'redirects to home' do
 | 
			
		||||
| 
						 | 
				
			
			@ -258,7 +258,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
 | 
			
		|||
 | 
			
		||||
      context 'using an invalid OTP' do
 | 
			
		||||
        before do
 | 
			
		||||
          post :create, params: { user: { otp_attempt: 'wrongotp' } }, session: { otp_user_id: user.id }
 | 
			
		||||
          post :create, params: { user: { otp_attempt: 'wrongotp' } }, session: { attempt_user_id: user.id }
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'shows a login error' do
 | 
			
		||||
| 
						 | 
				
			
			@ -270,5 +270,63 @@ RSpec.describe Auth::SessionsController, type: :controller do
 | 
			
		|||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when 2FA is disabled and IP is unfamiliar' do
 | 
			
		||||
      let!(:user) { Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', current_sign_in_at: 3.weeks.ago, current_sign_in_ip: '0.0.0.0') }
 | 
			
		||||
 | 
			
		||||
      before do
 | 
			
		||||
        request.remote_ip  = '10.10.10.10'
 | 
			
		||||
        request.user_agent = 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0'
 | 
			
		||||
 | 
			
		||||
        allow(UserMailer).to receive(:sign_in_token).and_return(double('email', deliver_later!: nil))
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'using email and password' do
 | 
			
		||||
        before do
 | 
			
		||||
          post :create, params: { user: { email: user.email, password: user.password } }
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'renders sign in token authentication page' do
 | 
			
		||||
          expect(controller).to render_template("sign_in_token")
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'generates sign in token' do
 | 
			
		||||
          expect(user.reload.sign_in_token).to_not be_nil
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'sends sign in token e-mail' do
 | 
			
		||||
          expect(UserMailer).to have_received(:sign_in_token)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'using a valid sign in token' do
 | 
			
		||||
        before do
 | 
			
		||||
          user.generate_sign_in_token && user.save
 | 
			
		||||
          post :create, params: { user: { sign_in_token_attempt: user.sign_in_token } }, session: { attempt_user_id: user.id }
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'redirects to home' do
 | 
			
		||||
          expect(response).to redirect_to(root_path)
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'logs the user in' do
 | 
			
		||||
          expect(controller.current_user).to eq user
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'using an invalid sign in token' do
 | 
			
		||||
        before do
 | 
			
		||||
          post :create, params: { user: { sign_in_token_attempt: 'wrongotp' } }, session: { attempt_user_id: user.id }
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'shows a login error' do
 | 
			
		||||
          expect(flash[:alert]).to match I18n.t('users.invalid_sign_in_token')
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it "doesn't log the user in" do
 | 
			
		||||
          expect(controller.current_user).to be_nil
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -59,4 +59,9 @@ class UserMailerPreview < ActionMailer::Preview
 | 
			
		|||
  def warning
 | 
			
		||||
    UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence), [Status.first.id])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Preview this email at http://localhost:3000/rails/mailers/user_mailer/sign_in_token
 | 
			
		||||
  def sign_in_token
 | 
			
		||||
    UserMailer.sign_in_token(User.first.tap { |user| user.generate_sign_in_token }, '127.0.0.1', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0', Time.now.utc)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue