SECURITY: Require POST with CSRF token for OmniAuth request phase

This commit is contained in:
David Taylor 2019-08-08 11:57:28 +01:00
parent 7bd54eaceb
commit 3b8c468832
11 changed files with 147 additions and 34 deletions

View File

@ -40,6 +40,12 @@ function handleRedirect(data) {
} }
} }
export function updateCsrfToken() {
return ajax("/session/csrf").then(result => {
Discourse.Session.currentProp("csrfToken", result.csrf);
});
}
/** /**
Our own $.ajax method. Makes sure the .then method executes in an Ember runloop Our own $.ajax method. Makes sure the .then method executes in an Ember runloop
for performance reasons. Also automatically adjusts the URL to support installs for performance reasons. Also automatically adjusts the URL to support installs
@ -157,10 +163,7 @@ export function ajax() {
!Discourse.Session.currentProp("csrfToken") !Discourse.Session.currentProp("csrfToken")
) { ) {
promise = new Ember.RSVP.Promise((resolve, reject) => { promise = new Ember.RSVP.Promise((resolve, reject) => {
ajaxObj = $.ajax(Discourse.getURL("/session/csrf"), { ajaxObj = updateCsrfToken().then(() => {
cache: false
}).done(result => {
Discourse.Session.currentProp("csrfToken", result.csrf);
performAjax(resolve, reject); performAjax(resolve, reject);
}); });
}); });

View File

@ -1,4 +1,5 @@
import computed from "ember-addons/ember-computed-decorators"; import computed from "ember-addons/ember-computed-decorators";
import { updateCsrfToken } from "discourse/lib/ajax";
const LoginMethod = Ember.Object.extend({ const LoginMethod = Ember.Object.extend({
@computed @computed
@ -30,8 +31,10 @@ const LoginMethod = Ember.Object.extend({
} }
if (reconnect || fullScreenLogin || this.full_screen_login) { if (reconnect || fullScreenLogin || this.full_screen_login) {
document.cookie = "fsl=true"; LoginMethod.buildPostForm(authUrl).then(form => {
window.location = authUrl; document.cookie = "fsl=true";
form.submit();
});
} else { } else {
this.set("authenticate", name); this.set("authenticate", name);
const left = this.lastX - 400; const left = this.lastX - 400;
@ -44,31 +47,51 @@ const LoginMethod = Ember.Object.extend({
authUrl += authUrl.includes("?") ? "&" : "?"; authUrl += authUrl.includes("?") ? "&" : "?";
authUrl += "display=popup"; authUrl += "display=popup";
} }
LoginMethod.buildPostForm(authUrl).then(form => {
const windowState = window.open(
"about:blank",
"auth_popup",
`menubar=no,status=no,height=${height},width=${width},left=${left},top=${top}`
);
const windowState = window.open( form.target = "auth_popup";
authUrl, form.submit();
"_blank",
"menubar=no,status=no,height=" +
height +
",width=" +
width +
",left=" +
left +
",top=" +
top
);
const timer = setInterval(() => { const timer = setInterval(() => {
if (!windowState || windowState.closed) { // If the process is aborted, reset state in this window
clearInterval(timer); if (!windowState || windowState.closed) {
this.set("authenticate", null); clearInterval(timer);
} this.set("authenticate", null);
}, 1000); }
}, 1000);
});
} }
} }
} }
}); });
LoginMethod.reopenClass({
buildPostForm(url) {
// Login always happens in an anonymous context, with no CSRF token
// So we need to fetch it before sending a POST request
return updateCsrfToken().then(() => {
const form = document.createElement("form");
form.setAttribute("style", "display:none;");
form.setAttribute("method", "post");
form.setAttribute("action", url);
const input = document.createElement("input");
input.setAttribute("name", "authenticity_token");
input.setAttribute("value", Discourse.Session.currentProp("csrfToken"));
form.appendChild(input);
document.body.appendChild(form);
return form;
});
}
});
let methods; let methods;
export function findAll() { export function findAll() {

View File

@ -18,6 +18,10 @@ class Users::OmniauthCallbacksController < ApplicationController
# will not have a CSRF token, however the payload is all validated so its safe # will not have a CSRF token, however the payload is all validated so its safe
skip_before_action :verify_authenticity_token, only: :complete skip_before_action :verify_authenticity_token, only: :complete
def confirm_request
self.class.find_authenticator(params[:provider])
end
def complete def complete
auth = request.env["omniauth.auth"] auth = request.env["omniauth.auth"]
raise Discourse::NotFound unless request.env["omniauth.auth"] raise Discourse::NotFound unless request.env["omniauth.auth"]

View File

@ -0,0 +1,11 @@
<div id='simple-container'>
<h2><%= t('login.omniauth_confirm_title', provider:(t "js.login.#{params[:provider]}.name")) %></h2>
<br/>
<%= form_tag do %>
<input type="hidden" name="full_screen_login" value="true">
<%= button_tag(type: "submit", class: "btn btn-primary") do %>
<%= SvgSprite.raw_svg('fa-plug') %><%= t 'login.omniauth_confirm_button' %>
<% end %>
<% end %>
</div>

View File

@ -7,3 +7,4 @@ require "middleware/omniauth_bypass_middleware"
Rails.application.config.middleware.use Middleware::OmniauthBypassMiddleware Rails.application.config.middleware.use Middleware::OmniauthBypassMiddleware
OmniAuth.config.logger = Rails.logger OmniAuth.config.logger = Rails.logger
OmniAuth.config.allowed_request_methods = [:post]

View File

@ -2226,6 +2226,8 @@ en:
something_already_taken: "Something went wrong, perhaps the username or email is already registered. Try the forgot password link." something_already_taken: "Something went wrong, perhaps the username or email is already registered. Try the forgot password link."
omniauth_error: "Sorry, there was an error authorizing your account. Perhaps you did not approve authorization?" omniauth_error: "Sorry, there was an error authorizing your account. Perhaps you did not approve authorization?"
omniauth_error_unknown: "Something went wrong processing your log in, please try again." omniauth_error_unknown: "Something went wrong processing your log in, please try again."
omniauth_confirm_title: "Log in using %{provider}"
omniauth_confirm_button: "Continue"
authenticator_error_no_valid_email: "No email addresses associated with %{account} are allowed. You may need to configure your account with a different email address." authenticator_error_no_valid_email: "No email addresses associated with %{account} are allowed. You may need to configure your account with a different email address."
new_registrations_disabled: "New account registrations are not allowed at this time." new_registrations_disabled: "New account registrations are not allowed at this time."
password_too_long: "Passwords are limited to 200 characters." password_too_long: "Passwords are limited to 200 characters."

View File

@ -599,6 +599,7 @@ Discourse::Application.routes.draw do
end end
end end
get "/auth/:provider", to: "users/omniauth_callbacks#confirm_request"
match "/auth/:provider/callback", to: "users/omniauth_callbacks#complete", via: [:get, :post] match "/auth/:provider/callback", to: "users/omniauth_callbacks#complete", via: [:get, :post]
match "/auth/failure", to: "users/omniauth_callbacks#failure", via: [:get, :post] match "/auth/failure", to: "users/omniauth_callbacks#failure", via: [:get, :post]
get "/associate/:token", to: "users/associate_accounts#connect_info", constraints: { token: /\h{32}/ } get "/associate/:token", to: "users/associate_accounts#connect_info", constraints: { token: /\h{32}/ }

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
# Provides a way to check a CSRF token outside of a controller
class CSRFTokenVerifier
include ActiveSupport::Configurable
include ActionController::RequestForgeryProtection
# Use config from ActionController::Base
config.each_key do |configuration_name|
undef_method configuration_name
define_method configuration_name do
ActionController::Base.config[configuration_name]
end
end
def call(env)
@request = ActionDispatch::Request.new(env.dup)
unless verified_request?
raise ActionController::InvalidAuthenticityToken
end
end
private
attr_reader :request
delegate :params, :session, to: :request
end

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require "csrf_token_verifier"
# omniauth loves spending lots cycles in its magic middleware stack # omniauth loves spending lots cycles in its magic middleware stack
# this middleware bypasses omniauth middleware and only hits it when needed # this middleware bypasses omniauth middleware and only hits it when needed
class Middleware::OmniauthBypassMiddleware class Middleware::OmniauthBypassMiddleware
@ -19,6 +21,9 @@ class Middleware::OmniauthBypassMiddleware
end end
@omniauth.before_request_phase do |env| @omniauth.before_request_phase do |env|
# Check for CSRF token
CSRFTokenVerifier.new.call(env)
# If the user is trying to reconnect to an existing account, store in session # If the user is trying to reconnect to an existing account, store in session
request = ActionDispatch::Request.new(env) request = ActionDispatch::Request.new(env)
request.session[:auth_reconnect] = !!request.params["reconnect"] request.session[:auth_reconnect] = !!request.params["reconnect"]

View File

@ -38,7 +38,7 @@ RSpec.describe Users::AssociateAccountsController do
sign_in(user) sign_in(user)
# Reconnect flow: # Reconnect flow:
get "/auth/google_oauth2?reconnect=true" post "/auth/google_oauth2?reconnect=true"
expect(response.status).to eq(302) expect(response.status).to eq(302)
expect(session[:auth_reconnect]).to eq(true) expect(session[:auth_reconnect]).to eq(true)

View File

@ -84,6 +84,41 @@ RSpec.describe Users::OmniauthCallbacksController do
SiteSetting.enable_google_oauth2_logins = true SiteSetting.enable_google_oauth2_logins = true
end end
describe "request" do
it "should error for non existant authenticators" do
post "/auth/fake_auth"
expect(response.status).to eq(404)
get "/auth/fake_auth"
expect(response.status).to eq(403)
end
it "should only start auth with a POST request" do
post "/auth/google_oauth2"
expect(response.status).to eq(302)
get "/auth/google_oauth2"
expect(response.status).to eq(200)
end
context "with CSRF protection enabled" do
before { ActionController::Base.allow_forgery_protection = true }
after { ActionController::Base.allow_forgery_protection = false }
it "should be CSRF protected" do
post "/auth/google_oauth2"
expect(response.status).to eq(422)
post "/auth/google_oauth2", params: { authenticity_token: "faketoken" }
expect(response.status).to eq(422)
get "/session/csrf.json"
token = JSON.parse(response.body)["csrf"]
post "/auth/google_oauth2", params: { authenticity_token: token }
expect(response.status).to eq(302)
end
end
end
context "without an `omniauth.auth` env" do context "without an `omniauth.auth` env" do
it "should return a 404" do it "should return a 404" do
get "/auth/eviltrout/callback" get "/auth/eviltrout/callback"
@ -348,7 +383,7 @@ RSpec.describe Users::OmniauthCallbacksController do
end end
it "doesn't attempt redirect to external origin" do it "doesn't attempt redirect to external origin" do
get "/auth/google_oauth2?origin=https://example.com/external" post "/auth/google_oauth2?origin=https://example.com/external"
get "/auth/google_oauth2/callback" get "/auth/google_oauth2/callback"
expect(response.status).to eq 302 expect(response.status).to eq 302
@ -359,7 +394,7 @@ RSpec.describe Users::OmniauthCallbacksController do
end end
it "redirects to internal origin" do it "redirects to internal origin" do
get "/auth/google_oauth2?origin=http://test.localhost/t/123" post "/auth/google_oauth2?origin=http://test.localhost/t/123"
get "/auth/google_oauth2/callback" get "/auth/google_oauth2/callback"
expect(response.status).to eq 302 expect(response.status).to eq 302
@ -370,7 +405,7 @@ RSpec.describe Users::OmniauthCallbacksController do
end end
it "redirects to relative origin" do it "redirects to relative origin" do
get "/auth/google_oauth2?origin=/t/123" post "/auth/google_oauth2?origin=/t/123"
get "/auth/google_oauth2/callback" get "/auth/google_oauth2/callback"
expect(response.status).to eq 302 expect(response.status).to eq 302
@ -381,7 +416,7 @@ RSpec.describe Users::OmniauthCallbacksController do
end end
it "redirects with query" do it "redirects with query" do
get "/auth/google_oauth2?origin=/t/123?foo=bar" post "/auth/google_oauth2?origin=/t/123?foo=bar"
get "/auth/google_oauth2/callback" get "/auth/google_oauth2/callback"
expect(response.status).to eq 302 expect(response.status).to eq 302
@ -392,7 +427,7 @@ RSpec.describe Users::OmniauthCallbacksController do
end end
it "removes authentication_data cookie on logout" do it "removes authentication_data cookie on logout" do
get "/auth/google_oauth2?origin=https://example.com/external" post "/auth/google_oauth2?origin=https://example.com/external"
get "/auth/google_oauth2/callback" get "/auth/google_oauth2/callback"
provider = log_in_user(Fabricate(:user)) provider = log_in_user(Fabricate(:user))
@ -440,7 +475,7 @@ RSpec.describe Users::OmniauthCallbacksController do
it 'should not reconnect normally' do it 'should not reconnect normally' do
# Log in normally # Log in normally
get "/auth/google_oauth2" post "/auth/google_oauth2"
expect(response.status).to eq(302) expect(response.status).to eq(302)
expect(session[:auth_reconnect]).to eq(false) expect(session[:auth_reconnect]).to eq(false)
@ -450,7 +485,7 @@ RSpec.describe Users::OmniauthCallbacksController do
# Log into another user # Log into another user
OmniAuth.config.mock_auth[:google_oauth2].uid = "123456" OmniAuth.config.mock_auth[:google_oauth2].uid = "123456"
get "/auth/google_oauth2" post "/auth/google_oauth2"
expect(response.status).to eq(302) expect(response.status).to eq(302)
expect(session[:auth_reconnect]).to eq(false) expect(session[:auth_reconnect]).to eq(false)
@ -462,7 +497,7 @@ RSpec.describe Users::OmniauthCallbacksController do
it 'should redirect to associate URL if parameter supplied' do it 'should redirect to associate URL if parameter supplied' do
# Log in normally # Log in normally
get "/auth/google_oauth2?reconnect=true" post "/auth/google_oauth2?reconnect=true"
expect(response.status).to eq(302) expect(response.status).to eq(302)
expect(session[:auth_reconnect]).to eq(true) expect(session[:auth_reconnect]).to eq(true)
@ -477,7 +512,7 @@ RSpec.describe Users::OmniauthCallbacksController do
UserAssociatedAccount.find_by(user_id: user.id).destroy UserAssociatedAccount.find_by(user_id: user.id).destroy
# Reconnect flow: # Reconnect flow:
get "/auth/google_oauth2?reconnect=true" post "/auth/google_oauth2?reconnect=true"
expect(response.status).to eq(302) expect(response.status).to eq(302)
expect(session[:auth_reconnect]).to eq(true) expect(session[:auth_reconnect]).to eq(true)