From c10e3df0126fa4be23100c49983eb4fbdb53153f Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 26 Nov 2014 17:25:54 +1100 Subject: [PATCH] FEATURE: implement SSO provider on Discourse so Auth can be farmed to it FEATURE: pass return_sso_url to SSO endpoints, for easier return --- app/controllers/session_controller.rb | 30 +++++++++++++- app/models/discourse_single_sign_on.rb | 1 + config/locales/server.en.yml | 1 + config/routes.rb | 1 + config/site_settings.yml | 1 + lib/single_sign_on.rb | 2 +- spec/controllers/session_controller_spec.rb | 41 ++++++++++++++++++-- spec/models/discourse_single_sign_on_spec.rb | 6 +-- 8 files changed, 74 insertions(+), 9 deletions(-) diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index f8efa8db5..cae250471 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -1,9 +1,10 @@ require_dependency 'rate_limiter' +require_dependency 'single_sign_on' class SessionController < ApplicationController skip_before_filter :redirect_to_login_if_required - skip_before_filter :check_xhr, only: ['sso', 'sso_login', 'become'] + skip_before_filter :check_xhr, only: ['sso', 'sso_login', 'become', 'sso_provider'] def csrf render json: {csrf: form_authenticity_token } @@ -17,6 +18,25 @@ class SessionController < ApplicationController end end + def sso_provider(payload=nil) + payload ||= request.query_string + if SiteSetting.enable_sso_provider + sso = SingleSignOn.parse(payload, SiteSetting.sso_secret) + if current_user + sso.name = current_user.name + sso.username = current_user.username + sso.email = current_user.email + sso.external_id = current_user.id.to_s + redirect_to sso.to_url(sso.return_sso_url) + else + session[:sso_payload] = request.query_string + redirect_to '/login' + end + else + render nothing: true, status: 404 + end + end + # For use in development mode only when login options could be limited or disabled. # NEVER allow this to work in production. def become @@ -83,6 +103,7 @@ class SessionController < ApplicationController login = params[:login].strip login = login[1..-1] if login[0] == "@" + if user = User.find_by_username_or_email(login) # If their password is correct @@ -200,7 +221,12 @@ class SessionController < ApplicationController def login(user) log_on_user(user) - render_serialized(user, UserSerializer) + + if payload = session.delete(:sso_payload) + sso_provider(payload) + else + render_serialized(user, UserSerializer) + end end end diff --git a/app/models/discourse_single_sign_on.rb b/app/models/discourse_single_sign_on.rb index 610f6aafe..1e3cee24c 100644 --- a/app/models/discourse_single_sign_on.rb +++ b/app/models/discourse_single_sign_on.rb @@ -14,6 +14,7 @@ class DiscourseSingleSignOn < SingleSignOn sso = new sso.nonce = SecureRandom.hex sso.register_nonce(return_path) + sso.return_sso_url = Discourse.base_url + "/session/sso_login" sso.to_url end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index e62e3f4d3..233d0a073 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -787,6 +787,7 @@ en: block_common_passwords: "Don't allow passwords that are in the 10,000 most common passwords." enable_sso: "Enable single sign on via an external site (Note: disables invites)" + enable_sso_provider: "Implement Discourse SSO protocol at the /session/sso_provider endpoint, requires sso_secret to be set" sso_url: "URL of single sign on endpoint" sso_secret: "Secret string used to encrypt/decrypt SSO information, be sure it is 10 chars or longer" sso_overrides_email: "Overrides local email with external site email from SSO payload (WARNING: discrepancies can occur due to normalization of local emails)" diff --git a/config/routes.rb b/config/routes.rb index d56d60337..f694e87d9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -197,6 +197,7 @@ Discourse::Application.routes.draw do get "session/sso" => "session#sso" get "session/sso_login" => "session#sso_login" + get "session/sso_provider" => "session#sso_provider" get "session/current" => "session#current" get "session/csrf" => "session#csrf" get "composer-messages" => "composer_messages#index" diff --git a/config/site_settings.yml b/config/site_settings.yml index b4a106255..7bde8b1e4 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -221,6 +221,7 @@ login: enable_sso: client: true default: false + enable_sso_provider: false sso_url: '' sso_secret: '' sso_overrides_email: false diff --git a/lib/single_sign_on.rb b/lib/single_sign_on.rb index bd01633c9..722fce4d3 100644 --- a/lib/single_sign_on.rb +++ b/lib/single_sign_on.rb @@ -1,6 +1,6 @@ class SingleSignOn ACCESSORS = [:nonce, :name, :username, :email, :avatar_url, :avatar_force_update, - :about_me, :external_id] + :about_me, :external_id, :return_sso_url] FIXNUMS = [] NONCE_EXPIRY_TIME = 10.minutes diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb index a46c6b9c0..7eca71f3b 100644 --- a/spec/controllers/session_controller_spec.rb +++ b/spec/controllers/session_controller_spec.rb @@ -26,9 +26,9 @@ describe SessionController do @sso_url = "http://somesite.com/discourse_sso" @sso_secret = "shjkfdhsfkjh" - SiteSetting.stubs("enable_sso").returns(true) - SiteSetting.stubs("sso_url").returns(@sso_url) - SiteSetting.stubs("sso_secret").returns(@sso_secret) + SiteSetting.enable_sso = true + SiteSetting.sso_url = @sso_url + SiteSetting.sso_secret = @sso_secret # We have 2 options, either fabricate an admin or don't # send welcome messages @@ -97,6 +97,7 @@ describe SessionController do it 'allows login to existing account with valid nonce' do sso = get_sso('/hello/world') sso.external_id = '997' + sso.sso_url = "http://somewhere.over.com/sso_login" user = Fabricate(:user) user.create_single_sign_on_record(external_id: '997', last_payload: '') @@ -116,6 +117,40 @@ describe SessionController do response.code.should == '500' end + it 'can act as an SSO provider' do + SiteSetting.enable_sso_provider = true + SiteSetting.enable_sso = false + SiteSetting.enable_local_logins = true + SiteSetting.sso_secret = "topsecret" + + sso = SingleSignOn.new + sso.nonce = "mynonce" + sso.sso_secret = SiteSetting.sso_secret + sso.return_sso_url = "http://somewhere.over.rainbow/sso" + + get :sso_provider, Rack::Utils.parse_query(sso.payload) + + response.should redirect_to("/login") + + user = Fabricate(:user, password: "frogs", active: true) + EmailToken.update_all(confirmed: true) + + xhr :post, :create, login: user.username, password: "frogs", format: :json + + location = response.header["Location"] + location.should =~ /^http:\/\/somewhere.over.rainbow\/sso/ + + payload = location.split("?")[1] + + sso2 = SingleSignOn.parse(payload, "topsecret") + + sso2.email.should == user.email + sso2.name.should == user.name + sso2.username.should == user.username + sso2.external_id == user.id.to_s + + end + describe 'local attribute override from SSO payload' do before do SiteSetting.stubs("sso_overrides_email").returns(true) diff --git a/spec/models/discourse_single_sign_on_spec.rb b/spec/models/discourse_single_sign_on_spec.rb index 5f174c8b4..3f2d1c464 100644 --- a/spec/models/discourse_single_sign_on_spec.rb +++ b/spec/models/discourse_single_sign_on_spec.rb @@ -5,9 +5,9 @@ describe DiscourseSingleSignOn do @sso_url = "http://somesite.com/discourse_sso" @sso_secret = "shjkfdhsfkjh" - SiteSetting.stubs("enable_sso").returns(true) - SiteSetting.stubs("sso_url").returns(@sso_url) - SiteSetting.stubs("sso_secret").returns(@sso_secret) + SiteSetting.enable_sso = true + SiteSetting.sso_url = @sso_url + SiteSetting.sso_secret = @sso_secret end def make_sso