Add Discourse SSO to allow login into Discourse via Foodsoft

This commit is contained in:
Patrick Gansterer 2017-09-24 17:27:23 +02:00
parent b5e5d7d246
commit 01950b48a1
8 changed files with 110 additions and 54 deletions

View file

@ -142,15 +142,19 @@ class ApplicationController < ActionController::Base
# end # end
# #
def require_plugin_enabled(plugin) def require_plugin_enabled(plugin)
unless plugin.enabled? redirect_to_root_with_feature_disabled_alert unless plugin.enabled?
redirect_to root_path, alert: I18n.t('application.controller.error_feature_disabled')
end end
def require_config_enabled(config)
redirect_to_root_with_feature_disabled_alert unless FoodsoftConfig[config]
end end
def require_config_disabled(config) def require_config_disabled(config)
if FoodsoftConfig[config] redirect_to_root_with_feature_disabled_alert if FoodsoftConfig[config]
redirect_to root_path, alert: I18n.t('application.controller.error_feature_disabled')
end end
def redirect_to_root_with_feature_disabled_alert
redirect_to root_path, alert: I18n.t('application.controller.error_feature_disabled')
end end
# Redirect to the login page, used in authenticate, plugins can override this. # Redirect to the login page, used in authenticate, plugins can override this.

View file

@ -1,8 +1,8 @@
FoodsoftDiscourse FoodsoftDiscourse
================= =================
This plugin adds the possibility to log in via Discourse. A new button is added This plugin adds the possibility to log in via Discourse or act as and SSO
to the login screen. provider for Discourse. A new button is added to the login screen if enabled.
This plugin is enabled by default in foodsoft, so you don't need to do anything This plugin is enabled by default in foodsoft, so you don't need to do anything
to install it. If you still want to, for example when it has been disabled, to install it. If you still want to, for example when it has been disabled,
@ -13,10 +13,14 @@ gem 'foodsoft_discourse', path: 'plugins/foodsoft_discourse'
``` ```
This plugin introduces the foodcoop config option `discourse_url`, which takes This plugin introduces the foodcoop config option `discourse_url`, which takes
the URL fo the Discourse installation (e.g. `https://forum.example.com`) and the the URL for the Discourse installation (e.g. `https://forum.example.com`) and
config option `discourse_sso_secret`, which must be set to the same values as the config option `discourse_sso_secret`, which must be set to the same values
configured in the `sso secret` setting of the Discourse installation. The plugin as configured in the `sso secret` setting of the Discourse installation. The
will be disabled if not both config options are set. plugin will be disabled if not both config options are set.
If `discourse_sso` is set to `true` Foodsoft will act as an SSO provider for
Discourse. The `sso url` for Discourse is `/discourse/sso` relative to root url
of Foodsoft (e.g. `https://foodsoft.example.com/f/discourse/sso`).
This plugin is part of the foodsoft package and uses the GPL-3 license (see This plugin is part of the foodsoft package and uses the GPL-3 license (see
foodsoft's LICENSE for the full license text). foodsoft's LICENSE for the full license text).

View file

@ -1,49 +1,25 @@
class DiscourseController < ApplicationController class DiscourseController < ApplicationController
before_filter -> { require_plugin_enabled FoodsoftDiscourse } before_filter -> { require_plugin_enabled FoodsoftDiscourse }
skip_before_filter :authenticate
def initiate protected
discourse_url = FoodsoftConfig[:discourse_url]
nonce = SecureRandom.hex() def valid_signature?
return_sso_url = url_for(action: :callback, only_path: false) return false if params[:sso].blank? || params[:sig].blank?
payload = "nonce=#{nonce}&return_sso_url=#{return_sso_url}" get_hmac_hex_string(params[:sso]) == params[:sig]
base64_payload = Base64.encode64 payload end
sso = URI.escape base64_payload
def redirect_to_with_payload(url, payload)
base64_payload = Base64.encode64 payload.to_query
sso = CGI::escape base64_payload
sig = get_hmac_hex_string base64_payload sig = get_hmac_hex_string base64_payload
redirect_to "#{url}#{url.include?('?') ? '&' : '?'}sso=#{sso}&sig=#{sig}"
session[:discourse_sso_nonce] = nonce
redirect_to "#{discourse_url}/session/sso_provider?sso=#{sso}&sig=#{sig}"
end end
def callback def parse_payload
raise I18n.t('discourse.callback.invalid_signature') if get_hmac_hex_string(params[:sso]) != params[:sig] payload = Rack::Utils.parse_query Base64.decode64(params[:sso])
payload.symbolize_keys!
info = Rack::Utils.parse_query(Base64.decode64(params[:sso]))
info.symbolize_keys!
raise I18n.t('discourse.callback.invalid_nonce') if info[:nonce] != session[:discourse_sso_nonce]
session[:discourse_sso_nonce] = nil
id = info[:external_id].to_i
user = User.find_or_initialize_by(id: id) do |user|
user.id = id
user.password = SecureRandom.random_bytes(25)
end end
user.nick = info[:username]
user.email = info[:email]
user.first_name = info[:name]
user.last_name = ''
user.last_login = Time.now
user.save!
login_and_redirect_to_return_to user, :notice => I18n.t('discourse.callback.logged_in')
rescue => error
redirect_to login_url, :alert => error.to_s
end
private
def get_hmac_hex_string payload def get_hmac_hex_string payload
discourse_sso_secret = FoodsoftConfig[:discourse_sso_secret] discourse_sso_secret = FoodsoftConfig[:discourse_sso_secret]

View file

@ -0,0 +1,43 @@
class DiscourseLoginController < DiscourseController
before_filter -> { require_config_disabled :discourse_sso }
skip_before_filter :authenticate
def initiate
discourse_url = FoodsoftConfig[:discourse_url]
nonce = SecureRandom.hex()
return_sso_url = url_for(action: :callback, only_path: false)
session[:discourse_sso_nonce] = nonce
redirect_to_with_payload "#{discourse_url}/session/sso_provider",
nonce: nonce,
return_sso_url: return_sso_url
end
def callback
raise I18n.t('discourse.callback.invalid_signature') unless valid_signature?
payload = parse_payload
raise I18n.t('discourse.callback.invalid_nonce') if payload[:nonce] != session[:discourse_sso_nonce]
session[:discourse_sso_nonce] = nil
id = payload[:external_id].to_i
user = User.find_or_initialize_by(id: id) do |user|
user.id = id
user.password = SecureRandom.random_bytes(25)
end
user.nick = payload[:username]
user.email = payload[:email]
user.first_name = payload[:name]
user.last_name = ''
user.last_login = Time.now
user.save!
login_and_redirect_to_return_to user, notice: I18n.t('discourse.callback.logged_in')
rescue => error
redirect_to login_url, alert: error.to_s
end
end

View file

@ -0,0 +1,25 @@
class DiscourseSsoController < DiscourseController
before_filter -> { require_config_enabled :discourse_sso }
def sso
raise I18n.t('discourse.sso.invalid_signature') unless valid_signature?
payload = parse_payload
nonce = payload[:nonce]
return_sso_url = payload[:return_sso_url] || "#{discourse_url}/session/sso_login"
raise I18n.t('discourse.sso.nonce_missing') if nonce.blank?
redirect_to_with_payload return_sso_url,
nonce: nonce,
email: current_user.email,
require_activation: true,
external_id: "#{FoodsoftConfig.scope}/#{current_user.id}",
username: current_user.nick,
name: current_user.name
rescue => error
redirect_to root_url, alert: error.to_s
end
end

View file

@ -1,5 +1,5 @@
/ insert_after 'noscript' / insert_after 'noscript'
- if FoodsoftDiscourse.enabled? - if FoodsoftDiscourse.enabled? && !FoodsoftConfig[:discourse_sso]
.center .center
= link_to t('.discourse_login'), discourse_initiate_path, class: 'btn btn-primary' = link_to t('.discourse_login'), discourse_initiate_path, class: 'btn btn-primary'
%hr %hr

View file

@ -2,8 +2,9 @@ Rails.application.routes.draw do
scope '/:foodcoop' do scope '/:foodcoop' do
get '/discourse/callback' => 'discourse#callback' get '/discourse/callback' => 'discourse_login#callback'
get '/discourse/initiate' => 'discourse#initiate' get '/discourse/initiate' => 'discourse_login#initiate'
get '/discourse/sso' => 'discourse_sso#sso'
end end

View file

@ -7,8 +7,11 @@ module FoodsoftDiscourse
alias orig_redirect_to_login redirect_to_login alias orig_redirect_to_login redirect_to_login
def redirect_to_login(options={}) def redirect_to_login(options={})
return orig_redirect_to_login(options) unless FoodsoftDiscourse.enabled? if FoodsoftDiscourse.enabled? && !FoodsoftConfig[:discourse_sso]
redirect_to discourse_initiate_path redirect_to discourse_initiate_path
else
orig_redirect_to_login(options)
end
end end
end end