Merge pull request 'Integrate Discourse Connect (SSO)' (#131) from feature/126_discourse_sso into master
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #131
This commit is contained in:
Râu Cao 2023-05-31 10:02:43 +00:00
commit 68eba80fd7
18 changed files with 153 additions and 19 deletions

View File

@ -19,6 +19,8 @@ LDAP_SUFFIX='dc=kosmos,dc=org'
WEBHOOKS_ALLOWED_IPS='10.1.1.163'
DISCOURSE_PUBLIC_URL='https://community.kosmos.org'
DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
GITEA_PUBLIC_URL='https://gitea.kosmos.org'
MASTODON_PUBLIC_URL='https://kosmos.social'
MEDIAWIKI_PUBLIC_URL='https://wiki.kosmos.org'

View File

@ -1,3 +1,6 @@
DISCOURSE_PUBLIC_URL='http://discourse.example.com'
DISCOURSE_CONNECT_SECRET='discourse_connect_ftw'
EJABBERD_API_URL='http://xmpp.example.com/api'
BTCPAY_API_URL='http://btcpay.example.com/api/v1'

View File

@ -51,6 +51,9 @@ gem 'faraday'
gem 'sidekiq', '< 7'
gem 'sidekiq-scheduler'
# Service integrations
gem 'discourse_api'
# Monitoring
gem "sentry-ruby"
gem "sentry-rails"

View File

@ -108,6 +108,11 @@ GEM
devise (>= 3.4.1)
net-ldap (>= 0.16.0)
diff-lcs (1.5.0)
discourse_api (2.0.0)
faraday (~> 2.7)
faraday-follow_redirects
faraday-multipart
rack (>= 1.6)
dotenv (2.8.1)
dotenv-rails (2.8.1)
dotenv (= 2.8.1)
@ -126,6 +131,10 @@ GEM
faraday (2.7.1)
faraday-net_http (>= 2.0, < 3.1)
ruby2_keywords (>= 0.0.4)
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.0.2)
ffi (1.15.5)
flipper (0.28.0)
@ -183,6 +192,7 @@ GEM
mini_mime (1.1.2)
mini_portile2 (2.8.0)
minitest (5.16.3)
multipart-post (2.3.0)
net-imap (0.3.1)
net-protocol
net-ldap (0.17.1)
@ -386,6 +396,7 @@ DEPENDENCIES
database_cleaner
devise (~> 4.9.0)
devise_ldap_authenticatable
discourse_api
dotenv-rails
factory_bot_rails
faker

View File

@ -1,5 +1,5 @@
class AccountController < ApplicationController
before_action :require_user_signed_in
before_action :authenticate_user!
def index
@current_section = :account

View File

@ -1,5 +1,5 @@
class Contributions::DonationsController < ApplicationController
before_action :require_user_signed_in
before_action :authenticate_user!
# GET /donations
# GET /donations.json

View File

@ -1,5 +1,5 @@
class Contributions::ProjectsController < ApplicationController
before_action :require_user_signed_in
before_action :authenticate_user!
# GET /contributions
def index

View File

@ -1,5 +1,5 @@
class DashboardController < ApplicationController
before_action :require_user_signed_in
before_action :authenticate_user!
def index
@current_section = :services

View File

@ -0,0 +1,17 @@
class Discourse::SsoController < ApplicationController
before_action :authenticate_user!
def connect
secret = Setting.discourse_connect_secret
sso = DiscourseApi::SingleSignOn.parse(request.query_string, secret)
sso.external_id = current_user.id
sso.email = current_user.email
sso.username = current_user.cn
sso.name = current_user.display_name
sso.admin = current_user.is_admin?
sso.sso_secret = secret
redirect_to sso.to_url("#{Setting.discourse_public_url}/session/sso_login"),
allow_other_host: true
end
end

View File

@ -1,5 +1,5 @@
class InvitationsController < ApplicationController
before_action :require_user_signed_in, except: ["show"]
before_action :authenticate_user!, except: ["show"]
before_action :require_user_signed_out, only: ["show"]
# GET /invitations

View File

@ -1,7 +1,7 @@
require "rqrcode"
class Services::LightningController < ApplicationController
before_action :require_user_signed_in
before_action :authenticate_user!
before_action :authenticate_with_lndhub
before_action :set_current_section
before_action :fetch_balance

View File

@ -2,6 +2,9 @@
class Setting < RailsSettings::Base
cache_prefix { "v1" }
field :accounts_domain, type: :string,
default: ENV["AKKOUNTS_DOMAIN"].presence
#
# Internal services
#
@ -41,6 +44,9 @@ class Setting < RailsSettings::Base
field :discourse_enabled, type: :boolean,
default: (ENV["DISCOURSE_PUBLIC_URL"].present?.to_s || false)
field :discourse_connect_secret, type: :string, readonly: true,
default: ENV["DISCOURSE_CONNECT_SECRET"].presence
#
# ejabberd
#

View File

@ -13,5 +13,40 @@
value: Setting.discourse_public_url,
class: "w-full", disabled: true %>
<% end %>
<%= render FormElements::FieldsetComponent.new(title: "Connect secret") do %>
<%= f.password_field :discourse_connect_secret,
value: Setting.discourse_connect_secret,
class: "w-full", disabled: true %>
<% end %>
<% end %>
</ul>
<% if Setting.discourse_enabled? %>
<% content_for :documentation do %>
<h3 class="mt-8">How to configure Discourse</h3>
<ol class="list-decimal list-inside">
<li class="mb-6">
Set the <strong>Discourse Connect URL</strong> to the following URL:
</li>
<li data-controller="clipboard" class="mb-6 flex gap-1">
<input type="text" class="grow" disabled="disabled"
value="https://<%= Setting.accounts_domain %>/discourse/connect"
data-clipboard-target="source" />
<button class="btn-md btn-icon btn-blue shrink-0"
data-clipboard-target="trigger" data-action="clipboard#copy"
title="Copy to clipboard">
<span class="content-initial">
<%= render partial: "icons/copy", locals: { custom_class: "text-white h-4 w-4 inline" } %>
</span>
<span class="content-active hidden">
<%= render partial: "icons/check", locals: { custom_class: "text-white h-4 w-4 inline" } %>
</span>
</button>
</li>
<li class="mb-6">
Set the <strong>Discourse Connect Secret</strong> to the value above.
</li>
<li>
Enable Discourse Connect.
</li>
<% end %>
<% end %>

View File

@ -19,7 +19,7 @@
class: "w-full", disabled: true %>
<% end %>
</ul>
<h3 class="mt-8">User default settings</h3>
<h3 class="mt-10">User default settings</h3>
<ul role="list">
<%= render FormElements::FieldsetComponent.new(
title: "Default rooms",

View File

@ -20,4 +20,10 @@
</p>
</section>
<% end %>
<% if content_for?(:documentation) %>
<section>
<%= yield :documentation %>
</section>
<% end %>
<% end %>

View File

@ -1,7 +1,13 @@
<%
# TODO remove when https://github.com/hotwired/turbo/issues/203 is fixed
enable_turbo = !session[:user_return_to].match?('/discourse/connect')
%>
<%= render HeaderCompactComponent.new(title: "Log in") %>
<%= render MainCompactComponent.new do %>
<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
<%= form_for(resource, as: resource_name, url: session_path(resource_name),
data: { turbo: enable_turbo.to_s }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="mb-6">
<%= f.label :cn, 'User', class: 'block mb-2 font-bold' %>

View File

@ -1,7 +1,7 @@
require 'sidekiq/web'
Rails.application.routes.draw do
devise_for :users, controllers: { confirmations: "users/confirmations" }
devise_for :users, controllers: { confirmations: 'users/confirmations' }
get 'welcome', to: 'welcome#index'
get 'check_your_email', to: 'welcome#check_your_email'
@ -61,16 +61,20 @@ Rails.application.routes.draw do
end
end
get ".well-known/webfinger" => "webfinger#show"
get ".well-known/webfinger", to: 'webfinger#show'
namespace :discourse do
get "connect", to: 'sso#connect'
end
authenticate :user, ->(user) { user.is_admin? } do
mount Sidekiq::Web => '/sidekiq'
mount Flipper::UI.app(Flipper) => '/flipper'
mount Sidekiq::Web, at: '/sidekiq'
mount Flipper::UI.app(Flipper), at: '/flipper'
end
# Letter Opener (open "sent" emails in dev and staging)
if Rails.env.match(/staging|development/)
mount LetterOpenerWeb::Engine, at: "letter_opener"
mount LetterOpenerWeb::Engine, at: '/letter_opener'
end
root to: 'dashboard#index'

View File

@ -0,0 +1,41 @@
require 'rails_helper'
require 'webmock/rspec'
RSpec.describe "Discourse SSO", type: :request do
describe "GET /discourse/connect" do
let(:user) { create :user, cn: 'jimmy', ou: 'kosmos.org' }
before do
Warden.test_mode!
login_as user, scope: :user
allow(user).to receive(:display_name).and_return('Jimbo')
allow(user).to receive(:is_admin?).and_return(false)
end
after do
Warden.test_reset!
end
context "with invalid SSO credentials" do
it "results in a failed signature check" do
expect {
get discourse_connect_path(
sso: "bm9uY2U9ODk2N2NiMmFlZTdlMjdjNzZiZTNkZWQ5ODIwYzMzN2QmcmV0dXJuX3Nzb191cmw9aHR0cCUzQSUyRiUyRmxvY2FsaG9zdCUzQTMwMDAlMkZzZXNzaW9uJTJGc3NvX2xvZ2lu",
sig: "01fc008ff7b51855217e879b6f14aaddefbbd4df2d128951f7bb70cfde834c2a"
)
}.to raise_error(DiscourseApi::SingleSignOn::ParseError)
end
end
context "valid SSO credentials" do
it "redirects to the Discourse SSO endpoint" do
get discourse_connect_path(
sso: "bm9uY2U9YjQwYWZmYzg0YWQ2NWE1ZTk5MjdlZWU1NWEzMjdhMTQmcmV0dXJuX3Nzb191cmw9aHR0cCUzQSUyRiUyRmxvY2FsaG9zdCUzQTMwMDAlMkZzZXNzaW9uJTJGc3NvX2xvZ2lu",
sig: "b7905c5db612391293249ad5272dac493681efcd255133f6c2aff91ba654a319"
)
expect(response).to redirect_to('http://discourse.example.com/session/sso_login?sso=YWRtaW49ZmFsc2UmZW1haWw9amltbXklNDBleGFtcGxlLmNvbSZleHRlcm5hbF9pZD0xJm5hbWU9SmltYm8mbm9uY2U9YjQwYWZmYzg0YWQ2NWE1ZTk5MjdlZWU1NWEzMjdhMTQmcmV0dXJuX3Nzb191cmw9aHR0cCUzQSUyRiUyRmxvY2FsaG9zdCUzQTMwMDAlMkZzZXNzaW9uJTJGc3NvX2xvZ2luJnVzZXJuYW1lPWppbW15&sig=d5f8b1d6db66569bef789fda4a3216119c2d42b84725d043c9a57dde1e528842')
end
end
end
end