Hub single sign-on

Hub is a Ruby On Rails application with accompanying library gem which manages user accounts for a web site. Its main use is for sites that run more than one Rails application within a single domain. Through integration with the Hub library gem, applications share Hub user details so that a user only has to create one account and log in or out of one place. Without Hub, users have to create and manage individual accounts for individual applications. Hub is therefore a single sign-on mechanism.

Applications require modifications to use Hub. Applications that already have the concept of users and accounts must be modified with some care, because the application's own account mechanism must be replaced or overlaid with the single sign-on alternative. Applications that have no account mechanism are much simpler to modify. You may wish to add Hub support to such applications so that users must create accounts to perform certain actions, such as posting to a forum or blog that might otherwise be completely open to the public — and therefore completely open to spam.

Hub has three main components. The Hub application handles users creating accounts, logging in and out and managing their account settings, through an ActiveRecord database connection and the library gem. User information stored securely in the database while the gem is used to record details when a user logs in or discard those details when a user logs out. The gem does this by sending objects to or reading objects from the third component, a small distributed Ruby server. Running on Unix domain sockets, the server allows all Hub-integrated Rails applications to share information on a logged in user without needing secondary ActiveRecord connections to the Hub database or any detailed knowledge of Hub's user account model. Everything is hidden by the Hub library gem API.

The Hub core is very distantly based on the Acts as Authenticated shell.

Installation

Downloading

Presently, only a version of Hub with views styled for the RISC OS Open web site is available. In future I hope to add a more generic and more easily customised version.

The latest version of the Hub application is available at:

https://github.com/pond/hub

The latest version of the Hub gem source code is available at:

https://github.com/pond/hubssolib

The Hub library gem

Include in a project by adding this to your Gemfile:

gem 'hubssolib', '~> 3.0', require: 'hub_sso_lib'

The DRb server

The Hub DRb server consists of a small wrapper Ruby script which does most of its work using the Hub gem. To run the server, you need to first specify a DRb connection URI in the HUB_CONNECTION_URI environment variable. Usually, this is a Unix domain socket and so lives in a location of your choice in the local filesystem. Run the server by running ruby on the hub_sso_server.rb file in the Hub archive. For example:

HUB_CONNECTION_URI="drbunix:/home/username/sockets/.hub_drb"
export HUB_CONNECTION_URI
ruby /home/username/hubssolib/hub_sso_server.rb &

The default is to use a file .hub_drb in the root of the current user's home directory. If you specify a custom URI, note that it MUST start with drbunix:; the hub server must not be run on an IP port for security reasons.

The Hub application

Finally you can install the Hub application using whatever mechanism you prefer to application installation. See ample documentation elsewhere on the Web for information on installing Ruby On Rails applications — Hub itself contains the default rails README file with quite a lot of information in it.

Some configuration is needed using externally set environment variables. These are actually picked up by the Hub gem but you won't know what values to set until the application, DRb server and gem are all installed.

  • HUB_CONNECTION_URI — as already discussed, this holds a DRb URI giving the connection socket on which the server listens and to which clients connect; it defaults to ~/.hub_drb.
  • HUB_PATH_PREFIX — sometimes the Hub Gem redirects to various locations within the Hub application. If you have installed the application away from document root, specify the prefix to put onto redirection paths here (otherwise, provide an empty string). For example, when redirecting to the account controller's login method, the path used is HUB_PATH_PREFIX + '/account/login'.
  • HUB_BYPASS_SSL - normally Hub sets cookies as secure-only in Production mode, requiring https fetches. This isn't enforced in e.g. development mode. If you want to allow insecure transport in Production, set HUB_BYPASS_SSL to true.

Usually, these are set up in a Web server configuration file as part of launching an FCGI process to host the Hub application. Don't forget to set up the application's database.yml file in the usual fashion. use rake db:migrate to build the empty database structure.

Optional environment variables for configuration are:

  • HUB_IDLE_TIME_LIMIT - by default Hub applies a 4 hour session idle timeout. Override by setting this variable to a number in seconds. This must be set equally in the environment of all applications using Hub including Hub itself, since it is the "beforehand" callback that checks the idle timer; this can run at any time in any of your collection of Hub-integrated applications, depending on the part of your site with which the user next interacts.

Cookies and domains

For Hub to work, your domains must all match. If one application on local development is fetched by http://127.0.0.1 while another is on http://locahost or on something like http://lvh.me, an independent Hub session cookie will be generated by each application so things won't work; while Hub might think you're logged in, the integrating application will not.

A simple rule is to always use e.g. http://127.0.0.1:3000 for the Hub application and http://127.0.0.1:<other-port> for other applications. So, for Hub, use:

bundle exec rails s -b 127.0.0.1 --port 3000

...and then launch integrating applications with:

HUB_BYPASS_SSL="true" HUB_PATH_PREFIX="http://127.0.0.1:3000" be rails s -b 127.0.0.1 --port <other-port>

...and fetch http://127.0.0.1:<other-port> in your web browser.

Your application's session cookies

It is often a good idea to clear application cookies when Hub users log in or out, so that there is no stale session data hanging around. The Hub application auto-clears all cookies ending with the name app_session for this purpose. Therefore, your application might include a config/initializers/session_store.rb file that says something like this:

# Be sure to restart your server when you modify this file.
Rails.application.config.session_store :cookie_store, key: 'yourappname_app_session'

This of course only applies if you're using cookies for your session data.

Testing the installation

Visit your application in a Web browser and follow the links to sign up for a new account. To sign up, provide a name that will be displayed to users and a valid e-mail address. A confirmation message is sent to the address, containing a link that must be followed to activate the account. One created, users can log in and out of their accounts (with the possibility of sending a password reset request to their e-mail address in case they forget how to log in) and change their screen names. Users cannot change their recorded e-mail address — instead, they must create a new account under the new address.

As the first user of the Hub application, you test your installation by simply going through the sign-up process. The first account is automatically constructed with administrator privileges. If you are successfully able to visit the signup page, create your account, validate the signup using the confirmation e-mail message and subsequently log in or out of the new account, then Hub is correctly installed :-)

Administrative use of the Hub application

Administrative account users are presented with extra options in the Hub control panel when they log on. You can list currently logged on users, list all users and modify account settings for any user, including deleting their accounts. Accounts have a list of roles associated with them. Roles define whether or not a user has administrative privileges, webmaster privileges and so-on. When you integrate Hub with another application, you define exactly what these roles are because (as described below) you must assign lists of roles required to access protected controller actions.

Accounts can be assigned more than one role. Whether or not you ever want to do this will depend entirely on how you set up the roles required to access various controller actions as you integrate Hub with whichever applications you wish to work under the single sign-on mechanism.

Integrating with applications

For full integration with Hub, particularly when it comes to showing or hiding things in application views, you need to know some of the Hub programmer interface. This API is described in detail later. First, we need to consider basic application integration issues, mostly revolving around modifying the application controllers. For more information on the interfaces used by the examples show, consult the detailed API documentation further down.

Applications without an existing user model

Applications with no concept of user log-in are easy to integrate with Hub. Applications with only the concept of logging in for administrative purposes are similarly easy, provided your administrators do not mind having to log in using the application's own administrative mechanisms (so you basically treat the application as if it has no existing user model).

To integrate, add the Hub callbacks into application.rb just inside the definition of the ApplicationController class:

# Hub single sign-on support.

require 'hub_sso_lib'
include HubSsoLib::Core
before_action :hubssolib_beforehand
after_action :hubssolib_afterwards</pre>

Within any controller which has actions which you wish to protect with Hub login, define a constant HUBSSOLIB_PERMISSIONS and provide an accessor method for it. I'll deal with the accessor method first; in any controller for which Hub is to guard access, add the following:

def self.hubssolib_permissions
  HUBSSOLIB_PERMISSIONS
end

More details are provided below but, in brief, to define the permissions variable you create an instance of HubSsoLib::Permissions. The constructor is passed a hash. The hash keys are symbolized names of the controller actions you want to protect. The hash values are an array of privileges required to access the action, from a choice of one or more of :admin, :webmaster, :privileged and :normal. These relate to the roles you can assign to accounts as Hub administrator. For example:

HUBSSOLIB_PERMISSIONS = HubSsoLib::Permissions.new({
  show: [ :admin, :webmaster, :privileged, :normal ],
  edit: [ :admin, :webmaster ]
})

In this example, any user can access the controller's show action but only users with an administrator or webmaster role associated with their account can access the edit action.

A user's role(s) must match at least one of the privileges in the array for a given action — so even if your account has an administrator role (and only an administrator role), it won't be able to access a protected action unless :admin is included in the array given within the hash to the HubSsoLib::Permissions constructor. For example:

HUBSSOLIB_PERMISSIONS = HubSsoLib::Permissions.new({
  weblist: [ :webmaster, :privileged ]
})

Here, only accounts with the webmaster or privileged role associated can access the weblist action. If an account has only normal and/or administrative roles, it won't be allowed through.

Applications with an existing user model

General concerns

If you want to integrate Hub with an application which already has the concept of user accounts, logging in and logging out, there are two main approaches.

  • Remove the existing mechanism and replace with Hub (see above). Removal may be through actually deleting code, models and callbacks related to that mechanism or simply removing or blocking access to the parts of the application that deal with the users and dropping in Hub equivalents over a minimum amount of code, reducing overall changes to the application but leaving a less clean result.
  • Use a before_action in the application controller to run special code which you write, which maps a logged in Hub user to an existing application user. If the visitor is logged into Hub and no corresponding local application user account exists, one is created automatically based on the Hub account credentials.

Neither approach is problem-free and both require quite a lot of effort and testing. Automated testing is very hard because the modified application's behaviour depends upon logging in or out of Hub, which is running elsewhere. Unfortunately Rails doesn't offer a universally supported single sign-on mechanism so applications all use different approaches to user management; this means that there is no magic bullet to integration with Hub. You have to learn and understand the structure of the application being integrated and be prepared to make changes that are potentially quite extensive.

Synchronisation concerns, where applicable

Some applications might use local User records for relational purposes. Suppose users could author Posts. We could simply freeze the author name with a Post using a column in that table. This means the author name of any Post is easy to display. However, if that author changed their name, only new Posts would show the new name. If the application wanted to answer a question such as, "List all Posts by a given author", it would be relatively expensive. And if an application wanted to internally keep a record of author e-mail addresses for things like e-mailed notifications of some kind, it would get worse; the author might change their e-mail address in Hub and the integrated application would not know.

To solve these problems, external applications integrated with Hub can participate in the user change handler mechanism. The Hub-integrated external application calls HubSsoLib::Core#hubssolib_register_user_change_handler to register an interest in Hub user alterations. The mechanism invokes an application-defined Rake task, which can update user records however it sees fit. The method is given the application's name, it's Rails application root - the filesystem location of the application, since we need to use that as a current working directory to issue a bundle exec rake your:task:name... - and the name of the Rake task to run. Registration is usually done in config/application.rb - for example:

module Foo
  class Application < Rails::Application
    include HubSsoLib::Core

    hubssolib_register_user_change_handler(
      app_name: Rails.application.name,
      app_root: Rails.root,
      task_name: 'hub:update_user'
    )

    # ...etc...
  end
end

The Rake task can have any name that works for your application. A "hub" namespace is recommended but entirely optional. If a Hub user edits their details, then the task is invoked with four positional parameters - the user's old e-mail address and old Hub unique name, as returned by HubSsoLib::Core#hubssolib_unique_name - followed by the new e-mail address and new unique name (at least one, or both of those will always have actually changed). The Rake task can then look up the external application's User record via the old address or unique name and, if found, update it. It should either throw an exception or exit with a non-zero status code if it fails to store the updated details - in that case, Hub will roll back user changes on its side and warn the end user.

For example, lib/tasks/hub.rake might look like this:

namespace :hub do
  desc 'Update a user with details sent from Hub'
  task :update_user, [:old_email, :old_name, :new_email, :new_name] => :environment do | t, args |
    user = User.find_by_email_address(args[:old_email])

    # ...or use "user&.update_columns" for speed and to bypass validations, as
    # Hub's validations may not be as strict as yours but it doesn't matter if
    # you're happy syncing Hub data in general.
    #
    user&.update!(email_address: args[:new_email], display_name: args[:new_name])
  end
end

The four arguments are guaranteed to be present and non-empty, with leading or trailing white space already stripped. You don't need to waste time checking for nil or blank values. The upper/lower case is preserved, as entered by the user, for all arguments - so e-mail addresses in particular might contain a mixture of upper and lower case letters. If this matters to your application, be sure to apply whatever normalisation you previously used when your user record was originally created with the old Hub-sourced e-mail and/or name.

Session maintenance

User-idle session expiry is routinely handled in the "beforehand" callback so that we can see the session is valid but expired, expire it and add a via-flash warning about what happened so the user is fully informed. The Hub gem tries to be nice about POST, PATCH and PUT operations too and allows those to complete before the expected subsequent redirection GET causing expiry, to try and avoid users losing form submission data entirely.

Users who log in but then go idle will not cause any natural session expiry by virtue of there being no new page fetch activity provoking the idle timeout check. For users who've just gone away full stop, this means a session record is left hanging around in DRb server memory (or if future iterations support other storage methods, they'd persist in whatever storage that is). For this reason, the Hub application provides a Rake task which sweeps sessions based on things idle for three times the HUB_IDLE_TIME_LIMIT, or two days, whichever is longer (so short idle timers still give users a couple of days to potentially come back to their old session and get the "nice" expiry message). Run bundle exec rake hub_sessions:sweep whenever you want (in practice, once a day is enough). Be sure to set up any custom Hub environment variables for your setup so that the Rake task knows where to find the DRb socket for the running server, what your expiry time is if so customised, and so-on.

  • Note: The DRb server persists sessions upon graceful shutdown (INT or TERM signals) into file ~/.hub_ses_arc by default, or the filename in environment variable HUB_SESSION_ARCHIVE if defined. When the server restarts, it loads this data and runs the same very old session expiry algorithm. This means that shutting down and restarting the DRb server is another way to expire very old sessions, but that results in downtime, even if only brief; so for general maintenance with uptime maintained, the Rake task should be used.

Hub library API

The Hub component interfaces that should be used by application authors when integrating with the Hub single sign-on mechanism are described below. If you want a complete list of all public interfaces, consult the file hub_sso_lib.rb inside the Hub gem. All functions and classes therein are fully commented to describe the purpose of each class, along with the purpose, input parameters and return values of class methods and instance methods.

Roles

Every Hub user account has assigned to it one or more Roles. For day to day use, roles are managed using the Hub application front-end. Role names are defined as symbols. Defined names are:

  • :admin — Administrators.
  • :webmaster — The site Webmaster.
  • :privileged — Normal users with privileges for certain actions.
  • :normal — Normal users. This role is assigned to new accounts by default.

When setting access permissions for actions in controllers (see next section), you specify the permissions in terms of the role names above. This means that you really define the true meaning of each of the four roles by their use within controllers. It isn't necessary to use all four roles or, if you want to add more, you can extend the ROLES constant in the HubSsoLib::Roles class inside hub_sso_lib.rb in the Hub gem.

Permissions

Hub protects against access to actions in controller by using a before_action which checks to see if the controller defines a permissions structure. Permissions are defined as a hash, using action names as keys. The values define a role or roles permitted to access that action. Hub is based around the idea of a loose, permissive access mechanism. Actions omitted from the permissions hash are permitted for general public access by default. Conversely, if an action is included but a particular role is not associated with it, that role is denied access. You can therefore allow a normal user to access an action which an administrator cannot use, by simply including the normal role but omitting the administrator role for that action.

Permitted roles are expressed as single symbols or their equivalent strings, or an array containing many symbols or equivalent strings. Most often, an array of symbols is used. To create a permissions object, instantiate HubSsoLib::Permissions. For example:

HUBSSOLIB_PERMISSIONS = HubSsoLib::Permissions.new({
  show: [ :admin, :webmaster, :privileged, :normal ],
  edit: [ :admin, :webmaster ]
})

Here, all roles are allowed to access the show action while only the admin and webmaster roles can access the edit action. Any other actions are unprotected, so even users who are not logged into to Hub can access them, along with any logged in Hub user regardless of the roles associated with their account.

The above line of code typically appears at the start of the class definition for the controller to which you are restricting access.

class AccountController < ApplicationController

  HUBSSOLIB_PERMISSIONS = HubSsoLib::Permissions.new({
    # ...permissions here...
  })

  # ...existing class contents here...
end

Having created the permissions object, you need to expose constant HUBSSOLIB_PERMISSIONS to Hub in a way that it understands. To do this, create a class method called hubssolib_permissions that just returns the variable:

  def self.hubssolib_permissions
    HUBSSOLIB_PERMISSIONS
  end

So the full preamble in this example is:

class AccountController < ApplicationController

  HUBSSOLIB_PERMISSIONS = HubSsoLib::Permissions.new({
    ...permissions here...
  })

  def self.hubssolib_permissions
    HUBSSOLIB_PERMISSIONS
  end

  ...existing class contents here...
end

While you can ask a specific Permissions object whether or not an action is permitted for a given role or roles using the permitted? method (see later), a more general purpose interface to achieve the same thing is provided in the Core module (see below). Use of the Core interface is strongly recommended.

Core

The Hub Core module is usually included in application.rb as follows, just inside the ApplicationController class definition:

# Hub single sign-on support.

require 'hub_sso_lib'
include HubSsoLib::Core
before_action :hubssolib_beforehand
after_action :hubssolib_afterwards

All internal methods have the hubssolib_ prefix in an effort to avoid namespace collision with anything else in the including application.

The "before" action: hubssolib_beforehand

Before any action in a Hub integrated application, hubssolib_beforehand must be invoked. To achieve this, ensure that application.rb includes the method as a before_action, as listed above:

before_action :hubssolib_beforehand

This callback is the core of the Hub protection mechanism, making sure that no action can run unless the user is logged in (unless the action is completely protected) and their account is associated with at least one of the roles required to access the action.

The "after" action: hubssolib_afterwards

After any action in a Hub integrated application, hubssolib_afterward must be invoked. To achieve this, ensure that application.rb includes the method as a after_action, as listed above:

after_action :hubssolib_afterwards

Finding out about the current user

Most Hub integration methods are geared around making it easy to find out about a currently logged in user.

Is the user logged in?

Method hubssolib_logged_in? returns true if there is a Hub user presently logged in, else false.

What is the user's name?

Method hubssolib_get_user_name returns the display name of the currently logged in user as a string or nil if there is nobody logged in right now.

Does the user have a unique identifier?

Method hubssolib_get_user_id returns the Hub database ID of the currently logged in user or nil if there is nobody logged in right now. This numerical ID is unique but not human-readable; sometimes it is desirable to generate unique display names. For example, perhaps you are integrating with an application that has its own account scheme based on unique user names shown in views (e.g. a forum) and want to create application accounts transparently to map to Hub accounts on the fly. For such purposes, call hubssolib_unique_name. The method returns a unique string containing the Hub user display name followed by the user ID in brackets or nil if nobody is logged in.

What is the user's e-mail address?

Method hubssolib_get_user_address returns the e-mail address specified by the currently logged in user when they signed up to Hub or nil if nobody is logged in right now. Since account confirmation in the Hub application is conducted by e-mail, the address ought to be valid unless the user created a temporary account purely for the purpose of signing up (there is no way to tell if this is the case). The e-mail address should only be used for sending solicited messages and never be displayed in views, since such addresses can be harvested for spam and as a result displaying an e-mail address without prior permission can make some account holders quite angry.

Checking for user permissions

Although controller actions are protected automatically by Hub, you may wish to hide things from certain users, such as links to actions they cannot perform or information that should only be seen by users with a different role set.

Can the user access a particular action?

Method hubssolib_authorized? takes two parameters:

  1. The first is an action name, specified as a string or symbol. If calling from view or helper code you need to specify this every time, but if calling from a Controller you can omit it and the current action's name (the value of action_name) is used instead.
  2. The second parameter is the name of the controller class, in which the action you're checking resides. If calling from that controller then again, you can omit this parameter; the class' own name (the value of self.class) is used instead. Otherwise, give the name; the ClassName.hubssolib_permissions method is used to discover the current set of permissions for the action.

By looking at the set of permissions for the controller class you specified in the second parameter for the action you specified in the first parameter, the method determines the list of permitted roles. If the currently logged in user's account has at least one of the roles associated with it, the method returns true to indicate that access is allowed. Otherwise, or if there is no user currently logged in, the method returns false.

Is the user's account privileged?

Although the use of roles in the lists of permissions written for controllers actually defines what each role means in practice, it's usually best to consider normal users as the least privileged and everyone else as having a more privileged access status. Sometimes you just want normal users to only access a limited amount of information or actions while anybody else, with any more privileged roles associated with their account, can do more interesting (but potentially dangerous!) things. Primarily for use in views, the hubssolib_privileged? method returns true if the currently logged in user account has any roles other than just :normal, else false for :normal roles only, or if there is no user logged in right now.

To obtain an array of the current user's roles, call hubssolib_get_user_roles. This returns a HubSsoLib:Roles object which implements the following public methods:

  • get_role_symbols — returns an array of all valid role symbols (e.g. :admin and :normal.
  • get_display_name — when passed a role symbol, returns a human-readable role name in English. If you are using an internationalised application, you'd probably just look up the key in some part of your messages file with I18n.t instead.
  • get_display_names — returns an array of human-readable role names for all valid roles (see get_display_name above).
  • include? — when passed a role symbol, returns true if this Roles object includes that symbol, else false.
  • includes? — aliased to include?.
  • validate — integrity check — if this returns false, somehow an unrecognised role symbol got injected into the database for the Hub user for which this Roles object was generated, so it includes an unrecognised role. This is really a debugging function and shouldn't ever happen, but if you want to be paranoid, you could always check that any Roles object returned by Hub returns true when this method is called, indicating that it is valid.

As described above when talking about HubSsoLib::Permissions earlier, Hub defines roles :normal, :privileged, :webmaster and :admin. The significance attached to these depends entirely on your chosen use of them in access permissions to parts of your site.

Permissions for arbitrary user accounts

Although applications are normally concerned with the abilities of the currently logged in user, leading to simple Hub accessor methods such as hubssolib_authorized?, sometimes you may need more control. For example, you may want to check action permissions of an arbitrary user as part of some administrative interface.

The HubSsoLib::Permissions instance you define in your controllers (see earlier) is key to this. You can create an instance as discussed earlier — usually just calling your controller's own hubssolib_permissions class method when considering action permissions for the controller of interest is simplest. This has member method permitted? on this to find out if an action is allowed. Pass a Roles object in the first parameter and an action, expressed as a symbol (e.g. :create, :edit) in the second parameter. The method returns true if the permissions object allows the given action under the given Roles, else false.

In the example of "permissions for arbitrary user" you may well not have ready access to an initialised Roles object for that user, so you will probably have to build one. Use Roles.new(...) to create a new Roles object, passing it true to assign a single initial role of :admin or false to assign a single initial role of :normal. You can use the instance methods add and delete to add or delete roles to the object, specified as role symbols. Use clear to empty the object of all roles.

Visual feedback

When a user logs in with a traditional log-in system, there's usually some message shown on the page presented when log-in is successful. This is achieved through the Hub equivalent of the Rails flash hash. Replace your preferred mechanism for including contents of the flash hash into your views (usually via one or more layout files) with an equivalent which calls Hub's flash handling code, which aggregates both current application and cross-application flash content into one.

Just using <%= hubssolib_flash_tags -%> in your layout(s) and/or view(s) will output merged flash data. HTML is generated consisting of h2 tags with class names derived from the keys used in the flash tag. Each heading tag encapsulates the value for the key and is followed by an empty paragraph tag for spacing reasons on older browsers when more than one key is present, though normally there is only one. Hub itself commonly uses keys :notice ("green" / general information - e.g. "you are now logged in"), :attention ("orange" / something unusual happened - e.g. "your session timed out so you were logged out") and :alert ("red" / something bad happened - e.g. "incorrect password given").