Add IP-based rules (#14963)
parent
dc52a778e1
commit
5e1364c448
|
@ -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
|
end
|
||||||
|
|
||||||
def create
|
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)
|
response = Doorkeeper::OAuth::TokenResponse.new(token)
|
||||||
|
|
||||||
headers.merge!(response.headers)
|
headers.merge!(response.headers)
|
||||||
|
|
|
@ -47,7 +47,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||||
|
|
||||||
resource.locale = I18n.locale
|
resource.locale = I18n.locale
|
||||||
resource.invite_code = params[:invite_code] if resource.invite_code.blank?
|
resource.invite_code = params[:invite_code] if resource.invite_code.blank?
|
||||||
resource.current_sign_in_ip = request.remote_ip
|
resource.sign_up_ip = request.remote_ip
|
||||||
|
|
||||||
resource.build_account if resource.account.nil?
|
resource.build_account if resource.account.nil?
|
||||||
end
|
end
|
||||||
|
|
|
@ -29,6 +29,8 @@ module Admin::ActionLogsHelper
|
||||||
link_to record.target_account.acct, admin_account_path(record.target_account_id)
|
link_to record.target_account.acct, admin_account_path(record.target_account_id)
|
||||||
when 'Announcement'
|
when 'Announcement'
|
||||||
link_to truncate(record.text), edit_admin_announcement_path(record.id)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -48,6 +50,8 @@ module Admin::ActionLogsHelper
|
||||||
end
|
end
|
||||||
when 'Announcement'
|
when 'Announcement'
|
||||||
truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text'])
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
included do
|
||||||
scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) }
|
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)
|
def expires_in=(interval)
|
||||||
self.expires_at = interval.to_i.seconds.from_now if interval.present?
|
self.expires_at = interval.to_i.seconds.from_now if interval.present?
|
||||||
|
|
|
@ -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
|
|
@ -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 :string
|
||||||
# sign_in_token_sent_at :datetime
|
# sign_in_token_sent_at :datetime
|
||||||
# webauthn_id :string
|
# webauthn_id :string
|
||||||
|
# sign_up_ip :inet
|
||||||
#
|
#
|
||||||
|
|
||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
|
@ -97,7 +98,7 @@ class User < ApplicationRecord
|
||||||
scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
|
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 :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_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) }
|
scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
|
||||||
|
|
||||||
before_validation :sanitize_languages
|
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 << [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 << [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!
|
arr.sort_by { |pair| pair.first || Time.now.utc }.uniq(&:last).reverse!
|
||||||
end
|
end
|
||||||
|
@ -385,7 +387,17 @@ class User < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_approved
|
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
|
end
|
||||||
|
|
||||||
def open_registrations?
|
def open_registrations?
|
||||||
|
|
|
@ -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
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class AppSignUpService < BaseService
|
class AppSignUpService < BaseService
|
||||||
def call(app, params)
|
def call(app, remote_ip, params)
|
||||||
return unless allowed_registrations?
|
return unless allowed_registrations?
|
||||||
|
|
||||||
user_params = params.slice(:email, :password, :agreement, :locale)
|
user_params = params.slice(:email, :password, :agreement, :locale)
|
||||||
account_params = params.slice(:username)
|
account_params = params.slice(:username)
|
||||||
invite_request_params = { text: params[:reason] }
|
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,
|
Doorkeeper::AccessToken.create!(application: app,
|
||||||
resource_owner_id: user.id,
|
resource_owner_id: user.id,
|
||||||
|
|
|
@ -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}")
|
|
@ -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
|
||||||
|
|
|
@ -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
|
%strong= account.user_email
|
||||||
= "(@#{account.username})"
|
= "(@#{account.username})"
|
||||||
%br/
|
%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)
|
= t 'admin.accounts.time_in_queue', time: time_ago_in_words(account.user&.created_at)
|
||||||
|
|
||||||
|
|
|
@ -3,13 +3,23 @@
|
||||||
class Scheduler::IpCleanupScheduler
|
class Scheduler::IpCleanupScheduler
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
RETENTION_PERIOD = 1.year
|
IP_RETENTION_PERIOD = 1.year.freeze
|
||||||
|
|
||||||
sidekiq_options lock: :until_executed, retry: 0
|
sidekiq_options lock: :until_executed, retry: 0
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
time_ago = RETENTION_PERIOD.ago
|
clean_ip_columns!
|
||||||
SessionActivation.where('updated_at < ?', time_ago).in_batches.destroy_all
|
clean_expired_ip_blocks!
|
||||||
User.where('last_sign_in_at < ?', time_ago).where.not(last_sign_in_ip: nil).in_batches.update_all(last_sign_in_ip: nil)
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -42,6 +42,10 @@ class Rack::Attack
|
||||||
req.remote_ip == '127.0.0.1' || req.remote_ip == '::1'
|
req.remote_ip == '127.0.0.1' || req.remote_ip == '::1'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Rack::Attack.blocklist('deny from blocklist') do |req|
|
||||||
|
IpBlock.blocked?(req.remote_ip)
|
||||||
|
end
|
||||||
|
|
||||||
throttle('throttle_authenticated_api', limit: 300, period: 5.minutes) do |req|
|
throttle('throttle_authenticated_api', limit: 300, period: 5.minutes) do |req|
|
||||||
req.authenticated_user_id if req.api_request?
|
req.authenticated_user_id if req.api_request?
|
||||||
end
|
end
|
||||||
|
|
|
@ -223,12 +223,14 @@ en:
|
||||||
create_domain_allow: Create Domain Allow
|
create_domain_allow: Create Domain Allow
|
||||||
create_domain_block: Create Domain Block
|
create_domain_block: Create Domain Block
|
||||||
create_email_domain_block: Create E-mail Domain Block
|
create_email_domain_block: Create E-mail Domain Block
|
||||||
|
create_ip_block: Create IP rule
|
||||||
demote_user: Demote User
|
demote_user: Demote User
|
||||||
destroy_announcement: Delete Announcement
|
destroy_announcement: Delete Announcement
|
||||||
destroy_custom_emoji: Delete Custom Emoji
|
destroy_custom_emoji: Delete Custom Emoji
|
||||||
destroy_domain_allow: Delete Domain Allow
|
destroy_domain_allow: Delete Domain Allow
|
||||||
destroy_domain_block: Delete Domain Block
|
destroy_domain_block: Delete Domain Block
|
||||||
destroy_email_domain_block: Delete e-mail domain block
|
destroy_email_domain_block: Delete e-mail domain block
|
||||||
|
destroy_ip_block: Delete IP rule
|
||||||
destroy_status: Delete Status
|
destroy_status: Delete Status
|
||||||
disable_2fa_user: Disable 2FA
|
disable_2fa_user: Disable 2FA
|
||||||
disable_custom_emoji: Disable Custom Emoji
|
disable_custom_emoji: Disable Custom Emoji
|
||||||
|
@ -259,12 +261,14 @@ en:
|
||||||
create_domain_allow: "%{name} allowed federation with domain %{target}"
|
create_domain_allow: "%{name} allowed federation with domain %{target}"
|
||||||
create_domain_block: "%{name} blocked domain %{target}"
|
create_domain_block: "%{name} blocked domain %{target}"
|
||||||
create_email_domain_block: "%{name} blocked e-mail domain %{target}"
|
create_email_domain_block: "%{name} blocked e-mail domain %{target}"
|
||||||
|
create_ip_block: "%{name} created rule for IP %{target}"
|
||||||
demote_user: "%{name} demoted user %{target}"
|
demote_user: "%{name} demoted user %{target}"
|
||||||
destroy_announcement: "%{name} deleted announcement %{target}"
|
destroy_announcement: "%{name} deleted announcement %{target}"
|
||||||
destroy_custom_emoji: "%{name} destroyed emoji %{target}"
|
destroy_custom_emoji: "%{name} destroyed emoji %{target}"
|
||||||
destroy_domain_allow: "%{name} disallowed federation with domain %{target}"
|
destroy_domain_allow: "%{name} disallowed federation with domain %{target}"
|
||||||
destroy_domain_block: "%{name} unblocked domain %{target}"
|
destroy_domain_block: "%{name} unblocked domain %{target}"
|
||||||
destroy_email_domain_block: "%{name} unblocked e-mail domain %{target}"
|
destroy_email_domain_block: "%{name} unblocked e-mail domain %{target}"
|
||||||
|
destroy_ip_block: "%{name} deleted rule for IP %{target}"
|
||||||
destroy_status: "%{name} removed status by %{target}"
|
destroy_status: "%{name} removed status by %{target}"
|
||||||
disable_2fa_user: "%{name} disabled two factor requirement for user %{target}"
|
disable_2fa_user: "%{name} disabled two factor requirement for user %{target}"
|
||||||
disable_custom_emoji: "%{name} disabled emoji %{target}"
|
disable_custom_emoji: "%{name} disabled emoji %{target}"
|
||||||
|
@ -449,6 +453,21 @@ en:
|
||||||
expired: Expired
|
expired: Expired
|
||||||
title: Filter
|
title: Filter
|
||||||
title: Invites
|
title: Invites
|
||||||
|
ip_blocks:
|
||||||
|
add_new: Create rule
|
||||||
|
created_msg: Successfully added new IP rule
|
||||||
|
delete: Delete
|
||||||
|
expires_in:
|
||||||
|
'1209600': 2 weeks
|
||||||
|
'15778476': 6 months
|
||||||
|
'2629746': 1 month
|
||||||
|
'31556952': 1 year
|
||||||
|
'86400': 1 day
|
||||||
|
'94670856': 3 years
|
||||||
|
new:
|
||||||
|
title: Create new IP rule
|
||||||
|
no_ip_block_selected: No IP rules were changed as none were selected
|
||||||
|
title: IP rules
|
||||||
pending_accounts:
|
pending_accounts:
|
||||||
title: Pending accounts (%{count})
|
title: Pending accounts (%{count})
|
||||||
relationships:
|
relationships:
|
||||||
|
|
|
@ -65,6 +65,14 @@ en:
|
||||||
data: CSV file exported from another Mastodon server
|
data: CSV file exported from another Mastodon server
|
||||||
invite_request:
|
invite_request:
|
||||||
text: This will help us review your application
|
text: This will help us review your application
|
||||||
|
ip_block:
|
||||||
|
comment: Optional. Remember why you added this rule.
|
||||||
|
expires_in: IP addresses are a finite resource, they are sometimes shared and often change hands. For this reason, indefinite IP blocks are not recommended.
|
||||||
|
ip: Enter an IPv4 or IPv6 address. You can block entire ranges using the CIDR syntax. Be careful not to lock yourself out!
|
||||||
|
severities:
|
||||||
|
no_access: Block access to all resources
|
||||||
|
sign_up_requires_approval: New sign-ups will require your approval
|
||||||
|
severity: Choose what will happen with requests from this IP
|
||||||
sessions:
|
sessions:
|
||||||
otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:'
|
otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:'
|
||||||
webauthn: If it's an USB key be sure to insert it and, if necessary, tap it.
|
webauthn: If it's an USB key be sure to insert it and, if necessary, tap it.
|
||||||
|
@ -170,6 +178,13 @@ en:
|
||||||
comment: Comment
|
comment: Comment
|
||||||
invite_request:
|
invite_request:
|
||||||
text: Why do you want to join?
|
text: Why do you want to join?
|
||||||
|
ip_block:
|
||||||
|
comment: Comment
|
||||||
|
ip: IP
|
||||||
|
severities:
|
||||||
|
no_access: Block access
|
||||||
|
sign_up_requires_approval: Limit sign-ups
|
||||||
|
severity: Rule
|
||||||
notification_emails:
|
notification_emails:
|
||||||
digest: Send digest e-mails
|
digest: Send digest e-mails
|
||||||
favourite: Someone favourited your status
|
favourite: Someone favourited your status
|
||||||
|
|
|
@ -41,6 +41,7 @@ SimpleNavigation::Configuration.run do |navigation|
|
||||||
s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.tags.title')]), admin_tags_path, highlights_on: %r{/admin/tags}
|
s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.tags.title')]), admin_tags_path, highlights_on: %r{/admin/tags}
|
||||||
s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? }
|
s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? }
|
||||||
s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? }
|
s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? }
|
||||||
|
s.item :ip_blocks, safe_join([fa_icon('ban fw'), t('admin.ip_blocks.title')]), admin_ip_blocks_url, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.admin? }
|
||||||
end
|
end
|
||||||
|
|
||||||
n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_dashboard_url, if: proc { current_user.staff? } do |s|
|
n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_dashboard_url, if: proc { current_user.staff? } do |s|
|
||||||
|
|
|
@ -283,6 +283,12 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :ip_blocks, only: [:index, :new, :create] do
|
||||||
|
collection do
|
||||||
|
post :batch
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
resources :account_moderation_notes, only: [:create, :destroy]
|
resources :account_moderation_notes, only: [:create, :destroy]
|
||||||
|
|
||||||
resources :tags, only: [:index, :show, :update] do
|
resources :tags, only: [:index, :show, :update] do
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
class CreateIpBlocks < ActiveRecord::Migration[5.2]
|
||||||
|
def change
|
||||||
|
create_table :ip_blocks do |t|
|
||||||
|
t.inet :ip, null: false, default: '0.0.0.0'
|
||||||
|
t.integer :severity, null: false, default: 0
|
||||||
|
t.datetime :expires_at
|
||||||
|
t.text :comment, null: false, default: ''
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,5 @@
|
||||||
|
class AddSignUpIpToUsers < ActiveRecord::Migration[5.2]
|
||||||
|
def change
|
||||||
|
add_column :users, :sign_up_ip, :inet
|
||||||
|
end
|
||||||
|
end
|
12
db/schema.rb
12
db/schema.rb
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2020_09_17_222734) do
|
ActiveRecord::Schema.define(version: 2020_10_08_220312) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -463,6 +463,15 @@ ActiveRecord::Schema.define(version: 2020_09_17_222734) do
|
||||||
t.index ["user_id"], name: "index_invites_on_user_id"
|
t.index ["user_id"], name: "index_invites_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "ip_blocks", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.datetime "expires_at"
|
||||||
|
t.inet "ip", default: "0.0.0.0", null: false
|
||||||
|
t.integer "severity", default: 0, null: false
|
||||||
|
t.text "comment", default: "", null: false
|
||||||
|
end
|
||||||
|
|
||||||
create_table "list_accounts", force: :cascade do |t|
|
create_table "list_accounts", force: :cascade do |t|
|
||||||
t.bigint "list_id", null: false
|
t.bigint "list_id", null: false
|
||||||
t.bigint "account_id", null: false
|
t.bigint "account_id", null: false
|
||||||
|
@ -891,6 +900,7 @@ ActiveRecord::Schema.define(version: 2020_09_17_222734) do
|
||||||
t.string "sign_in_token"
|
t.string "sign_in_token"
|
||||||
t.datetime "sign_in_token_sent_at"
|
t.datetime "sign_in_token_sent_at"
|
||||||
t.string "webauthn_id"
|
t.string "webauthn_id"
|
||||||
|
t.inet "sign_up_ip"
|
||||||
t.index ["account_id"], name: "index_users_on_account_id"
|
t.index ["account_id"], name: "index_users_on_account_id"
|
||||||
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
|
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
|
||||||
t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id"
|
t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id"
|
||||||
|
|
|
@ -13,6 +13,7 @@ require_relative 'mastodon/preview_cards_cli'
|
||||||
require_relative 'mastodon/cache_cli'
|
require_relative 'mastodon/cache_cli'
|
||||||
require_relative 'mastodon/upgrade_cli'
|
require_relative 'mastodon/upgrade_cli'
|
||||||
require_relative 'mastodon/email_domain_blocks_cli'
|
require_relative 'mastodon/email_domain_blocks_cli'
|
||||||
|
require_relative 'mastodon/ip_blocks_cli'
|
||||||
require_relative 'mastodon/version'
|
require_relative 'mastodon/version'
|
||||||
|
|
||||||
module Mastodon
|
module Mastodon
|
||||||
|
@ -57,6 +58,9 @@ module Mastodon
|
||||||
desc 'email_domain_blocks SUBCOMMAND ...ARGS', 'Manage e-mail domain blocks'
|
desc 'email_domain_blocks SUBCOMMAND ...ARGS', 'Manage e-mail domain blocks'
|
||||||
subcommand 'email_domain_blocks', Mastodon::EmailDomainBlocksCLI
|
subcommand 'email_domain_blocks', Mastodon::EmailDomainBlocksCLI
|
||||||
|
|
||||||
|
desc 'ip_blocks SUBCOMMAND ...ARGS', 'Manage IP blocks'
|
||||||
|
subcommand 'ip_blocks', Mastodon::IpBlocksCLI
|
||||||
|
|
||||||
option :dry_run, type: :boolean
|
option :dry_run, type: :boolean
|
||||||
desc 'self-destruct', 'Erase the server from the federation'
|
desc 'self-destruct', 'Erase the server from the federation'
|
||||||
long_desc <<~LONG_DESC
|
long_desc <<~LONG_DESC
|
||||||
|
|
|
@ -0,0 +1,132 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rubygems/package'
|
||||||
|
require_relative '../../config/boot'
|
||||||
|
require_relative '../../config/environment'
|
||||||
|
require_relative 'cli_helper'
|
||||||
|
|
||||||
|
module Mastodon
|
||||||
|
class IpBlocksCLI < Thor
|
||||||
|
def self.exit_on_failure?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
option :severity, required: true, enum: %w(no_access sign_up_requires_approval), desc: 'Severity of the block'
|
||||||
|
option :comment, aliases: [:c], desc: 'Optional comment'
|
||||||
|
option :duration, aliases: [:d], type: :numeric, desc: 'Duration of the block in seconds'
|
||||||
|
option :force, type: :boolean, aliases: [:f], desc: 'Overwrite existing blocks'
|
||||||
|
desc 'add IP...', 'Add one or more IP blocks'
|
||||||
|
long_desc <<-LONG_DESC
|
||||||
|
Add one or more IP blocks. You can use CIDR syntax to
|
||||||
|
block IP ranges. You must specify --severity of the block. All
|
||||||
|
options will be copied for each IP block you create in one command.
|
||||||
|
|
||||||
|
You can add a --comment. If an IP block already exists for one of
|
||||||
|
the provided IPs, it will be skipped unless you use the --force
|
||||||
|
option to overwrite it.
|
||||||
|
LONG_DESC
|
||||||
|
def add(*addresses)
|
||||||
|
if addresses.empty?
|
||||||
|
say('No IP(s) given', :red)
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
skipped = 0
|
||||||
|
processed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
addresses.each do |address|
|
||||||
|
ip_block = IpBlock.find_by(ip: address)
|
||||||
|
|
||||||
|
if ip_block.present? && !options[:force]
|
||||||
|
say("#{address} is already blocked", :yellow)
|
||||||
|
skipped += 1
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
ip_block ||= IpBlock.new(ip: address)
|
||||||
|
|
||||||
|
ip_block.severity = options[:severity]
|
||||||
|
ip_block.comment = options[:comment]
|
||||||
|
ip_block.expires_in = options[:duration]
|
||||||
|
|
||||||
|
if ip_block.save
|
||||||
|
processed += 1
|
||||||
|
else
|
||||||
|
say("#{address} could not be saved", :red)
|
||||||
|
failed += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
say("Added #{processed}, skipped #{skipped}, failed #{failed}", color(processed, failed))
|
||||||
|
end
|
||||||
|
|
||||||
|
option :force, type: :boolean, aliases: [:f], desc: 'Remove blocks for ranges that cover given IP(s)'
|
||||||
|
desc 'remove IP...', 'Remove one or more IP blocks'
|
||||||
|
long_desc <<-LONG_DESC
|
||||||
|
Remove one or more IP blocks. Normally, only exact matches are removed. If
|
||||||
|
you want to ensure that all of the given IP addresses are unblocked, you
|
||||||
|
can use --force which will also remove any blocks for IP ranges that would
|
||||||
|
cover the given IP(s).
|
||||||
|
LONG_DESC
|
||||||
|
def remove(*addresses)
|
||||||
|
if addresses.empty?
|
||||||
|
say('No IP(s) given', :red)
|
||||||
|
exit(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
addresses.each do |address|
|
||||||
|
ip_blocks = begin
|
||||||
|
if options[:force]
|
||||||
|
IpBlock.where('ip >>= ?', address)
|
||||||
|
else
|
||||||
|
IpBlock.where('ip <<= ?', address)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if ip_blocks.empty?
|
||||||
|
say("#{address} is not yet blocked", :yellow)
|
||||||
|
skipped += 1
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
ip_blocks.in_batches.destroy_all
|
||||||
|
processed += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
say("Removed #{processed}, skipped #{skipped}", color(processed, 0))
|
||||||
|
end
|
||||||
|
|
||||||
|
option :format, aliases: [:f], enum: %w(plain nginx), desc: 'Format of the output'
|
||||||
|
desc 'export', 'Export blocked IPs'
|
||||||
|
long_desc <<-LONG_DESC
|
||||||
|
Export blocked IPs. Different formats are supported for usage with other
|
||||||
|
tools. Only blocks with no_access severity are returned.
|
||||||
|
LONG_DESC
|
||||||
|
def export
|
||||||
|
IpBlock.where(severity: :no_access).find_each do |ip_block|
|
||||||
|
case options[:format]
|
||||||
|
when 'nginx'
|
||||||
|
puts "deny #{ip_block.ip}/#{ip_block.ip.prefix};"
|
||||||
|
else
|
||||||
|
puts "#{ip_block.ip}/#{ip_block.ip.prefix}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def color(processed, failed)
|
||||||
|
if !processed.zero? && failed.zero?
|
||||||
|
:green
|
||||||
|
elsif failed.zero?
|
||||||
|
:yellow
|
||||||
|
else
|
||||||
|
:red
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,6 @@
|
||||||
|
Fabricator(:ip_block) do
|
||||||
|
ip ""
|
||||||
|
severity ""
|
||||||
|
expires_at "2020-10-08 22:20:37"
|
||||||
|
comment "MyText"
|
||||||
|
end
|
|
@ -0,0 +1,21 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe FastIpMap do
|
||||||
|
describe '#include?' do
|
||||||
|
subject { described_class.new([IPAddr.new('20.4.0.0/16'), IPAddr.new('145.22.30.0/24'), IPAddr.new('189.45.86.3')])}
|
||||||
|
|
||||||
|
it 'returns true for an exact match' do
|
||||||
|
expect(subject.include?(IPAddr.new('189.45.86.3'))).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns true for a range match' do
|
||||||
|
expect(subject.include?(IPAddr.new('20.4.45.7'))).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false for no match' do
|
||||||
|
expect(subject.include?(IPAddr.new('145.22.40.64'))).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,5 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe IpBlock, type: :model do
|
||||||
|
pending "add some examples to (or delete) #{__FILE__}"
|
||||||
|
end
|
|
@ -3,6 +3,7 @@ require 'rails_helper'
|
||||||
RSpec.describe AppSignUpService, type: :service do
|
RSpec.describe AppSignUpService, type: :service do
|
||||||
let(:app) { Fabricate(:application, scopes: 'read write') }
|
let(:app) { Fabricate(:application, scopes: 'read write') }
|
||||||
let(:good_params) { { username: 'alice', password: '12345678', email: 'good@email.com', agreement: true } }
|
let(:good_params) { { username: 'alice', password: '12345678', email: 'good@email.com', agreement: true } }
|
||||||
|
let(:remote_ip) { IPAddr.new('198.0.2.1') }
|
||||||
|
|
||||||
subject { described_class.new }
|
subject { described_class.new }
|
||||||
|
|
||||||
|
@ -10,16 +11,16 @@ RSpec.describe AppSignUpService, type: :service do
|
||||||
it 'returns nil when registrations are closed' do
|
it 'returns nil when registrations are closed' do
|
||||||
tmp = Setting.registrations_mode
|
tmp = Setting.registrations_mode
|
||||||
Setting.registrations_mode = 'none'
|
Setting.registrations_mode = 'none'
|
||||||
expect(subject.call(app, good_params)).to be_nil
|
expect(subject.call(app, remote_ip, good_params)).to be_nil
|
||||||
Setting.registrations_mode = tmp
|
Setting.registrations_mode = tmp
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'raises an error when params are missing' do
|
it 'raises an error when params are missing' do
|
||||||
expect { subject.call(app, {}) }.to raise_error ActiveRecord::RecordInvalid
|
expect { subject.call(app, remote_ip, {}) }.to raise_error ActiveRecord::RecordInvalid
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates an unconfirmed user with access token' do
|
it 'creates an unconfirmed user with access token' do
|
||||||
access_token = subject.call(app, good_params)
|
access_token = subject.call(app, remote_ip, good_params)
|
||||||
expect(access_token).to_not be_nil
|
expect(access_token).to_not be_nil
|
||||||
user = User.find_by(id: access_token.resource_owner_id)
|
user = User.find_by(id: access_token.resource_owner_id)
|
||||||
expect(user).to_not be_nil
|
expect(user).to_not be_nil
|
||||||
|
@ -27,13 +28,13 @@ RSpec.describe AppSignUpService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates access token with the app\'s scopes' do
|
it 'creates access token with the app\'s scopes' do
|
||||||
access_token = subject.call(app, good_params)
|
access_token = subject.call(app, remote_ip, good_params)
|
||||||
expect(access_token).to_not be_nil
|
expect(access_token).to_not be_nil
|
||||||
expect(access_token.scopes.to_s).to eq 'read write'
|
expect(access_token.scopes.to_s).to eq 'read write'
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates an account' do
|
it 'creates an account' do
|
||||||
access_token = subject.call(app, good_params)
|
access_token = subject.call(app, remote_ip, good_params)
|
||||||
expect(access_token).to_not be_nil
|
expect(access_token).to_not be_nil
|
||||||
user = User.find_by(id: access_token.resource_owner_id)
|
user = User.find_by(id: access_token.resource_owner_id)
|
||||||
expect(user).to_not be_nil
|
expect(user).to_not be_nil
|
||||||
|
@ -42,7 +43,7 @@ RSpec.describe AppSignUpService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates an account with invite request text' do
|
it 'creates an account with invite request text' do
|
||||||
access_token = subject.call(app, good_params.merge(reason: 'Foo bar'))
|
access_token = subject.call(app, remote_ip, good_params.merge(reason: 'Foo bar'))
|
||||||
expect(access_token).to_not be_nil
|
expect(access_token).to_not be_nil
|
||||||
user = User.find_by(id: access_token.resource_owner_id)
|
user = User.find_by(id: access_token.resource_owner_id)
|
||||||
expect(user).to_not be_nil
|
expect(user).to_not be_nil
|
||||||
|
|
Reference in New Issue