From 6f31d3f0e53bb0070deb6533df7b0f8f44e82e47 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 25 Feb 2014 14:30:49 +1100 Subject: [PATCH] FEATURE: single sign on support Added support for outsourcing auth to a different website, documentation on meta --- .../discourse/routes/application_route.js | 8 +- .../templates/user/preferences.js.handlebars | 2 + app/controllers/session_controller.rb | 42 ++++++++++ app/models/discourse_single_sign_on.rb | 69 +++++++++++++++ app/models/single_sign_on_record.rb | 3 + app/models/user.rb | 1 + config/locales/server.en.yml | 5 ++ config/routes.rb | 2 + config/site_settings.yml | 7 ++ ...140224232913_add_single_sign_on_records.rb | 12 +++ lib/single_sign_on.rb | 70 ++++++++++++++++ spec/controllers/session_controller_spec.rb | 83 +++++++++++++++++++ spec/models/discourse_single_sign_on_spec.rb | 55 ++++++++++++ 13 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 app/models/discourse_single_sign_on.rb create mode 100644 app/models/single_sign_on_record.rb create mode 100644 db/migrate/20140224232913_add_single_sign_on_records.rb create mode 100644 lib/single_sign_on.rb create mode 100644 spec/models/discourse_single_sign_on_spec.rb diff --git a/app/assets/javascripts/discourse/routes/application_route.js b/app/assets/javascripts/discourse/routes/application_route.js index a63d9c51607..3e1cd343f3d 100644 --- a/app/assets/javascripts/discourse/routes/application_route.js +++ b/app/assets/javascripts/discourse/routes/application_route.js @@ -14,8 +14,12 @@ Discourse.ApplicationRoute = Em.Route.extend({ if (Discourse.get("isReadOnly")) { bootbox.alert(I18n.t("read_only_mode.login_disabled")); } else { - Discourse.Route.showModal(this, 'login'); - this.controllerFor('login').resetForm(); + if(Discourse.SiteSettings.enable_sso) { + window.location = Discourse.getURL('/session/sso'); + } else { + Discourse.Route.showModal(this, 'login'); + this.controllerFor('login').resetForm(); + } } }, diff --git a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars index 41f05183979..b9b2f886366 100644 --- a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars +++ b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars @@ -46,6 +46,7 @@ + {{#unless Discourse.SiteSettings.enable_sso }}
@@ -59,6 +60,7 @@ {{passwordProgress}}
+ {{/unless}}
diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 371a7fd4dda..45245d520ef 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -1,12 +1,49 @@ class SessionController < ApplicationController skip_before_filter :redirect_to_login_if_required + skip_before_filter :check_xhr, only: ['sso', 'sso_login'] def csrf render json: {csrf: form_authenticity_token } end + def sso + if SiteSetting.enable_sso + redirect_to DiscourseSingleSignOn.generate_url + else + render nothing: true, status: 404 + end + end + + def sso_login + unless SiteSetting.enable_sso + render nothing: true, status: 404 + return + end + + sso = DiscourseSingleSignOn.parse(request.query_string) + if !sso.nonce_valid? + render text: "Timeout expired, please try logging in again.", status: 500 + return + end + + sso.expire_nonce! + + if user = sso.lookup_or_create_user + log_on_user user + redirect_to sso.return_url || "/" + else + render text: "unable to log on user", status: 500 + end + end + def create + + if SiteSetting.enable_sso + render nothing: true, status: 500 + return + end + params.require(:login) params.require(:password) @@ -46,6 +83,11 @@ class SessionController < ApplicationController def forgot_password params.require(:login) + if SiteSetting.enable_sso + render nothing: true, status: 500 + return + end + user = User.find_by_username_or_email(params[:login]) if user.present? email_token = user.email_tokens.create(email: user.email) diff --git a/app/models/discourse_single_sign_on.rb b/app/models/discourse_single_sign_on.rb new file mode 100644 index 00000000000..b3644efcc5f --- /dev/null +++ b/app/models/discourse_single_sign_on.rb @@ -0,0 +1,69 @@ +require_dependency 'single_sign_on' +class DiscourseSingleSignOn < SingleSignOn + def self.sso_url + SiteSetting.sso_url + end + + def self.sso_secret + SiteSetting.sso_secret + end + + def self.generate_url(return_url="/") + sso = new + sso.return_url = return_url + sso.nonce = SecureRandom.hex + sso.register_nonce + sso.to_url + end + + def register_nonce + if nonce + $redis.setex(nonce_key, NONCE_EXPIRY_TIME, payload) + end + end + + def nonce_valid? + nonce && $redis.get(nonce_key).present? + end + + def expire_nonce! + if nonce + $redis.del nonce_key + end + end + + def nonce_key + "SSO_NONCE_#{nonce}" + end + + + def lookup_or_create_user + sso_record = SingleSignOnRecord.where(external_id: external_id).first + if sso_record && sso_record.user + sso_record.last_payload = unsigned_payload + sso_record.save + else + user = User.where(email: Email.downcase(email)).first + + user_params = { + email: email, + name: User.suggest_name(name || username || email), + username: UserNameSuggester.suggest(username || name || email), + } + + if user || user = User.create(user_params) + if sso_record = user.single_sign_on_record + sso_record.last_payload = unsigned_payload + sso_record.external_id = external_id + sso_record.save! + else + sso_record = user.create_single_sign_on_record(last_payload: unsigned_payload, + external_id: external_id) + end + end + end + + sso_record && sso_record.user + end +end + diff --git a/app/models/single_sign_on_record.rb b/app/models/single_sign_on_record.rb new file mode 100644 index 00000000000..7b9e7ecffd2 --- /dev/null +++ b/app/models/single_sign_on_record.rb @@ -0,0 +1,3 @@ +class SingleSignOnRecord < ActiveRecord::Base + belongs_to :user +end diff --git a/app/models/user.rb b/app/models/user.rb index 6fe3d9a35ac..d82be171581 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -37,6 +37,7 @@ class User < ActiveRecord::Base has_one :github_user_info, dependent: :destroy has_one :oauth2_user_info, dependent: :destroy has_one :user_stat, dependent: :destroy + has_one :single_sign_on_record, dependent: :destroy belongs_to :approved_by, class_name: 'User' has_many :group_users, dependent: :destroy diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 31be4d01fe1..0a602c86269 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -664,6 +664,11 @@ en: min_password_length: "Minimum password length." block_common_passwords: "Don't allow passwords that are in the 5000 most common passwords." + + enable_sso: "Enable single sign on via an external site" + 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" + enable_local_logins: "Enable traditional, local username and password authentication" enable_local_account_create: "Enable creating new local accounts" enable_google_logins: "Enable Google authentication" diff --git a/config/routes.rb b/config/routes.rb index 2576531637a..d42072a6e37 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -140,6 +140,8 @@ Discourse::Application.routes.draw do end end + get "session/sso" => "session#sso" + get "session/sso_login" => "session#sso_login" 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 50f89ea7fd9..24101afccb2 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -71,6 +71,13 @@ basic: default: 50 users: + enable_sso: + client: true + default: false + sso_url: + default: '' + sso_secret: + defalt: '' enable_local_logins: client: true default: true diff --git a/db/migrate/20140224232913_add_single_sign_on_records.rb b/db/migrate/20140224232913_add_single_sign_on_records.rb new file mode 100644 index 00000000000..8daa750a7f0 --- /dev/null +++ b/db/migrate/20140224232913_add_single_sign_on_records.rb @@ -0,0 +1,12 @@ +class AddSingleSignOnRecords < ActiveRecord::Migration + def change + create_table :single_sign_on_records do |t| + t.integer :user_id, null: false + t.string :external_id, null: false, length: 255 + t.text :last_payload, null: false + t.timestamps + end + + add_index :single_sign_on_records, :external_id, unique: true + end +end diff --git a/lib/single_sign_on.rb b/lib/single_sign_on.rb new file mode 100644 index 00000000000..4c7c3dd1553 --- /dev/null +++ b/lib/single_sign_on.rb @@ -0,0 +1,70 @@ +class SingleSignOn + ACCESSORS = [:nonce, :return_url, :name, :username, :email, :about_me, :external_id] + FIXNUMS = [] + NONCE_EXPIRY_TIME = 10.minutes + + attr_accessor(*ACCESSORS) + attr_accessor :sso_secret, :sso_url + + def self.sso_secret + raise RuntimeError, "sso_secret not implemented on class, be sure to set it on instance" + end + + def self.sso_url + raise RuntimeError, "sso_url not implemented on class, be sure to set it on instance" + end + + def sso_secret + @sso_secret || self.class.sso_secret + end + + def sso_url + @sso_url || self.class.sso_url + end + + def self.parse(payload, sso_secret = nil) + sso = new + sso.sso_secret = sso_secret if sso_secret + + parsed = Rack::Utils.parse_query(payload) + if sso.sign(parsed["sso"]) != parsed["sig"] + raise RuntimeError, "Bad signature for payload" + end + + decoded = Base64.decode64(parsed["sso"]) + decoded_hash = Rack::Utils.parse_query(decoded) + + ACCESSORS.each do |k| + val = decoded_hash[k.to_s] + val = val.to_i if FIXNUMS.include? k + sso.send("#{k}=", val) + end + sso + end + + def sign(payload) + Digest::SHA2.hexdigest(payload + sso_secret) + end + + + def to_url(base_url=nil) + "#{base_url || sso_url}?#{payload}" + end + + def payload + payload = Base64.encode64(unsigned_payload) + "sso=#{CGI::escape(payload)}&sig=#{sign(payload)}" + end + + def unsigned_payload + payload = {} + ACCESSORS.each do |k| + next unless (val = send k) + + payload[k] = val + end + + Rack::Utils.build_query(payload) + end + +end diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb index 4959351ce65..94d798d7586 100644 --- a/spec/controllers/session_controller_spec.rb +++ b/spec/controllers/session_controller_spec.rb @@ -2,6 +2,89 @@ require 'spec_helper' describe SessionController do + describe '.sso_login' do + + before 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) + end + + def get_sso + nonce = SecureRandom.hex + dso = DiscourseSingleSignOn.new + dso.nonce = nonce + dso.register_nonce + + sso = SingleSignOn.new + sso.nonce = nonce + sso.sso_secret = @sso_secret + sso + end + + it 'can take over an account' do + sso = get_sso + user = Fabricate(:user) + sso.email = user.email + sso.external_id = "abc" + + get :sso_login, Rack::Utils.parse_query(sso.payload) + + response.should redirect_to('/') + logged_on_user = Discourse.current_user_provider.new(request.env).current_user + logged_on_user.email.should == user.email + + logged_on_user.single_sign_on_record.external_id.should == "abc" + end + + it 'allows you to create an account' do + sso = get_sso + sso.external_id = '666' # the number of the beast + sso.email = 'bob@bob.com' + sso.name = 'Sam Saffron' + sso.username = 'sam' + + get :sso_login, Rack::Utils.parse_query(sso.payload) + response.should redirect_to('/') + + logged_on_user = Discourse.current_user_provider.new(request.env).current_user + + logged_on_user.email.should == 'bob@bob.com' + logged_on_user.name.should == 'Sam Saffron' + logged_on_user.username.should == 'sam' + + logged_on_user.single_sign_on_record.external_id.should == "666" + end + + it 'allows login to existing account with valid nonce' do + + sso = get_sso + sso.external_id = '997' + sso.return_url = '/hello/world' + + user = Fabricate(:user) + user.create_single_sign_on_record(external_id: '997', last_payload: '') + + get :sso_login, Rack::Utils.parse_query(sso.payload) + + user.single_sign_on_record.reload + user.single_sign_on_record.last_payload.should == sso.unsigned_payload + + response.should redirect_to('/hello/world') + logged_on_user = Discourse.current_user_provider.new(request.env).current_user + + user.id.should == logged_on_user.id + + # nonce is bad now + get :sso_login, Rack::Utils.parse_query(sso.payload) + response.code.should == '500' + + end + end + describe '.create' do let(:user) { Fabricate(:user) } diff --git a/spec/models/discourse_single_sign_on_spec.rb b/spec/models/discourse_single_sign_on_spec.rb new file mode 100644 index 00000000000..41c844c898e --- /dev/null +++ b/spec/models/discourse_single_sign_on_spec.rb @@ -0,0 +1,55 @@ +require "spec_helper" + +describe DiscourseSingleSignOn do + before 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) + end + + it "can fill in data on way back" do + sso = SingleSignOn.new + sso.sso_url = "http://meta.discorse.org/topics/111" + sso.sso_secret = "supersecret" + sso.nonce = "testing" + sso.email = "some@email.com" + sso.username = "sam" + sso.name = "sam saffron" + sso.external_id = "100" + + url, payload = sso.to_url.split("?") + url.should == sso.sso_url + parsed = SingleSignOn.parse(payload, "supersecret") + + parsed.nonce.should == sso.nonce + parsed.email.should == sso.email + parsed.username.should == sso.username + parsed.name.should == sso.name + parsed.external_id.should == sso.external_id + + end + + it "validates nonce" do + _ , payload = DiscourseSingleSignOn.generate_url.split("?") + + sso = DiscourseSingleSignOn.parse(payload) + sso.nonce_valid?.should == true + + sso.expire_nonce! + + sso.nonce_valid?.should == false + + end + + it "generates a correct sso url" do + + url, payload = DiscourseSingleSignOn.generate_url.split("?") + url.should == @sso_url + + sso = DiscourseSingleSignOn.parse(payload) + sso.nonce.should_not be_nil + end +end