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
#
def require_plugin_enabled(plugin)
unless plugin.enabled?
redirect_to root_path, alert: I18n.t('application.controller.error_feature_disabled')
redirect_to_root_with_feature_disabled_alert unless plugin.enabled?
end
def require_config_enabled(config)
redirect_to_root_with_feature_disabled_alert unless FoodsoftConfig[config]
end
def require_config_disabled(config)
if FoodsoftConfig[config]
redirect_to root_path, alert: I18n.t('application.controller.error_feature_disabled')
redirect_to_root_with_feature_disabled_alert if FoodsoftConfig[config]
end
def redirect_to_root_with_feature_disabled_alert
redirect_to root_path, alert: I18n.t('application.controller.error_feature_disabled')
end
# Redirect to the login page, used in authenticate, plugins can override this.

View file

@ -1,8 +1,8 @@
FoodsoftDiscourse
=================
This plugin adds the possibility to log in via Discourse. A new button is added
to the login screen.
This plugin adds the possibility to log in via Discourse or act as and SSO
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
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
the URL fo the Discourse installation (e.g. `https://forum.example.com`) and the
config option `discourse_sso_secret`, which must be set to the same values as
configured in the `sso secret` setting of the Discourse installation. The plugin
will be disabled if not both config options are set.
the URL for the Discourse installation (e.g. `https://forum.example.com`) and
the config option `discourse_sso_secret`, which must be set to the same values
as configured in the `sso secret` setting of the Discourse installation. The
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
foodsoft's LICENSE for the full license text).

View file

@ -1,49 +1,25 @@
class DiscourseController < ApplicationController
before_filter -> { require_plugin_enabled FoodsoftDiscourse }
skip_before_filter :authenticate
def initiate
discourse_url = FoodsoftConfig[:discourse_url]
protected
nonce = SecureRandom.hex()
return_sso_url = url_for(action: :callback, only_path: false)
payload = "nonce=#{nonce}&return_sso_url=#{return_sso_url}"
base64_payload = Base64.encode64 payload
sso = URI.escape base64_payload
def valid_signature?
return false if params[:sso].blank? || params[:sig].blank?
get_hmac_hex_string(params[:sso]) == params[:sig]
end
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
session[:discourse_sso_nonce] = nonce
redirect_to "#{discourse_url}/session/sso_provider?sso=#{sso}&sig=#{sig}"
redirect_to "#{url}#{url.include?('?') ? '&' : '?'}sso=#{sso}&sig=#{sig}"
end
def callback
raise I18n.t('discourse.callback.invalid_signature') if get_hmac_hex_string(params[:sso]) != params[:sig]
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)
def parse_payload
payload = Rack::Utils.parse_query Base64.decode64(params[:sso])
payload.symbolize_keys!
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
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'
- if FoodsoftDiscourse.enabled?
- if FoodsoftDiscourse.enabled? && !FoodsoftConfig[:discourse_sso]
.center
= link_to t('.discourse_login'), discourse_initiate_path, class: 'btn btn-primary'
%hr

View file

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

View file

@ -7,8 +7,11 @@ module FoodsoftDiscourse
alias orig_redirect_to_login redirect_to_login
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
else
orig_redirect_to_login(options)
end
end
end