diff --git a/.env.example b/.env.example index 824a96285..149d1f6e6 100644 --- a/.env.example +++ b/.env.example @@ -131,6 +131,11 @@ TWITTER_ENVIRONMENT= SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= +# Google OAuth +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= +GOOGLE_OAUTH_CALLBACK_URL= + ### Change this env variable only if you are using a custom build mobile app ## Mobile app env variables IOS_APP_ID=L7YLMN4634.com.chatwoot.app diff --git a/.gitignore b/.gitignore index fc77a7b55..4182a0ac7 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,7 @@ test/cypress/videos/* /config/*.enc .vscode/settings.json + +# yalc for local testing +.yalc +yalc.lock \ No newline at end of file diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 4d66828c3..48e714dd6 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -68,7 +68,6 @@ Naming/AccessorMethodName: - 'app/controllers/api/v1/accounts_controller.rb' - 'app/controllers/api/v1/callbacks_controller.rb' - 'app/controllers/api/v1/conversations_controller.rb' - - 'app/controllers/passwords_controller.rb' # Offense count: 9 # Configuration parameters: EnforcedStyleForLeadingUnderscores. diff --git a/Gemfile b/Gemfile index cc08fea3b..011acf584 100644 --- a/Gemfile +++ b/Gemfile @@ -199,5 +199,11 @@ group :development, :test do gem 'spring' gem 'spring-watcher-listen' end + # worked with microsoft refresh token gem 'omniauth-oauth2' + +# need for google auth +gem 'omniauth' +gem 'omniauth-google-oauth2' +gem 'omniauth-rails_csrf_protection', '~> 1.0' diff --git a/Gemfile.lock b/Gemfile.lock index eb12e2b6f..112e09e7f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -472,9 +472,17 @@ GEM hashie (>= 3.4.6) rack (>= 2.2.3) rack-protection + omniauth-google-oauth2 (1.1.1) + jwt (>= 2.0) + oauth2 (~> 2.0.6) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.8.0) omniauth-oauth2 (1.8.0) oauth2 (>= 1.4, < 3) omniauth (~> 2.0) + omniauth-rails_csrf_protection (1.0.1) + actionpack (>= 4.2) + omniauth (~> 2.0) openssl (3.1.0) orm_adapter (0.5.0) os (1.1.4) @@ -810,7 +818,10 @@ DEPENDENCIES net-pop net-smtp newrelic_rpm + omniauth + omniauth-google-oauth2 omniauth-oauth2 + omniauth-rails_csrf_protection (~> 1.0) pg pg_search procore-sift diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb new file mode 100644 index 000000000..e1cf76d6b --- /dev/null +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -0,0 +1,75 @@ +class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController + include EmailHelper + + def omniauth_success + get_resource_from_auth_hash + + @resource.present? ? sign_in_user : sign_up_user + end + + private + + def sign_in_user + @resource.skip_confirmation! if confirmable_enabled? + + # once the resource is found and verified + # we can just send them to the login page again with the SSO params + # that will log them in + encoded_email = ERB::Util.url_encode(@resource.email) + redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token) + end + + def sign_up_user + return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed? + return redirect_to login_page_url(error: 'business-account-only') unless validate_business_account? + + create_account_for_user + token = @resource.send(:set_reset_password_token) + frontend_url = ENV.fetch('FRONTEND_URL', nil) + redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" + end + + def login_page_url(error: nil, email: nil, sso_auth_token: nil) + frontend_url = ENV.fetch('FRONTEND_URL', nil) + params = { email: email, sso_auth_token: sso_auth_token }.compact + params[:error] = error if error.present? + + "#{frontend_url}/app/login?#{params.to_query}" + end + + def account_signup_allowed? + # set it to true by default, this is the behaviour across the app + GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false' + end + + def resource_class(_mapping = nil) + User + end + + def get_resource_from_auth_hash # rubocop:disable Naming/AccessorMethodName + # find the user with their email instead of UID and token + @resource = resource_class.where( + email: auth_hash['info']['email'] + ).first + end + + def validate_business_account? + # return true if the user is a business account, false if it is a gmail account + auth_hash['info']['email'].exclude?('@gmail.com') + end + + def create_account_for_user + @resource, @account = AccountBuilder.new( + account_name: extract_domain_without_tld(auth_hash['info']['email']), + user_full_name: auth_hash['info']['name'], + email: auth_hash['info']['email'], + locale: I18n.locale, + confirmed: auth_hash['info']['email_verified'] + ).perform + Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) + end + + def default_devise_mapping + 'user' + end +end diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb new file mode 100644 index 000000000..256a50387 --- /dev/null +++ b/app/helpers/email_helper.rb @@ -0,0 +1,6 @@ +module EmailHelper + def extract_domain_without_tld(email) + domain = email.split('@').last + domain.split('.').first + end +end diff --git a/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.spec.js b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.spec.js new file mode 100644 index 000000000..47cd387e5 --- /dev/null +++ b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.spec.js @@ -0,0 +1,69 @@ +import { shallowMount } from '@vue/test-utils'; +import GoogleOAuthButton from './GoogleOAuthButton.vue'; + +function getWrapper(showSeparator, buttonSize) { + return shallowMount(GoogleOAuthButton, { + propsData: { showSeparator: showSeparator, buttonSize: buttonSize }, + methods: { + $t(text) { + return text; + }, + }, + }); +} + +describe('GoogleOAuthButton.vue', () => { + beforeEach(() => { + window.chatwootConfig = { + googleOAuthClientId: 'clientId', + googleOAuthCallbackUrl: 'http://localhost:3000/test-callback', + }; + }); + + afterEach(() => { + window.chatwootConfig = {}; + }); + + it('renders the OR separator if showSeparator is true', () => { + const wrapper = getWrapper(true); + expect(wrapper.find('.separator').exists()).toBe(true); + }); + + it('does not render the OR separator if showSeparator is false', () => { + const wrapper = getWrapper(false); + expect(wrapper.find('.separator').exists()).toBe(false); + }); + + it('generates the correct Google Auth URL', () => { + const wrapper = getWrapper(); + const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl()); + + const params = googleAuthUrl.searchParams; + expect(googleAuthUrl.origin).toBe('https://accounts.google.com'); + expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth/oauthchooseaccount'); + expect(params.get('client_id')).toBe('clientId'); + expect(params.get('redirect_uri')).toBe( + 'http://localhost:3000/test-callback' + ); + expect(params.get('response_type')).toBe('code'); + expect(params.get('scope')).toBe('email profile'); + }); + + it('responds to buttonSize prop properly', () => { + let wrapper = getWrapper(true, 'tiny'); + expect(wrapper.find('.button.tiny').exists()).toBe(true); + + wrapper = getWrapper(true, 'small'); + expect(wrapper.find('.button.small').exists()).toBe(true); + + wrapper = getWrapper(true, 'large'); + expect(wrapper.find('.button.large').exists()).toBe(true); + + // should not render either + wrapper = getWrapper(true, 'default'); + expect(wrapper.find('.button.small').exists()).toBe(false); + expect(wrapper.find('.button.tiny').exists()).toBe(false); + expect(wrapper.find('.button.large').exists()).toBe(false); + expect(wrapper.find('.button').exists()).toBe(true); + }); +}); diff --git a/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue new file mode 100644 index 000000000..404afe619 --- /dev/null +++ b/app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue @@ -0,0 +1,96 @@ + + + + OR + + + + + {{ $t('LOGIN.OAUTH.GOOGLE_LOGIN') }} + + + + + + + + diff --git a/app/javascript/dashboard/i18n/locale/en/login.json b/app/javascript/dashboard/i18n/locale/en/login.json index 30f667052..25c956a81 100644 --- a/app/javascript/dashboard/i18n/locale/en/login.json +++ b/app/javascript/dashboard/i18n/locale/en/login.json @@ -14,6 +14,11 @@ "ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later", "UNAUTH": "Username / Password Incorrect. Please try again" }, + "OAUTH": { + "GOOGLE_LOGIN": "Login with Google", + "BUSINESS_ACCOUNTS_ONLY": "Please use your company email address to login", + "NO_ACCOUNT_FOUND": "We couldn't find an account for your email address." + }, "FORGOT_PASSWORD": "Forgot your password?", "CREATE_NEW_ACCOUNT": "Create new account", "SUBMIT": "Login" diff --git a/app/javascript/dashboard/i18n/locale/en/signup.json b/app/javascript/dashboard/i18n/locale/en/signup.json index 8defb3133..10ddc5b86 100644 --- a/app/javascript/dashboard/i18n/locale/en/signup.json +++ b/app/javascript/dashboard/i18n/locale/en/signup.json @@ -5,6 +5,9 @@ "TESTIMONIAL_HEADER": "All it takes is one step to move forward", "TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.", "TERMS_ACCEPT": "By creating an account, you agree to our T & C and Privacy policy", + "OAUTH": { + "GOOGLE_SIGNUP": "Sign up with Google" + }, "COMPANY_NAME": { "LABEL": "Company name", "PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises", diff --git a/app/javascript/dashboard/routes/auth/Signup.vue b/app/javascript/dashboard/routes/auth/Signup.vue index dc1fa4cbb..06d8355cb 100644 --- a/app/javascript/dashboard/routes/auth/Signup.vue +++ b/app/javascript/dashboard/routes/auth/Signup.vue @@ -88,7 +88,7 @@ export default { } .signup-form--content { - padding: var(--space-jumbo); + padding: var(--space-larger); max-width: 600px; width: 100%; } diff --git a/app/javascript/dashboard/routes/auth/components/Signup/Form.vue b/app/javascript/dashboard/routes/auth/components/Signup/Form.vue index 4b984db94..1f1d61d94 100644 --- a/app/javascript/dashboard/routes/auth/components/Signup/Form.vue +++ b/app/javascript/dashboard/routes/auth/components/Signup/Form.vue @@ -1,70 +1,79 @@ - - - + + + + + + + + + + + + + {{ $t('SET_NEW_PASSWORD.CAPTCHA.ERROR') }} + + + - - - - - - - - {{ $t('SET_NEW_PASSWORD.CAPTCHA.ERROR') }} - - - + + + {{ $t('REGISTER.OAUTH.GOOGLE_SIGNUP') }} + - + + + diff --git a/app/javascript/dashboard/routes/login/login.routes.js b/app/javascript/dashboard/routes/login/login.routes.js index 40038a154..37e333a5b 100644 --- a/app/javascript/dashboard/routes/login/login.routes.js +++ b/app/javascript/dashboard/routes/login/login.routes.js @@ -13,6 +13,7 @@ export default { ssoAuthToken: route.query.sso_auth_token, ssoAccountId: route.query.sso_account_id, ssoConversationId: route.query.sso_conversation_id, + authError: route.query.error, }), }, ], diff --git a/app/models/user.rb b/app/models/user.rb index a908bd1f8..278526557 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -57,7 +57,8 @@ class User < ApplicationRecord :trackable, :validatable, :confirmable, - :password_has_required_content + :password_has_required_content, + :omniauthable, omniauth_providers: [:google_oauth2] # TODO: remove in a future version once online status is moved to account users # remove the column availability from users diff --git a/app/views/layouts/vueapp.html.erb b/app/views/layouts/vueapp.html.erb index 4982f686c..206598032 100644 --- a/app/views/layouts/vueapp.html.erb +++ b/app/views/layouts/vueapp.html.erb @@ -35,6 +35,8 @@ hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>', helpCenterURL: '<%= ENV.fetch('HELPCENTER_URL', '') %>', fbAppId: '<%= ENV.fetch('FB_APP_ID', nil) %>', + googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>', + googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>', fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>', signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>', isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>', diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 000000000..d92d9b040 --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,5 @@ +Rails.application.config.middleware.use OmniAuth::Builder do + provider :google_oauth2, ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil), ENV.fetch('GOOGLE_OAUTH_CLIENT_SECRET', nil), { + provider_ignores_state: true + } +end diff --git a/config/routes.rb b/config/routes.rb index 42e6be883..0ac64012e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,11 +1,11 @@ Rails.application.routes.draw do # AUTH STARTS - match 'auth/:provider/callback', to: 'home#callback', via: [:get, :post] mount_devise_token_auth_for 'User', at: 'auth', controllers: { confirmations: 'devise_overrides/confirmations', passwords: 'devise_overrides/passwords', sessions: 'devise_overrides/sessions', - token_validations: 'devise_overrides/token_validations' + token_validations: 'devise_overrides/token_validations', + omniauth_callbacks: 'devise_overrides/omniauth_callbacks' }, via: [:get, :post] ## renders the frontend paths only if its not an api only server diff --git a/config/webpack/resolve.js b/config/webpack/resolve.js index ce17c0db4..cbef68e8a 100644 --- a/config/webpack/resolve.js +++ b/config/webpack/resolve.js @@ -9,6 +9,7 @@ const resolve = { survey: path.resolve('./app/javascript/survey'), assets: path.resolve('./app/javascript/dashboard/assets'), components: path.resolve('./app/javascript/dashboard/components'), + helpers: path.resolve('./app/javascript/shared/helpers'), './iconfont.eot': 'vue-easytable/libs/font/iconfont.eot', './iconfont.woff': 'vue-easytable/libs/font/iconfont.woff', './iconfont.ttf': 'vue-easytable/libs/font/iconfont.ttf', diff --git a/package.json b/package.json index 8bc10aeda..7640bec55 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dependencies": { "@braid/vue-formulate": "^2.5.2", "@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git", - "@chatwoot/utils": "^0.0.10", + "@chatwoot/utils": "^0.0.11", "@hcaptcha/vue-hcaptcha": "^0.3.2", "@june-so/analytics-next": "^1.36.5", "@rails/actioncable": "6.1.3", diff --git a/public/assets/images/auth/google.svg b/public/assets/images/auth/google.svg new file mode 100644 index 000000000..088288fa3 --- /dev/null +++ b/public/assets/images/auth/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb new file mode 100644 index 000000000..b6c5cd781 --- /dev/null +++ b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb @@ -0,0 +1,110 @@ +require 'rails_helper' + +RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do + let(:account_builder) { double } + let(:user_double) { object_double(:user) } + + def set_omniauth_config(for_email = 'test@example.com') + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new( + provider: 'google', + uid: '123545', + info: { + name: 'test', + email: for_email, + image: 'https://example.com/image.jpg' + } + ) + end + + describe '#omniauth_sucess' do + it 'allows signup' do + with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do + set_omniauth_config('test_not_preset@example.com') + allow(AccountBuilder).to receive(:new).and_return(account_builder) + allow(account_builder).to receive(:perform).and_return(user_double) + allow(Avatar::AvatarFromUrlJob).to receive(:perform_later).and_return(true) + + get '/omniauth/google_oauth2/callback' + + # expect a 302 redirect to auth/google_oauth2/callback + expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') + follow_redirect! + + expect(AccountBuilder).to have_received(:new).with({ + account_name: 'example', + user_full_name: 'test', + email: 'test_not_preset@example.com', + locale: I18n.locale, + confirmed: nil + }) + expect(account_builder).to have_received(:perform) + end + end + + it 'blocks personal accounts signup' do + with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do + set_omniauth_config('personal@gmail.com') + get '/omniauth/google_oauth2/callback' + + # expect a 302 redirect to auth/google_oauth2/callback + expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') + follow_redirect! + + # expect a 302 redirect to app/login with error disallowing personal accounts + expect(response).to redirect_to(%r{/app/login\?error=business-account-only$}) + end + end + + # This test does not affect line coverage, but it is important to ensure that the logic + # does not allow any signup if the ENV explicitly disables it + it 'blocks signup if ENV disabled' do + with_modified_env ENABLE_ACCOUNT_SIGNUP: 'false' do + set_omniauth_config('does-not-exist-for-sure@example.com') + get '/omniauth/google_oauth2/callback' + + # expect a 302 redirect to auth/google_oauth2/callback + expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') + follow_redirect! + + # expect a 302 redirect to app/login with error disallowing signup + expect(response).to redirect_to(%r{/app/login\?error=no-account-found$}) + end + end + + it 'allows login' do + create(:user, email: 'test@example.com') + set_omniauth_config('test@example.com') + + get '/omniauth/google_oauth2/callback' + # expect a 302 redirect to auth/google_oauth2/callback + expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') + + follow_redirect! + expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$}) + + # expect app/login page to respond with 200 and render + follow_redirect! + expect(response).to have_http_status(:ok) + end + + # from a line coverage point of view this may seem redundant + # but to ensure that the logic allows for existing users even if they have a gmail account + # we need to test this explicitly + it 'allows personal account login' do + create(:user, email: 'personal-existing@gmail.com') + set_omniauth_config('personal-existing@gmail.com') + + get '/omniauth/google_oauth2/callback' + # expect a 302 redirect to auth/google_oauth2/callback + expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') + + follow_redirect! + expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$}) + + # expect app/login page to respond with 200 and render + follow_redirect! + expect(response).to have_http_status(:ok) + end + end +end diff --git a/yarn.lock b/yarn.lock index 765a769f9..18ec29bc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1410,12 +1410,12 @@ prosemirror-utils "^0.9.6" prosemirror-view "^1.17.2" -"@chatwoot/utils@^0.0.10": - version "0.0.10" - resolved "https://registry.npmjs.org/@chatwoot/utils/-/utils-0.0.10.tgz#59f68cc28d8718b261ebed8b9c94d2c493b6c67f" - integrity sha512-Zd+wQTblWKUV1mhcXoabcfoLygx/Ock5pP0JQdfqW64lubhjYaRR4gCutEgqUcQB4nuOUH7MZ7BTzdZm4RoM/g== +"@chatwoot/utils@^0.0.11": + version "0.0.11" + resolved "https://registry.yarnpkg.com/@chatwoot/utils/-/utils-0.0.11.tgz#6922492e21c20bdb0ef733967a0b94829d8f620f" + integrity sha512-uiLsuBYTlZGXJ/d7QfJ+hlO1u7U1750ON5iu0pus8t6GlJQdxvMQWuf6fHQtfsDNcvL1aXsQu3H6BUk/nVZLlw== dependencies: - date-fns "^2.22.1" + date-fns "^2.29.1" "@cnakazawa/watch@^1.0.3": version "1.0.4" @@ -6902,10 +6902,10 @@ date-fns@^1.27.2: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== -date-fns@^2.22.1: - version "2.28.0" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" - integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== +date-fns@^2.29.1: + version "2.29.3" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" + integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA== date-format-parse@^0.2.6: version "0.2.6"