SECURITY: Require POST with CSRF token for OmniAuth request phase
This commit is contained in:
parent
7bd54eaceb
commit
3b8c468832
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
|
@ -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>
|
|
@ -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]
|
||||||
|
|
|
@ -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."
|
||||||
|
|
|
@ -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}/ }
|
||||||
|
|
|
@ -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
|
|
@ -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"]
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue