@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
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
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
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
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
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
--- /dev/null
+# 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
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;
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
--- /dev/null
+# 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
%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/
%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
+
--- /dev/null
+- 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 }
--- /dev/null
+- 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
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!
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
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
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
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
post :stop_delivery
end
end
-
+
resources :rules
resources :reports, only: [:index, :show] do
--- /dev/null
+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
#
# 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"
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
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
--- /dev/null
+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
--- /dev/null
+require 'rails_helper'
+
+RSpec.describe LoginActivity, type: :model do
+
+end