We use hosted services for many of our critical tools at Cloud 66: source control, ticketing (github), Helpdesk (HelpScout), sales (Close.io), transactional emails (Mandrill, Customer.io), mailing lists (Mailchimp) and I guess some more I can't remember now.
We also have 4 custom internal websites that we use for various things.
Recently we moved our entire login and credentials for those systems to GoogleApps. This has great benefits for the teams (less passwords to remember, less 2fa codes), for the admins (automatic granular provisioning of new team members and the ones who move or leave) and a better overview of who's got access to what for everyone.
Here is how we improved it:
What do we have?
Our internal websites are usually either in Rails or Sinatra. In most cases we use devise for authentication, and in some cases we need to have two factor authentication for our internal systems that can be accessed via public internet. Our internal systems were using rotp as their 2fa provider.
Here is a summary:
- 4 internal websites
- Powered by Rails or Sinatra
- Using Devise for user management
- Using ROTP for 2fa
- Used by different teams from 2 to 9 members with overlaps between members.
What was the issue?
Although this system was working, we had the following issues:
- Too many logins / credentials to remember, always increasing risks.
- Too many 2fa accounts in our Google Authenticators
- Setup of each new team member when they joined and removing them if they left.
- Repeat of the same dev work for credentials, roles and access control on each system.
Our GoogleApps setup
Like many others, we use GoogleApps for our email, calendar and some document sharing. This means everyone at Cloud 66 has a GoogleApps account and they are members of different Groups like support, sales, marketing, engineering, ops, etc.
Replacing our setup (Devise + ROTP) allowed us to achieve the following:
- Enforce 2fa only on certain teams: for example Ops and Devs can be forced to have 2fa.
- Grant or deny access to team members by just adding them to the right GoogleApps group.
- Solve all of the problems mentioned above.
Let's get started
Here is a summary of how to switched over:
- Make sure everyone on your team is a member of the correct Groups.
- Enforce 2fa on the right groups.
- Setup GoogleApps access for your application.
- Replace your login code with GoogleApps login.
Here is the detail of each setup:
Put everyone in the right Groups
If you are using GoogleApps, you have access to Groups. You can setup internal groups and put each member in the right groups, even with heirarchies.
Enforce 2fa
This step is optional. If you want some members of your team to have a higher level of security (for example if they deal with sensitive information, or they should be able to login from the public internet), you can enable 2fa for those groups:
This article about Enforcement tells you all you need to know about enabling 2fa for your groups.
Setup GoogleApps for logins
This was the trickiest part to do. To get the system working, you need to do three things:
- Allow your web application to authenticate users in GoogleApp.
- Setup a 'service account' so you can retrieve user's team memberships in GoogleApp.
Making your internal app Oauth2 friendly
If you use devise, you can simply use a gem like omniauth-google-oauth2 to add support for Google Oauth2 to your app.
If you are not using devise (which you should!), or on Sinatra you can still use omniauth-google-oauth2 alogside omniauth directly to get it to work.
On Rails, Devise
Add the following line to your Gemfile
and run bundle install
gem 'google-api-client'
gem 'omniauth-google-oauth2'
omniauth-google-oauth2
provides the Devise strategy needed to use Google as an oauth provider. google-api-client
is used to retrieve GoogleApps memeberships via their API.
Add your GoogleApp Client ID and Client Secret (see the next step) to your devise.rb
file:
config.omniauth :google_oauth2, 'CLIENT_ID', 'CLIENT_SECRET', {}
Make sure you have a custom omniauth callback controller. Add this to your routes.rb
:
devise_for :users, :controllers => { :omniauth_callbacks => "users/omniauth_callbacks" }
Create a file under app/controllers/users
called omniauth_callbacks_controller.rb
.
Here is what it can look like:
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
def google_oauth2
@user = User.find_for_google_oauth2(request.env["omniauth.auth"])
if @user.nil?
redirect_to home_path
return
end
if @user.persisted?
Rails.logger.info "User is persisted (existing)"
flash[:notice] = I18n.t "devise.omniauth_callbacks.success", :kind => "Google"
sign_in @user, :event => :authentication
redirect_to home_path
else
Rails.logger.info "User is new"
# create a new user...
end
end
end
The google_oauth2
action is called by Google when the user is authenticated (successfully) with a payload containing the authentication token and more information like name and email address (this depends on the scope of your request. See Google OAuth2 scopes for more information).
We get the payload and try to find (or create) our user. As you can see this is happening in the find_for_google_oauth2
of the User
model:
def self.find_for_google_oauth2(access_token, signed_in_resource=nil)
data = access_token.info
user = User.where(:email => data["email"]).first
# not found, but it's a ourdomain.com email
domain = data["email"].split("@").last
if domain == 'ourdomain.com'
access, err = check_membership(data["email"], 'engineering@ourdomain.com')
unless access
Rails.logger.error "Not a member of the engineering group #{err.message}"
return nil
end
name = data["name"] || data["email"]
unless user
Rails.logger.info "New user"
user = User.new(name: name, email: data["email"], password: Devise.friendly_token[0,20])
user.save!
else
user.oauth_token = access_token.credentials['token']
user.save!
end
return user
else
Rails.logger.info "Non ourdomain.com user"
return nil
end
end
This method:
- Checks user email’s domain. Let’s say you only allow ourdomain.com users to have access.
- Creates a new user if the email domain is right, but it’s not found. This allows automatic provisioning of new users.
- Checks user’s team membership and let’s them in if they are a member of a certain group.
The code to check group membership is here (add it to a file under your lib
folder):
require 'google/api_client'
require 'google/api_client/client_secrets'
require 'google/api_client/auth/installed_app'
def check_membership(email, group)
client = Google::APIClient.new(:application_name => 'My Internl App', :application_version => '1.0')
key = Google::APIClient::KeyUtils.load_from_pkcs12('PATH_TO_P12', 'notasecret')
client.authorization = Signet::OAuth2::Client.new(
:token_credential_uri => 'https://accounts.google.com/o/oauth2/token',
:audience => 'https://accounts.google.com/o/oauth2/token',
:scope => 'https://www.googleapis.com/auth/admin.directory.group.readonly',
:issuer => 'SERVICE_ACCOUNT',
:signing_key => key,
:person => 'admin@ourdomain.com')
client.authorization.fetch_access_token!
begin
result = client.execute!(:http_method => :get, :uri => "https://www.googleapis.com/admin/directory/v1/groups/#{group}/members/#{email}", :authenticated => true)
return true, nil
rescue => exc
return false, exc
end
end
See below as how to create a service account and get the P12 key.
This code uses a service account to check membership of our team members and returns a true
or false
(as well as a potential exception) based on that. You can improve this by fetching the token once and storing it alongside the refresh token somewhere safe in your app instead of fetching a new one every time.
On Sinatra, no Devise
If you use Sinatra or not using devise, you can still use this method more or less the same way.
Add the following lines to your Gemfile
and run bundle install
gem 'google-api-client'
gem 'omniauth'
gem 'omniauth-google-oauth2'
Create a new action in your app to receive the callback from Google (I'm using DataMapper in this example instead of ActiveRecord)
get '/auth/:provider/callback' do
auth = request.env['omniauth.auth']
data = auth.info
domain = data["email"].split("@").last
if domain == 'ourdomain.com'
email = data['email']
user = User.first(:email => email)
if user.nil?
# find the group
ops, err = check_membership(email, 'ops@ourdomain.com')
devs, err = check_membership(email, 'devs@ourdomain.com')
if ops
group = :ops
elsif devs
group = :devs
else
group = :everyone
end
# create the user
group = :everyone
user = User.create(:email => email, :group => group)
else
# there is a user. update it
ops, err = check_membership(email, 'ops@ourdomain.com')
devs, err = check_membership(email, 'devs@ourdomain.com')
if ops
group = :ops
elsif devs
group = :devs
else
group = :everyone
end
user.group = group
user.save!
end
# login (do whatever login means in your app)
redirect '/'
else
flash[:error] = "Invalid login"
redirect '/'
return
end
end
This example works slightly differently as it checks the membership and creates or updates the user found with the membership of the user in a hierarchy (ops > devs > everyone).
It is up to your app to decided what to do in each case. The check_membership
method is the same as with Rails.
Set your GoogleApp Oauth2 provider client id and client secret:
use Rack::Session::Cookie, :key => 'SOME_NAME',
:path => '/',
:expire_after => RELOGIN_IN_SECONDS,
:secret => 'SESSION_SECRET'
use OmniAuth::Builder do
provider :google_oauth2, 'GOOGLE_CLIENT_ID','GOOGLE_CLIENT_SECRET', { provider_ignores_state: ENV['RACK_ENV'] == 'development' }
end
Note that we are setting provider_ignores_state
to falst only for development environment. This allows us to run the app in development without getting the CRSF error while in production the app is still secure.
Now you can replace your login form with a button like this:
<a href="/auth/google_oauth2" class="button expand">Sign in with Google</a>
Now let's move on to creating the GoogleApp Oauth2 web application:
Setting up your GoogleApps
Setting up a web application access for GoogleApps is easy.
- Head to your Google console and choose (or create and choose) a project.
- Under APIs & Auth , choose Credentials
- Click on the Create new Client ID
- Select Web Application , enter your internal application's domain (
https://ourdomain.com
) and it's OAuth2 callback URL tohttps://ourdomain.com/auth/google_oauth2/callback
- Click on Create client ID
Now you should have the Client ID and Client Secret of a new app.
Creating the Service Account
For our app to check a user's memberships in our domain, we need to create a service account and grant it some admin rights. Here is how:
- First, head to your Google console and select your project (same as above will work).
- Now under APIs and Auth select Credentials
- Click on the Create new Client ID
- Select Service Account and click on Create client ID
This will create a service account (an account that can be used between the servers - your servers and Google's). You now need to generate and download a P12 file (there is a button right there for that!). Store this file safely next to your app (don't commit it in your repository). If you are using Cloud 66 to deploy your internal app, you can use cx upload
to push the file to your server. Make sure this file is readable by your web application process (on Cloud 66 deployed apps, it's nginx:nginx
).
The path of this file should be used in your check_membership
method above.
Once you have the service account, you need to grant it the right access rights:
Head to your GoogleApps management console: https://google.com/a/ourdomain.com
and select Security.
Now select Advanced Security and then select Manage API client.
Here enter your service account's Client ID and https://www.googleapis.com/auth/admin.directory.group.readonly
as scope.
One important note on check_membership
method is the :person
parameter. Make sure the value for that is set to a user account with administrator rights on your domain under GoogleApps.
Summary
This article shows how to replace your internal applications' login and access right management with GoogleApps if you use GoogleApps for other things like email and calendars. This should make your life easier in managing your team members' access rights as well as your team members' login to the internal systems is made much simpler.