Skip to content
This repository was archived by the owner on Dec 21, 2023. It is now read-only.

Commit d174d12

Browse files
authored
Add authentication history (mastodon#16408)
1 parent 946200b commit d174d12

19 files changed

+206
-21
lines changed

app/controllers/auth/omniauth_callbacks_controller.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ def self.provides_callback_for(provider)
1010
@user = User.find_for_oauth(request.env['omniauth.auth'], current_user)
1111

1212
if @user.persisted?
13+
LoginActivity.create(
14+
user: user,
15+
success: true,
16+
authentication_method: :omniauth,
17+
provider: provider,
18+
ip: request.remote_ip,
19+
user_agent: request.user_agent
20+
)
21+
1322
sign_in_and_redirect @user, event: :authentication
1423
set_flash_message(:notice, :success, kind: provider_id.capitalize) if is_navigational_format?
1524
else

app/controllers/auth/sessions_controller.rb

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ def new
2525

2626
def create
2727
super do |resource|
28-
resource.update_sign_in!(request, new_sign_in: true)
29-
remember_me(resource)
30-
flash.delete(:notice)
28+
# We only need to call this if this hasn't already been
29+
# called from one of the two-factor or sign-in token
30+
# authentication methods
31+
32+
on_authentication_success(resource, :password) unless @on_authentication_success_called
3133
end
3234
end
3335

@@ -42,10 +44,8 @@ def destroy
4244
def webauthn_options
4345
user = find_user
4446

45-
if user.webauthn_enabled?
46-
options_for_get = WebAuthn::Credential.options_for_get(
47-
allow: user.webauthn_credentials.pluck(:external_id)
48-
)
47+
if user&.webauthn_enabled?
48+
options_for_get = WebAuthn::Credential.options_for_get(allow: user.webauthn_credentials.pluck(:external_id))
4949

5050
session[:webauthn_challenge] = options_for_get.challenge
5151

@@ -136,4 +136,34 @@ def clear_attempt_from_session
136136
session.delete(:attempt_user_id)
137137
session.delete(:attempt_user_updated_at)
138138
end
139+
140+
def on_authentication_success(user, security_measure)
141+
@on_authentication_success_called = true
142+
143+
clear_attempt_from_session
144+
145+
user.update_sign_in!(request, new_sign_in: true)
146+
remember_me(user)
147+
sign_in(user)
148+
flash.delete(:notice)
149+
150+
LoginActivity.create(
151+
user: user,
152+
success: true,
153+
authentication_method: security_measure,
154+
ip: request.remote_ip,
155+
user_agent: request.user_agent
156+
)
157+
end
158+
159+
def on_authentication_failure(user, security_measure, failure_reason)
160+
LoginActivity.create(
161+
user: user,
162+
success: false,
163+
authentication_method: security_measure,
164+
failure_reason: failure_reason,
165+
ip: request.remote_ip,
166+
user_agent: request.user_agent
167+
)
168+
end
139169
end

app/controllers/concerns/sign_in_token_authentication_concern.rb

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,9 @@ def authenticate_with_sign_in_token
2929

3030
def authenticate_with_sign_in_token_attempt(user)
3131
if valid_sign_in_token_attempt?(user)
32-
clear_attempt_from_session
33-
remember_me(user)
34-
sign_in(user)
32+
on_authentication_success(user, :sign_in_token)
3533
else
34+
on_authentication_failure(user, :sign_in_token, :invalid_sign_in_token)
3635
flash.now[:alert] = I18n.t('users.invalid_sign_in_token')
3736
prompt_for_sign_in_token(user)
3837
end

app/controllers/concerns/two_factor_authentication_concern.rb

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,21 +52,19 @@ def authenticate_with_two_factor_via_webauthn(user)
5252
webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential])
5353

5454
if valid_webauthn_credential?(user, webauthn_credential)
55-
clear_attempt_from_session
56-
remember_me(user)
57-
sign_in(user)
55+
on_authentication_success(user, :webauthn)
5856
render json: { redirect_path: root_path }, status: :ok
5957
else
58+
on_authentication_failure(user, :webauthn, :invalid_credential)
6059
render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity
6160
end
6261
end
6362

6463
def authenticate_with_two_factor_via_otp(user)
6564
if valid_otp_attempt?(user)
66-
clear_attempt_from_session
67-
remember_me(user)
68-
sign_in(user)
65+
on_authentication_success(user, :otp)
6966
else
67+
on_authentication_failure(user, :otp, :invalid_otp_token)
7068
flash.now[:alert] = I18n.t('users.invalid_otp_token')
7169
prompt_for_two_factor(user)
7270
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# frozen_string_literal: true
2+
3+
class Settings::LoginActivitiesController < Settings::BaseController
4+
def index
5+
@login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
6+
end
7+
end

app/javascript/styles/mastodon/forms.scss

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,24 @@ code {
1111
margin: 0 auto;
1212
}
1313

14+
.indicator-icon {
15+
display: flex;
16+
align-items: center;
17+
justify-content: center;
18+
width: 40px;
19+
height: 40px;
20+
border-radius: 50%;
21+
color: $primary-text-color;
22+
23+
&.success {
24+
background: $success-green;
25+
}
26+
27+
&.failure {
28+
background: $error-red;
29+
}
30+
}
31+
1432
.simple_form {
1533
&.hidden {
1634
display: none;

app/models/concerns/ldap_authenticable.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ def authenticate_with_ldap(params = {})
1515

1616
def ldap_get_user(attributes = {})
1717
safe_username = attributes[Devise.ldap_uid.to_sym].first
18+
1819
if Devise.ldap_uid_conversion_enabled
1920
keys = Regexp.union(Devise.ldap_uid_conversion_search.chars)
2021
replacement = Devise.ldap_uid_conversion_replace
21-
2222
safe_username = safe_username.gsub(keys, replacement)
2323
end
2424

app/models/login_activity.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# frozen_string_literal: true
2+
# == Schema Information
3+
#
4+
# Table name: login_activities
5+
#
6+
# id :bigint(8) not null, primary key
7+
# user_id :bigint(8) not null
8+
# authentication_method :string
9+
# provider :string
10+
# success :boolean
11+
# failure_reason :string
12+
# ip :inet
13+
# user_agent :string
14+
# created_at :datetime
15+
#
16+
17+
class LoginActivity < ApplicationRecord
18+
enum authentication_method: { password: 'password', otp: 'otp', webauthn: 'webauthn', sign_in_token: 'sign_in_token', omniauth: 'omniauth' }
19+
20+
belongs_to :user
21+
22+
validates :authentication_method, inclusion: { in: authentication_methods.keys }
23+
24+
def detection
25+
@detection ||= Browser.new(user_agent)
26+
end
27+
28+
def browser
29+
detection.id
30+
end
31+
32+
def platform
33+
detection.platform.id
34+
end
35+
end

app/views/auth/registrations/_sessions.html.haml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
%h3= t 'sessions.title'
2-
%p.muted-hint= t 'sessions.explanation'
2+
%p.muted-hint
3+
= t 'sessions.explanation'
4+
= link_to t('sessions.view_authentication_history'), settings_login_activities_path
35

46
%hr.spacer/
57

@@ -29,3 +31,4 @@
2931
%td
3032
- if current_session.session_id != session.session_id && !current_account.suspended?
3133
= table_link_to 'times', t('sessions.revoke'), settings_session_path(session), method: :delete
34+
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
- 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')
2+
- ip_str = content_tag(:span, login_activity.ip, class: 'target')
3+
- 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')
4+
5+
.log-entry
6+
.log-entry__header
7+
.log-entry__avatar
8+
.indicator-icon{ class: login_activity.success? ? 'success' : 'failure' }
9+
= fa_icon login_activity.success? ? 'check' : 'times'
10+
.log-entry__content
11+
.log-entry__title
12+
- if login_activity.success?
13+
= t('login_activities.successful_sign_in_html', method: method_str, ip: ip_str, browser: browser_str)
14+
- else
15+
= t('login_activities.failed_sign_in_html', method: method_str, ip: ip_str, browser: browser_str)
16+
.log-entry__timestamp
17+
%time.formatted{ datetime: login_activity.created_at.iso8601 }
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
- content_for :page_title do
2+
= t 'login_activities.title'
3+
4+
%p= t('login_activities.description_html')
5+
6+
%hr.spacer/
7+
8+
- if @login_activities.empty?
9+
%div.muted-hint.center-text
10+
= t 'login_activities.empty'
11+
- else
12+
.announcements-list
13+
= render partial: 'login_activity', collection: @login_activities
14+
15+
= paginate @login_activities

app/workers/scheduler/ip_cleanup_scheduler.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def perform
1717
def clean_ip_columns!
1818
SessionActivation.where('updated_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all
1919
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)
20+
LoginActivity.where('created_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all
2021
end
2122

2223
def clean_expired_ip_blocks!

config/locales/en.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,17 @@ en:
10041004
lists:
10051005
errors:
10061006
limit: You have reached the maximum amount of lists
1007+
login_activities:
1008+
authentication_methods:
1009+
otp: two-factor authentication app
1010+
password: password
1011+
sign_in_token: e-mail security code
1012+
webauthn: security keys
1013+
description_html: If you see activity that you don't recognize, consider changing your password and enabling two-factor authentication.
1014+
empty: No authentication history available
1015+
failed_sign_in_html: Failed sign-in attempt with %{method} from %{ip} (%{browser})
1016+
successful_sign_in_html: Successful sign-in with %{method} from %{ip} (%{browser})
1017+
title: Authentication history
10071018
media_attachments:
10081019
validations:
10091020
images_and_video: Cannot attach a video to a post that already contains images
@@ -1211,6 +1222,7 @@ en:
12111222
revoke: Revoke
12121223
revoke_success: Session successfully revoked
12131224
title: Sessions
1225+
view_authentication_history: View authentication history of your account
12141226
settings:
12151227
account: Account
12161228
account_settings: Account settings

config/navigation.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? }
2121

2222
n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s|
23-
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}
23+
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}
2424
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}
2525
s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url
2626
end

config/routes.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@
164164
resources :aliases, only: [:index, :create, :destroy]
165165
resources :sessions, only: [:destroy]
166166
resources :featured_tags, only: [:index, :create, :destroy]
167+
resources :login_activities, only: [:index]
167168
end
168169

169170
resources :media, only: [:show] do
@@ -222,7 +223,7 @@
222223
post :stop_delivery
223224
end
224225
end
225-
226+
226227
resources :rules
227228

228229
resources :reports, only: [:index, :show] do
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class CreateLoginActivities < ActiveRecord::Migration[6.1]
2+
def change
3+
create_table :login_activities do |t|
4+
t.belongs_to :user, null: false, foreign_key: { on_delete: :cascade }
5+
t.string :authentication_method
6+
t.string :provider
7+
t.boolean :success
8+
t.string :failure_reason
9+
t.inet :ip
10+
t.string :user_agent
11+
t.datetime :created_at
12+
end
13+
end
14+
end

db/schema.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
#
1111
# It's strongly recommended that you check this file into your version control system.
1212

13-
ActiveRecord::Schema.define(version: 2021_05_26_193025) do
13+
ActiveRecord::Schema.define(version: 2021_06_09_202149) do
1414

1515
# These are extensions that must be enabled in order to support this database
1616
enable_extension "plpgsql"
@@ -494,6 +494,18 @@
494494
t.index ["account_id"], name: "index_lists_on_account_id"
495495
end
496496

497+
create_table "login_activities", force: :cascade do |t|
498+
t.bigint "user_id", null: false
499+
t.string "authentication_method"
500+
t.string "provider"
501+
t.boolean "success"
502+
t.string "failure_reason"
503+
t.inet "ip"
504+
t.string "user_agent"
505+
t.datetime "created_at"
506+
t.index ["user_id"], name: "index_login_activities_on_user_id"
507+
end
508+
497509
create_table "markers", force: :cascade do |t|
498510
t.bigint "user_id"
499511
t.string "timeline", default: "", null: false
@@ -1010,6 +1022,7 @@
10101022
add_foreign_key "list_accounts", "follows", on_delete: :cascade
10111023
add_foreign_key "list_accounts", "lists", on_delete: :cascade
10121024
add_foreign_key "lists", "accounts", on_delete: :cascade
1025+
add_foreign_key "login_activities", "users", on_delete: :cascade
10131026
add_foreign_key "markers", "users", on_delete: :cascade
10141027
add_foreign_key "media_attachments", "accounts", name: "fk_96dd81e81b", on_delete: :nullify
10151028
add_foreign_key "media_attachments", "scheduled_statuses", on_delete: :nullify
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Fabricator(:login_activity) do
2+
user
3+
strategy 'password'
4+
success true
5+
failure_reason nil
6+
ip { Faker::Internet.ip_v4_address }
7+
user_agent { Faker::Internet.user_agent }
8+
end

spec/models/login_activity_spec.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
require 'rails_helper'
2+
3+
RSpec.describe LoginActivity, type: :model do
4+
5+
end

0 commit comments

Comments
 (0)