Background
For a while now, I’m developing a sort of IoT controller with Rails 4. Until now, Devise was used to authenticate users locally using the Devise’s provided :database_authenticable module.
Things changed recently, and I had to move some features of this IoT controller toward AWS. Authentication against AWS/Cognito Identity is one part of the project.
“But Why?” you might wonder. Because we need to extend authentication to other products, using a common user database. Cognito fits the description and helps boost the development on the AWS ecosystem. In other words, we want to be able to use our IoT controller alongside with other new products without depending exclusively on the authentication part of the IoT controller.
This means that local user database will still be used for the application-related data like permissions, session, extended profile attributes or database relationship, but pure authentication happen exclusively on Cognito. I moved to AWS just the first A of AAA. This is a disputable choice, let’s call it team work.
So, the purpose of this post is to synthesize the procedure required in order to:
- authenticate against Cognito
- handle passwords manipulation and accounts validation
- migrate user data with a Lambda triggered by Cognito
- create new local users if they are not already existing
The following example should work well with recent Rails versions.
Dependencies
Obviously Rails and Devise are required, in addition to the following gem:
gem 'aws-sdk', '~> 3'
Rails 4/Devise adaptation
Environment variables will carry all the AWS specific access information to Rails. Depending on the setup, /.env file probably already exists in which we can add the two following Cognito Pool info (TLDR; Create a new App client without App client secret) :
AWS_COGNITO_USER_POOL_ID=eu-west-1_234567 AWS_COGNITO_CLIENT_ID=01234567890asdfghjkllqwertzuiop1234567890
where the variables values matches the AWS setup.
We’ll need a new a new Devise strategy, a safe location for this file is in /config/initializers/cognito_authenticable.rb :
require 'aws-sdk' require 'devise/strategies/authenticatable' module Devise module Strategies class CognitoAuthenticatable < Authenticatable def authenticate! if params[:user] client = Aws::CognitoIdentityProvider::Client.new begin resp = client.initiate_auth({ client_id: ENV["AWS_COGNITO_CLIENT_ID"], auth_flow: "USER_PASSWORD_AUTH", auth_parameters: { "USERNAME" => email, "PASSWORD" => password } }) if resp user = User.where(email: email).try(:first) if user success!(user) else user = User.create(email: email, password: password, password_confirmation: password) if user.valid? success!(user) else return fail(:failed_to_create_user) end end else return fail(:unknow_cognito_response) end rescue Aws::CognitoIdentityProvider::Errors::NotAuthorizedException => e return fail(:invalid_login) rescue return fail(:unknow_cognito_error) end end end def email params[:user][:email] end def password params[:user][:password] end end end end
This strategy will create a new local user if Cognito authenticated it, but the user doesn’t exist locally.
The we can configure Devise to use the strategy by giving it the class name in the file /config/initializers/devise.rb
Devise.setup do |config| # (...) config.warden do |manager| manager.strategies.add(:cognito, Devise::Strategies::CognitoAuthenticatable) manager.default_strategies(:scope => :user).unshift :cognito end # (...) end
Now Devise should authenticate against the Cognito user database.
The next step would be to allow password reset using the password recovery system of AWS Cognito.
class Users::PasswordsController < Devise::PasswordsController skip_before_action :assert_reset_token_passed def create raise ArgumentError, "Unexpected block given for requested action: #{params.inspect}" if block_given? begin client = Aws::CognitoIdentityProvider::Client.new resp = client.forgot_password({ client_id: ENV["AWS_COGNITO_CLIENT_ID"], username: params[:user][:email] }) session[:reset_password_email] = params[:user][:email] redirect_to edit_user_password_path rescue flash[:alert] = I18n.t("devise.errors.unknown_error") redirect_to new_user_password_path end end def edit gon.flash_notice = I18n.t("devise.notices.change_password_email") super end def update if params[:user][:password].blank? flash[:alert] = I18n.t("activerecord.errors.models.user.attributes.password.blank") redirect_to edit_user_password_path(reset_password_token: params[:user][:reset_password_token]) elsif params[:user][:password] != params[:user][:password_confirmation] flash[:alert] = I18n.t("activerecord.errors.models.user.attributes.password.mismatch") redirect_to edit_user_password_path(reset_password_token: params[:user][:reset_password_token]) elsif params[:user][:reset_password_token].blank? flash[:alert] = I18n.t("devise.errors.verification_code_missing") redirect_to edit_user_password_path(reset_password_token: params[:user][:reset_password_token]) elsif session[:reset_password_email].nil? flash[:alert] = I18n.t("devise.errors.verification_code_expired") redirect_to new_user_password_path else begin client = Aws::CognitoIdentityProvider::Client.new resp = client.confirm_forgot_password({ client_id: ENV["AWS_COGNITO_CLIENT_ID"], confirmation_code: params[:user][:reset_password_token], username: session[:reset_password_email], password: params[:user][:password] }) session.delete :reset_password_email redirect_to unauthenticated_root_path, notice: I18n.t("devise.notices.password_changed") rescue Aws::CognitoIdentityProvider::Errors::InvalidPasswordException => e flash[:alert] = e.to_s redirect_to edit_user_password_path(reset_password_token: params[:user][:reset_password_token]) rescue flash[:alert] = I18n.t("devise.errors.unknown_error") redirect_to edit_user_password_path(reset_password_token: params[:user][:reset_password_token]) end end end end
In this case, the sign up procedure is still handled by Rails on the local database (see below), therefore account confirmation (with Devise’s :confirmable module) is handled by Devise without any changes.
Migrating users from Rails to Cognito
We wanted to migrate a user from the Rails database to Cognito if the user isn’t already existing in the Cognito database. For that at least a new endpoint in config/routes.rb is required:
post '/aws/auth', to: 'users#aws_auth', defaults: {format: 'json'}, as: 'aws_auth'
For this route it is supposed that controllers/users_controller.rb have a aws_auth method:
class UsersController < ApplicationController # skip_filter other access restrictions... before_filter :restrict_access, only: [:aws_auth] # (...) def aws_auth defaults = { id: nil, first_name: nil, last_name: nil, email: nil, authentication_hash: nil } user = User.where(email: aws_auth_params[:email]).first if user answer = user.as_json(only: defaults.keys) answer[:user_exists] = true answer[:success] = user.valid_password?(aws_auth_params[:password]) else answer = defaults answer[:success] = false answer[:user_exists] = false end respond_to do |format| format.json { render json: answer } end end # (...) private def restrict_access head :unauthorized unless params[:access_token] == TOKEN_AUTH_OF_YOUR_CHOICE end end
That’s all for the Rails side, now in Cognito Pool of the AWS console there is a Trigger, in which lambdas can be attached. The User Migration trigger can be set to the following JavaScript lambda:
'use strict'; console.log('Loading function'); const https = require('https'); const attributes = (response) => { return { "email": response.email, "email_verified": "true", "name": response.first_name + " " + response.last_name, "custom:rails_app_id": response.id }; }; const checkUser = (server, data, callback) => { let postData = JSON.stringify( data ); let options = { hostname: server, port: 443, path: "/aws/auth", method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': postData.length } }; let req = https.request(options, (res) => { let data = ""; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if ( data ){ let response = JSON.parse( data ); console.log( 'response:', JSON.stringify(response, null, 2) ); callback( null, response); } else { callback( "Authentication error"); } }); }); req.on('error', (e) => { callback( e ); }); req.write( postData ); req.end(); } exports.handler = (event, context, callback) => { console.log('Migrating user:', event.userName); let rails_server_url = process.env.rails_server_url || "rails.app.your_company.com"; checkUser( rails_server_url, { email: event.userName, password: event.request && event.request.password, access_token: process.env.rails_server_access_token }, (err, response ) => { if ( err ){ return context.fail("Connection error"); } if ( event.triggerSource == "UserMigration_Authentication" ) { // authenticate the user with your existing user directory service if ( response.success ) { event.response.userAttributes = attributes( response ) ; event.response.finalUserStatus = "CONFIRMED"; event.response.messageAction = "SUPPRESS"; console.log('Migrating user:', event.userName); context.succeed(event); } else if ( response.user_exists ) { context.fail("Bad password"); } else { context.fail("Bad user"); } } else if ( event.triggerSource == "UserMigration_ForgotPassword" ) { if ( response.user_exists ) { event.response.userAttributes = attributes( response ) ; event.response.messageAction = "SUPPRESS"; console.log('Migrating user with password reset:', event.userName); context.succeed(event); } else { context.fail("Bad user"); } } else { context.fail("Bad triggerSource " + event.triggerSource); } }); };
This scripts use the two following environment variables, which should be set with the proper information:
rails_server_url=URL rails_server_access_token=TOKEN_AUTH_OF_YOUR_CHOICE
Conclusion
It’s possible.