mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-27 17:46:05 -05:00
FEATURE: single sign on support
Added support for outsourcing auth to a different website, documentation on meta
This commit is contained in:
parent
46d1c8c1e0
commit
6f31d3f0e5
13 changed files with 357 additions and 2 deletions
|
@ -13,10 +13,14 @@ Discourse.ApplicationRoute = Em.Route.extend({
|
||||||
showLogin: function() {
|
showLogin: function() {
|
||||||
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 {
|
||||||
|
if(Discourse.SiteSettings.enable_sso) {
|
||||||
|
window.location = Discourse.getURL('/session/sso');
|
||||||
} else {
|
} else {
|
||||||
Discourse.Route.showModal(this, 'login');
|
Discourse.Route.showModal(this, 'login');
|
||||||
this.controllerFor('login').resetForm();
|
this.controllerFor('login').resetForm();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
showCreateAccount: function() {
|
showCreateAccount: function() {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
69
app/models/discourse_single_sign_on.rb
Normal file
69
app/models/discourse_single_sign_on.rb
Normal 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
|
||||||
|
|
3
app/models/single_sign_on_record.rb
Normal file
3
app/models/single_sign_on_record.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
class SingleSignOnRecord < ActiveRecord::Base
|
||||||
|
belongs_to :user
|
||||||
|
end
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
12
db/migrate/20140224232913_add_single_sign_on_records.rb
Normal file
12
db/migrate/20140224232913_add_single_sign_on_records.rb
Normal 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
70
lib/single_sign_on.rb
Normal 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
|
|
@ -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) }
|
||||||
|
|
55
spec/models/discourse_single_sign_on_spec.rb
Normal file
55
spec/models/discourse_single_sign_on_spec.rb
Normal 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
|
Loading…
Reference in a new issue