diff --git a/Gemfile b/Gemfile index 274ad086..947b17c8 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,11 @@ gem 'paper_trail' # Use devise as authentification framework gem 'devise' +# Use omniauth to support openID authentication +gem 'omniauth' +gem 'omniauth-facebook' +gem 'omniauth-openid' +gem 'omniauth-google-oauth2' # Use cancan as authorization framework gem 'cancan' diff --git a/Gemfile.lock b/Gemfile.lock index ea81b1c9..5b210e94 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -103,6 +103,8 @@ GEM factory_girl_rails (4.4.1) factory_girl (~> 4.4.0) railties (>= 3.0.0) + faraday (0.9.0) + multipart-post (>= 1.2, < 3) ffi (1.9.3) formatador (0.2.4) formtastic (2.3.0.rc3) @@ -126,6 +128,7 @@ GEM activesupport (>= 4.0.1) haml (>= 3.1, < 5.0) railties (>= 4.0.1) + hashie (2.1.1) hike (1.2.3) hoptoad_notifier (2.4.11) activesupport @@ -143,6 +146,7 @@ GEM jquery-ui-rails (4.2.1) railties (>= 3.2.16) json (1.8.1) + jwt (1.0.0) launchy (2.4.2) addressable (~> 2.3) letter_opener (1.2.0) @@ -162,10 +166,34 @@ GEM mini_portile (0.6.0) minitest (5.3.3) multi_json (1.9.2) + multi_xml (0.5.5) + multipart-post (2.0.0) mysql2 (0.3.16) nio4r (1.0.0) nokogiri (1.6.2.1) mini_portile (= 0.6.0) + oauth2 (0.9.4) + faraday (>= 0.8, < 0.10) + jwt (~> 1.0) + multi_json (~> 1.3) + multi_xml (~> 0.5) + rack (~> 1.2) + omniauth (1.2.1) + hashie (>= 1.2, < 3) + rack (~> 1.0) + omniauth-facebook (1.6.0) + omniauth-oauth2 (~> 1.1) + omniauth-google-oauth2 (0.2.4) + omniauth (~> 1.0) + omniauth-oauth2 (~> 1.1) + omniauth-oauth2 (1.1.2) + faraday (>= 0.8, < 0.10) + multi_json (~> 1.3) + oauth2 (~> 0.9.3) + omniauth (~> 1.2) + omniauth-openid (1.0.1) + omniauth (~> 1.0) + rack-openid (~> 1.3.1) orm_adapter (0.5.0) paper_trail (3.0.1) activerecord (>= 3.0, < 5.0) @@ -196,6 +224,9 @@ GEM quiet_assets (1.0.2) railties (>= 3.1, < 5.0) rack (1.5.2) + rack-openid (1.3.1) + rack (>= 1.1.0) + ruby-openid (>= 2.1.8) rack-test (0.6.2) rack (>= 1.0) rails (4.1.0) @@ -259,6 +290,7 @@ GEM powerpack (~> 0.0.6) rainbow (>= 1.99.1, < 3.0) ruby-progressbar (~> 1.4) + ruby-openid (2.5.0) ruby-progressbar (1.5.1) rubyzip (1.0.0) sass (3.2.19) @@ -346,6 +378,10 @@ DEPENDENCIES jquery-ui-rails letter_opener mysql2 + omniauth + omniauth-facebook + omniauth-google-oauth2 + omniauth-openid paper_trail paperclip prawn_rails diff --git a/app/assets/images/facebook.png b/app/assets/images/facebook.png new file mode 100644 index 00000000..e8bca65f Binary files /dev/null and b/app/assets/images/facebook.png differ diff --git a/app/assets/images/google.png b/app/assets/images/google.png new file mode 100644 index 00000000..3909e9de Binary files /dev/null and b/app/assets/images/google.png differ diff --git a/app/assets/images/novell.png b/app/assets/images/novell.png new file mode 100644 index 00000000..a33e55e2 Binary files /dev/null and b/app/assets/images/novell.png differ diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index cd3a9ca9..b6137bac 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -1,6 +1,10 @@ class RegistrationsController < Devise::RegistrationsController before_action :configure_permitted_parameters, if: :devise_controller? + def edit + @openids = Openid.where(user_id: current_user.id).order(:provider) + end + def update @user = User.find(current_user.id) email_changed = false diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb new file mode 100644 index 00000000..6b9004b4 --- /dev/null +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -0,0 +1,32 @@ +class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController + skip_before_filter :verify_authenticity_token + + [:novell, :google, :facebook, :twitter].each do |provider| + define_method(provider) { handle(provider) } + end + + private + def handle(provider) + + auth_hash = request.env['omniauth.auth'] + openid = Openid.find_for_oauth(auth_hash) # Get or create openid + # If openid exists and is associated with a user, sign in with associated user, + # even if the email of the associated user and the email of the provided openid are different + unless user = openid.user + user = User.find_for_auth(auth_hash, current_user) # Get or create users + end + + begin + user.save! + if openid.user != user + openid.user = user + end + openid.save! + + sign_in user + redirect_to root_path, notice: user.email + " signed in successfully with #{provider}" + rescue Exception => e + redirect_back_or_to new_user_registration_path, alert: 'Failed' + e.message + end + end +end \ No newline at end of file diff --git a/app/models/openid.rb b/app/models/openid.rb new file mode 100644 index 00000000..b85a1525 --- /dev/null +++ b/app/models/openid.rb @@ -0,0 +1,22 @@ +class Openid < ActiveRecord::Base + belongs_to :user + validates :provider, :uid, :presence => true + + # Searches for openid based on provider and uid. + # Returns found openid or a new openid. + # ====Returns + # * Openid::ActiveRecord_Relation -> openid + def self.find_for_oauth(auth) + openid = Openid.where(provider: auth.provider, uid: auth.uid).first_or_initialize + + if openid.new_record? + openid.email = auth.info.email + if existing_openid = Openid.where(email: openid.email).first + openid.user_id = existing_openid.user_id + end + end + openid + + end + +end diff --git a/app/models/user.rb b/app/models/user.rb index 21200f4a..3bd4fad8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,13 +4,15 @@ class User < ActiveRecord::Base # :token_authenticatable, :confirmable, # :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :trackable, :validatable, - :confirmable + :recoverable, :rememberable, :trackable, :validatable, :confirmable, + :omniauthable, omniauth_providers: [:novell, :google, :facebook] has_and_belongs_to_many :roles has_one :person, :inverse_of => :user + has_many :openids - attr_accessible :email, :password, :password_confirmation, :remember_me, :role_id, :role_ids, :person_attributes + attr_accessible :email, :password, :password_confirmation, :remember_me, :role_id, :role_ids, + :person_attributes accepts_nested_attributes_for :person accepts_nested_attributes_for :roles @@ -19,6 +21,26 @@ class User < ActiveRecord::Base delegate :last_name, :first_name, :public_name, to: :person + # Searches for user based on email. Returns found user or new user. + # ====Returns + # * +User::ActiveRecord_Relation+ -> user + def self.find_for_auth(auth, current_user = nil) + + user = current_user + + if user.nil? # No current user available, user is not already logged in + user = User.where(email: auth.info.email).first_or_initialize + end + + if user.new_record? + user.email = auth.info.email + user.password = Devise.friendly_token[0,20] + user.skip_confirmation! + end + + user + end + def role?(role) Rails.logger.debug("Checking role in user") !!roles.find_by_name(role.to_s.downcase.camelize) diff --git a/app/views/devise/registrations/edit.html.haml b/app/views/devise/registrations/edit.html.haml index 435b5bff..36992da5 100644 --- a/app/views/devise/registrations/edit.html.haml +++ b/app/views/devise/registrations/edit.html.haml @@ -15,6 +15,18 @@ %br %br = render :partial => 'devise/registrations/volunteerperson', :locals => {:p => p} + = f.inputs :name => 'OpenID' do + %h4 + Currently using the following openIDs: + - @openids.each do |openid| + = openid.provider + = openid.email + %br + %h4 + To add an openID to your account using a different email address, sign in with your + openID while logged in to OSEM + = render 'devise/shared/openid' + = f.inputs :name => "Account" do = f.input :email, :required => false = f.input :password, :hint => "(Leave blank if you don't want to change it)", :input_html => {:autocomplete => "off"} diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index d9e25ccd..1badafdb 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -3,13 +3,17 @@ %h1.text-center Sign Up .col-md-5.col-md-offset-1 .well - = semantic_form_for(resource, :as => resource_name, :url => registration_path(resource_name)) do |f| + = semantic_form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| = f.input :email = f.input :password = f.input :password_confirmation - = f.action :submit, :as => :button, :label => "Sign Up", :button_html => {:class => "btn btn-primary"} - .col-md-3.col-md-offset-2 + = f.action :submit, as: :button, label: 'Sign Up', button_html: {class: 'btn btn-primary'} + %br + %br + %h4 Already have an account? + = render 'devise/shared/links' + .col-md-4 .well - %h4 - Already have an account? - = render "devise/shared/links" + %h4 Or use your openID + + = render 'devise/shared/openid' \ No newline at end of file diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml new file mode 100644 index 00000000..180aa561 --- /dev/null +++ b/app/views/devise/sessions/new.html.haml @@ -0,0 +1,21 @@ +.row + .page-header + %h2.text-center Sign In + .col-md-5.col-md-offset-1 + .well + = semantic_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| + = f.input :email + = f.input :password + - if devise_mapping.rememberable? + = f.check_box :remember_me + = f.label 'Remember me' + %br + = f.action :submit, as: :button, label: 'Sign In', button_html: {class: 'btn btn-primary'} + %br + %br + = render "devise/shared/links" + + .col-md-4 + .well + %h4 Or use your openID + = render 'devise/shared/openid' \ No newline at end of file diff --git a/app/views/devise/shared/_links.erb b/app/views/devise/shared/_links.erb new file mode 100644 index 00000000..b9123456 --- /dev/null +++ b/app/views/devise/shared/_links.erb @@ -0,0 +1,19 @@ +<%- if controller_name != 'sessions' %> + <%= link_to "Sign in", new_user_session_path %>
+<% end -%> + +<%- if devise_mapping.registerable? && controller_name != 'registrations' %> + <%= link_to "Sign up", new_registration_path(resource_name) %>
+<% end -%> + +<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> + <%= link_to "Forgot your password?", new_password_path(resource_name) %>
+<% end -%> + +<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> + <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>
+<% end -%> + +<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> + <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
+<% end -%> diff --git a/app/views/devise/shared/_openid.html.haml b/app/views/devise/shared/_openid.html.haml new file mode 100644 index 00000000..f9e2057e --- /dev/null +++ b/app/views/devise/shared/_openid.html.haml @@ -0,0 +1,4 @@ +- if devise_mapping.omniauthable? + - resource_class.omniauth_providers.each do |provider| + = link_to image_tag("#{provider}.png", size: '32x32') + provider , omniauth_authorize_path(resource_name, provider) + %br \ No newline at end of file diff --git a/app/views/layouts/_navigation.html.haml b/app/views/layouts/_navigation.html.haml index d63dd137..775c4bcf 100644 --- a/app/views/layouts/_navigation.html.haml +++ b/app/views/layouts/_navigation.html.haml @@ -51,5 +51,6 @@ %button.btn.btn-block Sign in %br = link_to "Forgot your password?", new_password_path('user') + = link_to 'Sign in with openID', new_user_session_path %li.hidden-lg = link_to('Sign In', new_user_session_path) diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 2dc7d680..06d3d126 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -1,9 +1,21 @@ # Use this hook to configure devise mailer, warden hooks and so forth. # Many of these configuration options can be set straight in your model. + Devise.setup do |config| # ==> Secret key, generate one with `rake secret` config.secret_key = Rails.application.secrets.devise_secret_key + # ==> openIDs configuration + # Define the available openID providers that can be used to log in + # Pass each provider to User model in :omniauth_providers (for open_id providers use their name) + # Include provider in users/omniauth_callbacks_controller.rb + + config.omniauth :open_id, name: 'novell', identifier: 'http://www.opensuse.org/openid/user' + config.omniauth :google_oauth2, Rails.application.secrets.google_key, Rails.application.secrets.google_secret, { + name: 'google', + scope: 'email' } + config.omniauth :facebook, Rails.application.secrets.facebook_key, Rails.application.secrets.facebook_secret + # ==> Mailer Configuration # Configure the e-mail address which will be shown in Devise::Mailer, # note that it will be overwritten if you use your own mailer class with default "from" parameter. diff --git a/config/routes.rb b/config/routes.rb index ae46877b..30c401ca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,7 +2,9 @@ Osem::Application.routes.draw do get 'conference/show' - devise_for :users, controllers: { registrations: :registrations }, path: 'accounts' + devise_for :users, :controllers => { registrations: :registrations, + omniauth_callbacks: 'users/omniauth_callbacks' }, + :path => 'accounts' namespace :admin do resources :users diff --git a/db/migrate/20140603092041_create_openids.rb b/db/migrate/20140603092041_create_openids.rb new file mode 100644 index 00000000..8c55e8fd --- /dev/null +++ b/db/migrate/20140603092041_create_openids.rb @@ -0,0 +1,12 @@ +class CreateOpenids< ActiveRecord::Migration + def change + create_table :openids do |t| + t.string :provider + t.string :email + t.string :uid + t.integer :user_id + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 030cce43..557c2177 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20140617145048) do +ActiveRecord::Schema.define(version: 20140618062623) do create_table "answers", force: true do |t| t.string "title" @@ -82,11 +82,11 @@ ActiveRecord::Schema.define(version: 20140617145048) do t.text "description" t.text "registration_description" t.text "ticket_description" + t.text "sponsor_description" + t.string "sponsor_email" t.string "twitter_url" t.string "facebook_url" t.string "google_url" - t.text "sponsor_description" - t.string "sponsor_email" t.text "lodging_description" t.boolean "include_registrations_in_splash", default: false t.boolean "include_sponsors_in_splash", default: false @@ -213,6 +213,15 @@ ActiveRecord::Schema.define(version: 20140617145048) do t.string "website_link" end + create_table "openids", force: true do |t| + t.string "provider" + t.string "email" + t.string "uid" + t.integer "user_id" + t.datetime "created_at" + t.datetime "updated_at" + end + create_table "people", force: true do |t| t.string "guid", null: false t.string "first_name", default: "" diff --git a/spec/features/omniauth_spec.rb b/spec/features/omniauth_spec.rb new file mode 100644 index 00000000..9e9ef482 --- /dev/null +++ b/spec/features/omniauth_spec.rb @@ -0,0 +1,108 @@ +require 'spec_helper' + +feature Openid do + let!(:participant_role) { create(:participant_role) } + let!(:admin_role) { create(:admin_role) } + + describe 'sign in with openid' do + + it 'has option to log in with Google account' do + visit '/accounts/sign_in' + expect(page.has_content?('Or use your openID')).to be true + expect(page.has_content?('google')).to be true + end + + it 'signs in *new* user with Google account' do + expected_count_openid = Openid.count + 1 + expected_count_user = User.count + 1 + visit '/accounts/sign_in' + + mock_auth_new_user + click_link 'google' + expect(flash).to eq('test-1@gmail.com signed in successfully with google') + expect(Openid.count).to eq(expected_count_openid) + expect(User.count).to eq(expected_count_user) + end + + it 'signs in an existing user' do + create(:participant, email: 'test-participant-1@google.com') + expected_count_openid = Openid.count + 1 + expected_count_user = User.count + visit '/accounts/sign_in' + + mock_auth_existing_user_participant + click_link 'google' + expect(flash).to eq('test-participant-1@google.com signed in successfully with google') + expect(Openid.count).to eq(expected_count_openid) + expect(User.count).to eq(expected_count_user) + end + + it 'can handle authentication error' do + OmniAuth.config.mock_auth[:google] = :invalid_credentials + visit '/accounts/sign_in' + expect(page.has_content?('Or use your openID')).to be true + click_link 'google' + expect(flash).to eq("Could not authenticate you from Google because \"Invalid credentials\".") + end + + it 'adds openid to existing user' do + # Sign in user + user = create(:participant, email: 'test-participant-1@google.com') + sign_in user + + # Add openID to current user + expected_count_openid = Openid.count + 1 + expected_count_user = User.count + visit '/accounts/edit' + + mock_auth_new_user + click_link 'google' + expect(flash).to eq('test-participant-1@google.com signed in successfully with google') + expect(Openid.count).to eq(expected_count_openid) + expect(User.count).to eq(expected_count_user) + expect(Openid.where(email: 'test-1@gmail.com').first.nil?).to eq(false) + end + + it 'signs in with openID using the same email as another associated openid' do |user| + # Sign in user + user = create(:participant, email: 'test-participant-1@google.com') + expected_count_openid = Openid.count + 1 + expected_count_user = User.count + visit '/accounts/sign_in' + + mock_auth_existing_user_participant + click_link 'google' + expect(flash).to eq('test-participant-1@google.com signed in successfully with google') + expect(Openid.count).to eq(expected_count_openid) + expect(User.count).to eq(expected_count_user) + + # Add openID to current user with email test-1@gmail.com + expected_count_openid = Openid.count + 1 + expected_count_user = User.count + visit '/accounts/edit' + + mock_auth_new_user + click_link 'google' + expect(flash).to eq('test-participant-1@google.com signed in successfully with google') + expect(Openid.count).to eq(expected_count_openid) + expect(User.count).to eq(expected_count_user) + expect(Openid.where(email: 'test-participant-1@google.com').first.nil?).to eq(false) + expect(Openid.where(email: 'test-1@gmail.com').first.nil?).to eq(false) + + # Sign in with different openID using same email (test-1@gmail.com) + sign_out + expected_count_openid = Openid.count + 1 + expected_count_user = User.count + + visit '/accounts/sign_in' + mock_auth_new_user_fb + click_link 'facebook' + expect(flash).to eq('test-participant-1@google.com signed in successfully with facebook') + expect(Openid.count).to eq(expected_count_openid) + expect(User.count).to eq(expected_count_user) + last_openid = Openid.last + expect(last_openid.uid).to eq('facebook-test-uid-1') + expect(last_openid.email).to eq('test-1@gmail.com') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 318b1650..0776c1b8 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -53,6 +53,9 @@ RSpec.configure do |config| # Includes support/login_macros for feature tests config.include LoginMacros, type: :feature + # Includes omniauth macro + config.include(OmniauthMacros) + # Includes support/flash for feature tests config.include Flash, type: :feature @@ -63,3 +66,5 @@ RSpec.configure do |config| c.syntax = :expect end end + +OmniAuth.config.test_mode = true diff --git a/spec/support/login_macros.rb b/spec/support/login_macros.rb index f4608fc5..1bff7302 100644 --- a/spec/support/login_macros.rb +++ b/spec/support/login_macros.rb @@ -4,7 +4,7 @@ module LoginMacros fill_in 'user_email', with: user.email fill_in 'user_password', with: user.password - find(:xpath, "//div[@id='content']//input[@name='commit']").click + find(:xpath, "//div[@id='content']//button[@type='submit']").click expect(page.has_content?('Signed in successfully')).to be true end diff --git a/spec/support/omniauth_macros.rb b/spec/support/omniauth_macros.rb new file mode 100644 index 00000000..767e048f --- /dev/null +++ b/spec/support/omniauth_macros.rb @@ -0,0 +1,64 @@ +module OmniauthMacros + # The mock_auth configuration allows you to set per-provider (or default) + # authentication hashes to return during integration testing. + + def mock_auth_new_user + OmniAuth.config.mock_auth[:google] = OmniAuth::AuthHash.new({ + provider: 'google', + uid: 'google-test-uid-1', + info: { + email: 'test-1@gmail.com' + }, + credentials: { + token: 'mock_token', + secret: 'mock_secret' + } + }) + end + + def mock_auth_new_user_fb + OmniAuth.config.mock_auth[:facebook] = OmniAuth::AuthHash.new({ + provider: 'google', + uid: 'facebook-test-uid-1', + info: { + email: 'test-1@gmail.com' + }, + credentials: { + token: 'mock_token', + secret: 'mock_secret' + } + }) + end + + def mock_auth_existing_user_participant + # The mock_auth configuration allows you to set per-provider (or default) + # authentication hashes to return during integration testing. + OmniAuth.config.mock_auth[:google] = OmniAuth::AuthHash.new({ + provider: 'google', + uid: 'google-test-uid-participant-1', + info: { + email: 'test-participant-1@google.com' + }, + credentials: { + token: 'mock_token', + secret: 'mock_secret' + } + }) + end + + def mock_auth_existing_user_admin + # The mock_auth configuration allows you to set per-provider (or default) + # authentication hashes to return during integration testing. + OmniAuth.config.mock_auth[:google] = OmniAuth::AuthHash.new({ + provider: 'google', + uid: 'google-test-uid-admin-1', + info: { + email: 'test-admin-1@google.com' + }, + credentials: { + token: 'mock_token', + secret: 'mock_secret' + } + }) + end +end \ No newline at end of file