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