Add IP-based rules (#14963)
This commit is contained in:
parent
dc52a778e1
commit
5e1364c448
30 changed files with 530 additions and 21 deletions
56
app/controllers/admin/ip_blocks_controller.rb
Normal file
56
app/controllers/admin/ip_blocks_controller.rb
Normal file
|
@ -0,0 +1,56 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class IpBlocksController < BaseController
|
||||
def index
|
||||
authorize :ip_block, :index?
|
||||
|
||||
@ip_blocks = IpBlock.page(params[:page])
|
||||
@form = Form::IpBlockBatch.new
|
||||
end
|
||||
|
||||
def new
|
||||
authorize :ip_block, :create?
|
||||
|
||||
@ip_block = IpBlock.new(ip: '', severity: :no_access, expires_in: 1.year)
|
||||
end
|
||||
|
||||
def create
|
||||
authorize :ip_block, :create?
|
||||
|
||||
@ip_block = IpBlock.new(resource_params)
|
||||
|
||||
if @ip_block.save
|
||||
log_action :create, @ip_block
|
||||
redirect_to admin_ip_blocks_path, notice: I18n.t('admin.ip_blocks.created_msg')
|
||||
else
|
||||
render :new
|
||||
end
|
||||
end
|
||||
|
||||
def batch
|
||||
@form = Form::IpBlockBatch.new(form_ip_block_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||
@form.save
|
||||
rescue ActionController::ParameterMissing
|
||||
flash[:alert] = I18n.t('admin.ip_blocks.no_ip_block_selected')
|
||||
rescue Mastodon::NotPermittedError
|
||||
flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
|
||||
ensure
|
||||
redirect_to admin_ip_blocks_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resource_params
|
||||
params.require(:ip_block).permit(:ip, :severity, :comment, :expires_in)
|
||||
end
|
||||
|
||||
def action_from_button
|
||||
'delete' if params[:delete]
|
||||
end
|
||||
|
||||
def form_ip_block_batch_params
|
||||
params.require(:form_ip_block_batch).permit(ip_block_ids: [])
|
||||
end
|
||||
end
|
||||
end
|
|
@ -20,7 +20,7 @@ class Api::V1::AccountsController < Api::BaseController
|
|||
end
|
||||
|
||||
def create
|
||||
token = AppSignUpService.new.call(doorkeeper_token.application, account_params)
|
||||
token = AppSignUpService.new.call(doorkeeper_token.application, request.remote_ip, account_params)
|
||||
response = Doorkeeper::OAuth::TokenResponse.new(token)
|
||||
|
||||
headers.merge!(response.headers)
|
||||
|
|
|
@ -45,9 +45,9 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||
def build_resource(hash = nil)
|
||||
super(hash)
|
||||
|
||||
resource.locale = I18n.locale
|
||||
resource.invite_code = params[:invite_code] if resource.invite_code.blank?
|
||||
resource.current_sign_in_ip = request.remote_ip
|
||||
resource.locale = I18n.locale
|
||||
resource.invite_code = params[:invite_code] if resource.invite_code.blank?
|
||||
resource.sign_up_ip = request.remote_ip
|
||||
|
||||
resource.build_account if resource.account.nil?
|
||||
end
|
||||
|
|
|
@ -29,6 +29,8 @@ module Admin::ActionLogsHelper
|
|||
link_to record.target_account.acct, admin_account_path(record.target_account_id)
|
||||
when 'Announcement'
|
||||
link_to truncate(record.text), edit_admin_announcement_path(record.id)
|
||||
when 'IpBlock'
|
||||
"#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})"
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -48,6 +50,8 @@ module Admin::ActionLogsHelper
|
|||
end
|
||||
when 'Announcement'
|
||||
truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text'])
|
||||
when 'IpBlock'
|
||||
"#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
32
app/lib/fast_ip_map.rb
Normal file
32
app/lib/fast_ip_map.rb
Normal file
|
@ -0,0 +1,32 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FastIpMap
|
||||
MAX_IPV4_PREFIX = 32
|
||||
MAX_IPV6_PREFIX = 128
|
||||
|
||||
# @param [Enumerable<IPAddr>] addresses
|
||||
def initialize(addresses)
|
||||
@fast_lookup = {}
|
||||
@ranges = []
|
||||
|
||||
# Hash look-up is faster but only works for exact matches, so we split
|
||||
# exact addresses from non-exact ones
|
||||
addresses.each do |address|
|
||||
if (address.ipv4? && address.prefix == MAX_IPV4_PREFIX) || (address.ipv6? && address.prefix == MAX_IPV6_PREFIX)
|
||||
@fast_lookup[address.to_s] = true
|
||||
else
|
||||
@ranges << address
|
||||
end
|
||||
end
|
||||
|
||||
# We're more likely to hit wider-reaching ranges when checking for
|
||||
# inclusion, so make sure they're sorted first
|
||||
@ranges.sort_by!(&:prefix)
|
||||
end
|
||||
|
||||
# @param [IPAddr] address
|
||||
# @return [Boolean]
|
||||
def include?(address)
|
||||
@fast_lookup[address.to_s] || @ranges.any? { |cidr| cidr.include?(address) }
|
||||
end
|
||||
end
|
|
@ -6,7 +6,15 @@ module Expireable
|
|||
included do
|
||||
scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) }
|
||||
|
||||
attr_reader :expires_in
|
||||
def expires_in
|
||||
return @expires_in if defined?(@expires_in)
|
||||
|
||||
if expires_at.nil?
|
||||
nil
|
||||
else
|
||||
(expires_at - created_at).to_i
|
||||
end
|
||||
end
|
||||
|
||||
def expires_in=(interval)
|
||||
self.expires_at = interval.to_i.seconds.from_now if interval.present?
|
||||
|
|
31
app/models/form/ip_block_batch.rb
Normal file
31
app/models/form/ip_block_batch.rb
Normal file
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Form::IpBlockBatch
|
||||
include ActiveModel::Model
|
||||
include Authorization
|
||||
include AccountableConcern
|
||||
|
||||
attr_accessor :ip_block_ids, :action, :current_account
|
||||
|
||||
def save
|
||||
case action
|
||||
when 'delete'
|
||||
delete!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ip_blocks
|
||||
@ip_blocks ||= IpBlock.where(id: ip_block_ids)
|
||||
end
|
||||
|
||||
def delete!
|
||||
ip_blocks.each { |ip_block| authorize(ip_block, :destroy?) }
|
||||
|
||||
ip_blocks.each do |ip_block|
|
||||
ip_block.destroy
|
||||
log_action :destroy, ip_block
|
||||
end
|
||||
end
|
||||
end
|
41
app/models/ip_block.rb
Normal file
41
app/models/ip_block.rb
Normal file
|
@ -0,0 +1,41 @@
|
|||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: ip_blocks
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# expires_at :datetime
|
||||
# ip :inet default(#<IPAddr: IPv4:0.0.0.0/255.255.255.255>), not null
|
||||
# severity :integer default(NULL), not null
|
||||
# comment :text default(""), not null
|
||||
#
|
||||
|
||||
class IpBlock < ApplicationRecord
|
||||
CACHE_KEY = 'blocked_ips'
|
||||
|
||||
include Expireable
|
||||
|
||||
enum severity: {
|
||||
sign_up_requires_approval: 5000,
|
||||
no_access: 9999,
|
||||
}
|
||||
|
||||
validates :ip, :severity, presence: true
|
||||
|
||||
after_commit :reset_cache
|
||||
|
||||
class << self
|
||||
def blocked?(remote_ip)
|
||||
blocked_ips_map = Rails.cache.fetch(CACHE_KEY) { FastIpMap.new(IpBlock.where(severity: :no_access).pluck(:ip)) }
|
||||
blocked_ips_map.include?(remote_ip)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reset_cache
|
||||
Rails.cache.delete(CACHE_KEY)
|
||||
end
|
||||
end
|
|
@ -41,6 +41,7 @@
|
|||
# sign_in_token :string
|
||||
# sign_in_token_sent_at :datetime
|
||||
# webauthn_id :string
|
||||
# sign_up_ip :inet
|
||||
#
|
||||
|
||||
class User < ApplicationRecord
|
||||
|
@ -97,7 +98,7 @@ class User < ApplicationRecord
|
|||
scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
|
||||
scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended_at: nil }) }
|
||||
scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
|
||||
scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) }
|
||||
scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.sign_up_ip <<= ?', value)).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) }
|
||||
scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
|
||||
|
||||
before_validation :sanitize_languages
|
||||
|
@ -331,6 +332,7 @@ class User < ApplicationRecord
|
|||
|
||||
arr << [current_sign_in_at, current_sign_in_ip] if current_sign_in_ip.present?
|
||||
arr << [last_sign_in_at, last_sign_in_ip] if last_sign_in_ip.present?
|
||||
arr << [created_at, sign_up_ip] if sign_up_ip.present?
|
||||
|
||||
arr.sort_by { |pair| pair.first || Time.now.utc }.uniq(&:last).reverse!
|
||||
end
|
||||
|
@ -385,7 +387,17 @@ class User < ApplicationRecord
|
|||
end
|
||||
|
||||
def set_approved
|
||||
self.approved = open_registrations? || valid_invitation? || external?
|
||||
self.approved = begin
|
||||
if sign_up_from_ip_requires_approval?
|
||||
false
|
||||
else
|
||||
open_registrations? || valid_invitation? || external?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sign_up_from_ip_requires_approval?
|
||||
!sign_up_ip.nil? && IpBlock.where(severity: :sign_up_requires_approval).where('ip >>= ?', sign_up_ip.to_s).exists?
|
||||
end
|
||||
|
||||
def open_registrations?
|
||||
|
|
15
app/policies/ip_block_policy.rb
Normal file
15
app/policies/ip_block_policy.rb
Normal file
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class IpBlockPolicy < ApplicationPolicy
|
||||
def index?
|
||||
admin?
|
||||
end
|
||||
|
||||
def create?
|
||||
admin?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
admin?
|
||||
end
|
||||
end
|
|
@ -1,13 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AppSignUpService < BaseService
|
||||
def call(app, params)
|
||||
def call(app, remote_ip, params)
|
||||
return unless allowed_registrations?
|
||||
|
||||
user_params = params.slice(:email, :password, :agreement, :locale)
|
||||
account_params = params.slice(:username)
|
||||
invite_request_params = { text: params[:reason] }
|
||||
user = User.create!(user_params.merge(created_by_application: app, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params))
|
||||
user = User.create!(user_params.merge(created_by_application: app, sign_up_ip: remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params))
|
||||
|
||||
Doorkeeper::AccessToken.create!(application: app,
|
||||
resource_owner_id: user.id,
|
||||
|
|
11
app/views/admin/ip_blocks/_ip_block.html.haml
Normal file
11
app/views/admin/ip_blocks/_ip_block.html.haml
Normal file
|
@ -0,0 +1,11 @@
|
|||
.batch-table__row
|
||||
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
|
||||
= f.check_box :ip_block_ids, { multiple: true, include_hidden: false }, ip_block.id
|
||||
.batch-table__row__content
|
||||
.batch-table__row__content__text
|
||||
%samp= "#{ip_block.ip}/#{ip_block.ip.prefix}"
|
||||
- if ip_block.comment.present?
|
||||
•
|
||||
= ip_block.comment
|
||||
%br/
|
||||
= t("simple_form.labels.ip_block.severities.#{ip_block.severity}")
|
28
app/views/admin/ip_blocks/index.html.haml
Normal file
28
app/views/admin/ip_blocks/index.html.haml
Normal file
|
@ -0,0 +1,28 @@
|
|||
- content_for :page_title do
|
||||
= t('admin.ip_blocks.title')
|
||||
|
||||
- content_for :header_tags do
|
||||
= javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous'
|
||||
|
||||
- if can?(:create, :ip_block)
|
||||
- content_for :heading_actions do
|
||||
= link_to t('admin.ip_blocks.add_new'), new_admin_ip_block_path, class: 'button'
|
||||
|
||||
= form_for(@form, url: batch_admin_ip_blocks_path) do |f|
|
||||
= hidden_field_tag :page, params[:page] || 1
|
||||
|
||||
.batch-table
|
||||
.batch-table__toolbar
|
||||
%label.batch-table__toolbar__select.batch-checkbox-all
|
||||
= check_box_tag :batch_checkbox_all, nil, false
|
||||
.batch-table__toolbar__actions
|
||||
- if can?(:destroy, :ip_block)
|
||||
= f.button safe_join([fa_icon('times'), t('admin.ip_blocks.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
|
||||
.batch-table__body
|
||||
- if @ip_blocks.empty?
|
||||
= nothing_here 'nothing-here--under-tabs'
|
||||
- else
|
||||
= render partial: 'ip_block', collection: @ip_blocks, locals: { f: f }
|
||||
|
||||
= paginate @ip_blocks
|
||||
|
20
app/views/admin/ip_blocks/new.html.haml
Normal file
20
app/views/admin/ip_blocks/new.html.haml
Normal file
|
@ -0,0 +1,20 @@
|
|||
- content_for :page_title do
|
||||
= t('.title')
|
||||
|
||||
= simple_form_for @ip_block, url: admin_ip_blocks_path do |f|
|
||||
= render 'shared/error_messages', object: @ip_block
|
||||
|
||||
.fields-group
|
||||
= f.input :ip, as: :string, wrapper: :with_block_label, input_html: { placeholder: '192.0.2.0/24' }
|
||||
|
||||
.fields-group
|
||||
= f.input :expires_in, wrapper: :with_block_label, collection: [1.day, 2.weeks, 1.month, 6.months, 1.year, 3.years].map(&:to_i), label_method: lambda { |i| I18n.t("admin.ip_blocks.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
|
||||
|
||||
.fields-group
|
||||
= f.input :severity, as: :radio_buttons, collection: IpBlock.severities.keys, include_blank: false, wrapper: :with_block_label, label_method: lambda { |severity| safe_join([I18n.t("simple_form.labels.ip_block.severities.#{severity}"), content_tag(:span, I18n.t("simple_form.hints.ip_block.severities.#{severity}"), class: 'hint')]) }
|
||||
|
||||
.fields-group
|
||||
= f.input :comment, as: :string, wrapper: :with_block_label
|
||||
|
||||
.actions
|
||||
= f.button :button, t('admin.ip_blocks.add_new'), type: :submit
|
|
@ -7,7 +7,7 @@
|
|||
%strong= account.user_email
|
||||
= "(@#{account.username})"
|
||||
%br/
|
||||
= account.user_current_sign_in_ip
|
||||
%samp= account.user_current_sign_in_ip
|
||||
•
|
||||
= t 'admin.accounts.time_in_queue', time: time_ago_in_words(account.user&.created_at)
|
||||
|
||||
|
|
|
@ -3,13 +3,23 @@
|
|||
class Scheduler::IpCleanupScheduler
|
||||
include Sidekiq::Worker
|
||||
|
||||
RETENTION_PERIOD = 1.year
|
||||
IP_RETENTION_PERIOD = 1.year.freeze
|
||||
|
||||
sidekiq_options lock: :until_executed, retry: 0
|
||||
|
||||
def perform
|
||||
time_ago = RETENTION_PERIOD.ago
|
||||
SessionActivation.where('updated_at < ?', time_ago).in_batches.destroy_all
|
||||
User.where('last_sign_in_at < ?', time_ago).where.not(last_sign_in_ip: nil).in_batches.update_all(last_sign_in_ip: nil)
|
||||
clean_ip_columns!
|
||||
clean_expired_ip_blocks!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
def clean_expired_ip_blocks!
|
||||
IpBlock.expired.in_batches.destroy_all
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue