Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Gemfile.d/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@
conditionally('authentication.strategy', 'ldap') do
gem 'net-ldap', github: 'ruby-ldap/ruby-net-ldap', require: 'net/ldap'
end

conditionally('authentication.strategy', 'google') do
gem 'simple_google_auth'
end
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ GEM
serialbox (1.0.0)
activesupport
json
simple_google_auth (0.2.0)
rails (>= 3.2.0)
slugalicious (2.1.0)
rails (>= 4.0)
stringex
Expand Down Expand Up @@ -250,6 +252,7 @@ DEPENDENCIES
rspec-rails
safe_yaml
sass-rails
simple_google_auth
slugalicious
sourcemap
sprockets (< 2.12.0)
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,10 @@ delivered if all of the following conditions are met:

### Authentication and Authorization

Authentication is done using either password verification or LDAP; see
{AuthenticationHelpers} and related controller mixins, as well as the
model mixins under `app/models/additions` for more information.
Authentication is done using either password verification, LDAP or Google OAuth
(via the `simple_google_auth` gem); see {AuthenticationHelpers} and related
controller mixins, as well as the model mixins under `app/models/additions`
for more information.

There are four permissions levels that a User can have, specific to an
individual Project:
Expand Down
27 changes: 22 additions & 5 deletions app/controllers/additions/authentication_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@

# Controller and view mixin with shared methods pertaining for authenticating
# and authorizing {User Users}. For specifics on different authentication
# methods, see {PasswordAuthenticationHelpers} and {LdapAuthenticationHelpers}.
# methods, see {PasswordAuthenticationHelpers}, {LdapAuthenticationHelpers}
# and {GoogleAuthenticationHelpers}.
#
# The ID of the authenticated user is stored in the session. The presence of a
# valid user ID in `session[:user_id]` is indicative of an authenticated
Expand All @@ -28,12 +29,19 @@ module AuthenticationHelpers
extend ActiveSupport::Concern

included do
helper_method :current_user, :logged_in?, :logged_out?
helper_method :current_user, :logged_in?, :logged_out?, :third_party_login?

# An overridable method to let the base view disable certain elements that
# aren't appropriate when using a 3rd-party Login Service (e.g. Logout button)
def self.third_party_login?
false
end
end

# Clears a user session.

def log_out
#TODO: Why doesn't this use #reset_session ?
session[:user_id] = nil
@current_user = nil
end
Expand Down Expand Up @@ -78,14 +86,23 @@ def login_required
format.xml { head :unauthorized }
format.json { head :unauthorized }
format.atom { head :unauthorized }
format.html do
redirect_to login_url(next: request.fullpath), notice: t('controllers.authentication.login_required')
end
format.html { login_required_redirect }
end
return false
end
end

# An overridable method for selecting where a not-logged-in redirect goes
# Primarily used by 3rd-party Login Services

def login_required_redirect
redirect_to login_url(next: request.fullpath), notice: t('controllers.authentication.login_required')
end

def third_party_login?
self.class.third_party_login?
end

# A `before_filter` that requires an unauthenticated session to continue. If
# the session is authenticated...
#
Expand Down
53 changes: 53 additions & 0 deletions app/controllers/additions/google_authentication_helpers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Copyright 2015 Powershop Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Includes methods for authentication with Google OAuth.
# NONO: Information for the LDAP server
# is stored in the `authentication.yml` Configoro file for the current
# environment. A successful BIND yields an authenticated session.
#
# If this is the user's first time logging in, the User will be created for him
# or her.

module GoogleAuthenticationHelpers
extend ActiveSupport::Concern

included do
def self.third_party_login?
true
end
end

def log_in
unless google_auth_data
logger.tagged('AuthenticationHelpers') { logger.info "Denying login: not Google Auth data provided." }
return false
end

unless user = User.find_or_create_by_google_auth_data(google_auth_data)
logger.tagged('AuthenticationHelpers') { logger.info "Denying login to #{google_auth_data["email"]}: could not find or create." }
return false
end

log_in_user user
end

def login_required_redirect
logger.info "Redirecting to Big G for Authentication"

# If we're Google authenticated, find/create user
# If we're not google-authenticated, then go get it!
redirect_if_not_google_authenticated unless log_in
end
end
3 changes: 2 additions & 1 deletion app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# {AuthenticationHelpers} for more information on how authentication works.

class SessionsController < ApplicationController
skip_before_filter :login_required, only: [:new, :create]
skip_before_filter :login_required, only: [:new, :create] unless ApplicationController.third_party_login?
before_filter :must_be_unauthenticated, except: :destroy

respond_to :html
Expand Down Expand Up @@ -93,6 +93,7 @@ def create

def destroy
log_out
reset_session if third_party_login?
redirect_to login_url, notice: t('controllers.sessions.destroy.logged_out')
end
end
111 changes: 111 additions & 0 deletions app/models/additions/google_authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Copyright 2015 Powershop Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Adds GoogleAuth-based authentication to the {User} model. Mixed in if this
# Squash install is configured to use "google" authentication.

module GoogleAuthentication
extend ActiveSupport::Concern

class InvalidGoogleAuthError <RuntimeError; end
class InvalidGoogleUsernameError <InvalidGoogleAuthError; end

included do
attr_accessor :google_auth_data

# We choose the username:
before_validation(on: :create) {|obj| obj.username ||= unique_username.downcase }

validates :google_auth_data,
presence: true,
on: :create
validates :google_email_address,
presence: true,
email: true,
on: :create
validate :validate_email_address_unique, on: :create

# Because we need an associated email object for the google_email_address
# validates_associated :emails ?

# @return [User] Searches the Users for an entry that matches appropriate Google Auth data
# @return [nil] If it can't find a user that matches the provided Google Auth data
#
# In this case, we search for a matching email address
def self.find_by_google_auth_data(auth_data)
return nil unless auth_data && auth_data["email"]
e = (Email.find_by(email: auth_data["email"]) or return nil)
logger.info "Email found = #{e}"
e.user.tap {|u| u.google_auth_data = auth_data }
end

# @return [User] Finds or Creates a User from Google Auth data
# @raise [RecordInvalid] If it fails to create the User (this should never happen!)
def self.find_or_create_by_google_auth_data(auth_data)
find_by_google_auth_data(auth_data) or
User.create(google_auth_data: auth_data)
end
end

# @return [String] The unique Google ID for the authenticated account
def google_user_id
google_auth_data["sub"]
end

# @return [String] The Google email for the authenticated account
def google_email_address
google_auth_data["email"]
end

private

# @return [String] Calculate a username that's not already in-use for a new Google account / email-address
def unique_username
raise InvalidGoogleAuthError, "google_auth_data is null" if google_auth_data.nil?

[sanitised_google_username, sanitised_google_username_id].each do |a_username|
logger.info "Searching for #{a_username.inspect}"
return a_username unless User.where(username: a_username).exists?
end
end

# @return [String] The username part of the Google email, sanitised for Squash's use
def sanitised_google_username
sanitised_username(google_email_address)
end

# @return [String] The username part of the Google email, sanitised for Squash's use, combined with the Unique Google ID
def sanitised_google_username_id
[sanitised_username(google_email_address), google_user_id].join("-")
end

# @return [String] A username extracted from a Google email-address, and then sanitised.
#
# In this context, "sanitised" means certain disallowed char's are replaced with "_" as Google also
# allows `.` and `'` in a G.Apps email username but Squash doesn't.
def sanitised_username(an_email_address)
raise InvalidGoogleUsernameError, "Can't extract username from #{an_email_address.inspect}" if an_email_address.nil?
m = an_email_address.match(/^(.+)@.+$/) or
raise InvalidGoogleUsernameError, "Can't extract username from #{an_email_address.inspect}"
m[1].gsub(/[\.\']/, "_")
end

def create_primary_email
emails.create!(email: google_email_address, primary: true)
end

def validate_email_address_unique
errors.add(:google_email_address, :taken) if Email.primary.where(email: google_email_address).exists?
end
end
28 changes: 28 additions & 0 deletions config/initializers/google_authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# encoding: utf-8

#TODO: License
# Copyright 2015 Powershop Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

SimpleGoogleAuth.configure do |config|
config.client_id = Squash::Configuration.authentication.google.client_id
config.client_secret = Squash::Configuration.authentication.google.client_secret
config.redirect_uri = Squash::Configuration.authentication.google.redirect_uri
config.authenticate = lambda do |data|
allowed_domains = Squash::Configuration.authentication.google.allowed_domains
fail "Must provide a list of accepted Google domains" if allowed_domains.nil? || allowed_domains.empty?

! allowed_domains.select {|domain| data.email.ends_with? "@#{domain}" }.empty?
end
end if Squash::Configuration.authentication.strategy == 'google'
27 changes: 24 additions & 3 deletions setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -332,8 +332,9 @@ def choose(question, choices, default=nil)
say
say "Now we'll cover authentication.".bold

auth = choose("How will users authenticate to Squash?", %w(password LDAP))
if auth == 'LDAP'
auth = choose("How will users authenticate to Squash?", %w(password LDAP GoogleAuth))
case auth
when 'LDAP'
ldap_host = query("What's the hostname of your LDAP server?")
ldap_ssl = prompt("Is your LDAP service using SSL?")
ldap_port = query("What port is your LDAP service running on?", ldap_ssl ? '636' : '389').to_i
Expand All @@ -356,7 +357,8 @@ def choose(question, choices, default=nil)
}
}.to_yaml)
end
elsif auth == 'password'

when 'password'
say "Updating config/environments/common/authentication.yml..."
File.open('config/environments/common/authentication.yml', 'w') do |f|
f.puts({
Expand All @@ -367,6 +369,25 @@ def choose(question, choices, default=nil)
}
}.to_yaml)
end

when 'GoogleAuth'
say "You will need your Google OAuth API Credentials from your project"
say "under https://console.developers.google.com/project to answer the following:"
google_client_id = query("What's your Google OAuth Client ID?")
google_client_secret = query("What's your Google OAuth Client Secret?")
google_redirect_uri = query("What Google OAuth Redirect URI do you plan to use for Squash?")

say "Updating config/environments/common/authentication.yml..."
File.open('config/environments/common/authentication.yml', 'w') do |f|
f.puts({
'strategy' => 'google',
'google' => {
'client_id' => google_client_id,
'client_secret' => google_client_secret,
'redirect_uri' => google_redirect_uri
}
}.to_yaml)
end
end

File.open('/tmp/squash_install_progress', 'w') { |f| f.puts '2' }
Expand Down
3 changes: 2 additions & 1 deletion spec/controllers/account/bugs_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ def sort(bugs, field, reverse=false)
bugs
end

include_context "setup for required logged-in user"
it "should require a logged-in user" do
get :index
expect(response).to redirect_to(login_url(next: request.fullpath))
expect(response).to redirect_to(login_required_redirection_url(next: request.fullpath))
end

context '[authenticated]' do
Expand Down
3 changes: 2 additions & 1 deletion spec/controllers/accounts_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@
@attrs = {password: 'newpass', password_confirmation: 'newpass', first_name: 'NewFN', last_name: 'NewLN'}
end

include_context "setup for required logged-in user"
it "should require a logged-in user" do
patch :update, user: @attrs
expect(response).to redirect_to(login_url(next: request.fullpath))
expect(response).to redirect_to(login_required_redirection_url(next: request.fullpath))
expect { @user.reload }.not_to change(@user, :first_name)
end

Expand Down
6 changes: 6 additions & 0 deletions spec/controllers/additions/authentication_helpers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ def _current_user=(u) @current_user = u end
end
end

describe "#third_party_login?" do
it "should return false for being a 3rd-party login service" do
expect(@controller.send(:third_party_login?)).to be_false
end unless Squash::Configuration.authentication.strategy == 'google'
end

describe "#must_be_unauthenticated" do
it "should return false and redirect if the user is logged in" do
expect(@controller).to receive(:respond_to).once
Expand Down
Loading