Skip to content

Commit

Permalink
feat: Google OAuth for login & signup (#6346)
Browse files Browse the repository at this point in the history
This PR adds Google OAuth for all existing users, allowing users to log in or sign up via their Google account.

---------

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
Co-authored-by: Fayaz Ahmed <15716057+fayazara@users.noreply.github.com>
Co-authored-by: Sojan <sojan@pepalo.com>
  • Loading branch information
4 people committed Feb 16, 2023
1 parent 2c8ecbe commit 7be2ef3
Show file tree
Hide file tree
Showing 26 changed files with 567 additions and 92 deletions.
5 changes: 5 additions & 0 deletions .env.example
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Expand Up @@ -62,3 +62,7 @@ test/cypress/videos/*
/config/*.enc

.vscode/settings.json

# yalc for local testing
.yalc
yalc.lock
1 change: 0 additions & 1 deletion .rubocop_todo.yml
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions Gemfile
Expand Up @@ -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'
11 changes: 11 additions & 0 deletions Gemfile.lock
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions 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
6 changes: 6 additions & 0 deletions 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
@@ -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);
});
});
96 changes: 96 additions & 0 deletions app/javascript/dashboard/components/ui/Auth/GoogleOAuthButton.vue
@@ -0,0 +1,96 @@
<template>
<div>
<div v-if="showSeparator" class="separator">
OR
</div>
<a :href="getGoogleAuthUrl()">
<button
class="button expanded button__google_login"
:class="{
// Explicit checking to ensure no other value is used
large: buttonSize === 'large',
small: buttonSize === 'small',
tiny: buttonSize === 'tiny',
}"
>
<img
src="/assets/images/auth/google.svg"
alt="Google Logo"
class="icon"
/>
<slot>{{ $t('LOGIN.OAUTH.GOOGLE_LOGIN') }}</slot>
</button>
</a>
</div>
</template>

<script>
const validButtonSizes = ['small', 'tiny', 'large'];
export default {
props: {
showSeparator: {
type: Boolean,
default: true,
},
buttonSize: {
type: String,
default: undefined,
validator: value =>
validButtonSizes.includes(value) || value === undefined,
},
},
methods: {
getGoogleAuthUrl() {
// Ideally a request to /auth/google_oauth2 should be made
// Creating the URL manually because the devise-token-auth with
// omniauth has a standing issue on redirecting the post request
// https://github.com/lynndylanhurley/devise_token_auth/issues/1466
const baseUrl =
'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount';
const clientId = window.chatwootConfig.googleOAuthClientId;
const redirectUri = window.chatwootConfig.googleOAuthCallbackUrl;
const responseType = 'code';
const scope = 'email profile';
// Build the query string
const queryString = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope: scope,
}).toString();
// Construct the full URL
return `${baseUrl}?${queryString}`;
},
},
};
</script>

<style lang="scss" scoped>
.separator {
display: flex;
align-items: center;
margin: var(--space-two) var(--space-zero);
gap: var(--space-one);
color: var(--s-300);
font-size: var(--font-size-small);
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: var(--s-100);
}
}
.button__google_login {
background: var(--white);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-one);
border: 1px solid var(--s-100);
color: var(--b-800);
}
</style>
5 changes: 5 additions & 0 deletions app/javascript/dashboard/i18n/locale/en/login.json
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions app/javascript/dashboard/i18n/locale/en/signup.json
Expand Up @@ -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 <a href=\"https://www.chatwoot.com/terms\">T & C</a> and <a href=\"https://www.chatwoot.com/privacy-policy\">Privacy policy</a>",
"OAUTH": {
"GOOGLE_SIGNUP": "Sign up with Google"
},
"COMPANY_NAME": {
"LABEL": "Company name",
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/dashboard/routes/auth/Signup.vue
Expand Up @@ -88,7 +88,7 @@ export default {
}
.signup-form--content {
padding: var(--space-jumbo);
padding: var(--space-larger);
max-width: 600px;
width: 100%;
}
Expand Down

0 comments on commit 7be2ef3

Please sign in to comment.