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!
|
||||
|
Reference in New Issue
Block a user