Add authentication history (#16408)
This commit is contained in:
		| @@ -10,6 +10,15 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController | ||||
|       @user = User.find_for_oauth(request.env['omniauth.auth'], current_user) | ||||
|  | ||||
|       if @user.persisted? | ||||
|         LoginActivity.create( | ||||
|           user: user, | ||||
|           success: true, | ||||
|           authentication_method: :omniauth, | ||||
|           provider: provider, | ||||
|           ip: request.remote_ip, | ||||
|           user_agent: request.user_agent | ||||
|         ) | ||||
|  | ||||
|         sign_in_and_redirect @user, event: :authentication | ||||
|         set_flash_message(:notice, :success, kind: provider_id.capitalize) if is_navigational_format? | ||||
|       else | ||||
|   | ||||
| @@ -25,9 +25,11 @@ class Auth::SessionsController < Devise::SessionsController | ||||
|  | ||||
|   def create | ||||
|     super do |resource| | ||||
|       resource.update_sign_in!(request, new_sign_in: true) | ||||
|       remember_me(resource) | ||||
|       flash.delete(:notice) | ||||
|       # We only need to call this if this hasn't already been | ||||
|       # called from one of the two-factor or sign-in token | ||||
|       # authentication methods | ||||
|  | ||||
|       on_authentication_success(resource, :password) unless @on_authentication_success_called | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -42,10 +44,8 @@ class Auth::SessionsController < Devise::SessionsController | ||||
|   def webauthn_options | ||||
|     user = find_user | ||||
|  | ||||
|     if user.webauthn_enabled? | ||||
|       options_for_get = WebAuthn::Credential.options_for_get( | ||||
|         allow: user.webauthn_credentials.pluck(:external_id) | ||||
|       ) | ||||
|     if user&.webauthn_enabled? | ||||
|       options_for_get = WebAuthn::Credential.options_for_get(allow: user.webauthn_credentials.pluck(:external_id)) | ||||
|  | ||||
|       session[:webauthn_challenge] = options_for_get.challenge | ||||
|  | ||||
| @@ -136,4 +136,34 @@ class Auth::SessionsController < Devise::SessionsController | ||||
|     session.delete(:attempt_user_id) | ||||
|     session.delete(:attempt_user_updated_at) | ||||
|   end | ||||
|  | ||||
|   def on_authentication_success(user, security_measure) | ||||
|     @on_authentication_success_called = true | ||||
|  | ||||
|     clear_attempt_from_session | ||||
|  | ||||
|     user.update_sign_in!(request, new_sign_in: true) | ||||
|     remember_me(user) | ||||
|     sign_in(user) | ||||
|     flash.delete(:notice) | ||||
|  | ||||
|     LoginActivity.create( | ||||
|       user: user, | ||||
|       success: true, | ||||
|       authentication_method: security_measure, | ||||
|       ip: request.remote_ip, | ||||
|       user_agent: request.user_agent | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def on_authentication_failure(user, security_measure, failure_reason) | ||||
|     LoginActivity.create( | ||||
|       user: user, | ||||
|       success: false, | ||||
|       authentication_method: security_measure, | ||||
|       failure_reason: failure_reason, | ||||
|       ip: request.remote_ip, | ||||
|       user_agent: request.user_agent | ||||
|     ) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -29,10 +29,9 @@ module SignInTokenAuthenticationConcern | ||||
|  | ||||
|   def authenticate_with_sign_in_token_attempt(user) | ||||
|     if valid_sign_in_token_attempt?(user) | ||||
|       clear_attempt_from_session | ||||
|       remember_me(user) | ||||
|       sign_in(user) | ||||
|       on_authentication_success(user, :sign_in_token) | ||||
|     else | ||||
|       on_authentication_failure(user, :sign_in_token, :invalid_sign_in_token) | ||||
|       flash.now[:alert] = I18n.t('users.invalid_sign_in_token') | ||||
|       prompt_for_sign_in_token(user) | ||||
|     end | ||||
|   | ||||
| @@ -52,21 +52,19 @@ module TwoFactorAuthenticationConcern | ||||
|     webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential]) | ||||
|  | ||||
|     if valid_webauthn_credential?(user, webauthn_credential) | ||||
|       clear_attempt_from_session | ||||
|       remember_me(user) | ||||
|       sign_in(user) | ||||
|       on_authentication_success(user, :webauthn) | ||||
|       render json: { redirect_path: root_path }, status: :ok | ||||
|     else | ||||
|       on_authentication_failure(user, :webauthn, :invalid_credential) | ||||
|       render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def authenticate_with_two_factor_via_otp(user) | ||||
|     if valid_otp_attempt?(user) | ||||
|       clear_attempt_from_session | ||||
|       remember_me(user) | ||||
|       sign_in(user) | ||||
|       on_authentication_success(user, :otp) | ||||
|     else | ||||
|       on_authentication_failure(user, :otp, :invalid_otp_token) | ||||
|       flash.now[:alert] = I18n.t('users.invalid_otp_token') | ||||
|       prompt_for_two_factor(user) | ||||
|     end | ||||
|   | ||||
							
								
								
									
										7
									
								
								app/controllers/settings/login_activities_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								app/controllers/settings/login_activities_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Settings::LoginActivitiesController < Settings::BaseController | ||||
|   def index | ||||
|     @login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page]) | ||||
|   end | ||||
| end | ||||
| @@ -11,6 +11,24 @@ code { | ||||
|   margin: 0 auto; | ||||
| } | ||||
|  | ||||
| .indicator-icon { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   width: 40px; | ||||
|   height: 40px; | ||||
|   border-radius: 50%; | ||||
|   color: $primary-text-color; | ||||
|  | ||||
|   &.success { | ||||
|     background: $success-green; | ||||
|   } | ||||
|  | ||||
|   &.failure { | ||||
|     background: $error-red; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .simple_form { | ||||
|   &.hidden { | ||||
|     display: none; | ||||
|   | ||||
| @@ -15,10 +15,10 @@ module LdapAuthenticable | ||||
|  | ||||
|     def ldap_get_user(attributes = {}) | ||||
|       safe_username = attributes[Devise.ldap_uid.to_sym].first | ||||
|  | ||||
|       if Devise.ldap_uid_conversion_enabled | ||||
|         keys = Regexp.union(Devise.ldap_uid_conversion_search.chars) | ||||
|         replacement = Devise.ldap_uid_conversion_replace | ||||
|  | ||||
|         safe_username = safe_username.gsub(keys, replacement) | ||||
|       end | ||||
|  | ||||
|   | ||||
							
								
								
									
										35
									
								
								app/models/login_activity.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/models/login_activity.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| # frozen_string_literal: true | ||||
| # == Schema Information | ||||
| # | ||||
| # Table name: login_activities | ||||
| # | ||||
| #  id                    :bigint(8)        not null, primary key | ||||
| #  user_id               :bigint(8)        not null | ||||
| #  authentication_method :string | ||||
| #  provider              :string | ||||
| #  success               :boolean | ||||
| #  failure_reason        :string | ||||
| #  ip                    :inet | ||||
| #  user_agent            :string | ||||
| #  created_at            :datetime | ||||
| # | ||||
|  | ||||
| class LoginActivity < ApplicationRecord | ||||
|   enum authentication_method: { password: 'password', otp: 'otp', webauthn: 'webauthn', sign_in_token: 'sign_in_token', omniauth: 'omniauth' } | ||||
|  | ||||
|   belongs_to :user | ||||
|  | ||||
|   validates :authentication_method, inclusion: { in: authentication_methods.keys } | ||||
|  | ||||
|   def detection | ||||
|     @detection ||= Browser.new(user_agent) | ||||
|   end | ||||
|  | ||||
|   def browser | ||||
|     detection.id | ||||
|   end | ||||
|  | ||||
|   def platform | ||||
|     detection.platform.id | ||||
|   end | ||||
| end | ||||
| @@ -1,5 +1,7 @@ | ||||
| %h3= t 'sessions.title' | ||||
| %p.muted-hint= t 'sessions.explanation' | ||||
| %p.muted-hint | ||||
|   = t 'sessions.explanation' | ||||
|   = link_to t('sessions.view_authentication_history'), settings_login_activities_path | ||||
|  | ||||
| %hr.spacer/ | ||||
|  | ||||
| @@ -29,3 +31,4 @@ | ||||
|           %td | ||||
|             - if current_session.session_id != session.session_id && !current_account.suspended? | ||||
|               = table_link_to 'times', t('sessions.revoke'), settings_session_path(session), method: :delete | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,17 @@ | ||||
| - method_str = content_tag(:span, login_activity.omniauth? ? t(login_activity.provider, scope: 'auth.providers') : t(login_activity.authentication_method, scope: 'login_activities.authentication_methods'), class: 'target') | ||||
| - ip_str = content_tag(:span, login_activity.ip, class: 'target') | ||||
| - browser_str = content_tag(:span, t('sessions.description', browser: t("sessions.browsers.#{login_activity.browser}", default: "#{login_activity.browser}"), platform: t("sessions.platforms.#{login_activity.platform}", default: "#{login_activity.platform}")), class: 'target') | ||||
|  | ||||
| .log-entry | ||||
|   .log-entry__header | ||||
|     .log-entry__avatar | ||||
|       .indicator-icon{ class: login_activity.success? ? 'success' : 'failure' } | ||||
|         = fa_icon login_activity.success? ? 'check' : 'times' | ||||
|     .log-entry__content | ||||
|       .log-entry__title | ||||
|         - if login_activity.success? | ||||
|           = t('login_activities.successful_sign_in_html', method: method_str, ip: ip_str, browser: browser_str) | ||||
|         - else | ||||
|           = t('login_activities.failed_sign_in_html', method: method_str, ip: ip_str, browser: browser_str) | ||||
|       .log-entry__timestamp | ||||
|         %time.formatted{ datetime: login_activity.created_at.iso8601 } | ||||
							
								
								
									
										15
									
								
								app/views/settings/login_activities/index.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/views/settings/login_activities/index.html.haml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| - content_for :page_title do | ||||
|   = t 'login_activities.title' | ||||
|  | ||||
| %p= t('login_activities.description_html') | ||||
|  | ||||
| %hr.spacer/ | ||||
|  | ||||
| - if @login_activities.empty? | ||||
|   %div.muted-hint.center-text | ||||
|     = t 'login_activities.empty' | ||||
| - else | ||||
|   .announcements-list | ||||
|     = render partial: 'login_activity', collection: @login_activities | ||||
|  | ||||
| = paginate @login_activities | ||||
| @@ -17,6 +17,7 @@ class Scheduler::IpCleanupScheduler | ||||
|   def clean_ip_columns! | ||||
|     SessionActivation.where('updated_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all | ||||
|     User.where('current_sign_in_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(last_sign_in_ip: nil, current_sign_in_ip: nil, sign_up_ip: nil) | ||||
|     LoginActivity.where('created_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all | ||||
|   end | ||||
|  | ||||
|   def clean_expired_ip_blocks! | ||||
|   | ||||
| @@ -1004,6 +1004,17 @@ en: | ||||
|   lists: | ||||
|     errors: | ||||
|       limit: You have reached the maximum amount of lists | ||||
|   login_activities: | ||||
|     authentication_methods: | ||||
|       otp: two-factor authentication app | ||||
|       password: password | ||||
|       sign_in_token: e-mail security code | ||||
|       webauthn: security keys | ||||
|     description_html: If you see activity that you don't recognize, consider changing your password and enabling two-factor authentication. | ||||
|     empty: No authentication history available | ||||
|     failed_sign_in_html: Failed sign-in attempt with %{method} from %{ip} (%{browser}) | ||||
|     successful_sign_in_html: Successful sign-in with %{method} from %{ip} (%{browser}) | ||||
|     title: Authentication history | ||||
|   media_attachments: | ||||
|     validations: | ||||
|       images_and_video: Cannot attach a video to a post that already contains images | ||||
| @@ -1211,6 +1222,7 @@ en: | ||||
|     revoke: Revoke | ||||
|     revoke_success: Session successfully revoked | ||||
|     title: Sessions | ||||
|     view_authentication_history: View authentication history of your account | ||||
|   settings: | ||||
|     account: Account | ||||
|     account_settings: Account settings | ||||
|   | ||||
| @@ -20,7 +20,7 @@ SimpleNavigation::Configuration.run do |navigation| | ||||
|     n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? } | ||||
|  | ||||
|     n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s| | ||||
|       s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases} | ||||
|       s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases|/settings/login_activities} | ||||
|       s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_methods_url, highlights_on: %r{/settings/two_factor_authentication|/settings/otp_authentication|/settings/security_keys} | ||||
|       s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url | ||||
|     end | ||||
|   | ||||
| @@ -164,6 +164,7 @@ Rails.application.routes.draw do | ||||
|     resources :aliases, only: [:index, :create, :destroy] | ||||
|     resources :sessions, only: [:destroy] | ||||
|     resources :featured_tags, only: [:index, :create, :destroy] | ||||
|     resources :login_activities, only: [:index] | ||||
|   end | ||||
|  | ||||
|   resources :media, only: [:show] do | ||||
| @@ -222,7 +223,7 @@ Rails.application.routes.draw do | ||||
|         post :stop_delivery | ||||
|       end | ||||
|     end | ||||
|    | ||||
|  | ||||
|     resources :rules | ||||
|  | ||||
|     resources :reports, only: [:index, :show] do | ||||
|   | ||||
							
								
								
									
										14
									
								
								db/migrate/20210609202149_create_login_activities.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								db/migrate/20210609202149_create_login_activities.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| class CreateLoginActivities < ActiveRecord::Migration[6.1] | ||||
|   def change | ||||
|     create_table :login_activities do |t| | ||||
|       t.belongs_to :user, null: false, foreign_key: { on_delete: :cascade } | ||||
|       t.string :authentication_method | ||||
|       t.string :provider | ||||
|       t.boolean :success | ||||
|       t.string :failure_reason | ||||
|       t.inet :ip | ||||
|       t.string :user_agent | ||||
|       t.datetime :created_at | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										15
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								db/schema.rb
									
									
									
									
									
								
							| @@ -10,7 +10,7 @@ | ||||
| # | ||||
| # It's strongly recommended that you check this file into your version control system. | ||||
|  | ||||
| ActiveRecord::Schema.define(version: 2021_05_26_193025) do | ||||
| ActiveRecord::Schema.define(version: 2021_06_09_202149) do | ||||
|  | ||||
|   # These are extensions that must be enabled in order to support this database | ||||
|   enable_extension "plpgsql" | ||||
| @@ -494,6 +494,18 @@ ActiveRecord::Schema.define(version: 2021_05_26_193025) do | ||||
|     t.index ["account_id"], name: "index_lists_on_account_id" | ||||
|   end | ||||
|  | ||||
|   create_table "login_activities", force: :cascade do |t| | ||||
|     t.bigint "user_id", null: false | ||||
|     t.string "authentication_method" | ||||
|     t.string "provider" | ||||
|     t.boolean "success" | ||||
|     t.string "failure_reason" | ||||
|     t.inet "ip" | ||||
|     t.string "user_agent" | ||||
|     t.datetime "created_at" | ||||
|     t.index ["user_id"], name: "index_login_activities_on_user_id" | ||||
|   end | ||||
|  | ||||
|   create_table "markers", force: :cascade do |t| | ||||
|     t.bigint "user_id" | ||||
|     t.string "timeline", default: "", null: false | ||||
| @@ -1010,6 +1022,7 @@ ActiveRecord::Schema.define(version: 2021_05_26_193025) do | ||||
|   add_foreign_key "list_accounts", "follows", on_delete: :cascade | ||||
|   add_foreign_key "list_accounts", "lists", on_delete: :cascade | ||||
|   add_foreign_key "lists", "accounts", on_delete: :cascade | ||||
|   add_foreign_key "login_activities", "users", on_delete: :cascade | ||||
|   add_foreign_key "markers", "users", on_delete: :cascade | ||||
|   add_foreign_key "media_attachments", "accounts", name: "fk_96dd81e81b", on_delete: :nullify | ||||
|   add_foreign_key "media_attachments", "scheduled_statuses", on_delete: :nullify | ||||
|   | ||||
							
								
								
									
										8
									
								
								spec/fabricators/login_activity_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								spec/fabricators/login_activity_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| Fabricator(:login_activity) do | ||||
|   user | ||||
|   strategy       'password' | ||||
|   success        true | ||||
|   failure_reason nil | ||||
|   ip             { Faker::Internet.ip_v4_address } | ||||
|   user_agent     { Faker::Internet.user_agent } | ||||
| end | ||||
							
								
								
									
										5
									
								
								spec/models/login_activity_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								spec/models/login_activity_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe LoginActivity, type: :model do | ||||
|  | ||||
| end | ||||
		Reference in New Issue
	
	Block a user