From 78088537768e20126b67111df13e9d5b2a23b3dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Hern=C3=A1ndez?= Date: Mon, 5 Oct 2026 08:29:06 +0000 Subject: [PATCH] Send a security code challenge to users that try to long in after a long inactive period (#40745) --- ...ign_in_token_authentications_controller.rb | 27 ++++++ app/controllers/auth/sessions_controller.rb | 3 +- .../sign_in_token_authentication_concern.rb | 45 +++++++++ app/javascript/styles/mastodon/forms.scss | 28 ++++++ app/mailers/user_mailer.rb | 17 ++++ app/models/concerns/user/sign_in_token.rb | 61 ++++++++++++ app/models/user.rb | 1 + app/policies/user_policy.rb | 8 ++ .../admin/accounts/_local_account.html.haml | 10 +- .../auth/sessions/sign_in_token.html.haml | 24 +++++ app/views/user_mailer/sign_in_token.html.haml | 32 ++++++ app/views/user_mailer/sign_in_token.text.erb | 17 ++++ config/locales/en.yml | 14 +++ config/locales/simple_form.en.yml | 1 + config/routes/admin.rb | 1 + lib/mastodon/cli/accounts.rb | 13 +++ .../auth/sessions_controller_spec.rb | 97 +++++++++++++++++++ spec/lib/mastodon/cli/accounts_spec.rb | 24 +++++ spec/mailers/previews/user_mailer_preview.rb | 5 + spec/mailers/user_mailer_spec.rb | 16 +++ spec/models/user/sign_in_token_spec.rb | 78 +++++++++++++++ spec/policies/user_policy_spec.rb | 44 +++++++++ .../sign_in_token_authentications_spec.rb | 54 +++++++++++ 23 files changed, 617 insertions(+), 3 deletions(-) create mode 100644 app/controllers/admin/users/sign_in_token_authentications_controller.rb create mode 100644 app/controllers/concerns/auth/sign_in_token_authentication_concern.rb create mode 100644 app/models/concerns/user/sign_in_token.rb create mode 100644 app/views/auth/sessions/sign_in_token.html.haml create mode 100644 app/views/user_mailer/sign_in_token.html.haml create mode 100644 app/views/user_mailer/sign_in_token.text.erb create mode 100644 spec/models/user/sign_in_token_spec.rb create mode 100644 spec/system/admin/users/sign_in_token_authentications_spec.rb diff --git a/app/controllers/admin/users/sign_in_token_authentications_controller.rb b/app/controllers/admin/users/sign_in_token_authentications_controller.rb new file mode 100644 index 00000000000..7c7c5598c96 --- /dev/null +++ b/app/controllers/admin/users/sign_in_token_authentications_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Admin + class Users::SignInTokenAuthenticationsController < BaseController + before_action :set_target_user + + def create + authorize @user, :enable_sign_in_token? + @user.enable_sign_in_token! + log_action :enable_sign_in_token, @user + redirect_to admin_account_path(@user.account_id) + end + + def destroy + authorize @user, :disable_sign_in_token? + @user.disable_sign_in_token! + log_action :disable_sign_in_token, @user + redirect_to admin_account_path(@user.account_id) + end + + private + + def set_target_user + @user = User.find(params[:user_id]) + end + end +end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 3d757be6795..3bc8b8143be 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -17,6 +17,7 @@ class Auth::SessionsController < Devise::SessionsController prepend_before_action :check_suspicious!, only: [:create] include Auth::TwoFactorAuthenticationConcern + include Auth::SignInTokenAuthenticationConcern content_security_policy only: :new do |p| p.form_action(false) @@ -62,7 +63,7 @@ class Auth::SessionsController < Devise::SessionsController end def user_params - params.expect(user: [:email, :password, :otp_attempt, credential: {}]) + params.expect(user: [:email, :password, :otp_attempt, :sign_in_token_attempt, credential: {}]) end def after_sign_in_path_for(resource) diff --git a/app/controllers/concerns/auth/sign_in_token_authentication_concern.rb b/app/controllers/concerns/auth/sign_in_token_authentication_concern.rb new file mode 100644 index 00000000000..cdef5031d9f --- /dev/null +++ b/app/controllers/concerns/auth/sign_in_token_authentication_concern.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Auth::SignInTokenAuthenticationConcern + extend ActiveSupport::Concern + + included do + prepend_before_action :authenticate_with_sign_in_token, if: :sign_in_token_required?, only: [:create] + end + + def sign_in_token_required? + find_user&.suspicious_inactive_sign_in? + end + + def authenticate_with_sign_in_token + user = self.resource = find_user + + if params.key?(:sign_in_token_resend) && session[:attempt_user_id] + prompt_for_sign_in_token(user, resend: true) + elsif user_params[:sign_in_token_attempt].present? && session[:attempt_user_id] + authenticate_with_sign_in_token_attempt(user) + elsif user.present? && user.valid_password?(user_params[:password]) + prompt_for_sign_in_token(user) + end + end + + def authenticate_with_sign_in_token_attempt(user) + if user.valid_sign_in_token?(user_params[:sign_in_token_attempt]) + clear_attempt_from_session + sign_in(user) + else + flash.now[:alert] = I18n.t('users.invalid_sign_in_token') + prompt_for_sign_in_token(user) + end + end + + def prompt_for_sign_in_token(user, resend: false) + if resend || user.sign_in_token_expired? + user.generate_sign_in_token && user.save + UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later! + end + + register_attempt_in_session(user) + render :sign_in_token + end +end diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 3c68f6e0614..7fe0e22ea7a 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -686,6 +686,34 @@ code { background: var(--color-bg-error-base-hover); } } + + &.secondary { + color: var(--color-text-brand); + background: transparent; + border: 1px solid transparent; + border-color: var(--color-border-brand); + + &:active, + &:focus, + &:hover { + border-color: var(--color-border-brand); + color: var(--color-text-brand); + background-color: transparent; + text-decoration: none; + } + + &:disabled { + border-color: var(--color-text-disabled); + color: var(--color-text-disabled); + + &:active, + &:focus, + &:hover { + border-color: var(--color-text-disabled); + color: var(--color-text-disabled); + } + } + } } select { diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index caaf4763d0f..bf8ed8a622c 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -235,6 +235,23 @@ class UserMailer < Devise::Mailer end end + def sign_in_token(user, remote_ip, user_agent, timestamp) + @resource = user + @instance = Rails.configuration.x.local_domain + @remote_ip = remote_ip + @user_agent = user_agent + @detection = Browser.new(user_agent) + @timestamp = timestamp.to_time.utc + + return if @resource.disabled? + + I18n.with_locale(@resource.locale || I18n.default_locale) do + mail to: @resource.email, + subject: I18n.t('user_mailer.sign_in_token.subject'), + reply_to: Setting.site_contact_email + end + end + private def default_devise_subject diff --git a/app/models/concerns/user/sign_in_token.rb b/app/models/concerns/user/sign_in_token.rb new file mode 100644 index 00000000000..9ac5a17e611 --- /dev/null +++ b/app/models/concerns/user/sign_in_token.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module User::SignInToken + extend ActiveSupport::Concern + + # If a user hasn't logged in in this period of time, we consider new attempts suspicious + # and start a security code challenge to verify said user + SUSPICIOUS_INACTIVITY_DURATION = 90.days.freeze + SIGN_IN_TOKEN_DISABLED_KEY = 'user.%d.sign_in_token_disabled' + SIGN_IN_TOKEN_DISABLED_TTL = 96.hours.freeze + + included do + attr_reader :sign_in_token_attempt + end + + # We consider suspicious any log in of users that have been inactive for SUSPICIOUS_INACTIVITY_DURATION + # Users with OTP enabled or that are managed through SSO, LDAP or PAM are not taken into account + def suspicious_inactive_sign_in? + !sign_in_token_disabled? && + !two_factor_enabled? && + encrypted_password? && + current_sign_in_at? && + current_sign_in_at < SUSPICIOUS_INACTIVITY_DURATION.ago + end + + def valid_sign_in_token?(token) + Devise.secure_compare(sign_in_token, token) + end + + def sign_in_token_expired? + sign_in_token_sent_at.nil? || sign_in_token_sent_at < 5.minutes.ago + end + + def generate_sign_in_token + self.sign_in_token = Devise.friendly_token(6) + self.sign_in_token_sent_at = Time.now.utc + end + + def disable_sign_in_token! + self.sign_in_token = nil + self.sign_in_token_sent_at = nil + + redis.set(sign_in_token_disabled_key, true, ex: SIGN_IN_TOKEN_DISABLED_TTL.seconds.to_i) + + save! + end + + def enable_sign_in_token! + redis.del(sign_in_token_disabled_key) + end + + def sign_in_token_disabled? + redis.exists?(sign_in_token_disabled_key) + end + + private + + def sign_in_token_disabled_key + SIGN_IN_TOKEN_DISABLED_KEY % id + end +end diff --git a/app/models/user.rb b/app/models/user.rb index b443deac81d..ce5de763467 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -57,6 +57,7 @@ class User < ApplicationRecord include User::LdapAuthenticable include User::Omniauthable include User::PamAuthenticable + include User::SignInToken devise :two_factor_authenticatable, otp_secret_length: 32 diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 6751b8b8fb5..09aa15000ee 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -13,6 +13,14 @@ class UserPolicy < ApplicationPolicy role.can?(:manage_user_access) && role.overrides?(record.role) end + def disable_sign_in_token? + role.can?(:manage_user_access) && role.overrides?(record.role) + end + + def enable_sign_in_token? + role.can?(:manage_user_access) && role.overrides?(record.role) + end + def change_role? role.can?(:manage_roles) && role.overrides?(record.role) end diff --git a/app/views/admin/accounts/_local_account.html.haml b/app/views/admin/accounts/_local_account.html.haml index bff752332c6..21c6febb1dd 100644 --- a/app/views/admin/accounts/_local_account.html.haml +++ b/app/views/admin/accounts/_local_account.html.haml @@ -46,9 +46,15 @@ = t 'admin.accounts.security_measures.password_and_2fa' - else = t 'admin.accounts.security_measures.only_password' - %td - - if account.user&.two_factor_enabled? && can?(:disable_2fa, account.user) + - if account.user&.two_factor_enabled? && can?(:disable_2fa, account.user) + %td = table_link_to 'lock_open', t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(account.user.id), method: :delete + - elsif account.user&.suspicious_inactive_sign_in? && can?(:disable_sign_in_token, account.user) + %td + = table_link_to 'lock_open', t('admin.accounts.disable_sign_in_token'), admin_user_sign_in_token_authentication_path(account.user.id), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } + - elsif account.user&.sign_in_token_disabled? && can?(:enable_sign_in_token, account.user) + %td + = table_link_to 'lock', t('admin.accounts.enable_sign_in_token'), admin_user_sign_in_token_authentication_path(account.user.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } - if can?(:reset_password, account.user) %tr %td diff --git a/app/views/auth/sessions/sign_in_token.html.haml b/app/views/auth/sessions/sign_in_token.html.haml new file mode 100644 index 00000000000..0153679bf6c --- /dev/null +++ b/app/views/auth/sessions/sign_in_token.html.haml @@ -0,0 +1,24 @@ +- content_for :page_title do + = t('auth.login') + += simple_form_for(resource, + as: resource_name, + url: session_path(resource_name), + method: :post) do |f| + %h1.title= t('users.inactive_sign_in_title') + %p.lead= t('users.inactive_sign_in_confirmation') + + .fields-group + = f.input :sign_in_token_attempt, + wrapper: :with_label, + label: t('simple_form.labels.defaults.sign_in_token_attempt'), + input_html: { 'aria-label' => t('simple_form.labels.defaults.sign_in_token_attempt'), :autocomplete => 'off' }, + required: true, + autofocus: true + + .actions + = f.button :button, t('simple_form.labels.defaults.sign_in_token_resend'), type: :submit, name: :sign_in_token_resend, class: 'secondary' + = f.button :button, t('auth.login'), type: :submit + + - if Setting.site_contact_email.present? + %p.hint.subtle-hint= t('users.generic_access_help_html', email: mail_to(Setting.site_contact_email, nil)) diff --git a/app/views/user_mailer/sign_in_token.html.haml b/app/views/user_mailer/sign_in_token.html.haml new file mode 100644 index 00000000000..9c83c705e58 --- /dev/null +++ b/app/views/user_mailer/sign_in_token.html.haml @@ -0,0 +1,32 @@ += content_for :heading do + = render 'application/mailer/heading', + image_url: frontend_asset_url('images/mailer-new/heading/login.png'), + subtitle: t('user_mailer.sign_in_token.explanation'), + title: t('user_mailer.sign_in_token.title') +%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } + %tr + %td.email-body-padding-td + %table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } + %tr + %td.email-inner-card-td.email-prose + %table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } + %tr + %td.email-frame-wrapper-td + = render 'application/mailer/frame', text: tag.strong(@resource.sign_in_token) + %p= t 'user_mailer.sign_in_token.details' + %p + %strong #{t('sessions.ip')}: + = @remote_ip + %br/ + %strong #{t('sessions.browser')}: + %span{ title: @user_agent } + = t 'sessions.description', + browser: t("sessions.browsers.#{@detection.id}", default: @detection.id.to_s), + platform: t("sessions.platforms.#{@detection.platform.id}", default: @detection.platform.id.to_s) + %br/ + %strong #{t('sessions.date')}: + = l(@timestamp.in_time_zone(@resource.time_zone.presence), format: :with_time_zone) + = render 'application/mailer/button', text: t('settings.account_settings'), url: edit_user_registration_url + + %p= t 'user_mailer.sign_in_token.further_actions_html', + action: link_to(t('user_mailer.sign_in_token.change_password'), edit_user_registration_url) diff --git a/app/views/user_mailer/sign_in_token.text.erb b/app/views/user_mailer/sign_in_token.text.erb new file mode 100644 index 00000000000..2539ddaf6da --- /dev/null +++ b/app/views/user_mailer/sign_in_token.text.erb @@ -0,0 +1,17 @@ +<%= t 'user_mailer.sign_in_token.title' %> + +=== + +<%= t 'user_mailer.sign_in_token.explanation' %> + +=> <%= @resource.sign_in_token %> + +<%= t 'user_mailer.sign_in_token.details' %> + +<%= t('sessions.ip') %>: <%= @remote_ip %> +<%= t('sessions.browser') %>: <%= t('sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: "#{@detection.id}"), platform: t("sessions.platforms.#{@detection.platform.id}", default: "#{@detection.platform.id}")) %> +<%= l(@timestamp) %> + +<%= t 'user_mailer.sign_in_token.further_actions' %> + +=> <%= edit_user_registration_url %> diff --git a/config/locales/en.yml b/config/locales/en.yml index e27c9dea8ba..10f84864b22 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -69,6 +69,7 @@ en: demote: Demote destroyed_msg: "%{username}'s data is now queued to be deleted imminently" disable: Freeze + disable_sign_in_token: Temporarily disable security code challenge disable_sign_in_token_auth: Disable email token authentication disable_two_factor_authentication: Disable 2FA disabled: Frozen @@ -82,6 +83,7 @@ en: email: Email email_status: Email status enable: Unfreeze + enable_sign_in_token: Enable security code challenge enable_sign_in_token_auth: Enable email token authentication enabled: Enabled enabled_msg: Successfully unfroze %{username}'s account @@ -2344,6 +2346,14 @@ en: further_actions_html: If this wasn't you, we recommend that you %{action} immediately as it may be compromised. subject: Second factor authentication failure title: Failed second factor authentication + sign_in_token: + change_password: change your password + details: 'Here are details of the attempt:' + explanation: 'We detected an attempt to sign in to your account after a long time of inactivity. If this is you, please enter the security code below on the sign in challenge page:' + further_actions: 'If this wasn''t you, please change your password and enable two-factor authentication on your account. You can do so here:' + further_actions_html: If this wasn't you, we recommend that you %{action} immediately and enable two-factor authentication to keep your account secure. + subject: Please confirm attempted sign in + title: Sign in attempt suspicious_sign_in: change_password: change your password details: 'Here are details of the sign-in:' @@ -2433,8 +2443,12 @@ en: title: Welcome aboard, %{name}! users: follow_limit_reached: You cannot follow more than %{limit} people + generic_access_help_html: Trouble accessing your account? You may get in touch with %{email} for assistance go_to_sso_account_settings: Go to your identity provider's account settings + inactive_sign_in_confirmation: It's been a while since you logged in, so we're sending a security code to your e-mail address to confirm that it's you. + inactive_sign_in_title: Please confirm security code invalid_otp_token: Invalid two-factor code + invalid_sign_in_token: Invalid security code otp_lost_help_html: If you lost access to both, you may get in touch with %{email} rate_limited: Too many authentication attempts, try again later. seamless_external_login: You are logged in via an external service, so password and email settings are not available. diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 6f388e8b16a..9eed1f2ef70 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -273,6 +273,7 @@ en: setting_use_pending_items: Slow mode severity: Severity sign_in_token_attempt: Security code + sign_in_token_resend: Resend security code title: Title type: Import type username: Username diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 68da27421be..73eb7b6fc1d 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -188,6 +188,7 @@ namespace :admin do resources :users, only: [] do scope module: :users do resource :two_factor_authentication, only: [:destroy] + resource :sign_in_token_authentication, only: [:create, :destroy] resource :role, only: [:show, :update] end end diff --git a/lib/mastodon/cli/accounts.rb b/lib/mastodon/cli/accounts.rb index a907e1d298a..5b0ebf6a8d3 100644 --- a/lib/mastodon/cli/accounts.rb +++ b/lib/mastodon/cli/accounts.rb @@ -125,6 +125,7 @@ module Mastodon::CLI option :enable, type: :boolean option :disable, type: :boolean option :disable_2fa, type: :boolean + option :disable_sign_in_token, type: :boolean option :approve, type: :boolean option :reset_password, type: :boolean desc 'modify USERNAME', 'Modify a user account' @@ -146,6 +147,16 @@ module Mastodon::CLI With the --disable-2fa option, the two-factor authentication requirement for the user can be removed. + With the --disable-sign-in-token option, an account that has been + inactive for a long time won't have to introduce a security code that + has been sent to their email when trying to log in. This option + only disables the security code check for a limited amount of time + (up to 92 hours) + + With the --enable-sign-in-token option, ensure that the account + generates a security code challenge if it has been inactive for too + long. + With the --reset-password option, the user's password is replaced by a randomly-generated one, printed in the output. LONG_DESC @@ -169,6 +180,8 @@ module Mastodon::CLI user.disabled = true if options[:disable] user.approved = true if options[:approve] user.disable_two_factor! if options[:disable_2fa] + user.disable_sign_in_token! if options[:disable_sign_in_token] + user.enable_sign_in_token! if options[:enable_sign_in_token] # Password changes are a little different, as we also need to ensure # sessions, subscriptions, and access tokens are revoked after changing: diff --git a/spec/controllers/auth/sessions_controller_spec.rb b/spec/controllers/auth/sessions_controller_spec.rb index 88c5f508d5e..55b46ad7b29 100644 --- a/spec/controllers/auth/sessions_controller_spec.rb +++ b/spec/controllers/auth/sessions_controller_spec.rb @@ -434,6 +434,103 @@ RSpec.describe Auth::SessionsController do end end + context 'when 2FA is disabled and inactive for long' do + let!(:user) { Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', current_sign_in_at: 7.months.ago) } + + before do + allow(UserMailer).to receive(:sign_in_token).and_return(instance_double(ActionMailer::MessageDelivery, deliver_later!: nil)) + end + + context 'when using email and password' do + before do + post :create, params: { user: { email: user.email, password: user.password } } + end + + it 'renders sign in token authentication page' do + expect(response.parsed_body) + .to have_css('p.lead', text: I18n.t('users.inactive_sign_in_confirmation')) + end + + it 'generates sign in token' do + expect(user.reload.sign_in_token).to_not be_nil + end + + it 'sends sign in token e-mail' do + expect(UserMailer).to have_received(:sign_in_token) + end + end + + context 'when using an invalid password' do + before do + post :create, params: { user: { email: user.email, password: 'wrongpw' } } + end + + it 'shows a login error and does not log the user in' do + expect(flash[:alert]).to match(/#{failure_message_invalid_email}/i) + + expect(controller.current_user).to be_nil + end + end + + context 'when using a valid sign in token' do + before do + user.generate_sign_in_token + user.save + post :create, params: { user: { sign_in_token_attempt: user.sign_in_token } }, session: { attempt_user_id: user.id } + end + + it 'redirects to home' do + expect(response).to redirect_to(root_path) + end + + it 'logs the user in' do + expect(controller.current_user).to eq user + end + end + + context 'when using an invalid sign in token' do + before do + post :create, params: { user: { sign_in_token_attempt: 'wrongotp' } }, session: { attempt_user_id: user.id } + end + + it 'shows a login error' do + expect(flash[:alert]).to match I18n.t('users.invalid_sign_in_token') + end + + it "doesn't log the user in" do + expect(controller.current_user).to be_nil + end + end + + context 'when requesting a new sign in token' do + subject do + post :create, params: { sign_in_token_resend: '', user: { sign_in_token_attempt: '' } }, session: { attempt_user_id: user.id } + end + + before do + user.generate_sign_in_token + user.save + end + + it 'renders sign in token authentication page' do + subject + + expect(response.parsed_body) + .to have_css('p.lead', text: I18n.t('users.inactive_sign_in_confirmation')) + end + + it 'generates a new sign in token' do + expect { subject }.to(change { user.reload.sign_in_token }) + end + + it 'sends sign in token e-mail' do + subject + + expect(UserMailer).to have_received(:sign_in_token) + end + end + end + def failure_message_invalid_email I18n.t('devise.failure.invalid', authentication_keys: I18n.t('activerecord.attributes.user.email')) end diff --git a/spec/lib/mastodon/cli/accounts_spec.rb b/spec/lib/mastodon/cli/accounts_spec.rb index 0e468785926..2d629654a1e 100644 --- a/spec/lib/mastodon/cli/accounts_spec.rb +++ b/spec/lib/mastodon/cli/accounts_spec.rb @@ -403,6 +403,30 @@ RSpec.describe Mastodon::CLI::Accounts do end end + context 'with --disable-sign-in-token option' do + let(:user) { Fabricate(:user, current_sign_in_at: 8.months.ago) } + let(:options) { { disable_sign_in_token: true } } + + it 'disables the sign in token for the user' do + expect { subject } + .to output_results('OK') + .and change { user.reload.suspicious_inactive_sign_in? }.from(true).to(false) + end + end + + context 'with --enable-sign-in-token option' do + let(:user) { Fabricate(:user, current_sign_in_at: 8.months.ago) } + let(:options) { { enable_sign_in_token: true } } + + before { user.disable_sign_in_token! } + + it 'enables the sign in token for the user' do + expect { subject } + .to output_results('OK') + .and change { user.reload.suspicious_inactive_sign_in? }.from(false).to(true) + end + end + context 'when provided data is invalid' do let(:user) { Fabricate(:user) } let(:options) { { email: 'invalid' } } diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 3dc3f66c75a..51879556f1f 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -108,4 +108,9 @@ class UserMailerPreview < ActionMailer::Preview def announcement_published UserMailer.announcement_published(User.first, Announcement.last) end + + # Preview this email at http://localhost:3000/rails/mailers/user_mailer/sign_in_token + def sign_in_token + UserMailer.sign_in_token(User.first.tap(&:generate_sign_in_token), '127.0.0.1', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0', Time.now.utc) + end end diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb index 1587cd1652f..2512b4cdeeb 100644 --- a/spec/mailers/user_mailer_spec.rb +++ b/spec/mailers/user_mailer_spec.rb @@ -374,4 +374,20 @@ RSpec.describe UserMailer do it_behaves_like 'optional bulk mailer settings' end + + describe '#sign_in_token' do + let(:ip) { '192.168.0.1' } + let(:agent) { 'NCSA_Mosaic/2.0 (Windows 3.1)' } + let(:timestamp) { Time.now.utc } + let(:mail) { described_class.sign_in_token(receiver, ip, agent, timestamp) } + + it 'renders security code notification' do + receiver.update!(locale: nil) + + expect(mail.text_part.body) + .to match(I18n.t('user_mailer.sign_in_token.explanation')) + end + + it_behaves_like 'localized subject', 'user_mailer.sign_in_token.subject' + end end diff --git a/spec/models/user/sign_in_token_spec.rb b/spec/models/user/sign_in_token_spec.rb new file mode 100644 index 00000000000..990ce7b4b13 --- /dev/null +++ b/spec/models/user/sign_in_token_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe User::SignInToken do + describe '#valid_sign_in_token?' do + subject { Fabricate(:user) } + + before { subject.generate_sign_in_token } + + context 'when the token is random' do + let(:token) { Devise.friendly_token(6) } + + it { expect(subject.valid_sign_in_token?(token)).to be false } + end + + context 'when the token is the same' do + let(:token) { subject.sign_in_token } + + it { expect(subject.valid_sign_in_token?(token)).to be true } + end + end + + describe '#suspicious_inactive_sign_in?' do + subject { Fabricate(:user, current_sign_in_at:) } + + context 'when current_sign_in_at is nil' do + let(:current_sign_in_at) { nil } + + it { is_expected.to_not be_suspicious_inactive_sign_in } + end + + context 'when current_sign_in_at is before the threshold' do + let(:current_sign_in_at) { 80.days.ago } + + it { is_expected.to_not be_suspicious_inactive_sign_in } + end + + context 'when current_sign_in_at is after the threshold' do + let(:current_sign_in_at) { 91.days.ago } + + it { is_expected.to be_suspicious_inactive_sign_in } + end + + context 'when the user is external' do + subject { Fabricate.build(:user, password: nil, current_sign_in_at: 7.months.ago) } + + before { subject.save!(validate: false) } + + it { is_expected.to_not be_suspicious_inactive_sign_in } + end + + context 'when the otp is required for login' do + subject { Fabricate(:user, otp_required_for_login: true, current_sign_in_at: 7.months.ago) } + + it { is_expected.to_not be_suspicious_inactive_sign_in } + end + end + + describe '#generate_sign_in_token' do + subject { Fabricate(:user) } + + before { subject.generate_sign_in_token } + + it 'generates a new sign in token' do + expect(subject.sign_in_token).to_not be_nil + expect(subject.sign_in_token_sent_at).to_not be_nil + end + end + + describe '#disable_sign_in_token!' do + subject { Fabricate(:user, current_sign_in_at: 95.days.ago) } + + before { subject.disable_sign_in_token! } + + it { is_expected.to_not be_suspicious_inactive_sign_in } + end +end diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index ba4e31189a8..955045887c2 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -52,6 +52,50 @@ RSpec.describe UserPolicy do end end + permissions :disable_sign_in_token? do + context 'when admin?' do + context 'with !record.staff?' do + it 'permits' do + expect(subject).to permit(admin, john.user) + end + end + + context 'when record.staff?' do + it 'denies' do + expect(subject).to_not permit(admin, admin.user) + end + end + end + + context 'with !admin?' do + it 'denies' do + expect(subject).to_not permit(john, User) + end + end + end + + permissions :enable_sign_in_token? do + context 'when admin?' do + context 'with !record.staff?' do + it 'permits' do + expect(subject).to permit(admin, john.user) + end + end + + context 'when record.staff?' do + it 'denies' do + expect(subject).to_not permit(admin, admin.user) + end + end + end + + context 'with !admin?' do + it 'denies' do + expect(subject).to_not permit(john, User) + end + end + end + permissions :confirm? do context 'when staff?' do context 'with !record.confirmed?' do diff --git a/spec/system/admin/users/sign_in_token_authentications_spec.rb b/spec/system/admin/users/sign_in_token_authentications_spec.rb new file mode 100644 index 00000000000..120a48839d6 --- /dev/null +++ b/spec/system/admin/users/sign_in_token_authentications_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin Users SignInTokenAuthentications' do + let(:user) { Fabricate(:user) } + + before { sign_in Fabricate(:admin_user) } + + describe 'Disabling Sign In Token for users' do + context 'when user has been inactive for too long' do + before { user.update(current_sign_in_at: 95.days.ago) } + + it 'disables sign in token and redirects to admin account page' do + visit admin_account_path(user.account.id) + + expect { disable_sign_in_token } + .to change { user.reload.suspicious_inactive_sign_in? }.to(false) + expect(page) + .to have_title(user.account.pretty_acct) + end + end + + def disable_sign_in_token + click_on I18n.t('admin.accounts.disable_sign_in_token') + end + end + + describe 'Enabling Sign In Token for users' do + context 'when user has been inactive for too long' do + before do + user.update(current_sign_in_at: 95.days.ago) + user.disable_sign_in_token! + end + + it 'enables sign in token and redirects to admin account page' do + visit admin_account_path(user.account.id) + + expect { enable_sign_in_token } + .to change { user.reload.suspicious_inactive_sign_in? }.to(true) + expect(page) + .to have_title(user.account.pretty_acct) + end + end + + def disable_sign_in_token + click_on I18n.t('admin.accounts.disable_sign_in_token') + end + + def enable_sign_in_token + click_on I18n.t('admin.accounts.enable_sign_in_token') + end + end +end