From fca01bb7db1762f3eb2c10909e6c35055256ba01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Federico=20M=2E=20Zagarzaz=C3=BA?= Date: Sat, 9 Mar 2013 14:28:49 -0200 Subject: [PATCH 1/6] Add support for RESTful JSON APIs access tokens and OAuth2.0 For Login This commit adds a new submodule feature 'access_token', it is intended for RESTful JSON APIs, it allows rich client-side applications to use access tokens to communicate with the backend server. It also adds support for OAuth 2.0 For Login - Implicit Grant Flow -. --- README.rdoc | 181 +++++++++++++++ lib/generators/sorcery/install_generator.rb | 15 ++ .../sorcery/templates/initializer.rb | 36 +++ .../templates/migration/access_token.rb | 16 ++ .../sorcery/templates/models/access_token.rb | 142 ++++++++++++ lib/sorcery.rb | 4 +- lib/sorcery/controller.rb | 42 +++- .../controller/submodules/access_token.rb | 93 ++++++++ lib/sorcery/controller/submodules/external.rb | 58 ++++- .../submodules/external/providers/facebook.rb | 16 +- .../submodules/external/providers/github.rb | 13 +- .../submodules/external/providers/google.rb | 14 +- .../submodules/external/providers/liveid.rb | 13 +- .../submodules/external/providers/twitter.rb | 5 + .../submodules/external/providers/vk.rb | 15 +- lib/sorcery/engine.rb | 10 +- lib/sorcery/model/adapters/active_record.rb | 4 - lib/sorcery/model/submodules/access_token.rb | 100 ++++++++ lib/sorcery/test_helpers/internal.rb | 10 +- .../app/controllers/application_controller.rb | 47 +++- spec/rails3/app/models/access_token.rb | 141 ++++++++++++ spec/rails3/app/models/authentication.rb | 3 +- spec/rails3/app/models/user.rb | 3 +- .../20130220134306_create_access_tokens.rb | 16 ++ .../spec/controller_access_token_spec.rb | 200 ++++++++++++++++ spec/rails3/spec/controller_oauth2_spec.rb | 75 +++++- spec/rails3/spec/user_access_token_spec.rb | 15 ++ spec/rails3_mongo_mapper/Gemfile.lock | 4 +- .../app/models/access_token.rb | 148 ++++++++++++ spec/rails3_mongo_mapper/app/models/user.rb | 1 + .../spec/user_access_token_spec.rb | 8 + spec/rails3_mongoid/Gemfile.lock | 4 +- .../rails3_mongoid/app/models/access_token.rb | 149 ++++++++++++ spec/rails3_mongoid/app/models/user.rb | 1 + .../spec/user_access_token_spec.rb | 8 + .../user_access_token_shared_examples.rb | 216 ++++++++++++++++++ 36 files changed, 1769 insertions(+), 57 deletions(-) create mode 100644 lib/generators/sorcery/templates/migration/access_token.rb create mode 100644 lib/generators/sorcery/templates/models/access_token.rb create mode 100644 lib/sorcery/controller/submodules/access_token.rb create mode 100644 lib/sorcery/model/submodules/access_token.rb create mode 100644 spec/rails3/app/models/access_token.rb create mode 100644 spec/rails3/db/migrate/access_token/20130220134306_create_access_tokens.rb create mode 100644 spec/rails3/spec/controller_access_token_spec.rb create mode 100644 spec/rails3/spec/user_access_token_spec.rb create mode 100644 spec/rails3_mongo_mapper/app/models/access_token.rb create mode 100644 spec/rails3_mongo_mapper/spec/user_access_token_spec.rb create mode 100644 spec/rails3_mongoid/app/models/access_token.rb create mode 100644 spec/rails3_mongoid/spec/user_access_token_spec.rb create mode 100644 spec/shared_examples/user_access_token_shared_examples.rb diff --git a/README.rdoc b/README.rdoc index eb923e5f..e0436a6b 100644 --- a/README.rdoc +++ b/README.rdoc @@ -190,6 +190,187 @@ External (see lib/sorcery/controller/submodules/external.rb): * OAuth1 and OAuth2 support (currently twitter & facebook) * configurable db field names and authentications table. +AccessToken (see lib/sorcery/model/submodules/access_token.rb) +* OAuth 2.0 For Login - RESTful JSON APIs. +* Please read instructions below. + +== AccessToken Submodule (rails-api) + +This submodule is intended to be used with rails as the backend server to +rich client-side applications. + +See the rails-api project for building RESTful JSON APIs. + +=== Supported Modes: + +* *single_token*: One access token per user, shared between all user clients. Creates an access token on user creation. + +* *session*: Allows multiple tokens per user, a user can have many clients acting on its behalf, with this mode each client can have its own access token. A maximum value can be defined using the 'max_per_user' configuration option. + +=== Expiration: + +Tokens expiration can be configured by setting the 'duration' value (in seconds), +this value will be used against token's creation time to know if the token has expired. + +Expiration can also be evaluated against token's last actvity time by setting +'duration_from_last_activity' to true. + +Expired tokens will be automatically deleted for each user after login. + +Tokens are deleted on client logout. + +==== Permanent Tokens: + +All tokens are set to expire by default, permanent tokens can be created by setting +its 'expirable' attribute to false. + +Example use case: mobile applications where the user never logs in/out. + +=== Security Considerations: + +* Use of TLS is *required* (HTTPS). + +=== OAuth 2.0 For Login: + +Outsources user authentication to OAuth 2.0 providers. + +Flow: Implicit Grant [1] + +==== How does it work? + +Client-side application gets an access token from an OAuth 2.0 provider, +validates the received token, and sends the token with the rest of its +properties to this API server for 'login'. + +The API server then attempts to login the user by sending a request to the +provider with the access token, if the access token is valid the API server +uses the user identifier included in the provider's response to find +and login (and optionaly create) the external user in the local database. + +Please see links below for more information, in particular Ryan Boyd's +excellent talk about OAuth 2.0 [2] and Google's OAuth2 playground +(use the settings button to change the OAuth flow to Client-side, +you can also enter your own OAuth credentials) [4]. + +==== Notes: +- After login, the API server can then return one of its own api_access_token to the client application. +- Tested with the following providers: Google. +- The client-side application only needs to store the OAuth Client ID, the secret can be set in the API server configuration file. + +==== References: + +1. {https://tools.ietf.org/html/draft-ietf-oauth-v2-30#section-1.3.2}[https://tools.ietf.org/html/draft-ietf-oauth-v2-30#section-1.3.2] +2. {Google I/O 2012 - OAuth 2.0 for Identity and Data Access}[http://www.youtube.com/watch?v=YLHyeSuBspI] +3. {https://developers.google.com/accounts/docs/OAuth2}[https://developers.google.com/accounts/docs/OAuth2] +4. {https://developers.google.com/oauthplayground}[https://developers.google.com/oauthplayground] + + +=== Setup Example (OAuth 2.0 For Login): + +* Installation + rails generate sorcery:install access_token external + +* Example configuration file config/initializers/sorcery.rb + Rails.application.config.sorcery.submodules = [:access_token, :external] + config.restful_json_api = true + config.external_providers = [:google] + config.google.key = "client_id" + config.google.secret = "client_secret" + config.user_config do |user| + user.username_attribute_names = [:email] + user.access_token_mode = 'single_token' + user.access_token_duration = 10.minutes.to_i + user.access_token_duration_from_last_activity = true + user.access_token_register_last_activity = true + end + +* Example user model: + + + class User < ActiveRecord::Base + authenticates_with_sorcery! do |config| + config.authentications_class = Authentication + end + attr_accessible :email, :password, :password_confirmation, :authentications_attributes + has_many :access_tokens, :dependent => :delete_all + has_many :authentications, :dependent => :destroy + + accepts_nested_attributes_for :authentications + + end + +* Example authentication model + + class Authentication < ActiveRecord::Base + attr_accessible :user_id, :provider, :uid + belongs_to :user + end + + +* Example sessions controller + + + class SessionsController < ApplicationController + skip_before_filter :require_login + + def create + # Login + if params[:provider] && params[:access_token_hash] + login_or_create_from_client_side(params[:provider], params[:access_token_hash]) + elsif params[:email] && params[:password] + login(params[:email], params[:password]) + end + + # Response + if @api_access_token + render :json => {:access_token => @api_access_token.token } + else + head :unauthorized + end + end + + def destroy + logout + head :ok + end + end + +* Example application controller + + class ApplicationController < ActionController::API + before_filter :require_login + end + + +=== Notes: + +* It should be compatible with the following submodules: + activity_logging, brute_force_protection, external, reset_password, user_activation + +* curl examples: + + # Examples, remember to use HTTPS. + + # GET request + curl -v -H "Accept: application/json" -H "Content-type: application/json" \ + -X GET http://localhost:3000/apples?access_token=DdQoWMGzsVdPAL5egq67 + + # POST login request with email and password credentials + curl -v -H "Accept: application/json" -H "Content-type: application/json" \ + -X POST -d '{"email": "johndoe@example.com", "password": "secret_passwd"}' http://localhost:3000/sessions + + # POST login request with provider's access token + curl -v -H "Accept: application/json" -H "Content-type: application/json" \ + -X POST -d '{"provider": "google", "access_token_hash": { \ + "access_token": "9d2H0I9AEa834B45cRTfBEcA82bF010B53BFGT83eFDB6097" \ + "token_type": "Bearer", "expires_in": 3600 } }' http://localhost:3000/sessions + +* Mongoid and MongoMapper + +Support included but not completely tested, please review code before using it. + +* Code review, feedback and contributions much appreciated. + == Next Planned Features: diff --git a/lib/generators/sorcery/install_generator.rb b/lib/generators/sorcery/install_generator.rb index 968e92d3..8439dbda 100644 --- a/lib/generators/sorcery/install_generator.rb +++ b/lib/generators/sorcery/install_generator.rb @@ -37,6 +37,11 @@ def configure_initializer_file generate "model #{model_class_name} --skip-migration" insert_into_file "app/models/#{model_class_name.underscore}.rb", " authenticates_with_sorcery!\n", :after => "class #{model_class_name} < ActiveRecord::Base\n" end + + if submodules && submodules.include?("access_token") + generate_access_token_model + end + end # Copy the migrations files to db/migrate folder @@ -72,6 +77,16 @@ def self.next_migration_number(dirname) def model_class_name options[:model] ? options[:model].classify : "User" end + + def generate_access_token_model + access_token_class_name = 'AccessToken' + access_token_model_file = "app/models/#{access_token_class_name.underscore}.rb" + template "models/access_token.rb", access_token_model_file + + insert_into_file("app/models/#{model_class_name.underscore}.rb", + "\n has_many :access_tokens, :dependent => :delete_all\n", + :after => " authenticates_with_sorcery!") + end end end end diff --git a/lib/generators/sorcery/templates/initializer.rb b/lib/generators/sorcery/templates/initializer.rb index 37851573..328a7b49 100644 --- a/lib/generators/sorcery/templates/initializer.rb +++ b/lib/generators/sorcery/templates/initializer.rb @@ -26,6 +26,10 @@ # # config.cookie_domain = + # -- RESTful JSON API ( access_token ) -- + # (required by access_token submodule) + # + # config.restful_json_api = false # -- session timeout -- # How long in seconds to keep the session alive. @@ -419,6 +423,38 @@ # Default: `:uid` # # user.provider_uid_attribute_name = + + # -- access_token -- + # NOTE requires config.restful_json_api to be true + # + + # Set access token mode: + # - 'single_token' : A single access token per user, shared between all + # user clients, a new access token is generated on each login + # iff previous expired. + # - 'session' : Allows multiple access tokens per user, + # deletes user expired access tokens on each login. + # + # user.access_token_mode = 'single_token' + + # Set access token duration (in seconds), if present this value is used + # to evaluate token expiration. + # + # user.access_token_duration = nil + + # Set access token expiration to be evaluated against token last activity time + # + # user.access_token_duration_from_last_activity = false + + # Set max number of access token allowed per user for 'session' mode, + # past this value, on each login the returned access token is a valid + # stored token. + # + # user.access_token_max_per_user = nil + + # Always register last activity time of token + # + # user.access_token_register_last_activity = false end # This line must come after the 'user config' block. diff --git a/lib/generators/sorcery/templates/migration/access_token.rb b/lib/generators/sorcery/templates/migration/access_token.rb new file mode 100644 index 00000000..9c3da9ad --- /dev/null +++ b/lib/generators/sorcery/templates/migration/access_token.rb @@ -0,0 +1,16 @@ +class SorceryAccessToken < ActiveRecord::Migration + def self.up + create_table :access_tokens do |t| + t.string :token, :default => nil + t.boolean :expirable, :default => true + t.datetime :last_activity_at + t.references :<%= model_class_name.downcase %> + + t.timestamps + end + end + + def self.down + drop_table :access_tokens + end +end diff --git a/lib/generators/sorcery/templates/models/access_token.rb b/lib/generators/sorcery/templates/models/access_token.rb new file mode 100644 index 00000000..7ebdba60 --- /dev/null +++ b/lib/generators/sorcery/templates/models/access_token.rb @@ -0,0 +1,142 @@ +<% user = model_class_name.downcase -%> +class AccessToken < ActiveRecord::Base + belongs_to :<%= user %> + validates :<%= user %>_id, :presence => true + + before_create :generate_token, :update_last_activity_time + + # + # Class methods + # + + # Class method to delete expired access tokens, it can receive an optional + # 'user_id' parameter to apply the deletion only to the given user. + def self.delete_expired(user_id = nil) + tokens = self.scoped rescue self # <- mongo_mapper does not have anonymous scope + if self.sorcery_config.access_token_duration + due_date = Time.zone.now - self.sorcery_config.access_token_duration.to_i + tokens = tokens.with_<%= user %>_id(user_id) if user_id + + if self.sorcery_config.access_token_duration_from_last_activity + tokens = tokens.with_last_activity_at_less_than(due_date) + else + tokens = tokens.with_created_at_less_than(due_date) + end + + tokens = tokens.with_expirable(true) + + if ! tokens.empty? + if defined?(MongoMapper) && self.ancestors.include?(MongoMapper::Document) + tokens.each {|t| t.delete } # ... + else + tokens.delete_all + end + end + end + end + + # Expose sorcery config (User model) + def self.sorcery_config + User.sorcery_config + end + + ## + # Finders + # + + def self.find_token(token) + self.query_adapter(:token, '=', token).first + end + + ## + # Scopes + # + + def self.with_<%= user %>_id(user_id) + self.query_adapter(:user_id, '=', user_id) + end + + def self.with_last_activity_at_less_than(due_date) + self.query_adapter(:last_activity_at, '<', due_date) + end + + def self.with_created_at_less_than(due_date) + self.query_adapter(:created_at, '<', due_date) + end + + def self.with_expirable(bool) + self.query_adapter(:expirable, '=', bool) + end + + # Auxiliary class method, query adapter + def self.query_adapter(attr, comparison_operator, value) + if (defined?(Mongoid) && self.ancestors.include?(Mongoid::Document)) || + (defined?(MongoMapper) && self.ancestors.include?(MongoMapper::Document)) + + case comparison_operator + when '=' + self.where(attr => value) + when '<' + self.where(attr.lt => value) + else + nil + end + + else + case comparison_operator + when '=' + self.where(attr => value) + when '<' + self.where("#{attr.to_s} < ?", value) + else + nil + end + end + end + + # + # Instance methods + # + + # Overwrite ActiveRecord valid? method to support auth context, + # returns true if token is valid for authentication. + def valid?(context = nil) + if context == :auth + ! expired? + else + super(context) + end + end + + # Returns true if token has expired + def expired? + if self.expirable && sorcery_config.access_token_duration + due_date = Time.zone.now - sorcery_config.access_token_duration.to_i + if sorcery_config.access_token_duration_from_last_activity + self.last_activity_at < due_date + else + self.created_at < due_date + end + else + false + end + end + + def sorcery_config + self.class.sorcery_config + end + + def update_last_activity_time + self.last_activity_at = Time.zone.now + end + + private + + # Generate random token + def generate_token + begin + self.token = TemporaryToken.generate_random_token + end while self.class.find_token(self.token) + end + +end diff --git a/lib/sorcery.rb b/lib/sorcery.rb index 47fd9312..357a08b0 100644 --- a/lib/sorcery.rb +++ b/lib/sorcery.rb @@ -14,6 +14,7 @@ module Submodules autoload :ActivityLogging, 'sorcery/model/submodules/activity_logging' autoload :BruteForceProtection, 'sorcery/model/submodules/brute_force_protection' autoload :External, 'sorcery/model/submodules/external' + autoload :AccessToken, 'sorcery/model/submodules/access_token' end end autoload :Controller, 'sorcery/controller' @@ -25,6 +26,7 @@ module Submodules autoload :HttpBasicAuth, 'sorcery/controller/submodules/http_basic_auth' autoload :ActivityLogging, 'sorcery/controller/submodules/activity_logging' autoload :External, 'sorcery/controller/submodules/external' + autoload :AccessToken, 'sorcery/controller/submodules/access_token' module External module Protocols autoload :Oauth1, 'sorcery/controller/submodules/external/protocols/oauth1' @@ -81,4 +83,4 @@ module Internal end require 'sorcery/engine' if defined?(Rails) && Rails::VERSION::MAJOR >= 3 -end \ No newline at end of file +end diff --git a/lib/sorcery/controller.rb b/lib/sorcery/controller.rb index 695ef5a9..919b1f35 100644 --- a/lib/sorcery/controller.rb +++ b/lib/sorcery/controller.rb @@ -21,7 +21,11 @@ module InstanceMethods # If all attempts to auto-login fail, the failure callback will be called. def require_login if !logged_in? - session[:return_to_url] = request.url if Config.save_return_to_url && request.get? + if ! Config.restful_json_api + if Config.save_return_to_url && request.get? + session[:return_to_url] = request.url + end + end self.send(Config.not_authenticated_action) end end @@ -32,12 +36,14 @@ def login(*credentials) @current_user = nil user = user_class.authenticate(*credentials) if user - old_session = session.dup.to_hash - reset_session # protect from session fixation attacks - old_session.each_pair do |k,v| - session[k.to_sym] = v + if ! Config.restful_json_api + old_session = session.dup.to_hash + reset_session # protect from session fixation attacks + old_session.each_pair do |k,v| + session[k.to_sym] = v + end end - form_authenticity_token + form_authenticity_token unless Config.restful_json_api auto_login(user) after_login!(user, credentials) @@ -52,7 +58,7 @@ def login(*credentials) def logout if logged_in? before_logout!(current_user) - reset_session + reset_session unless Config.restful_json_api after_logout! @current_user = nil end @@ -65,7 +71,11 @@ def logged_in? # attempts to auto-login from the sources defined (session, basic_auth, cookie, etc.) # returns the logged in user if found, false if not (using old restful-authentication trick, nil != false). def current_user - @current_user ||= login_from_session || login_from_other_sources unless @current_user == false + if Config.restful_json_api + @current_user ||= login_from_other_sources + else + @current_user ||= login_from_session || login_from_other_sources unless @current_user == false + end end def current_user=(user) @@ -83,7 +93,11 @@ def redirect_back_or_to(url, flash_hash = {}) # You can override this method in your controllers, # or provide a different method in the configuration. def not_authenticated - redirect_to root_path + if Config.restful_json_api + head :unauthorized + else + redirect_to root_path + end end # login a user instance @@ -91,13 +105,13 @@ def not_authenticated # @param [] user the user instance. # @return - do not depend on the return value. def auto_login(user) - session[:user_id] = user.id + session[:user_id] = user.id unless Config.restful_json_api @current_user = user end # Overwrite Rails' handle unverified request def handle_unverified_request - cookies[:remember_me_token] = nil + cookies[:remember_me_token] = nil unless Config.restful_json_api @current_user = nil super # call the default behaviour which resets the session end @@ -155,6 +169,9 @@ class << self :cookie_domain, # set domain option for cookies + :restful_json_api, # RESTful JSON API, + # does not use session and cookies, + # use access tokens to allow / deny requests :login_sources, :after_login, :after_failed_login, @@ -172,7 +189,8 @@ def init! :@before_logout => [], :@after_logout => [], :@save_return_to_url => true, - :@cookie_domain => nil + :@cookie_domain => nil, + :@restful_json_api => false } end diff --git a/lib/sorcery/controller/submodules/access_token.rb b/lib/sorcery/controller/submodules/access_token.rb new file mode 100644 index 00000000..e02bd8fa --- /dev/null +++ b/lib/sorcery/controller/submodules/access_token.rb @@ -0,0 +1,93 @@ +module Sorcery + module Controller + module Submodules + # + # Access Token submodule + # + # Registers an alternative login source which is used to handle + # the user's client requests by checking the validity of the api_access_token. + # + # It also register two methods to be run after user login/logout to + # handle the creation and deletion of api_access_tokens. + # + # See Sorcery::Model::Submodules::AccessToken for configuration options + # + module AccessToken + def self.included(base) + base.send(:include, InstanceMethods) + Config.module_eval do + class << self + def merge_access_token_defaults! + @defaults.merge!(:@restful_json_api => true) + end + end + merge_access_token_defaults! + end + + Config.login_sources << :login_from_access_token + Config.after_login << :set_access_token + Config.after_logout << :destroy_access_token + end + + module InstanceMethods + + def auto_login(user, create_token = false) + @current_user = user + set_access_token(user) if create_token + end + + protected + + # Allow client request iff its access_token is valid, + # update token last_activity_at (if feature is enabled) + def login_from_access_token + @api_access_token = nil + client_token = params[:access_token].to_s + access_token = ::AccessToken.find_token(client_token) + if access_token && access_token.valid?(:auth) + update_token_last_activity_time(access_token) + @api_access_token = access_token.reload + @current_user = access_token.user + else + @current_user = false + end + end + + # Update access token last_activity_at to current time iff 'duration' + # and 'duration_from_last_activity' are both enabled + def update_token_last_activity_time(access_token) + config = user_class.sorcery_config + if (config.access_token_register_last_activity || + (config.access_token_duration && + config.access_token_duration_from_last_activity)) + + access_token.update_last_activity_time + access_token.save! + end + end + + # Set an access_token for client after successful login, + # attempts to create a new token first, if max number of allowed + # tokens has been reached it assigns one of the stored tokens. + # This method deletes user invalid access tokens as side effect. + def set_access_token(user, credentials = nil) + @api_access_token ||= user.create_access_token! + @api_access_token ||= user.reload.access_tokens.last + !!@api_access_token + end + + # Destroy access token after client logout + def destroy_access_token + if @api_access_token.delete + @api_access_token = nil + true + else + false + end + end + + end + end + end + end +end diff --git a/lib/sorcery/controller/submodules/external.rb b/lib/sorcery/controller/submodules/external.rb index 16f80551..8c495803 100644 --- a/lib/sorcery/controller/submodules/external.rb +++ b/lib/sorcery/controller/submodules/external.rb @@ -63,6 +63,60 @@ def login_from(provider_name) end end + # Login external user with access token obtained from an authorization + # server by the client-side application. + # + # Requirements: OAuth 2.0 Protocol. (Implicit Grant) + # + # Params: + # +provider_name+:: name of provider. + # +access_token_hash+:: access token properties from client-side app. + # + def login_from_client_side(provider_name, access_token_hash) + provider = Config.send(provider_name) + + return nil if provider.oauth_version == '1.0' + if ! ( access_token_hash.key?(:access_token) || + access_token_hash.key?('access_token') ) + + raise 'Missing access_token parameter in properties hash' + end + + + client_options = provider.client_options + provider_client = provider.build_client(client_options) + provider.access_token = ::OAuth2::AccessToken.from_hash(provider_client, + access_token_hash) + + user_hash = provider.get_user_hash rescue nil # bad token + if user_hash + user = user_class.load_from_provider(provider_name, user_hash[:uid].to_s) + if user + auto_login(user) + after_login!(user) + user + end + else + nil + end + end + + # Login external user from access token, + # create user if it doesn't exist in database. + def login_or_create_from_client_side(provider_name, access_token_hash) + user = login_from_client_side(provider_name, access_token_hash) + if ! user + provider = Config.send(provider_name) + user_hash = provider.get_user_hash rescue nil + if user_hash && !!user_hash[:uid] + user = create_from(provider_name, user_hash) + auto_login(user) + after_login!(user) + end + end + user + end + # get provider access account def access_token(provider_name) provider = Config.send(provider_name) @@ -126,10 +180,10 @@ def create_and_validate_from(provider_name) # # create_from(provider) {|user| user.some_check } # - def create_from(provider_name) + def create_from(provider_name, user_hash = nil) provider_name = provider_name.to_sym provider = Config.send(provider_name) - user_hash = provider.get_user_hash + user_hash ||= provider.get_user_hash config = user_class.sorcery_config attrs = user_attrs(provider.user_info_mapping, user_hash) diff --git a/lib/sorcery/controller/submodules/external/providers/facebook.rb b/lib/sorcery/controller/submodules/external/providers/facebook.rb index 75b7a500..09f6f318 100644 --- a/lib/sorcery/controller/submodules/external/providers/facebook.rb +++ b/lib/sorcery/controller/submodules/external/providers/facebook.rb @@ -36,8 +36,8 @@ class << self :scope, :user_info_mapping, :display, - :access_permissions - attr_reader :access_token + :access_permissions, + :access_token include Protocols::Oauth2 @@ -80,10 +80,20 @@ def authorize_url # tries to login the user from access token def process_callback(params,session) args = {} - options = { :token_url => @token_url, :mode => @mode, :param_name => @param_name, :parse => @parse } + options = client_options args.merge!({:code => params[:code]}) if params[:code] @access_token = self.get_access_token(args, options) end + + # Returns options for building the client. + def client_options + return { + :token_url => @token_url, + :mode => @mode, + :param_name => @param_name, + :parse => @parse + } + end end init diff --git a/lib/sorcery/controller/submodules/external/providers/github.rb b/lib/sorcery/controller/submodules/external/providers/github.rb index dd6419ec..212bba5b 100644 --- a/lib/sorcery/controller/submodules/external/providers/github.rb +++ b/lib/sorcery/controller/submodules/external/providers/github.rb @@ -36,8 +36,8 @@ class << self :site, :scope, :user_info_path, - :user_info_mapping - attr_reader :access_token + :user_info_mapping, + :access_token include Protocols::Oauth2 @@ -72,11 +72,16 @@ def login_url(params,session) def process_callback(params,session) args = {} args.merge!({:code => params[:code]}) if params[:code] - options = { + options = client_options + @access_token = self.get_access_token(args, options) + end + + # Returns options for building the client. + def client_options + return { :token_url => @token_path, :token_method => :post } - @access_token = self.get_access_token(args, options) end end diff --git a/lib/sorcery/controller/submodules/external/providers/google.rb b/lib/sorcery/controller/submodules/external/providers/google.rb index 4acb4ff7..dd5f3a14 100644 --- a/lib/sorcery/controller/submodules/external/providers/google.rb +++ b/lib/sorcery/controller/submodules/external/providers/google.rb @@ -36,8 +36,8 @@ class << self :token_path, :user_info_url, :scope, - :user_info_mapping - attr_reader :access_token + :user_info_mapping, + :access_token include Protocols::Oauth2 @@ -72,12 +72,18 @@ def login_url(params,session) def process_callback(params,session) args = {} args.merge!({:code => params[:code]}) if params[:code] - options = { + options = client_options + @access_token = self.get_access_token(args, options) + end + + # Returns options for building the client. + def client_options + return { :token_url => @token_url, :token_method => :post } - @access_token = self.get_access_token(args, options) end + end init end diff --git a/lib/sorcery/controller/submodules/external/providers/liveid.rb b/lib/sorcery/controller/submodules/external/providers/liveid.rb index 79f9ce52..feeaa457 100644 --- a/lib/sorcery/controller/submodules/external/providers/liveid.rb +++ b/lib/sorcery/controller/submodules/external/providers/liveid.rb @@ -36,8 +36,8 @@ class << self :token_path, :user_info_url, :scope, - :user_info_mapping - attr_reader :access_token + :user_info_mapping, + :access_token include Protocols::Oauth2 @@ -73,11 +73,16 @@ def login_url(params,session) def process_callback(params,session) args = {} args.merge!({:code => params[:code]}) if params[:code] - options = { + options = client_options + @access_token = self.get_access_token(args, options) + end + + # Returns options for building the client. + def client_options + return { :access_token_path => @token_path, :access_token_method => :post } - @access_token = self.get_access_token(args, options) end end init diff --git a/lib/sorcery/controller/submodules/external/providers/twitter.rb b/lib/sorcery/controller/submodules/external/providers/twitter.rb index 7ff92fb2..56a3c1b9 100644 --- a/lib/sorcery/controller/submodules/external/providers/twitter.rb +++ b/lib/sorcery/controller/submodules/external/providers/twitter.rb @@ -81,6 +81,11 @@ def process_callback(params,session) @access_token = self.get_access_token(args) end + # Returns options for building the client. + def client_options + return {} + end + end init end diff --git a/lib/sorcery/controller/submodules/external/providers/vk.rb b/lib/sorcery/controller/submodules/external/providers/vk.rb index f0ca3ed3..bdcc9b49 100644 --- a/lib/sorcery/controller/submodules/external/providers/vk.rb +++ b/lib/sorcery/controller/submodules/external/providers/vk.rb @@ -34,8 +34,8 @@ class << self :auth_path, :token_path, :site, - :user_info_mapping - attr_reader :access_token + :user_info_mapping, + :access_token include Protocols::Oauth2 @@ -80,11 +80,16 @@ def login_url(params,session) def process_callback(params,session) args = {} args.merge!({:code => params[:code]}) if params[:code] - options = { - :token_url => @token_path, + options = client_options + @access_token = self.get_access_token(args, options) + end + + # Returns options for building the client. + def client_options + return { + :token_url => @token_url, :token_method => :post } - @access_token = self.get_access_token(args, options) end end diff --git a/lib/sorcery/engine.rb b/lib/sorcery/engine.rb index dff5cbe5..ab965457 100644 --- a/lib/sorcery/engine.rb +++ b/lib/sorcery/engine.rb @@ -8,9 +8,11 @@ class Engine < Rails::Engine config.sorcery = ::Sorcery::Controller::Config initializer "extend Controller with sorcery" do |app| - ActionController::Base.send(:include, Sorcery::Controller) - ActionController::Base.helper_method :current_user - ActionController::Base.helper_method :logged_in? + klass = ActionController::Base + klass = ActionController::API if defined?(ActionController::API) # rails-api + klass.send(:include, Sorcery::Controller) + klass.helper_method :current_user + klass.helper_method :logged_in? end rake_tasks do @@ -18,4 +20,4 @@ class Engine < Rails::Engine end end -end \ No newline at end of file +end diff --git a/lib/sorcery/model/adapters/active_record.rb b/lib/sorcery/model/adapters/active_record.rb index b4c36dee..79320322 100644 --- a/lib/sorcery/model/adapters/active_record.rb +++ b/lib/sorcery/model/adapters/active_record.rb @@ -32,10 +32,6 @@ def find_by_credentials(credentials) where(sql.join(' OR '), :login => credentials[0]).first end - def find_by_sorcery_token(token_attr_name, token) - where("#{token_attr_name} = ?", token).first - end - def get_current_users config = sorcery_config where("#{config.last_activity_at_attribute_name} IS NOT NULL") \ diff --git a/lib/sorcery/model/submodules/access_token.rb b/lib/sorcery/model/submodules/access_token.rb new file mode 100644 index 00000000..3efafd13 --- /dev/null +++ b/lib/sorcery/model/submodules/access_token.rb @@ -0,0 +1,100 @@ +module Sorcery + module Model + module Submodules + # + # Access Token submodule + # + # Handles the creation and deletion of user access_tokens, + # + module AccessToken + def self.included(base) + base.sorcery_config.class_eval do + attr_accessor(:access_token_mode, + :access_token_duration, + :access_token_duration_from_last_activity, + :access_token_max_per_user, + :access_token_register_last_activity) + end + + base.sorcery_config.instance_eval do + @defaults.merge!(:@access_token_mode => 'single_token', + :@access_token_duration => nil, + :@access_token_duration_from_last_activity => false, + :@access_token_max_per_user => nil, + :@access_token_register_last_activity => false) + + reset! + end + + base.send(:include, InstanceMethods) + + if defined?(Mongoid) && base.ancestors.include?(Mongoid::Document) + base.sorcery_config.after_config << :define_access_token_mongoid_fields + end + if defined?(MongoMapper) && base.ancestors.include?(MongoMapper::Document) + base.sorcery_config.after_config << :define_access_token_mongo_mapper_fields + end + + base.sorcery_config.after_config << :register_access_token_creation_callback + + base.extend(ClassMethods) + end + + module ClassMethods + protected + + def define_access_token_mongoid_fields + include Mongoid::Timestamps + field :user_id, :type => Integer + field :token, :type => String + field :expirable, :type => Boolean, :default => true + field :last_activity_at, :type => Time + end + + def define_access_token_mongo_mapper_fields + key :token, String + key :expirable, Boolean, :default => true + key :last_activity_at, Time + timestamps! + end + + # Register conditional create_access_token on after_create callback, + # create token after user creation when mode is set to 'single_token' + def register_access_token_creation_callback + after_create do + create_access_token!(:user_creation) + end + end + end + + module InstanceMethods + # Create and return access token, or nil if nothing was created, + # it has the side effect of destroying user invalid tokens. + # Creation conditions: + # * session mode : number of valid stored tokens must be less than + # value defined in max_per_user (if any). + # * single_token mode : stored token must be invalid or nonexistent. + def create_access_token!(context = nil) + access_token_mode = sorcery_config.access_token_mode.to_s + max = sorcery_config.access_token_max_per_user + delete_expired_access_tokens + self.reload + if access_token_mode == 'session' && context != :user_creation + if ! max || access_tokens.count < max.to_i + access_tokens.create! + end + elsif access_token_mode == 'single_token' && access_tokens.empty? + access_tokens.create! + end + end + + # Delete user expired access tokens + def delete_expired_access_tokens + ::AccessToken.delete_expired(self.id) + end + + end + end + end + end +end diff --git a/lib/sorcery/test_helpers/internal.rb b/lib/sorcery/test_helpers/internal.rb index 82d61255..7c23c64d 100644 --- a/lib/sorcery/test_helpers/internal.rb +++ b/lib/sorcery/test_helpers/internal.rb @@ -44,6 +44,14 @@ def sorcery_model_property_set(property, *values) end end + def create_new_access_token(attributes_hash = {}) + attributes_hash[:user_id] ||= 1 + @api_access_token = AccessToken.new + @api_access_token.user_id = attributes_hash[:user_id] + @api_access_token.save! + @api_access_token + end + private # reload user class between specs @@ -54,4 +62,4 @@ def reload_user_class end end end -end \ No newline at end of file +end diff --git a/spec/rails3/app/controllers/application_controller.rb b/spec/rails3/app/controllers/application_controller.rb index 5b244b0e..b734f28a 100644 --- a/spec/rails3/app/controllers/application_controller.rb +++ b/spec/rails3/app/controllers/application_controller.rb @@ -5,7 +5,9 @@ class ApplicationController < ActionController::Base #before_filter :validate_session, :only => [:test_should_be_logged_in] if defined?(:validate_session) before_filter :require_login_from_http_basic, :only => [:test_http_basic_auth] - before_filter :require_login, :only => [:test_logout, :test_should_be_logged_in, :some_action] + before_filter :require_login, :only => [:test_logout, :test_should_be_logged_in, + :some_action, :test_action_access_token, + :test_logout_access_token] def index end @@ -137,6 +139,15 @@ def test_login_from5 end end + def test_login_from_client_side4 + @user = login_from_client_side(params[:provider], params[:access_token_hash]) + if @api_access_token + render :json => { access_token: @api_access_token.token } + else + head :unauthorized + end + end + def test_return_to_with_external if @user = login_from(:twitter) redirect_back_or_to "bla", :notice => "Success!" @@ -201,6 +212,40 @@ def test_create_from_provider_with_block end end + ## + # Access Token + + # Login, returns access_token on successful login or unauthorized + def test_login_access_token + @user = login(params[:username], params[:password]) + respond_to do |format| + format.json do + if @api_access_token + render :json => { access_token: @api_access_token.token } + else + head :unauthorized + end + end + end + end + + # Action, unauthorized if access_token is invalid (default) + def test_action_access_token + respond_to do |format| + format.json do + if @api_access_token + head :ok + end + end + end + end + + # Logout + def test_logout_access_token + logout + head :ok + end + protected diff --git a/spec/rails3/app/models/access_token.rb b/spec/rails3/app/models/access_token.rb new file mode 100644 index 00000000..0f94cbc7 --- /dev/null +++ b/spec/rails3/app/models/access_token.rb @@ -0,0 +1,141 @@ +class AccessToken < ActiveRecord::Base + belongs_to :user + validates :user_id, :presence => true + + before_create :generate_token, :update_last_activity_time + + # + # Class methods + # + + # Class method to delete expired access tokens, it can receive an optional + # 'user_id' parameter to apply the deletion only to the given user. + def self.delete_expired(user_id = nil) + tokens = self.scoped rescue self # <- mongo_mapper does not have anonymous scope + if self.sorcery_config.access_token_duration + due_date = Time.zone.now - self.sorcery_config.access_token_duration.to_i + tokens = tokens.with_user_id(user_id) if user_id + + if self.sorcery_config.access_token_duration_from_last_activity + tokens = tokens.with_last_activity_at_less_than(due_date) + else + tokens = tokens.with_created_at_less_than(due_date) + end + + tokens = tokens.with_expirable(true) + + if ! tokens.empty? + if defined?(MongoMapper) && self.ancestors.include?(MongoMapper::Document) + tokens.each {|t| t.delete } # ... + else + tokens.delete_all + end + end + end + end + + # Expose sorcery config (User model) + def self.sorcery_config + User.sorcery_config + end + + ## + # Finders + # + + def self.find_token(token) + self.query_adapter(:token, '=', token).first + end + + ## + # Scopes + # + + def self.with_user_id(user_id) + self.query_adapter(:user_id, '=', user_id) + end + + def self.with_last_activity_at_less_than(due_date) + self.query_adapter(:last_activity_at, '<', due_date) + end + + def self.with_created_at_less_than(due_date) + self.query_adapter(:created_at, '<', due_date) + end + + def self.with_expirable(bool) + self.query_adapter(:expirable, '=', bool) + end + + # Auxiliary class method, query adapter + def self.query_adapter(attr, comparison_operator, value) + if (defined?(Mongoid) && self.ancestors.include?(Mongoid::Document)) || + (defined?(MongoMapper) && self.ancestors.include?(MongoMapper::Document)) + + case comparison_operator + when '=' + self.where(attr => value) + when '<' + self.where(attr.lt => value) + else + nil + end + + else + case comparison_operator + when '=' + self.where(attr => value) + when '<' + self.where("#{attr.to_s} < ?", value) + else + nil + end + end + end + + # + # Instance methods + # + + # Overwrite ActiveRecord valid? method to support auth context, + # returns true if token is valid for authentication. + def valid?(context = nil) + if context == :auth + ! expired? + else + super(context) + end + end + + # Returns true if token has expired + def expired? + if self.expirable && sorcery_config.access_token_duration + due_date = Time.zone.now - sorcery_config.access_token_duration.to_i + if sorcery_config.access_token_duration_from_last_activity + self.last_activity_at < due_date + else + self.created_at < due_date + end + else + false + end + end + + def sorcery_config + self.class.sorcery_config + end + + def update_last_activity_time + self.last_activity_at = Time.zone.now + end + + private + + # Generate random token + def generate_token + begin + self.token = TemporaryToken.generate_random_token + end while self.class.find_token(self.token) + end + +end diff --git a/spec/rails3/app/models/authentication.rb b/spec/rails3/app/models/authentication.rb index 33234367..e677eda6 100644 --- a/spec/rails3/app/models/authentication.rb +++ b/spec/rails3/app/models/authentication.rb @@ -1,3 +1,4 @@ class Authentication < ActiveRecord::Base + attr_accessible :provider, :uid, :user_id belongs_to :user -end \ No newline at end of file +end diff --git a/spec/rails3/app/models/user.rb b/spec/rails3/app/models/user.rb index 58bb0caa..7e4ef720 100644 --- a/spec/rails3/app/models/user.rb +++ b/spec/rails3/app/models/user.rb @@ -1,6 +1,7 @@ class User < ActiveRecord::Base - attr_accessible :email, :password, :password_confirmation, :authentications_attributes + attr_accessible :email, :password, :password_confirmation, :authentications_attributes, :username has_many :authentications, :dependent => :destroy + has_many :access_tokens, :dependent => :delete_all accepts_nested_attributes_for :authentications end diff --git a/spec/rails3/db/migrate/access_token/20130220134306_create_access_tokens.rb b/spec/rails3/db/migrate/access_token/20130220134306_create_access_tokens.rb new file mode 100644 index 00000000..94848dc9 --- /dev/null +++ b/spec/rails3/db/migrate/access_token/20130220134306_create_access_tokens.rb @@ -0,0 +1,16 @@ +class CreateAccessTokens < ActiveRecord::Migration + def self.up + create_table :access_tokens do |t| + t.string :token, :default => nil + t.boolean :expirable, :default => true + t.datetime :last_activity_at + t.references :user + + t.timestamps + end + end + + def self.down + drop_table :access_tokens + end +end diff --git a/spec/rails3/spec/controller_access_token_spec.rb b/spec/rails3/spec/controller_access_token_spec.rb new file mode 100644 index 00000000..401c98c2 --- /dev/null +++ b/spec/rails3/spec/controller_access_token_spec.rb @@ -0,0 +1,200 @@ +require File.expand_path(File.dirname(__FILE__) + '/spec_helper') + +describe ApplicationController do + + # + # Access Token + # + + describe ApplicationController, "with access token features" do + + before(:all) do + ActiveRecord::Migrator.migrate("#{Rails.root}/db/migrate/access_token") + sorcery_reload!([:access_token]) + sorcery_controller_property_set(:restful_json_api, true) + sorcery_model_property_set(:access_token_mode, 'session') + sorcery_model_property_set(:access_token_max_per_user, 3) + sorcery_model_property_set(:access_token_duration, 120) + end + + before(:each) do + create_new_user + end + + after(:all) do + ActiveRecord::Migrator.rollback("#{Rails.root}/db/migrate/access_token") + end + + after(:each) do + User.delete_all + AccessToken.delete_all + end + + + # Login + + it "should create and return access token on successful login" do + post(:test_login_access_token, :username => 'gizmo', :password => 'secret', + :format => :json) + + assigns[:current_user].should == @user + assigns[:api_access_token].should_not be_nil + assigns[:api_access_token].token.should == @user.access_tokens(true).first.token + + response.code.should == '200' + response.header['Content-Type'].should include('application/json') + parsed_body = JSON.parse(response.body) + parsed_body.key?('access_token').should be_true + parsed_body['access_token'].length.should be > 0 + end + + it "should return stored access token on successful login when max allowed tokens has been reached" do + 3.times { @user.create_access_token! } + tokens = @user.access_tokens(true).map(&:id) + post(:test_login_access_token, :username => 'gizmo', :password => 'secret', + :format => :json) + + assigns[:current_user].should == @user + assigns[:api_access_token].should_not be_nil + tokens.should include(assigns[:access_token].id) + + response.code.should == '200' + end + + it "should return unauthorized and not create access_token on failed login" do + post(:test_login_access_token, :username => 'gizmo', :password => 'wrong_secret', + :format => :json) + + assigns[:current_user].should be_nil + assigns[:api_access_token].should be_nil + @user.access_tokens(true).count.should == 0 + + response.code.should == '401' + end + + # Logout + + it "should destroy access token on logout" do + subject.auto_login(@user, true) + api_access_token = @user.access_tokens.first + + post(:test_logout_access_token, :access_token => api_access_token.token, + :format => :json) + + assigns[:current_user].should be_nil + assigns[:api_access_token].should be_nil + @user.access_tokens(true).count.should == 0 + + response.code.should == '200' + end + + # Requests with valid access token + + it "should allow request with valid access token" do + subject.auto_login(@user, true) + api_access_token = @user.access_tokens.first + + get(:test_action_access_token, :access_token => api_access_token.token, + :format => :json) + + assigns[:current_user].should == @user + assigns[:api_access_token].token.should == api_access_token.token + + response.code.should == '200' + end + + it "should update last activity time of valid token if setting is enabled" do + sorcery_model_property_set(:access_token_duration_from_last_activity, true) + api_access_token = @user.create_access_token! + api_access_token.last_activity_at = Time.zone.now - 60 + api_access_token.save! + + get(:test_action_access_token, :access_token => api_access_token.token, + :format => :json) + + assigns[:current_user].should == @user + assigns[:api_access_token].token.should == api_access_token.token + + assigns[:api_access_token].last_activity_at.should > api_access_token.last_activity_at + + response.code.should == '200' + end + + it "should not update last_activity time of valid token if setting is disabled" do + sorcery_model_property_set(:access_token_duration_from_last_activity, false) + api_access_token = @user.create_access_token! + api_access_token.last_activity_at = Time.zone.now - 60 + api_access_token.save! + + get(:test_action_access_token, :access_token => api_access_token.token, + :format => :json) + + assigns[:current_user].should == @user + assigns[:api_access_token].token.should == api_access_token.token + + prev_time = api_access_token.last_activity_at + assigns[:api_access_token].last_activity_at.to_i.should == prev_time.to_i + + response.code.should == '200' + + end + + # Requests with invalid access token + + it "should deny request with nonexistent access token" do + api_access_token = @user.create_access_token! + get(:test_action_access_token, :access_token => api_access_token.token + '_invalid', + :format => :json) + + assigns[:current_user].should be_false + assigns[:api_access_token].should be_nil + + response.code.should == '401' + end + + it "should deny request with expired access token (against created_at)" do + sorcery_model_property_set(:access_token_duration_from_last_activity, false) + api_access_token = @user.create_access_token! + api_access_token.created_at = Time.zone.now - 2.days + api_access_token.save! + + get(:test_action_access_token, :access_token => api_access_token.token, + :format => :json) + + assigns[:current_user].should be_false + assigns[:api_access_token].should be_nil + + response.code.should == '401' + end + + it "should deny request with expired access token (against last_activity_at)" do + sorcery_model_property_set(:access_token_duration_from_last_activity, true) + api_access_token = @user.create_access_token! + api_access_token.last_activity_at = Time.zone.now - 2.days + api_access_token.save! + + get(:test_action_access_token, :access_token => api_access_token.token, + :format => :json) + + assigns[:current_user].should be_false + assigns[:api_access_token].should be_nil + + response.code.should == '401' + end + + specify { should respond_to(:auto_login) } + + it "auto_login(user) should login a user instance without creating an access token" do + subject.auto_login(@user) + assigns[:current_user].should == @user + assigns[:api_access_token].should be_nil + end + + it "auto_login(user, true) should login a user instance and create an access token" do + subject.auto_login(@user, true) + assigns[:current_user].should == @user + assigns[:api_access_token].should_not be_nil + assigns[:api_access_token].should == @user.access_tokens(true).first + end + end +end diff --git a/spec/rails3/spec/controller_oauth2_spec.rb b/spec/rails3/spec/controller_oauth2_spec.rb index fd545618..714db1a7 100644 --- a/spec/rails3/spec/controller_oauth2_spec.rb +++ b/spec/rails3/spec/controller_oauth2_spec.rb @@ -2,10 +2,7 @@ require File.expand_path(File.dirname(__FILE__) + '/../../shared_examples/controller_oauth2_shared_examples') def stub_all_oauth2_requests! - auth_code = OAuth2::Strategy::AuthCode.any_instance - access_token = mock(OAuth2::AccessToken) - access_token.stub(:token_param=) - response = mock(OAuth2::Response) + response = mock(OAuth2::Response) response.stub(:body).and_return({ "id"=>"123", "name"=>"Noam Ben Ari", @@ -22,8 +19,10 @@ def stub_all_oauth2_requests! "languages"=>[{"id"=>"108405449189952", "name"=>"Hebrew"}, {"id"=>"106059522759137", "name"=>"English"}, {"id"=>"112624162082677", "name"=>"Russian"}], "verified"=>true, "updated_time"=>"2011-02-16T20:59:38+0000"}.to_json) - access_token.stub(:get).and_return(response) - auth_code.stub(:get_token).and_return(access_token) + access_token = mock(OAuth2::AccessToken) + access_token.stub(:get => response) + access_token.stub(:token_param= => nil) + OAuth2::Strategy::AuthCode.any_instance.stub(:get_token => access_token) end def set_external_property @@ -377,4 +376,68 @@ def set_external_property end end end + + describe ApplicationController, "OAuth login from client-side application (Implicit Grant)" do + before(:all) do + ActiveRecord::Migrator.migrate("#{Rails.root}/db/migrate/external") + ActiveRecord::Migrator.migrate("#{Rails.root}/db/migrate/access_token") + sorcery_reload!([:external, :access_token]) + set_external_property + sorcery_controller_property_set(:restful_json_api, true) + sorcery_model_property_set(:access_token_mode, 'session') + sorcery_model_property_set(:access_token_max_per_user, 3) + sorcery_model_property_set(:access_token_duration, 120) + sorcery_model_property_set(:authentications_class, Authentication) + end + + after(:all) do + ActiveRecord::Migrator.rollback("#{Rails.root}/db/migrate/external") + ActiveRecord::Migrator.rollback("#{Rails.root}/db/migrate/access_token") + end + + before(:each) do + stub_all_oauth2_requests! + end + + after(:each) do + User.delete_all + Authentication.delete_all + end + + it "'login_from_client_side' logins if user exists" do + pending("Fix stub of OAuth2::AccessToken.from_hash") # FIXME + create_new_external_user(:google) + access_token_hash = { + 'access_token' => '1/fFBGRNJru1FQd44AzqT3Zg', + 'token_type' => 'Bearer', + 'expires_in' => 3600 + } + post(:test_login_from_client_side4, :provider => 'google', + :access_token_hash => access_token_hash, + :format => :json) + + assigns[:api_access_token].should_not be_nil + + response.code.should == '200' + response.header['Content-Type'].should include('application/json') + parsed_body = JSON.parse(response.body) + parsed_body.key?('access_token').should be_true + parsed_body['access_token'].length.should be > 0 + end + + it "'login_from_client_side' fails if user doesn't exist" do + create_new_user + access_token_hash = { + 'access_token' => '1/fFBGRNJru1FQd44AzqT3Zg', + 'token_type' => 'Bearer', + 'expires_in' => 3600 + } + post(:test_login_from_client_side4, :provider => 'google', + :access_token_hash => access_token_hash, + :format => :json) + + response.code.should == '401' + end + + end end diff --git a/spec/rails3/spec/user_access_token_spec.rb b/spec/rails3/spec/user_access_token_spec.rb new file mode 100644 index 00000000..9aadab1c --- /dev/null +++ b/spec/rails3/spec/user_access_token_spec.rb @@ -0,0 +1,15 @@ +require File.expand_path(File.dirname(__FILE__) + '/spec_helper') +require File.expand_path(File.dirname(__FILE__) + '/../../shared_examples/user_access_token_shared_examples') + +describe "User with access_token submodule" do + before(:all) do + ActiveRecord::Migrator.migrate("#{Rails.root}/db/migrate/access_token") + end + + after(:all) do + ActiveRecord::Migrator.rollback("#{Rails.root}/db/migrate/access_token") + end + + it_behaves_like "rails_3_access_token_model" + +end diff --git a/spec/rails3_mongo_mapper/Gemfile.lock b/spec/rails3_mongo_mapper/Gemfile.lock index 05af741e..a78ced3d 100644 --- a/spec/rails3_mongo_mapper/Gemfile.lock +++ b/spec/rails3_mongo_mapper/Gemfile.lock @@ -48,7 +48,7 @@ GEM diff-lcs (1.1.3) erubis (2.6.6) abstract (>= 1.0.0) - faraday (0.8.5) + faraday (0.8.6) multipart-post (~> 1.1) httpauth (0.2.0) i18n (0.6.0) @@ -70,7 +70,7 @@ GEM activesupport (~> 3.0) plucky (~> 0.4.0) multi_json (1.1.0) - multipart-post (1.1.5) + multipart-post (1.2.0) oauth (0.4.7) oauth2 (0.8.1) faraday (~> 0.8) diff --git a/spec/rails3_mongo_mapper/app/models/access_token.rb b/spec/rails3_mongo_mapper/app/models/access_token.rb new file mode 100644 index 00000000..338302ed --- /dev/null +++ b/spec/rails3_mongo_mapper/app/models/access_token.rb @@ -0,0 +1,148 @@ +#class AccessToken < ActiveRecord::Base +class AccessToken + include MongoMapper::Document + key :token, String + key :expirable, Boolean, :default => true + key :last_activity_at, Time + timestamps! + + belongs_to :user + validates :user_id, :presence => true + + before_create :generate_token, :update_last_activity_time + + # + # Class methods + # + + # Class method to delete expired access tokens, it can receive an optional + # 'user_id' parameter to apply the deletion only to the given user. + def self.delete_expired(user_id = nil) + tokens = self.scoped rescue self # <- mongo_mapper does not have anonymous scope + if self.sorcery_config.access_token_duration + due_date = Time.zone.now - self.sorcery_config.access_token_duration.to_i + tokens = tokens.with_user_id(user_id) if user_id + + if self.sorcery_config.access_token_duration_from_last_activity + tokens = tokens.with_last_activity_at_less_than(due_date) + else + tokens = tokens.with_created_at_less_than(due_date) + end + + tokens = tokens.with_expirable(true) + + if ! tokens.empty? + if defined?(MongoMapper) && self.ancestors.include?(MongoMapper::Document) + tokens.each {|t| t.delete } # ... + else + tokens.delete_all + end + end + end + end + + # Expose sorcery config (User model) + def self.sorcery_config + User.sorcery_config + end + + ## + # Finders + # + + def self.find_token(token) + self.query_adapter(:token, '=', token).first + end + + ## + # Scopes + # + + def self.with_user_id(user_id) + self.query_adapter(:user_id, '=', user_id) + end + + def self.with_last_activity_at_less_than(due_date) + self.query_adapter(:last_activity_at, '<', due_date) + end + + def self.with_created_at_less_than(due_date) + self.query_adapter(:created_at, '<', due_date) + end + + def self.with_expirable(bool) + self.query_adapter(:expirable, '=', bool) + end + + # Auxiliary class method, query adapter + def self.query_adapter(attr, comparison_operator, value) + if (defined?(Mongoid) && self.ancestors.include?(Mongoid::Document)) || + (defined?(MongoMapper) && self.ancestors.include?(MongoMapper::Document)) + + case comparison_operator + when '=' + self.where(attr => value) + when '<' + self.where(attr.lt => value) + else + nil + end + + else + case comparison_operator + when '=' + self.where(attr => value) + when '<' + self.where("#{attr.to_s} < ?", value) + else + nil + end + end + end + + # + # Instance methods + # + + # Overwrite ActiveRecord valid? method to support auth context, + # returns true if token is valid for authentication. + def valid?(context = nil) + if context == :auth + ! expired? + else + super(context) + end + end + + # Returns true if token has expired + def expired? + if self.expirable && sorcery_config.access_token_duration + due_date = Time.zone.now - sorcery_config.access_token_duration.to_i + if sorcery_config.access_token_duration_from_last_activity + self.last_activity_at < due_date + else + self.created_at < due_date + end + else + false + end + end + + def sorcery_config + self.class.sorcery_config + end + + def update_last_activity_time + self.last_activity_at = Time.zone.now + end + + private + + # Generate random token + def generate_token + begin + self.token = TemporaryToken.generate_random_token + end while self.class.find_token(self.token) + end + +end diff --git a/spec/rails3_mongo_mapper/app/models/user.rb b/spec/rails3_mongo_mapper/app/models/user.rb index 7d36ebf4..7731341e 100644 --- a/spec/rails3_mongo_mapper/app/models/user.rb +++ b/spec/rails3_mongo_mapper/app/models/user.rb @@ -2,4 +2,5 @@ class User include MongoMapper::Document many :authentications, :dependent => :destroy + many :access_tokens, :dependent => :delete_all end diff --git a/spec/rails3_mongo_mapper/spec/user_access_token_spec.rb b/spec/rails3_mongo_mapper/spec/user_access_token_spec.rb new file mode 100644 index 00000000..10f66fe9 --- /dev/null +++ b/spec/rails3_mongo_mapper/spec/user_access_token_spec.rb @@ -0,0 +1,8 @@ +require File.expand_path(File.dirname(__FILE__) + '/spec_helper') +require File.expand_path(File.dirname(__FILE__) + '/../../shared_examples/user_access_token_shared_examples') + +describe "User with access_token submodule" do + + it_behaves_like "rails_3_access_token_model" + +end diff --git a/spec/rails3_mongoid/Gemfile.lock b/spec/rails3_mongoid/Gemfile.lock index 287bc7c8..6745b367 100644 --- a/spec/rails3_mongoid/Gemfile.lock +++ b/spec/rails3_mongoid/Gemfile.lock @@ -47,7 +47,7 @@ GEM diff-lcs (1.1.3) erubis (2.6.6) abstract (>= 1.0.0) - faraday (0.8.5) + faraday (0.8.6) multipart-post (~> 1.1) httpauth (0.2.0) i18n (0.6.0) @@ -68,7 +68,7 @@ GEM mongo (~> 1.3) tzinfo (~> 0.3.22) multi_json (1.1.0) - multipart-post (1.1.5) + multipart-post (1.2.0) oauth (0.4.7) oauth2 (0.8.1) faraday (~> 0.8) diff --git a/spec/rails3_mongoid/app/models/access_token.rb b/spec/rails3_mongoid/app/models/access_token.rb new file mode 100644 index 00000000..571bce4f --- /dev/null +++ b/spec/rails3_mongoid/app/models/access_token.rb @@ -0,0 +1,149 @@ +#class AccessToken < ActiveRecord::Base +class AccessToken + include Mongoid::Document + include Mongoid::Timestamps + field :user_id, :type => Integer + field :token, :type => String + field :expirable, :type => Boolean, :default => true + field :last_activity_at, :type => Time + + belongs_to :user + validates :user_id, :presence => true + + before_create :generate_token, :update_last_activity_time + + # + # Class methods + # + + # Class method to delete expired access tokens, it can receive an optional + # 'user_id' parameter to apply the deletion only to the given user. + def self.delete_expired(user_id = nil) + tokens = self.scoped rescue self # <- mongo_mapper does not have anonymous scope + if self.sorcery_config.access_token_duration + due_date = Time.zone.now - self.sorcery_config.access_token_duration.to_i + tokens = tokens.with_user_id(user_id) if user_id + + if self.sorcery_config.access_token_duration_from_last_activity + tokens = tokens.with_last_activity_at_less_than(due_date) + else + tokens = tokens.with_created_at_less_than(due_date) + end + + tokens = tokens.with_expirable(true) + + if ! tokens.empty? + if defined?(MongoMapper) && self.ancestors.include?(MongoMapper::Document) + tokens.each {|t| t.delete } # ... + else + tokens.delete_all + end + end + end + end + + # Expose sorcery config (User model) + def self.sorcery_config + User.sorcery_config + end + + ## + # Finders + # + + def self.find_token(token) + self.query_adapter(:token, '=', token).first + end + + ## + # Scopes + # + + def self.with_user_id(user_id) + self.query_adapter(:user_id, '=', user_id) + end + + def self.with_last_activity_at_less_than(due_date) + self.query_adapter(:last_activity_at, '<', due_date) + end + + def self.with_created_at_less_than(due_date) + self.query_adapter(:created_at, '<', due_date) + end + + def self.with_expirable(bool) + self.query_adapter(:expirable, '=', bool) + end + + # Auxiliary class method, query adapter + def self.query_adapter(attr, comparison_operator, value) + if (defined?(Mongoid) && self.ancestors.include?(Mongoid::Document)) || + (defined?(MongoMapper) && self.ancestors.include?(MongoMapper::Document)) + + case comparison_operator + when '=' + self.where(attr => value) + when '<' + self.where(attr.lt => value) + else + nil + end + + else + case comparison_operator + when '=' + self.where(attr => value) + when '<' + self.where("#{attr.to_s} < ?", value) + else + nil + end + end + end + + # + # Instance methods + # + + # Overwrite ActiveRecord valid? method to support auth context, + # returns true if token is valid for authentication. + def valid?(context = nil) + if context == :auth + ! expired? + else + super(context) + end + end + + # Returns true if token has expired + def expired? + if self.expirable && sorcery_config.access_token_duration + due_date = Time.zone.now - sorcery_config.access_token_duration.to_i + if sorcery_config.access_token_duration_from_last_activity + self.last_activity_at < due_date + else + self.created_at < due_date + end + else + false + end + end + + def sorcery_config + self.class.sorcery_config + end + + def update_last_activity_time + self.last_activity_at = Time.zone.now + end + + private + + # Generate random token + def generate_token + begin + self.token = TemporaryToken.generate_random_token + end while self.class.find_token(self.token) + end + +end diff --git a/spec/rails3_mongoid/app/models/user.rb b/spec/rails3_mongoid/app/models/user.rb index 64907d08..44d4f5e0 100644 --- a/spec/rails3_mongoid/app/models/user.rb +++ b/spec/rails3_mongoid/app/models/user.rb @@ -2,4 +2,5 @@ class User include Mongoid::Document has_many :authentications, :dependent => :destroy + has_many :access_tokens, :dependent => :delete_all end diff --git a/spec/rails3_mongoid/spec/user_access_token_spec.rb b/spec/rails3_mongoid/spec/user_access_token_spec.rb new file mode 100644 index 00000000..10f66fe9 --- /dev/null +++ b/spec/rails3_mongoid/spec/user_access_token_spec.rb @@ -0,0 +1,8 @@ +require File.expand_path(File.dirname(__FILE__) + '/spec_helper') +require File.expand_path(File.dirname(__FILE__) + '/../../shared_examples/user_access_token_shared_examples') + +describe "User with access_token submodule" do + + it_behaves_like "rails_3_access_token_model" + +end diff --git a/spec/shared_examples/user_access_token_shared_examples.rb b/spec/shared_examples/user_access_token_shared_examples.rb new file mode 100644 index 00000000..cb29f78b --- /dev/null +++ b/spec/shared_examples/user_access_token_shared_examples.rb @@ -0,0 +1,216 @@ +shared_examples_for "rails_3_access_token_model" do + + # + # Plugin Configuration + # + + describe User, "loaded plugin configuration" do + before(:all) do + sorcery_reload!([:access_token]) + create_new_user + end + + after(:each) do + User.sorcery_config.reset! + end + + it "should allow configuration option 'access_token_mode'" do + sorcery_model_property_set(:access_token_mode, 'session') + User.sorcery_config.access_token_mode.should == 'session' + end + + it "should allow configuration option 'access_token_duration'" do + sorcery_model_property_set(:access_token_duration, 7.days.to_i) + User.sorcery_config.access_token_duration.should == 7.days.to_i + end + + it "should allow configuration option 'access_token_duration_from_last_activity'" do + sorcery_model_property_set(:access_token_duration_from_last_activity, true) + User.sorcery_config.access_token_duration_from_last_activity.should be_true + end + + it "should allow configuration option 'access_token_max_per_user'" do + sorcery_model_property_set(:access_token_max_per_user, 5) + User.sorcery_config.access_token_max_per_user.should == 5 + end + + end + + # + # Token creation and deletion + # + + describe User, "token creation and deletion" do + + context "with 'access_token_mode' set to 'session'" do + before(:all) do + sorcery_reload!([:access_token]) + end + + before(:each) do + sorcery_model_property_set(:access_token_mode, 'session') + sorcery_model_property_set(:access_token_max_per_user, 5) + User.delete_all + AccessToken.delete_all + create_new_user + end + + after(:each) do + User.sorcery_config.reset! + end + + it "should create a token on each 'create_access_token'" do + @user.create_access_token! + @user.reload # <-- common support (mongomapper, mongoid, ..) + @user.access_tokens.count.should == 1 + end + + it "should set access_token 'last_activity_at' on 'create_access_token'" do + access_token = @user.create_access_token! + access_token.last_activity_at.should_not be_nil + end + + it "should not create more tokens than defined in 'max_per_user' attribute" do + 10.times do |i| + ret = @user.create_access_token! + if i >= 5 + ret.should be_nil + end + end + @user.reload + @user.access_tokens.count.should == 5 + end + + it "should delete invalid tokens and create new when max has been reached" do + sorcery_model_property_set(:access_token_duration, 120) # seconds + 5.times { @user.create_access_token! } + initial_count = @user.access_tokens.count + expired = [] + @user.access_tokens.each_with_index do |token, i| + if i % 2 == 0 + token.created_at = Time.zone.now - 1.year + token.save! + expired << token.id + end + end + @user.create_access_token! + # expired tokens should no longer exist + expired.any? {|token_id| AccessToken.find_by_id(token_id) }.should be_false + @user.access_tokens.count.should == (initial_count - expired.count + 1) + end + + end + + context "with 'access_token_mode' set to 'single_token'" do + before(:all) do + sorcery_reload!([:access_token]) + end + + before(:each) do + sorcery_model_property_set(:access_token_mode, 'single_token') + User.delete_all + AccessToken.delete_all + create_new_user + end + + after(:each) do + User.sorcery_config.reset! + end + + it "should create the access_token on user creation" do + @user.access_tokens.count.should == 1 + end + + it "should set last_activity_at of user access token" do + @user.access_tokens.first.last_activity_at.should_not be_nil + end + + it "should return nil on 'create_access_token' when stored token is valid" do + @user.create_access_token!.should be_nil + end + + it "should destroy invalid tokens and create new on 'create_access_token'" do + sorcery_model_property_set(:access_token_duration, 120) + # expire current token + token = @user.access_tokens.first + token.created_at = Time.zone.now - 1.year + token.expired?.should be_true + token.valid?(:auth).should be_false + token.save! + expired_token_id = token.id + # create new token, expect deletion of expired token + new_token = @user.create_access_token! + expired_token_id.should_not == new_token.id + AccessToken.find_by_id(expired_token_id).should be_nil + end + + end + + end + + # + # Token authentication + # + + describe AccessToken, "token authentication" do + before(:all) do + sorcery_reload!([:access_token]) + end + + before(:each) do + AccessToken.delete_all + end + + after(:each) do + User.sorcery_config.reset! + end + + it "should be valid if duration is unset" do + sorcery_model_property_set(:access_token_duration, nil) + create_new_access_token + @api_access_token.created_at = Time.zone.now - 1.year + @api_access_token.save! + @api_access_token.valid?(:auth).should be_true + end + + it "should be invalid if it has expired when evaluated against creation time" do + sorcery_model_property_set(:access_token_duration, 120) + sorcery_model_property_set(:access_token_duration_from_last_activity, false) + create_new_access_token + @api_access_token.created_at = Time.zone.now - 1.year + @api_access_token.save! + @api_access_token.expired?.should be_true + @api_access_token.valid?(:auth).should be_false + end + + it "should be valid if it has not expired when evaluated against creation time" do + sorcery_model_property_set(:access_token_duration, 120) + sorcery_model_property_set(:access_token_duration_from_last_activity, false) + create_new_access_token + @api_access_token.expired?.should be_false + @api_access_token.valid?(:auth).should be_true + end + + it "should be invalid if it has expired when evaluated against last activity time" do + sorcery_model_property_set(:access_token_duration, 120) + sorcery_model_property_set(:access_token_duration_from_last_activity, true) + create_new_access_token + @api_access_token.last_activity_at = Time.zone.now - 3.minutes + @api_access_token.save! + @api_access_token.expired?.should be_true + @api_access_token.valid?(:auth).should be_false + end + + it "should be valid if it has not expired when evaluated against last activity time" do + sorcery_model_property_set(:access_token_duration, 120) + sorcery_model_property_set(:access_token_duration_from_last_activity, true) + create_new_access_token + @api_access_token.last_activity_at = Time.zone.now - 1.minutes + @api_access_token.save! + @api_access_token.expired?.should be_false + @api_access_token.valid?(:auth).should be_true + end + + end + +end From 64390627c959e5357555bf7bfc3b5259502017cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Federico=20M=2E=20Zagarzaz=C3=BA?= Date: Sun, 10 Mar 2013 23:30:18 -0300 Subject: [PATCH 2/6] Fix, do not hit the database when access_token request argument is blank --- lib/sorcery/controller/submodules/access_token.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/sorcery/controller/submodules/access_token.rb b/lib/sorcery/controller/submodules/access_token.rb index e02bd8fa..51fd9bd6 100644 --- a/lib/sorcery/controller/submodules/access_token.rb +++ b/lib/sorcery/controller/submodules/access_token.rb @@ -43,7 +43,10 @@ def auto_login(user, create_token = false) def login_from_access_token @api_access_token = nil client_token = params[:access_token].to_s - access_token = ::AccessToken.find_token(client_token) + access_token = nil + if ! client_token.blank? + access_token = ::AccessToken.find_token(client_token) + end if access_token && access_token.valid?(:auth) update_token_last_activity_time(access_token) @api_access_token = access_token.reload From ec97aa5aab02d362bc51acac83b9d13fd1880082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Federico=20M=2E=20Zagarzaz=C3=BA?= Date: Sun, 10 Mar 2013 23:40:51 -0300 Subject: [PATCH 3/6] Fix, curl example in README file was missing a comma --- README.rdoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rdoc b/README.rdoc index e0436a6b..5eea6f9d 100644 --- a/README.rdoc +++ b/README.rdoc @@ -362,7 +362,7 @@ you can also enter your own OAuth credentials) [4]. # POST login request with provider's access token curl -v -H "Accept: application/json" -H "Content-type: application/json" \ -X POST -d '{"provider": "google", "access_token_hash": { \ - "access_token": "9d2H0I9AEa834B45cRTfBEcA82bF010B53BFGT83eFDB6097" \ + "access_token": "9d2H0I9AEa834B45cRTfBEcA82bF010B53BFGT83eFDB6097", \ "token_type": "Bearer", "expires_in": 3600 } }' http://localhost:3000/sessions * Mongoid and MongoMapper From 58e85116fe009227be8f0610b11e73ccacafe48d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Federico=20M=2E=20Zagarzaz=C3=BA?= Date: Thu, 18 Apr 2013 02:23:05 -0300 Subject: [PATCH 4/6] Add missing bits of information and link to demo in README --- README.rdoc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.rdoc b/README.rdoc index 5eea6f9d..6e927469 100644 --- a/README.rdoc +++ b/README.rdoc @@ -267,6 +267,8 @@ you can also enter your own OAuth credentials) [4]. === Setup Example (OAuth 2.0 For Login): +* Update: demo with rails-api and Google's OAuth 2.0 For Login: https://github.com/fzagarzazu/sorcery_access_token_demo + * Installation rails generate sorcery:install access_token external @@ -278,6 +280,7 @@ you can also enter your own OAuth credentials) [4]. config.google.secret = "client_secret" config.user_config do |user| user.username_attribute_names = [:email] + user.authentications_class = Authentication user.access_token_mode = 'single_token' user.access_token_duration = 10.minutes.to_i user.access_token_duration_from_last_activity = true @@ -311,7 +314,7 @@ you can also enter your own OAuth credentials) [4]. class SessionsController < ApplicationController - skip_before_filter :require_login + skip_before_filter :require_login, :except => [:destroy] def create # Login From af7ab3c05ecd67a178f9bb3221926feddd4ab7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Federico=20M=2E=20Zagarzaz=C3=BA?= Date: Sun, 29 Sep 2013 21:49:49 -0300 Subject: [PATCH 5/6] Use change in access_token migration --- lib/generators/sorcery/templates/migration/access_token.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/generators/sorcery/templates/migration/access_token.rb b/lib/generators/sorcery/templates/migration/access_token.rb index 9c3da9ad..85d416f6 100644 --- a/lib/generators/sorcery/templates/migration/access_token.rb +++ b/lib/generators/sorcery/templates/migration/access_token.rb @@ -1,5 +1,5 @@ class SorceryAccessToken < ActiveRecord::Migration - def self.up + def change create_table :access_tokens do |t| t.string :token, :default => nil t.boolean :expirable, :default => true @@ -9,8 +9,4 @@ def self.up t.timestamps end end - - def self.down - drop_table :access_tokens - end end From 8c432e66436ce015841097c8524335cd5780679a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Federico=20M=2E=20Zagarzaz=C3=BA?= Date: Fri, 25 Oct 2013 19:00:38 -0200 Subject: [PATCH 6/6] Restore find_by_sorcery_token method --- lib/sorcery/model/adapters/active_record.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/sorcery/model/adapters/active_record.rb b/lib/sorcery/model/adapters/active_record.rb index 79320322..b4c36dee 100644 --- a/lib/sorcery/model/adapters/active_record.rb +++ b/lib/sorcery/model/adapters/active_record.rb @@ -32,6 +32,10 @@ def find_by_credentials(credentials) where(sql.join(' OR '), :login => credentials[0]).first end + def find_by_sorcery_token(token_attr_name, token) + where("#{token_attr_name} = ?", token).first + end + def get_current_users config = sorcery_config where("#{config.last_activity_at_attribute_name} IS NOT NULL") \