about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/auth/omniauth_callbacks_controller.rb9
-rw-r--r--app/controllers/auth/sessions_controller.rb44
-rw-r--r--app/controllers/concerns/sign_in_token_authentication_concern.rb5
-rw-r--r--app/controllers/concerns/two_factor_authentication_concern.rb10
-rw-r--r--app/controllers/follower_accounts_controller.rb2
-rw-r--r--app/controllers/following_accounts_controller.rb2
-rw-r--r--app/controllers/settings/login_activities_controller.rb13
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss18
-rw-r--r--app/javascript/styles/mastodon/forms.scss18
-rw-r--r--app/models/concerns/ldap_authenticable.rb2
-rw-r--r--app/models/login_activity.rb35
-rw-r--r--app/views/auth/registrations/_sessions.html.haml5
-rw-r--r--app/views/settings/login_activities/_login_activity.html.haml17
-rw-r--r--app/views/settings/login_activities/index.html.haml15
-rw-r--r--app/workers/scheduler/ip_cleanup_scheduler.rb1
15 files changed, 176 insertions, 20 deletions
diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb
index 682c77016..7925e23cb 100644
--- a/app/controllers/auth/omniauth_callbacks_controller.rb
+++ b/app/controllers/auth/omniauth_callbacks_controller.rb
@@ -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
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index 548832b21..f07f38075 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -27,9 +27,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
 
@@ -44,10 +46,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
 
@@ -142,4 +142,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
diff --git a/app/controllers/concerns/sign_in_token_authentication_concern.rb b/app/controllers/concerns/sign_in_token_authentication_concern.rb
index 51ebcb115..016ab8f52 100644
--- a/app/controllers/concerns/sign_in_token_authentication_concern.rb
+++ b/app/controllers/concerns/sign_in_token_authentication_concern.rb
@@ -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
diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb
index 4800db348..d3f00a4b4 100644
--- a/app/controllers/concerns/two_factor_authentication_concern.rb
+++ b/app/controllers/concerns/two_factor_authentication_concern.rb
@@ -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
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index 18b281325..d519138cd 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -86,7 +86,7 @@ class FollowerAccountsController < ApplicationController
     if page_requested? || !@account.user_hides_network?
       # Return all fields
     else
-      %i(id type totalItems)
+      %i(id type total_items)
     end
   end
 end
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index eba44d34b..4b4978fb9 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -86,7 +86,7 @@ class FollowingAccountsController < ApplicationController
     if page_requested? || !@account.user_hides_network?
       # Return all fields
     else
-      %i(id type totalItems)
+      %i(id type total_items)
     end
   end
 end
diff --git a/app/controllers/settings/login_activities_controller.rb b/app/controllers/settings/login_activities_controller.rb
new file mode 100644
index 000000000..ee77524b1
--- /dev/null
+++ b/app/controllers/settings/login_activities_controller.rb
@@ -0,0 +1,13 @@
+# 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
+
+  private
+
+  def set_pack
+    use_pack 'settings'
+  end
+end
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index b93acd6cd..3433abcdd 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -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;
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index ef4a08c59..5b71b6334 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -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;
diff --git a/app/models/concerns/ldap_authenticable.rb b/app/models/concerns/ldap_authenticable.rb
index e3f94bb6c..dc5abcd5a 100644
--- a/app/models/concerns/ldap_authenticable.rb
+++ b/app/models/concerns/ldap_authenticable.rb
@@ -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
 
diff --git a/app/models/login_activity.rb b/app/models/login_activity.rb
new file mode 100644
index 000000000..52a0fd01d
--- /dev/null
+++ b/app/models/login_activity.rb
@@ -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
diff --git a/app/views/auth/registrations/_sessions.html.haml b/app/views/auth/registrations/_sessions.html.haml
index d3a04c00e..5d993f574 100644
--- a/app/views/auth/registrations/_sessions.html.haml
+++ b/app/views/auth/registrations/_sessions.html.haml
@@ -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
+
diff --git a/app/views/settings/login_activities/_login_activity.html.haml b/app/views/settings/login_activities/_login_activity.html.haml
new file mode 100644
index 000000000..19a3cc3dd
--- /dev/null
+++ b/app/views/settings/login_activities/_login_activity.html.haml
@@ -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 }
diff --git a/app/views/settings/login_activities/index.html.haml b/app/views/settings/login_activities/index.html.haml
new file mode 100644
index 000000000..ce524fbef
--- /dev/null
+++ b/app/views/settings/login_activities/index.html.haml
@@ -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
diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb
index df7e6ad56..918c10ac9 100644
--- a/app/workers/scheduler/ip_cleanup_scheduler.rb
+++ b/app/workers/scheduler/ip_cleanup_scheduler.rb
@@ -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!