Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use SSO for moderation portal #21

Closed
wants to merge 11 commits into from
Prev Previous commit
Replace developer strategy with SAML
pixeltrix committed Jan 26, 2024
commit 1a0293e52d97cbbfecc6d86403277168b202c1eb
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -36,6 +36,7 @@ gem 'redcarpet'
gem 'scrypt'
gem 'omniauth'
gem 'omniauth-rails_csrf_protection'
gem 'omniauth-saml'

gem 'aws-sdk-codedeploy'
gem 'aws-sdk-cloudwatchlogs'
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -244,6 +244,9 @@ GEM
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth-saml (2.1.0)
omniauth (~> 2.0)
ruby-saml (~> 1.12)
orm_adapter (0.5.0)
pg (1.2.3)
pry (0.13.1)
@@ -319,6 +322,9 @@ GEM
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-support (3.10.2)
ruby-saml (1.16.0)
nokogiri (>= 1.13.10)
rexml
ruby-vips (2.1.4)
ffi (~> 1.12)
rubyzip (2.3.2)
@@ -422,6 +428,7 @@ DEPENDENCIES
nokogiri
omniauth
omniauth-rails_csrf_protection
omniauth-saml
pg
pry
puma (< 6)
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -12,12 +12,6 @@ We recommend using [Docker Desktop][2] to get setup quickly. If you'd prefer not
docker compose run --rm web rake db:setup
```

### Create an admin user

```
docker compose run --rm web rake epets:add_sysadmin_user
```

### Load the country list

```
32 changes: 26 additions & 6 deletions app/controllers/admin/omniauth_callbacks_controller.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,45 @@
class Admin::OmniauthCallbacksController < Devise::OmniauthCallbacksController
skip_before_action :require_admin
skip_before_action :verify_authenticity_token, only: %i[developer]
skip_before_action :verify_authenticity_token, only: %i[saml]

def developer
@user = AdminUser.find_by(email: omniauth_hash["uid"])
rescue_from ActiveRecord::RecordNotFound do
redirect_to admin_login_url, alert: :login_failed
end

def saml
@user = AdminUser.find_or_create_from!(provider, auth_data)

if @user.present?
sign_in_and_redirect @user, event: :authentication
set_flash_message(:notice, :signed_in) if is_navigational_format?
sign_in @user, event: :authentication

set_flash_message(:notice, :signed_in)
set_refresh_header

render "admin/admin/index"
else
redirect_to admin_login_url, alert: :invalid_login
end
end

def failure
redirect_to admin_login_url, alert: :login_failed
end

private

def after_omniauth_failure_path_for(scope)
admin_login_url
end

def omniauth_hash
def auth_data
request.env["omniauth.auth"]
end

def provider
IdentityProvider.find_by!(name: auth_data.provider)
end

def set_refresh_header
headers['Refresh'] = "0; url=#{admin_root_url}"
end
end
22 changes: 22 additions & 0 deletions app/controllers/admin/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -4,6 +4,14 @@ class Admin::SessionsController < Devise::SessionsController

helper_method :last_request_at

def create
if provider?
redirect_to sso_provider_url(provider), status: :temporary_redirect
else
redirect_to admin_login_url, alert: :invalid_login
end
end

def continue
respond_to do |format|
format.json
@@ -18,6 +26,20 @@ def status

private

def email_domain
Mail::Address.new(sign_in_params[:email]).domain
rescue Mail::Field::ParseError
nil
end

def provider
@provider ||= IdentityProvider.find_by(domain: email_domain)
end

def provider?
provider.present?
end

def skip_timeout
request.env['devise.skip_trackable'] = true
end
24 changes: 22 additions & 2 deletions app/models/admin_user.rb
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@ class AdminUser < ActiveRecord::Base
class CannotDeleteCurrentUser < RuntimeError; end
class MustBeAtLeastOneAdminUser < RuntimeError; end

devise :trackable, :timeoutable, :omniauthable, omniauth_providers: %i[developer]
devise :trackable, :timeoutable

# TODO: Drop these columns once rollout of SSO has been completed
self.ignored_columns = %i[
@@ -27,7 +27,7 @@ class MustBeAtLeastOneAdminUser < RuntimeError; end
validates :first_name, :last_name, presence: true
validates :email, presence: true, email: true
validates :email, uniqueness: { case_sensitive: false }
validates :role, inclusion: { in: ROLES }
validates :role, presence: true, inclusion: { in: ROLES }

# = Callbacks =
before_save :reset_persistence_token, unless: :persistence_token?
@@ -41,6 +41,26 @@ def self.timeout_in
Site.login_timeout.seconds
end

def self.find_or_create_from!(provider, auth_data)
find_or_create_by!(email: auth_data.fetch(:uid)) do |user|
user.first_name = auth_data.info.fetch(:first_name)
user.last_name = auth_data.info.fetch(:last_name)
groups = Array.wrap(auth_data.info.fetch(:groups))

if (groups & provider.sysadmins).any?
user.role = SYSADMIN_ROLE
elsif (groups & provider.moderators).any?
user.role = MODERATOR_ROLE
elsif (groups & provider.reviewers).any?
user.role = REVIEWER_ROLE
end
end
rescue ActiveRecord::RecordNotUnique => e
find_by!(email: auth_data.fetch(:uid))
rescue ActiveRecord::RecordInvalid => e
Appsignal.send_exception(e) and return nil
end

def reset_persistence_token
self.persistence_token = SecureRandom.hex(64)
end
100 changes: 100 additions & 0 deletions app/models/identity_provider.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
class IdentityProvider
class ProviderNotFound < ArgumentError; end

class << self
delegate :each, to: :providers

def providers
@providers ||= load_providers
end

def names
providers.map(&:name)
end

def find_by(domain:)
providers.detect { |provider| provider.domains.include?(domain) }
end

def find_by!(name:)
providers.detect { |provider| provider.name.to_s == name } || raise_provider_not_found(name)
end

private

def load_providers
Rails.application.config_for(:sso).map { |options| IdentityProvider.new(options) }
end

def raise_provider_not_found(name)
raise ProviderNotFound, "Couldn't find the provider '#{name}'"
end
end

attr_reader :name, :attribute_statements
attr_reader :assertion_consumer_service_url, :sp_entity_id
attr_reader :idp_sso_service_url, :idp_cert, :domains
attr_reader :sysadmins, :moderators, :reviewers

def initialize(options)
@name = options.fetch(:name).to_sym
@attribute_statements = options.fetch(:attributes, default_attributes)
@assertion_consumer_service_url = "#{Site.moderate_url}/admin/auth/#{name}/callback"
@sp_entity_id = "#{Site.moderate_url}/admin/auth/#{name}"
@idp_sso_service_url = options.fetch(:idp_sso_service_url)
@idp_cert = options.fetch(:idp_cert, "")
@domains = options.fetch(:domains)
@sysadmins = options.fetch(:sysadmins, [])
@moderators = options.fetch(:moderators, [])
@reviewers = options.fetch(:reviewers, [])

unless klass_defined?
strategies.const_set(klass, new_klass)
end
end

def to_param
name.to_s
end

def config
{
attribute_statements: attribute_statements,
assertion_consumer_service_url: assertion_consumer_service_url,
sp_entity_id: sp_entity_id,
idp_sso_service_url: idp_sso_service_url,
idp_cert: idp_cert
}
end

private

def default_attributes
{
email: ["email"],
first_name: ["first_name"],
last_name: ["last_name"],
groups: ["groups"]
}
end

def strategies
OmniAuth::Strategies
end

def parent_klass
OmniAuth::Strategies::SAML
end

def new_klass
Class.new(parent_klass)
end

def klass
@klass ||= name.to_s.camelize.to_sym
end

def klass_defined?
strategies.const_defined?(klass, false)
end
end
8 changes: 8 additions & 0 deletions app/models/site.rb
Original file line number Diff line number Diff line change
@@ -86,6 +86,14 @@ def moderate_host_with_port
instance.moderate_host_with_port
end

def moderate_url
if table_exists?
instance.moderate_url
else
default_moderate_url
end
end

def constraints_for_moderation
if table_exists?
instance.constraints_for_moderation
9 changes: 7 additions & 2 deletions app/views/admin/sessions/new.html.erb
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
<h1>Sign in</h1>
<div class="grid-row">
<div class="column-half">
<%= form_tag(admin_auth_developer_path, method: :post) do %>
<button type="submit" class="button">Login with developer strategy</button>
<%= form_for(@user, as: :user, url: admin_login_path, authenticity_token: form_authenticity_token) do |form| %>
<div class="form-group">
<%= form.label :email, "Email", class: "form-label" %>
<%= form.text_field :email, class: "form-control", type: "email", autofocus: "autofocus" %>
</div>

<%= form.submit "Sign in", class: "button" %>
<% end %>
</div>
</div>
4 changes: 0 additions & 4 deletions config/initializers/devise.rb
Original file line number Diff line number Diff line change
@@ -22,10 +22,6 @@
# ==> Navigation configuration
config.sign_out_via = :get

# ==> Omniauth configuration
config.omniauth_path_prefix = '/admin/auth'
config.omniauth :developer, fields: %i[email]

# ==> Warden configuration
# Reset the token after logging in so that other sessions are logged out
Warden::Manager.after_set_user except: :fetch do |user, warden, options|
11 changes: 9 additions & 2 deletions config/initializers/omniauth.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
OmniAuth.configure do |config|
config.logger = Rails.logger
Rails.application.config.middleware.use OmniAuth::Builder do
configure do |config|
config.path_prefix = '/admin/auth'
config.logger = Rails.logger
end

IdentityProvider.each do |idp|
provider idp.name, idp.config
end
end
1 change: 1 addition & 0 deletions config/locales/admin.en-GB.yml
Original file line number Diff line number Diff line change
@@ -74,6 +74,7 @@ en-GB:
invalidation_started: "Enqueued the invalidation %{summary}"
invalidation_updated: "Invalidation updated successfully"
invalid_login: "Invalid login details"
login_failed: "There was a problem logging in - please contact support"
logged_out: "You have been logged out"
moderator_required: "You must be logged in as a moderator or system administrator to view this page"
moderation_delay_sent: "An email has been sent to creators that moderation has been delayed"
15 changes: 10 additions & 5 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -244,27 +244,32 @@
get '/', action: 'index', as: :stats
post '/', action: 'create', as: nil
end

end

devise_for :users, class_name: 'AdminUser', module: 'admin', skip: %i[sessions]

as :user do
controller 'admin/sessions' do
get '/admin/login', action: 'new'
post '/admin/login', action: 'create', as: nil
get '/admin/logout', action: 'destroy'
get '/admin/continue', action: 'continue'
get '/admin/status', action: 'status'
end

controller 'admin/omniauth_callbacks' do
get '/admin/auth/failure', action: 'failure'

scope '/admin/auth/:provider', via: %i[get post] do
match '/', action: 'passthru', as: :sso_provider
match '/callback', action: 'saml', as: :sso_provider_callback
end
end
end
end

# Devise needs a `new_user_session_url` helper for its failure app
direct(:new_user_session) { route_for(:admin_login) }

# Friendly url helpers for Omniauth
direct(:admin_auth_developer) { route_for(:user_developer_omniauth_authorize) }
direct(:admin_auth_developer_callback) { route_for(:user_developer_omniauth_callback) }

get 'ping', to: 'ping#ping'
end
13 changes: 13 additions & 0 deletions config/sso.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
development: []

test:
- name: "example"
idp_sso_service_url: "http://localhost:3000/sso"
domains:
- "example.com"
sysadmins:
- "sysadmins"
moderators:
- "moderators"
reviewers:
- "reviewers"
Loading