FEATURE: single sign on support

Added support for outsourcing auth to a different website, documentation on meta
This commit is contained in:
Sam 2014-02-25 14:30:49 +11:00
parent 46d1c8c1e0
commit 6f31d3f0e5
13 changed files with 357 additions and 2 deletions

View file

@ -14,8 +14,12 @@ Discourse.ApplicationRoute = Em.Route.extend({
if (Discourse.get("isReadOnly")) { if (Discourse.get("isReadOnly")) {
bootbox.alert(I18n.t("read_only_mode.login_disabled")); bootbox.alert(I18n.t("read_only_mode.login_disabled"));
} else { } else {
Discourse.Route.showModal(this, 'login'); if(Discourse.SiteSettings.enable_sso) {
this.controllerFor('login').resetForm(); window.location = Discourse.getURL('/session/sso');
} else {
Discourse.Route.showModal(this, 'login');
this.controllerFor('login').resetForm();
}
} }
}, },

View file

@ -46,6 +46,7 @@
</div> </div>
</div> </div>
{{#unless Discourse.SiteSettings.enable_sso }}
<div class="control-group"> <div class="control-group">
<label class="control-label">{{i18n user.password.title}}</label> <label class="control-label">{{i18n user.password.title}}</label>
<div class="controls"> <div class="controls">
@ -59,6 +60,7 @@
{{passwordProgress}} {{passwordProgress}}
</div> </div>
</div> </div>
{{/unless}}
<div class="control-group"> <div class="control-group">
<label class="control-label">{{i18n user.avatar.title}}</label> <label class="control-label">{{i18n user.avatar.title}}</label>

View file

@ -1,12 +1,49 @@
class SessionController < ApplicationController class SessionController < ApplicationController
skip_before_filter :redirect_to_login_if_required skip_before_filter :redirect_to_login_if_required
skip_before_filter :check_xhr, only: ['sso', 'sso_login']
def csrf def csrf
render json: {csrf: form_authenticity_token } render json: {csrf: form_authenticity_token }
end 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 def create
if SiteSetting.enable_sso
render nothing: true, status: 500
return
end
params.require(:login) params.require(:login)
params.require(:password) params.require(:password)
@ -46,6 +83,11 @@ class SessionController < ApplicationController
def forgot_password def forgot_password
params.require(:login) params.require(:login)
if SiteSetting.enable_sso
render nothing: true, status: 500
return
end
user = User.find_by_username_or_email(params[:login]) user = User.find_by_username_or_email(params[:login])
if user.present? if user.present?
email_token = user.email_tokens.create(email: user.email) email_token = user.email_tokens.create(email: user.email)

View file

@ -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

View file

@ -0,0 +1,3 @@
class SingleSignOnRecord < ActiveRecord::Base
belongs_to :user
end

View file

@ -37,6 +37,7 @@ class User < ActiveRecord::Base
has_one :github_user_info, dependent: :destroy has_one :github_user_info, dependent: :destroy
has_one :oauth2_user_info, dependent: :destroy has_one :oauth2_user_info, dependent: :destroy
has_one :user_stat, dependent: :destroy has_one :user_stat, dependent: :destroy
has_one :single_sign_on_record, dependent: :destroy
belongs_to :approved_by, class_name: 'User' belongs_to :approved_by, class_name: 'User'
has_many :group_users, dependent: :destroy has_many :group_users, dependent: :destroy

View file

@ -664,6 +664,11 @@ en:
min_password_length: "Minimum password length." min_password_length: "Minimum password length."
block_common_passwords: "Don't allow passwords that are in the 5000 most common passwords." 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_logins: "Enable traditional, local username and password authentication"
enable_local_account_create: "Enable creating new local accounts" enable_local_account_create: "Enable creating new local accounts"
enable_google_logins: "Enable Google authentication" enable_google_logins: "Enable Google authentication"

View file

@ -140,6 +140,8 @@ Discourse::Application.routes.draw do
end end
end end
get "session/sso" => "session#sso"
get "session/sso_login" => "session#sso_login"
get "session/current" => "session#current" get "session/current" => "session#current"
get "session/csrf" => "session#csrf" get "session/csrf" => "session#csrf"
get "composer-messages" => "composer_messages#index" get "composer-messages" => "composer_messages#index"

View file

@ -71,6 +71,13 @@ basic:
default: 50 default: 50
users: users:
enable_sso:
client: true
default: false
sso_url:
default: ''
sso_secret:
defalt: ''
enable_local_logins: enable_local_logins:
client: true client: true
default: true default: true

View file

@ -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

70
lib/single_sign_on.rb Normal file
View file

@ -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

View file

@ -2,6 +2,89 @@ require 'spec_helper'
describe SessionController do 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 describe '.create' do
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }

View file

@ -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