Commit 38cb0347 authored by Sean McGivern's avatar Sean McGivern

Merge branch '21518_recaptcha_spam_issues' into 'master'

Use reCaptcha when an issue identified as spam

Closes #21518

See merge request !8846
parents 8d5ae0d9 3d2954e4
...@@ -148,3 +148,7 @@ ul.related-merge-requests > li { ...@@ -148,3 +148,7 @@ ul.related-merge-requests > li {
border: 1px solid $border-gray-normal; border: 1px solid $border-gray-normal;
} }
} }
.recaptcha {
margin-bottom: 30px;
}
module SpammableActions module SpammableActions
extend ActiveSupport::Concern extend ActiveSupport::Concern
include Recaptcha::Verify
included do included do
before_action :authorize_submit_spammable!, only: :mark_as_spam before_action :authorize_submit_spammable!, only: :mark_as_spam
end end
...@@ -15,6 +17,15 @@ module SpammableActions ...@@ -15,6 +17,15 @@ module SpammableActions
private private
def recaptcha_params
return {} unless params[:recaptcha_verification] && Gitlab::Recaptcha.load_configurations! && verify_recaptcha
{
recaptcha_verified: true,
spam_log_id: params[:spam_log_id]
}
end
def spammable def spammable
raise NotImplementedError, "#{self.class} does not implement #{__method__}" raise NotImplementedError, "#{self.class} does not implement #{__method__}"
end end
...@@ -22,4 +33,11 @@ module SpammableActions ...@@ -22,4 +33,11 @@ module SpammableActions
def authorize_submit_spammable! def authorize_submit_spammable!
access_denied! unless current_user.admin? access_denied! unless current_user.admin?
end end
def render_recaptcha?
return false if spammable.errors.count > 1 # re-render "new" template in case there are other errors
return false unless Gitlab::Recaptcha.enabled?
spammable.spam
end
end end
...@@ -93,15 +93,13 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -93,15 +93,13 @@ class Projects::IssuesController < Projects::ApplicationController
def create def create
extra_params = { request: request, extra_params = { request: request,
merge_request_for_resolving_discussions: merge_request_for_resolving_discussions } merge_request_for_resolving_discussions: merge_request_for_resolving_discussions }
extra_params.merge!(recaptcha_params)
@issue = Issues::CreateService.new(project, current_user, issue_params.merge(extra_params)).execute @issue = Issues::CreateService.new(project, current_user, issue_params.merge(extra_params)).execute
respond_to do |format| respond_to do |format|
format.html do format.html do
if @issue.valid? html_response_create
redirect_to issue_path(@issue)
else
render :new
end
end end
format.js do format.js do
@link = @issue.attachment.url.to_js @link = @issue.attachment.url.to_js
...@@ -178,6 +176,20 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -178,6 +176,20 @@ class Projects::IssuesController < Projects::ApplicationController
protected protected
def html_response_create
if @issue.valid?
redirect_to issue_path(@issue)
elsif render_recaptcha?
if params[:recaptcha_verification]
flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
end
render :verify
else
render :new
end
end
def issue def issue
# The Sortable default scope causes performance issues when used with find_by # The Sortable default scope causes performance issues when used with find_by
@noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take || redirect_old @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take || redirect_old
......
...@@ -17,7 +17,7 @@ class RegistrationsController < Devise::RegistrationsController ...@@ -17,7 +17,7 @@ class RegistrationsController < Devise::RegistrationsController
if !Gitlab::Recaptcha.load_configurations! || verify_recaptcha if !Gitlab::Recaptcha.load_configurations! || verify_recaptcha
super super
else else
flash[:alert] = 'There was an error with the reCAPTCHA. Please re-solve the reCAPTCHA.' flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
flash.delete :recaptcha_error flash.delete :recaptcha_error
render action: 'new' render action: 'new'
end end
......
...@@ -11,6 +11,7 @@ module Spammable ...@@ -11,6 +11,7 @@ module Spammable
has_one :user_agent_detail, as: :subject, dependent: :destroy has_one :user_agent_detail, as: :subject, dependent: :destroy
attr_accessor :spam attr_accessor :spam
attr_accessor :spam_log
after_validation :check_for_spam, on: :create after_validation :check_for_spam, on: :create
...@@ -34,9 +35,14 @@ module Spammable ...@@ -34,9 +35,14 @@ module Spammable
end end
def check_for_spam def check_for_spam
if spam? error_msg = if Gitlab::Recaptcha.enabled?
self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.") "Your #{spammable_entity_type} has been recognized as spam. "\
"You can still submit it by solving Captcha."
else
"Your #{spammable_entity_type} has been recognized as spam and has been discarded."
end end
self.errors.add(:base, error_msg) if spam?
end end
def spammable_entity_type def spammable_entity_type
......
...@@ -3,6 +3,8 @@ module Issues ...@@ -3,6 +3,8 @@ module Issues
def execute def execute
@request = params.delete(:request) @request = params.delete(:request)
@api = params.delete(:api) @api = params.delete(:api)
@recaptcha_verified = params.delete(:recaptcha_verified)
@spam_log_id = params.delete(:spam_log_id)
issue_attributes = params.merge(merge_request_for_resolving_discussions: merge_request_for_resolving_discussions) issue_attributes = params.merge(merge_request_for_resolving_discussions: merge_request_for_resolving_discussions)
@issue = BuildService.new(project, current_user, issue_attributes).execute @issue = BuildService.new(project, current_user, issue_attributes).execute
...@@ -11,7 +13,13 @@ module Issues ...@@ -11,7 +13,13 @@ module Issues
end end
def before_create(issuable) def before_create(issuable)
if @recaptcha_verified
spam_log = current_user.spam_logs.find_by(id: @spam_log_id, title: issuable.title)
spam_log.update!(recaptcha_verified: true) if spam_log
else
issuable.spam = spam_service.check(@api) issuable.spam = spam_service.check(@api)
issuable.spam_log = spam_service.spam_log
end
end end
def after_create(issuable) def after_create(issuable)
...@@ -35,7 +43,7 @@ module Issues ...@@ -35,7 +43,7 @@ module Issues
private private
def spam_service def spam_service
SpamService.new(@issue, @request) @spam_service ||= SpamService.new(@issue, @request)
end end
def user_agent_detail_service def user_agent_detail_service
......
class SpamService class SpamService
attr_accessor :spammable, :request, :options attr_accessor :spammable, :request, :options
attr_reader :spam_log
def initialize(spammable, request = nil) def initialize(spammable, request = nil)
@spammable = spammable @spammable = spammable
...@@ -63,7 +64,7 @@ class SpamService ...@@ -63,7 +64,7 @@ class SpamService
end end
def create_spam_log(api) def create_spam_log(api)
SpamLog.create( @spam_log = SpamLog.create!(
{ {
user_id: spammable_owner_id, user_id: spammable_owner_id,
title: spammable.spam_title, title: spammable.spam_title,
......
...@@ -13,6 +13,8 @@ ...@@ -13,6 +13,8 @@
= spam_log.source_ip = spam_log.source_ip
%td %td
= spam_log.via_api? ? 'Y' : 'N' = spam_log.via_api? ? 'Y' : 'N'
%td
= spam_log.recaptcha_verified ? 'Y' : 'N'
%td %td
= spam_log.noteable_type = spam_log.noteable_type
%td %td
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
%th User %th User
%th Source IP %th Source IP
%th API? %th API?
%th Recaptcha verified?
%th Type %th Type
%th Title %th Title
%th Description %th Description
......
...@@ -23,7 +23,7 @@ ...@@ -23,7 +23,7 @@
= f.password_field :password, class: "form-control bottom", required: true, pattern: ".{#{@minimum_password_length},}", title: "Minimum length is #{@minimum_password_length} characters." = f.password_field :password, class: "form-control bottom", required: true, pattern: ".{#{@minimum_password_length},}", title: "Minimum length is #{@minimum_password_length} characters."
%p.gl-field-hint Minimum length is #{@minimum_password_length} characters %p.gl-field-hint Minimum length is #{@minimum_password_length} characters
%div %div
- if current_application_settings.recaptcha_enabled - if Gitlab::Recaptcha.enabled?
= recaptcha_tags = recaptcha_tags
%div %div
= f.submit "Register", class: "btn-register btn" = f.submit "Register", class: "btn-register btn"
......
- page_title "Anti-spam verification"
%h3.page-title
Anti-spam verification
%hr
%p
We detected potential spam in the issue description. Please verify that you are not a robot to submit the issue.
= form_for [@project.namespace.becomes(Namespace), @project, @issue] do |f|
.recaptcha
- params[:issue].each do |field, value|
= hidden_field(:issue, field, value: value)
= hidden_field_tag(:merge_request_for_resolving_discussions, params[:merge_request_for_resolving_discussions])
= hidden_field_tag(:spam_log_id, @issue.spam_log.id)
= hidden_field_tag(:recaptcha_verification, true)
= recaptcha_tags
.row-content-block.footer-block
= f.submit "Submit #{@issue.class.model_name.human.downcase}", class: 'btn btn-create'
---
title: Use reCaptcha when an issue is identified as a spam
merge_request: 8846
author:
class AddRecaptchaVerifiedToSpamLogs < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
def up
add_column_with_default(:spam_logs, :recaptcha_verified, :boolean, default: false)
end
def down
remove_column(:spam_logs, :recaptcha_verified)
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,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: 20170204181513) do ActiveRecord::Schema.define(version: 20170206071414) 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"
...@@ -1114,6 +1114,7 @@ ActiveRecord::Schema.define(version: 20170204181513) do ...@@ -1114,6 +1114,7 @@ ActiveRecord::Schema.define(version: 20170204181513) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.boolean "submitted_as_ham", default: false, null: false t.boolean "submitted_as_ham", default: false, null: false
t.boolean "recaptcha_verified", default: false, null: false
end end
create_table "subscriptions", force: :cascade do |t| create_table "subscriptions", force: :cascade do |t|
......
...@@ -10,5 +10,9 @@ module Gitlab ...@@ -10,5 +10,9 @@ module Gitlab
true true
end end
end end
def self.enabled?
current_application_settings.recaptcha_enabled
end
end end
end end
...@@ -326,7 +326,7 @@ describe Projects::IssuesController do ...@@ -326,7 +326,7 @@ describe Projects::IssuesController do
end end
describe 'POST #create' do describe 'POST #create' do
def post_new_issue(attrs = {}) def post_new_issue(issue_attrs = {}, additional_params = {})
sign_in(user) sign_in(user)
project = create(:empty_project, :public) project = create(:empty_project, :public)
project.team << [user, :developer] project.team << [user, :developer]
...@@ -334,8 +334,8 @@ describe Projects::IssuesController do ...@@ -334,8 +334,8 @@ describe Projects::IssuesController do
post :create, { post :create, {
namespace_id: project.namespace.to_param, namespace_id: project.namespace.to_param,
project_id: project.to_param, project_id: project.to_param,
issue: { title: 'Title', description: 'Description' }.merge(attrs) issue: { title: 'Title', description: 'Description' }.merge(issue_attrs)
} }.merge(additional_params)
project.issues.first project.issues.first
end end
...@@ -378,24 +378,81 @@ describe Projects::IssuesController do ...@@ -378,24 +378,81 @@ describe Projects::IssuesController do
context 'Akismet is enabled' do context 'Akismet is enabled' do
before do before do
stub_application_setting(recaptcha_enabled: true)
allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true)
allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true)
end end
context 'when an issue is not identified as a spam' do
before do
allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false)
allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(false)
end
it 'does not create an issue' do
expect { post_new_issue(title: '') }.not_to change(Issue, :count)
end
end
context 'when an issue is identified as a spam' do
before { allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true) }
context 'when captcha is not verified' do
def post_spam_issue def post_spam_issue
post_new_issue(title: 'Spam Title', description: 'Spam lives here') post_new_issue(title: 'Spam Title', description: 'Spam lives here')
end end
it 'rejects an issue recognized as spam' do before { allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false) }
expect{ post_spam_issue }.not_to change(Issue, :count)
expect(response).to render_template(:new) it 'rejects an issue recognized as a spam' do
expect { post_spam_issue }.not_to change(Issue, :count)
end end
it 'creates a spam log' do it 'creates a spam log' do
post_spam_issue post_spam_issue
spam_logs = SpamLog.all spam_logs = SpamLog.all
expect(spam_logs.count).to eq(1) expect(spam_logs.count).to eq(1)
expect(spam_logs[0].title).to eq('Spam Title') expect(spam_logs.first.title).to eq('Spam Title')
expect(spam_logs.first.recaptcha_verified).to be_falsey
end
it 'does not create an issue when it is not valid' do
expect { post_new_issue(title: '') }.not_to change(Issue, :count)
end
it 'does not create an issue when recaptcha is not enabled' do
stub_application_setting(recaptcha_enabled: false)
expect { post_spam_issue }.not_to change(Issue, :count)
end
end
context 'when captcha is verified' do
let!(:spam_logs) { create_list(:spam_log, 2, user: user, title: 'Title') }
def post_verified_issue
post_new_issue({}, { spam_log_id: spam_logs.last.id, recaptcha_verification: true } )
end
before do
allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(true)
end
it 'accepts an issue after recaptcha is verified' do
expect { post_verified_issue }.to change(Issue, :count)
end
it 'marks spam log as recaptcha_verified' do
expect { post_verified_issue }.to change { SpamLog.last.recaptcha_verified }.from(false).to(true)
end
it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do
spam_log = create(:spam_log)
expect { post_new_issue({}, { spam_log_id: spam_log.id, recaptcha_verification: true } ) }.
not_to change { SpamLog.last.recaptcha_verified }
end
end
end end
end end
...@@ -405,7 +462,7 @@ describe Projects::IssuesController do ...@@ -405,7 +462,7 @@ describe Projects::IssuesController do
end end
it 'creates a user agent detail' do it 'creates a user agent detail' do
expect{ post_new_issue }.to change(UserAgentDetail, :count).by(1) expect { post_new_issue }.to change(UserAgentDetail, :count).by(1)
end end
end end
......
...@@ -44,7 +44,7 @@ describe RegistrationsController do ...@@ -44,7 +44,7 @@ describe RegistrationsController do
post(:create, user_params) post(:create, user_params)
expect(response).to render_template(:new) expect(response).to render_template(:new)
expect(flash[:alert]).to include 'There was an error with the reCAPTCHA. Please re-solve the reCAPTCHA.' expect(flash[:alert]).to include 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
end end
it 'redirects to the dashboard when the recaptcha is solved' do it 'redirects to the dashboard when the recaptcha is solved' do
......
require 'rails_helper'
describe 'New issue', feature: true do
include StubENV
let(:project) { create(:project, :public) }
let(:user) { create(:user)}
before do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
current_application_settings.update!(
akismet_enabled: true,
akismet_api_key: 'testkey',
recaptcha_enabled: true,
recaptcha_site_key: 'test site key',
recaptcha_private_key: 'test private key'
)
project.team << [user, :master]
login_as(user)
end
context 'when identified as a spam' do
before do
WebMock.stub_request(:any, /.*akismet.com.*/).to_return(body: "true", status: 200)
visit new_namespace_project_issue_path(project.namespace, project)
end
it 'creates an issue after solving reCaptcha' do
fill_in 'issue_title', with: 'issue title'
fill_in 'issue_description', with: 'issue description'
click_button 'Submit issue'
# it is impossible to test recaptcha automatically and there is no possibility to fill in recaptcha
# recaptcha verification is skipped in test environment and it always returns true
expect(page).not_to have_content('issue title')
expect(page).to have_css('.recaptcha')
click_button 'Submit issue'
expect(page.find('.issue-details h2.title')).to have_content('issue title')
expect(page.find('.issue-details .description')).to have_content('issue description')
end
end
context 'when not identified as a spam' do
before do
WebMock.stub_request(:any, /.*akismet.com.*/).to_return(body: 'false', status: 200)
visit new_namespace_project_issue_path(project.namespace, project)
end
it 'creates an issue' do
fill_in 'issue_title', with: 'issue title'
fill_in 'issue_description', with: 'issue description'
click_button 'Submit issue'
expect(page.find('.issue-details h2.title')).to have_content('issue title')
expect(page.find('.issue-details .description')).to have_content('issue description')
end
end
end
...@@ -181,5 +181,107 @@ describe Issues::CreateService, services: true do ...@@ -181,5 +181,107 @@ describe Issues::CreateService, services: true do
expect(issue.title).to be_nil expect(issue.title).to be_nil
end end
end end
context 'checking spam' do
let(:opts) do
{
title: 'Awesome issue',
description: 'please fix',
request: double(:request, env: {})
}
end
before do
allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true)
end
context 'when recaptcha was verified' do
let(:log_user) { user }
let(:spam_logs) { create_list(:spam_log, 2, user: log_user, title: 'Awesome issue') }
before do
opts[:recaptcha_verified] = true
opts[:spam_log_id] = spam_logs.last.id
expect(AkismetService).not_to receive(:new)
end
it 'does no mark an issue as a spam ' do
expect(issue).not_to be_spam
end
it 'an issue is valid ' do
expect(issue.valid?).to be_truthy
end
it 'does not assign a spam_log to an issue' do
expect(issue.spam_log).to be_nil
end
it 'marks related spam_log as recaptcha_verified' do
expect { issue }.to change{SpamLog.last.recaptcha_verified}.from(false).to(true)
end
context 'when spam log does not belong to a user' do
let(:log_user) { create(:user) }
it 'does not mark spam_log as recaptcha_verified' do
expect { issue }.not_to change{SpamLog.last.recaptcha_verified}
end
end
context 'when spam log title does not match the issue title' do
before do
opts[:title] = 'Another issue'
end
it 'does not mark spam_log as recaptcha_verified' do
expect { issue }.not_to change{SpamLog.last.recaptcha_verified}
end
end
end
context 'when recaptcha was not verified' do
context 'when akismet detects spam' do
before do
allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true)
end
it 'marks an issue as a spam ' do
expect(issue).to be_spam
end
it 'an issue is not valid ' do
expect(issue.valid?).to be_falsey
end
it 'creates a new spam_log' do
expect{issue}.to change{SpamLog.count}.from(0).to(1)
end
it 'assigns a spam_log to an issue' do
expect(issue.spam_log).to eq(SpamLog.last)
end
end
context 'when akismet does not detect spam' do
before do
allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(false)
end
it 'does not mark an issue as a spam ' do
expect(issue).not_to be_spam
end
it 'an issue is valid ' do
expect(issue.valid?).to be_truthy
end
it 'does not assign a spam_log to an issue' do
expect(issue.spam_log).to be_nil
end
end
end
end
end end
end end
require 'spec_helper'
describe SpamService, services: true do
describe '#check' do
let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project) }
let(:request) { double(:request, env: {}) }
def check_spam(issue, request)
described_class.new(issue, request).check
end
context 'when indicated as spam by akismet' do
before { allow(AkismetService).to receive(:new).and_return(double(is_spam?: true)) }
it 'returns false when request is missing' do
expect(check_spam(issue, nil)).to be_falsey
end
it 'returns false when issue is not public' do
issue = create(:issue, project: create(:project, :private))
expect(check_spam(issue, request)).to be_falsey
end
it 'returns true' do
expect(check_spam(issue, request)).to be_truthy
end
it 'creates a spam log' do
expect { check_spam(issue, request) }.to change { SpamLog.count }.from(0).to(1)
end
end
context 'when not indicated as spam by akismet' do
before { allow(AkismetService).to receive(:new).and_return(double(is_spam?: false)) }
it 'returns false' do
expect(check_spam(issue, request)).to be_falsey
end
it 'does not create a spam log' do
expect { check_spam(issue, request) }.not_to change { SpamLog.count }
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment