Skip to content

Commit

Permalink
Replace developer strategy with SAML
Browse files Browse the repository at this point in the history
  • Loading branch information
pixeltrix committed Jan 26, 2024
1 parent 7fd91b5 commit 1a0293e
Show file tree
Hide file tree
Showing 25 changed files with 514 additions and 82 deletions.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -422,6 +428,7 @@ DEPENDENCIES
nokogiri
omniauth
omniauth-rails_csrf_protection
omniauth-saml
pg
pry
puma (< 6)
Expand Down
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
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
Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 22 additions & 2 deletions app/models/admin_user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand All @@ -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?
Expand All @@ -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
Expand Down
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
Expand Up @@ -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
Expand Down
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
Expand Up @@ -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|
Expand Down
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
Expand Up @@ -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"
Expand Down
15 changes: 10 additions & 5 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 1a0293e

Please sign in to comment.