forked from mirrors/catstodon
Add authentication history (#16408)
This commit is contained in:
parent
946200b471
commit
d174d12c83
19 changed files with 206 additions and 21 deletions
|
@ -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
|
Loading…
Reference in a new issue