diff --git a/Gemfile b/Gemfile index 887a8aef..c1855e23 100644 --- a/Gemfile +++ b/Gemfile @@ -11,6 +11,7 @@ gem "sqlite3-ruby" gem "webrat", "0.7.0" gem "mocha", :require => false gem "bcrypt-ruby", :require => "bcrypt" +gem "oauth2" if RUBY_VERSION < '1.9' gem "ruby-debug", ">= 0.10.3" diff --git a/Gemfile.lock b/Gemfile.lock index 09dbae25..38ea02ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -56,6 +56,7 @@ GEM remote: http://rubygems.org/ specs: abstract (1.0.0) + addressable (2.1.2) arel (0.4.0) activesupport (>= 3.0.0.beta) bcrypt-ruby (2.1.2) @@ -65,6 +66,9 @@ GEM columnize (0.3.1) erubis (2.6.6) abstract (>= 1.0.0) + faraday (0.4.6) + addressable (>= 2.1.1) + rack (>= 1.0.1) i18n (0.4.1) linecache (0.43) mail (2.2.5) @@ -76,7 +80,11 @@ GEM rake mongo (1.0.5) bson (>= 1.0.4) + multi_json (0.0.4) nokogiri (1.4.2) + oauth2 (0.0.10) + faraday (~> 0.4.1) + multi_json (>= 0.0.4) polyglot (0.3.1) rack (1.2.1) rack-mount (0.6.9) @@ -111,6 +119,7 @@ DEPENDENCIES mocha mongo mongoid! + oauth2 rails! ruby-debug (>= 0.10.3) sqlite3-ruby diff --git a/README.rdoc b/README.rdoc index ebbe1c82..698a8270 100644 --- a/README.rdoc +++ b/README.rdoc @@ -7,10 +7,11 @@ Devise is a flexible authentication solution for Rails based on Warden. It: * Allows you to have multiple roles (or models/scopes) signed in at the same time; * Is based on a modularity concept: use just what you really need. -Right now it's composed of 11 modules: +Right now it's composed of 12 modules: * Database Authenticatable: encrypts and stores a password in the database to validate the authenticity of an user while signing in. The authentication can be done both through POST requests or HTTP Basic Authentication. * Token Authenticatable: signs in an user based on an authentication token (also known as "single access token"). The token can be given both through query string or HTTP Basic Authentication. +* Oauthable: adds OAuth2 support; * Confirmable: sends emails with confirmation instructions and verifies whether an account is already confirmed during sign in. * Recoverable: resets the user password and sends reset instructions. * Registerable: handles signing up users through a registration process, also allowing them to edit and destroy their account. @@ -99,15 +100,9 @@ Configure your routes after setting up your model. Open your config/routes.rb fi devise_for :users -This will use your User model to create a set of needed routes (you can see them by running `rake routes`). +This will use your User model to create a set of needed routes (you can see them by running `rake routes`). If you invoked the devise generator, you noticed that this is exactly what the generator produces for us: model, routes and migrations. -Options for configuring your routes include :class_name (to set the class for that route), :path_prefix, :path and :path_names, where the last two have the same meaning as in common routes. The available :path_names are: - - devise_for :users, :path => "usuarios", :path_names => { :sign_in => 'login', :sign_out => 'logout', :password => 'secret', :confirmation => 'verification', :unlock => 'unblock', :registration => 'register', :sign_up => 'cmon_let_me_in' } - -Be sure to check devise_for documentation for details. - -This exactly what the devise generator produces for you: model, routes and migrations. Don't forget to run rake db:migrate and you are ready to go! But don't stop reading here, we still have a lot to tell you. +Don't forget to run rake db:migrate and you are ready to go! But don't stop reading here, we still have a lot to tell you. == Controller filters and helpers @@ -179,9 +174,13 @@ Since Devise is an engine, all its views are packaged inside the gem. These view rails generate devise:views -However, if you have more than one role in your application (such as "User" and "Admin"), you will notice that Devise uses the same views for all roles. Fortunately, Devise offers an easy way to customize views. All you need to do is set "config.scoped_views = true" inside "config/initializers/devise.rb". +If you are using HAML, you will need hpricot installed to convert Devise views to HAML. -After doing so, you will be able to have views based on the role like "users/sessions/new" and "admins/sessions/new". If no view is found within the scope, Devise will use the default view at "devise/sessions/new". +If you have more than one role in your application (such as "User" and "Admin"), you will notice that Devise uses the same views for all roles. Fortunately, Devise offers an easy way to customize views. All you need to do is set "config.scoped_views = true" inside "config/initializers/devise.rb". + +After doing so, you will be able to have views based on the role like "users/sessions/new" and "admins/sessions/new". If no view is found within the scope, Devise will use the default view at "devise/sessions/new". You can also use the generator to generate scoped views: + + rails generate devise:views users == Configuring controllers @@ -194,12 +193,28 @@ If the customization at the views level is not enough, you can customize each co 2) Tell the router to use this controller: - devise_for :admins, :controllers => { :sessions => "admin/sessions" } + devise_for :admins, :controllers => { :sessions => "admins/sessions" } 3) And since we changed the controller, it won't use the "devise/sessions" views, so remember to copy "devise/sessions" to "admin/sessions". Remember that Devise uses flash messages to let users know if sign in was successful or failed. Devise expects your application to call "flash[:notice]" and "flash[:alert]" as appropriate. +== Configuring routes + +Devise also ships with default routes. If you need to customize them, you should probably be able to do it through the devise_for method. It accepts several options like :class_name, :path_prefix and so on, including the possibility to change path names for I18n: + + devise_for :users, :path => "usuarios", :path_names => { :sign_in => 'login', :sign_out => 'logout', :password => 'secret', :confirmation => 'verification', :unlock => 'unblock', :registration => 'register', :sign_up => 'cmon_let_me_in' } + +Be sure to check devise_for documentation for details. + +If you have the need for more deep customization, for instance to also allow "/sign_in" besides "/users/sign_in", all you need to do is to create your routes normally and wrap them in a +devise_scope+ block in the router: + + devise_scope :user do + get "sign_in", :to => "devise/sessions#new" + end + +This way you tell devise to use the scope :user when "/sign_in" is accessed. Notice +devise_scope+ is also aliased as +as+, feel free to choose the one you prefer. + == I18n Devise uses flash messages with I18n with the flash keys :success and :failure. To customize your app, you can set up your locale file: @@ -219,15 +234,16 @@ You can also create distinct messages based on the resource you've configured us admin: signed_in: 'Hello admin!' -The Devise mailer uses the same pattern to create subject messages: +The Devise mailer uses a similar pattern to create subject messages: en: devise: mailer: - confirmation_instructions: 'Hello everybody!' - user: - confirmation_instructions: 'Hello User! Please confirm your email' - reset_password_instructions: 'Reset instructions' + confirmation_instructions: + subject: 'Hello everybody!' + user_subject: 'Hello User! Please confirm your email' + reset_password_instructions: + subject: 'Reset instructions' Take a look at our locale file to check all available messages. @@ -249,13 +265,179 @@ You can include the Devise Test Helpers in all of your tests by adding the follo Do not use such helpers for integration tests such as Cucumber or Webrat. Instead, fill in the form or explicitly set the user in session. For more tips, check the wiki (http://wiki.github.com/plataformatec/devise). +== OAuth2 + +Since version 1.2, Devise comes with OAuth support out of the box. To create OAuth support from Github for example, first you need to register your app in Github and add it as provider in your devise initializer: + + config.oauth :github, 'APP_ID', 'APP_SECRET', + :site => 'https://github.com/', + :authorize_path => '/login/oauth/authorize', + :access_token_path => '/login/oauth/access_token', + :scope => %w(user public_repo) + +Then, you need to mark your model as oauthable: + + class User < ActiveRecord::Base + devise :database_authenticatable, :oauthable + end + +And add a link to your views/sign up form: + + <%= link_to "Sign in as User with Github", user_oauth_authorize_url(:github) %> + +This link will send the user straight to Github. After the user authorizes your application, Github will redirect the user back to your application at "/users/oauth/github/callback". This URL will be handled *internally* and *automatically* by +Devise::OauthCallbacksController#github+ action, which looks like this: + + def github + access_token = github_config.access_token_by_code(params[:code]) + @user = User.find_for_github_oauth(access_token, signed_in_resource) + + if @user && @user.persisted? + sign_in @user + set_oauth_flash_message :notice, :success + redirect_to after_oauth_success_path_for(@user) #=> redirects to user_root_path or root_path + elsif @user + session[:user_github_oauth_token] = access_token.token + render_for_auth #=> renders sign up view by default + else + set_oauth_flash_message :alert, :skipped + redirect_to after_oauth_skipped_path_for(:user) #=> redirects to user_new_session_path + end + end + +In other words, Devise does all the work for you but it expects you to implement the +find_for_github_oauth+ method in your model that receives two arguments: the first is an +access_token+ object from OAuth2 library (http://github.com/intridea/oauth2) and the second is the signed in resource which we will ignore for this while. Depending on what this method returns, Devise act in a different way as seen above. + +A basic implementation for +find_for_github_oauth+ would be: + + def self.find_for_github_oauth(access_token, signed_in_resource=nil) + # Get the user email info from Github for sign up + data = ActiveSupport::JSON.decode(access_token.get('/api/v2/json/user/show'))["user"] + + if user = User.find_by_email(data["email"]) + user + else + # Create an user with a stub password. + User.create!(:name => user["name"], :email => user["email"], :password => Devise.friendly_token) + end + end + +First, notice the +access_token+ object allows you to make requests to the provider using get/post/put/delete methods to retrieve user information. Next, our method above has two branches and both of them returns a persisted user. So, if we go back to our github action above, we will see that after returning a persisted record, it will sign in the returned user and redirect to the configured +after_oauth_success_path_for+ with a flash message. This flash message is retrieved from I18n and looks like this: + + en: + devise: + oauth_callbacks: + # Higher priority message: + user: + github: + success: 'Hello dear user! Welcome to our app!' + + # With medium priority + github: + success: 'Hello coder! Welcome to our app!' + + # With lower priority + success: 'Successfully authorized from %{kind} account.' + +Our basic implementation assumes that all information retrieved from Github is enough for us to create an user, however this may not be true for all providers. That said, Devise allows +find_for_github_oauth+ to have different outcomes. For instance, if it returns a record which was not persisted (usually a new record with errors), it will render the sign up views from the registrations controller and show all error messages. On the other hand, if you decide to return nil from +find_for_github_oauth+, Devise will consider that you decided to skip the authentication and will redirect to +after_oauth_skipped_path_for+ (defaults to the sign in page) with the skipped flash message. + +All these methods +after_oauth_skipped_path_for+, +render_for_oauth+ and so on can be customized and overwritten in your application by inheriting from Devise::OauthCallbacksController as we have seen above in the "Configuring controllers" section. + +For last but not least, Devise also supports linking accounts. The setup discussed above only uses Github for sign up and assumes that after the user signs up, there will not have any interaction with Github at all. However, this is not true for some applications. + +If you need to interact with Github after sign up, the first step is to create a +github_token+ in the database and store in it in the +access_token+ given to +find_for_github_oauth+. You may also want to allow an already signed in user to link his account to a Github account without a need to sign up again. This is where the +signed_in_resource+ we discussed earlier takes place. If +find_for_github_oauth+ receives a signed in resource as parameter, you can link the github account to it like below: + + def self.find_for_github_oauth(access_token, signed_in_resource=nil) + data = ActiveSupport::JSON.decode(access_token.get('/api/v2/json/user/show'))["user"] + + # Link the account if an e-mail already exists in the database + # or a signed_in_resource, which is already in session was given. + if user = signed_in_resource || User.find_by_email(data["email"]) + user.update_attribute(:github_token, access_token.token) + user + else + User.create!(:name => user["name"], :email => user["email"], + :password => Devise.friendly_token){ |u| u.github_token = access_token.token } + end + end + +Since the access token is stored as string in the database, you can create another +access_token+ object to do get/post/put/delete requests like this: + + def oauth_github_token + @oauth_github_token ||= self.class.oauth_access_token(:github, github_token) + end + +Or use a composition pattern through ActiveRecord's composed_of. + +For github, the access token never expires. For facebook, you need to ask for offline access to get a token that won't expire. However, some providers like 37 Signals may expire the token and you need to store both access_token and refresh token in your database. This mechanism is not yet supported by Devise by default and you should check OAuth2 documentation for more information. + +Finally, notice in cases a resource is returned by +find_for_github_oauth+ but is not persisted, we store the access token in the session before rendering the registrations form. This allows you to recover your token later by overwriting +new_with_session+ class method in your model: + + def self.new_with_session(params, session) + super.tap { |u| u.github_token = session[:user_github_oauth_token] } + end + +This method is called automatically by Devise::RegistrationsController before building/creating a new resource. All oauth tokens in sessions are removed after the user signs in/up. + +=== Testing OAuth + +Devise provides a few helpers to aid testing. Since the +user_oauth_authorize_url(:github)+ link added to our views points to Github, we certainly don't want our integration tests to send users to Github. That said, Devise provides a way to short circuit these url helpers and make them point straight to the oauth callback url with a fake code bypassing Github. + +All you need to do is to call the following helpers: + + # Inside our (test|spec)_helper.rb + Devise::Oauth.test_mode! + + # Inside our integration tests for Oauth + setup { Devise::Oauth.short_circuit_authorizers! } + teardown { Devise::Oauth.unshort_circuit_authorizers! } + +Since we are now passing a fake code to Devise OAuth callback, if we try to retrieve an access token from Github, it will obviously fail. That said, all following requests to the provider needs to be stubbed. Luckily, Devise provides a method called +Devise::Oauth.stub!+ that yields a block to help us build our stubs. All in all, our integration test would look like this: + + # Inside our (test|spec)_helper.rb + Devise::Oauth.test_mode! + + # Inside our integration tests for Oauth + ACCESS_TOKEN = { + :access_token => "plataformatec" + } + + GITHUB_INFO = { + :user => { + :name => 'User Example', + :email => 'user@example.com' + } + } + + setup do + Devise::Oauth.short_circuit_authorizers! + Devise::Oauth.stub!(:github) do |b| + b.post('/login/oauth/access_token') { [200, {}, ACCESS_TOKEN.to_json] } + b.post('/api/v2/json/user/show') { [200, {}, GITHUB_INFO.to_json] } + end + end + + teardown do + Devise::Oauth.unshort_circuit_authorizers! + Devise::Oauth.reset_stubs! + end + + test "auth from Github" do + assert_difference "User.count", 1 do + visit "/users/sign_in" + click_link "Sign in with Github" + end + + assert_contain "Successfully authorized from Github account." + end + +Enjoy! + == Migrating from other solutions Devise implements encryption strategies for Clearance, Authlogic and Restful-Authentication. To make use of these strategies, set the desired encryptor in the encryptor initializer config option. You might also need to rename your encrypted password and salt columns to match Devise's fields (encrypted_password and password_salt). == Other ORMs -Devise supports ActiveRecord (by default) and Mongoid. We offer experimental Datamapper support (with the limitation that the Devise test suite does not run completely with Datamapper). To choose other ORM, you just need to configure it in the initializer file. +Devise supports ActiveRecord (default) and Mongoid. To choose other ORM, you just need to require it in the initializer file. == Extensions @@ -263,32 +445,29 @@ Devise also has extensions created by the community: * http://github.com/scambra/devise_invitable adds support to Devise for sending invitations by email. -* http://github.com/grimen/devise_facebook_connectable adds support for Facebook Connect authentication, and optionally fetching user info from Facebook in the same step. - * http://github.com/joshk/devise_imapable adds support for imap based authentication, excellent for internal apps when an LDAP server isn't available. * http://github.com/cschiewek/devise_ldap_authenticatable adds support for LDAP authentication via simple bind. Please consult their respective documentation for more information and requirements. -== TODO +== Bugs and Feedback -Please refer to TODO file. +If you discover any bugs, we would like to know about it. Be sure to include as much relevant information as possible, as Devise and Rails versions. If possible, please try to go through the following steps: -== Security +1) Look at the source code a bit to find out whether your assumptions are correct. We try to keep it as clean and documented as possible; -Needless to say, security is extremely important to Devise. If you find yourself in a possible security issue with Devise, please go through the following steps, trying to reproduce the bug: +2) Provide a simple way to reproduce the bug: a small test case to Devise test suite or a simple app on Github; -1) Look at the source code a bit to find out whether your assumptions are correct; -2) If possible, provide a way to reproduce the bug: a small app on Github or a step-by-step to reproduce; -3) E-mail us or send a Github private message instead of using the normal issues; +Our Issues Tracker is available at: -Being able to reproduce the bug is the first step to fix it. Thanks for your understanding. +http://github.com/plataformatec/devise/issues -== Maintainers +If you found a security bug, we ask you to *NOT* use the Issues Tracker and instead send us a private message through Github or send an e-mail to the developers. -* José Valim (http://github.com/josevalim) -* Carlos Antônio da Silva (http://github.com/carlosantoniodasilva) +Finally, if you have questions or would like to give some feedback, please use the Mailing List instead of Issues Tracker: + +http://groups.google.com/group/plataformatec-devise == Contributors @@ -296,15 +475,12 @@ We have a long list of valued contributors. Check them all at: http://github.com/plataformatec/devise/contributors -== Bugs and Feedback +If you want to add new features, let us know in the Issues Tracker! If you want to scratch our itches, feel free to check the TODO file in the repository. :) -If you discover any bugs, please create an issue on GitHub. +== Maintainers -http://github.com/plataformatec/devise/issues - -For support, send an e-mail to the mailing list. - -http://groups.google.com/group/plataformatec-devise +* José Valim (http://github.com/josevalim) +* Carlos Antônio da Silva (http://github.com/carlosantoniodasilva) == License diff --git a/TODO b/TODO index 12d52a48..66c71a87 100644 --- a/TODO +++ b/TODO @@ -1,3 +1,5 @@ * Move integration tests to Capybara * Better ORM integration * Extract activatable models tests from confirmable +* Add support to automatically refresh the access token for OAuth +* Add test to generators using the new Rails::Generators::TestCase diff --git a/app/controllers/devise/confirmations_controller.rb b/app/controllers/devise/confirmations_controller.rb index 1b0bc140..d18f361d 100644 --- a/app/controllers/devise/confirmations_controller.rb +++ b/app/controllers/devise/confirmations_controller.rb @@ -3,7 +3,7 @@ class Devise::ConfirmationsController < ApplicationController # GET /resource/confirmation/new def new - build_resource + build_resource({}) render_with_scope :new end diff --git a/app/controllers/devise/oauth_callbacks_controller.rb b/app/controllers/devise/oauth_callbacks_controller.rb new file mode 100644 index 00000000..8d4ab1ab --- /dev/null +++ b/app/controllers/devise/oauth_callbacks_controller.rb @@ -0,0 +1,4 @@ +class Devise::OauthCallbacksController < ApplicationController + include Devise::Controllers::InternalHelpers + include Devise::Oauth::InternalHelpers +end \ No newline at end of file diff --git a/app/controllers/devise/passwords_controller.rb b/app/controllers/devise/passwords_controller.rb index 6f9696e8..6460f9e7 100644 --- a/app/controllers/devise/passwords_controller.rb +++ b/app/controllers/devise/passwords_controller.rb @@ -4,7 +4,7 @@ class Devise::PasswordsController < ApplicationController # GET /resource/password/new def new - build_resource + build_resource({}) render_with_scope :new end diff --git a/app/controllers/devise/registrations_controller.rb b/app/controllers/devise/registrations_controller.rb index a49506b8..f173d424 100644 --- a/app/controllers/devise/registrations_controller.rb +++ b/app/controllers/devise/registrations_controller.rb @@ -1,5 +1,5 @@ class Devise::RegistrationsController < ApplicationController - prepend_before_filter :require_no_authentication, :only => [ :new, :create ] + prepend_before_filter :require_no_authentication, :only => [ :new, :create, :cancel ] prepend_before_filter :authenticate_scope!, :only => [:edit, :update, :destroy] include Devise::Controllers::InternalHelpers @@ -45,8 +45,23 @@ class Devise::RegistrationsController < ApplicationController sign_out_and_redirect(self.resource) end + # GET /resource/cancel + # Forces the session data which is usually expired after sign + # in to be expired now. + def cancel + expire_session_data_after_sign_in! + redirect_to new_registration_path(resource_name) + end + protected + # Build a devise resource passing in the session. Useful to move + # temporary session data to the newly created user. + def build_resource(hash=nil) + hash ||= params[resource_name] || {} + self.resource = resource_class.new_with_session(hash, session) + end + # Authenticates the current scope and gets a copy of the current resource. # We need to use a copy because we don't want actions like update changing # the current user in place. diff --git a/app/controllers/devise/unlocks_controller.rb b/app/controllers/devise/unlocks_controller.rb index d82c96b9..86d52f67 100644 --- a/app/controllers/devise/unlocks_controller.rb +++ b/app/controllers/devise/unlocks_controller.rb @@ -4,7 +4,7 @@ class Devise::UnlocksController < ApplicationController # GET /resource/unlock/new def new - build_resource + build_resource({}) render_with_scope :new end diff --git a/app/views/devise/shared/_links.erb b/app/views/devise/shared/_links.erb index 414904bd..f3ef2f54 100644 --- a/app/views/devise/shared/_links.erb +++ b/app/views/devise/shared/_links.erb @@ -17,3 +17,9 @@ <%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
<% end -%> + +<%- if devise_mapping.oauthable? %> + <%- resource_class.oauth_providers.each do |provider| %> + <%= link_to "Sign in with #{provider.to_s.titleize}", oauth_authorize_url(resource_name, provider) %>
+ <% end =%> +<% end -%> \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 5e4e4332..4c32adfa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -30,6 +30,10 @@ en: unlocks: send_instructions: 'You will receive an email with instructions about how to unlock your account in a few minutes.' unlocked: 'Your account was successfully unlocked. You are now signed in.' + oauth_callbacks: + success: 'Successfully authorized from %{kind} account.' + failure: 'Could not authorize you from %{kind} because "%{reason}".' + skipped: 'Skipped Oauth authorization for %{kind}.' mailer: confirmation_instructions: subject: 'Confirmation instructions' diff --git a/lib/devise.rb b/lib/devise.rb index 9190c9a5..a563050e 100644 --- a/lib/devise.rb +++ b/lib/devise.rb @@ -1,8 +1,10 @@ require 'active_support/core_ext/numeric/time' require 'active_support/dependencies' +require 'set' module Devise autoload :FailureApp, 'devise/failure_app' + autoload :Oauth, 'devise/oauth' autoload :PathChecker, 'devise/path_checker' autoload :Schema, 'devise/schema' autoload :TestHelpers, 'devise/test_helpers' @@ -30,11 +32,12 @@ module Devise end # Constants which holds devise configuration for extensions. Those should - # not be modified by the "end user". + # not be modified by the "end user" (this is why they are constants). ALL = [] CONTROLLERS = ActiveSupport::OrderedHash.new ROUTES = ActiveSupport::OrderedHash.new STRATEGIES = ActiveSupport::OrderedHash.new + URL_HELPERS = ActiveSupport::OrderedHash.new # True values used to check params TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'] @@ -69,7 +72,7 @@ module Devise mattr_accessor :http_authenticatable @@http_authenticatable = true - # If http authentication is used for ajax requests. True by default. + # If http authentication is used for ajax requests. True by default. mattr_accessor :http_authenticatable_on_xhr @@http_authenticatable_on_xhr = true @@ -111,11 +114,7 @@ module Devise # Used to define the password encryption algorithm. mattr_accessor :encryptor - @@encryptor = nil - - # Store scopes mappings. - mattr_accessor :mappings - @@mappings = ActiveSupport::OrderedHash.new + @@encryptor = :bcrypt # Tells if devise should apply the schema in ORMs where devise declaration # and schema belongs to the same class (as Datamapper and Mongoid). @@ -161,21 +160,37 @@ module Devise mattr_accessor :navigational_formats @@navigational_formats = [:html] - # Private methods to interface with Warden. - mattr_accessor :warden_config - @@warden_config = nil - @@warden_config_block = nil - # When set to true, signing out an user signs out all other scopes. mattr_accessor :sign_out_all_scopes @@sign_out_all_scopes = false - def self.use_default_scope=(*) - ActiveSupport::Deprecation.warn "config.use_default_scope is deprecated and removed from Devise. " << - "If you are using non conventional routes in Devise, all you need to do is to pass the devise " << - "scope in the router DSL:\n\n as :user do\n get \"sign_in\", :to => \"devise/sessions\"\n end\n\n" << - "The method :as is also aliased to :devise_scope. Choose the one you prefer.", caller - end + # Oauth providers + mattr_accessor :oauth_providers + @@oauth_providers = [] + + # PRIVATE CONFIGURATION + + # Store scopes mappings. + mattr_reader :mappings + @@mappings = ActiveSupport::OrderedHash.new + + # Oauth configurations. + mattr_reader :oauth_configs + @@oauth_configs = ActiveSupport::OrderedHash.new + + # Define a set of modules that are called when a mapping is added. + mattr_reader :helpers + @@helpers = Set.new + @@helpers << Devise::Controllers::Helpers + + # Define a set of modules that are called when a provider is added. + mattr_reader :oauth_helpers + @@oauth_helpers = Set.new + + # Private methods to interface with Warden. + mattr_accessor :warden_config + @@warden_config = nil + @@warden_config_block = nil # Default way to setup Devise. Run rails generate devise_install to create # a fresh initializer with all configuration values. @@ -197,19 +212,19 @@ module Devise # Small method that adds a mapping to Devise. def self.add_mapping(resource, options) mapping = Devise::Mapping.new(resource, options) - self.mappings[mapping.name] = mapping - self.default_scope ||= mapping.name + @@mappings[mapping.name] = mapping + @@default_scope ||= mapping.name + @@helpers.each { |h| h.define_helpers(mapping) } mapping end - # Make Devise aware of an 3rd party Devise-module. For convenience. + # Make Devise aware of an 3rd party Devise-module (like invitable). For convenience. # # == Options: # # +model+ - String representing the load path to a custom *model* for this module (to autoload.) # +controller+ - Symbol representing the name of an exisiting or custom *controller* for this module. # +route+ - Symbol representing the named *route* helper for this module. - # +flash+ - Symbol representing the *flash messages* used by this helper. # +strategy+ - Symbol representing if this module got a custom *strategy*. # # All values, except :model, accept also a boolean and will have the same name as the given module @@ -225,26 +240,36 @@ module Devise ALL << module_name options.assert_valid_keys(:strategy, :model, :controller, :route) - config = { - :strategy => STRATEGIES, - :route => ROUTES, - :controller => CONTROLLERS - } + if strategy = options[:strategy] + STRATEGIES[module_name] = (strategy == true ? module_name : strategy) + end - config.each do |key, value| - next unless options[key] - name = (options[key] == true ? module_name : options[key]) + if controller = options[:controller] + CONTROLLERS[module_name] = (controller == true ? module_name : controller) + end - if value.is_a?(Hash) - value[module_name] = name + if route = options[:route] + case route + when TrueClass + key, value = module_name, [] + when Symbol + key, value = route, [] + when Hash + key, value = route.keys.first, route.values.flatten else - value << name unless value.include?(name) + raise ArgumentError, ":route should be true, a Symbol or a Hash" end + + URL_HELPERS[key] ||= [] + URL_HELPERS[key].concat(value) + URL_HELPERS[key].uniq! + + ROUTES[module_name] = key end if options[:model] - model_path = (options[:model] == true ? "devise/models/#{module_name}" : options[:model]) - Devise::Models.send(:autoload, module_name.to_s.camelize.to_sym, model_path) + path = (options[:model] == true ? "devise/models/#{module_name}" : options[:model]) + Devise::Models.send(:autoload, module_name.to_s.camelize.to_sym, path) end Devise::Mapping.add_module module_name @@ -265,6 +290,37 @@ module Devise @@warden_config_block = block end + # Specify an oauth provider. + # + # config.oauth :github, APP_ID, APP_SECRET, + # :site => 'https://github.com/', + # :authorize_path => '/login/oauth/authorize', + # :access_token_path => '/login/oauth/access_token', + # :scope => %w(user public_repo) + # + def self.oauth(provider, *args) + @@helpers << Devise::Oauth::UrlHelpers + @@oauth_helpers << Devise::Oauth::InternalHelpers + + @@oauth_providers << provider + @@oauth_providers.uniq! + + @@oauth_helpers.each { |h| h.define_oauth_helpers(provider) } + @@oauth_configs[provider] = Devise::Oauth::Config.new(*args) + end + + # Include helpers in the given scope to AC and AV. + def self.include_helpers(scope) + ActiveSupport.on_load(:action_controller) do + include scope::Helpers + include scope::UrlHelpers + end + + ActiveSupport.on_load(:action_view) do + include scope::UrlHelpers + end + end + # A method used internally to setup warden manager from the Rails initialize # block. def self.configure_warden! #:nodoc: diff --git a/lib/devise/controllers/helpers.rb b/lib/devise/controllers/helpers.rb index 2129c990..8908696e 100644 --- a/lib/devise/controllers/helpers.rb +++ b/lib/devise/controllers/helpers.rb @@ -9,6 +9,52 @@ module Devise *Devise.mappings.keys.map { |m| [:"current_#{m}", :"#{m}_signed_in?", :"#{m}_session"] }.flatten end + # Define authentication filters and accessor helpers based on mappings. + # These filters should be used inside the controllers as before_filters, + # so you can control the scope of the user who should be signed in to + # access that specific controller/action. + # Example: + # + # Roles: + # User + # Admin + # + # Generated methods: + # authenticate_user! # Signs user in or redirect + # authenticate_admin! # Signs admin in or redirect + # user_signed_in? # Checks whether there is an user signed in or not + # admin_signed_in? # Checks whether there is an admin signed in or not + # current_user # Current signed in user + # current_admin # Currend signed in admin + # user_session # Session data available only to the user scope + # admin_session # Session data available only to the admin scope + # + # Use: + # before_filter :authenticate_user! # Tell devise to use :user map + # before_filter :authenticate_admin! # Tell devise to use :admin map + # + def self.define_helpers(mapping) #:nodoc: + mapping = mapping.name + + class_eval <<-METHODS, __FILE__, __LINE__ + 1 + def authenticate_#{mapping}! + warden.authenticate!(:scope => :#{mapping}) + end + + def #{mapping}_signed_in? + !!current_#{mapping} + end + + def current_#{mapping} + @current_#{mapping} ||= warden.authenticate(:scope => :#{mapping}) + end + + def #{mapping}_session + current_#{mapping} && warden.session(:#{mapping}) + end + METHODS + end + # The main accessor for the warden proxy instance def warden request.env['warden'] @@ -40,13 +86,16 @@ module Devise # # Examples: # - # sign_in :user, @user # sign_in(scope, resource) - # sign_in @user # sign_in(resource) + # sign_in :user, @user # sign_in(scope, resource) + # sign_in @user # sign_in(resource) + # sign_in @user, :event => :authentication # sign_in(resource, options) # - def sign_in(resource_or_scope, resource=nil) - scope = Devise::Mapping.find_scope!(resource_or_scope) - resource ||= resource_or_scope - warden.set_user(resource, :scope => scope) + def sign_in(resource_or_scope, *args) + options = args.extract_options! + scope = Devise::Mapping.find_scope!(resource_or_scope) + resource = args.last || resource_or_scope + expire_session_data_after_sign_in! + warden.set_user(resource, options.merge!(:scope => scope)) end # Sign out a given user or scope. This helper is useful for signing out an user @@ -159,14 +208,17 @@ module Devise end # Sign in an user and tries to redirect first to the stored location and - # then to the url specified by after_sign_in_path_for. - # - # If just a symbol is given, consider that the user was already signed in - # through other means and just perform the redirection. - def sign_in_and_redirect(resource_or_scope, resource=nil) - scope = Devise::Mapping.find_scope!(resource_or_scope) - resource ||= resource_or_scope - sign_in(scope, resource) unless warden.user(scope) == resource + # then to the url specified by after_sign_in_path_for. It accepts the same + # parameters as the sign_in method. + def sign_in_and_redirect(resource_or_scope, *args) + options = args.extract_options! + scope = Devise::Mapping.find_scope!(resource_or_scope) + resource = args.last || resource_or_scope + sign_in(scope, resource, options) unless warden.user(scope) == resource + redirect_for_sign_in(scope, resource) + end + + def redirect_for_sign_in(scope, resource) #:nodoc: redirect_to stored_location_for(scope) || after_sign_in_path_for(resource) end @@ -179,53 +231,17 @@ module Devise else sign_out(scope) end + redirect_for_sign_out(scope) + end + + def redirect_for_sign_out(scope) #:nodoc: redirect_to after_sign_out_path_for(scope) end - # Define authentication filters and accessor helpers based on mappings. - # These filters should be used inside the controllers as before_filters, - # so you can control the scope of the user who should be signed in to - # access that specific controller/action. - # Example: - # - # Roles: - # User - # Admin - # - # Generated methods: - # authenticate_user! # Signs user in or redirect - # authenticate_admin! # Signs admin in or redirect - # user_signed_in? # Checks whether there is an user signed in or not - # admin_signed_in? # Checks whether there is an admin signed in or not - # current_user # Current signed in user - # current_admin # Currend signed in admin - # user_session # Session data available only to the user scope - # admin_session # Session data available only to the admin scope - # - # Use: - # before_filter :authenticate_user! # Tell devise to use :user map - # before_filter :authenticate_admin! # Tell devise to use :admin map - # - Devise.mappings.each_key do |mapping| - class_eval <<-METHODS, __FILE__, __LINE__ + 1 - def authenticate_#{mapping}! - warden.authenticate!(:scope => :#{mapping}) - end - - def #{mapping}_signed_in? - warden.authenticate?(:scope => :#{mapping}) - end - - def current_#{mapping} - @current_#{mapping} ||= warden.authenticate(:scope => :#{mapping}) - end - - def #{mapping}_session - current_#{mapping} && warden.session(:#{mapping}) - end - METHODS + # A hook called to expire session data after sign up/in. This is used + # by a few extensions, like oauth, to expire tokens stored in session. + def expire_session_data_after_sign_in! end - end end end diff --git a/lib/devise/controllers/internal_helpers.rb b/lib/devise/controllers/internal_helpers.rb index 7be60c94..36b4ad49 100644 --- a/lib/devise/controllers/internal_helpers.rb +++ b/lib/devise/controllers/internal_helpers.rb @@ -10,7 +10,7 @@ module Devise included do helper DeviseHelper - helpers = %w(resource scope_name resource_name + helpers = %w(resource scope_name resource_name signed_in_resource resource_class devise_mapping devise_controller?) hide_action *helpers helper_method *helpers @@ -35,6 +35,11 @@ module Devise devise_mapping.to end + # Returns a signed in resource from session (if one exists) + def signed_in_resource + warden.authenticate(:scope => resource_name) + end + # Attempt to find the mapped route for devise based on request path def devise_mapping @devise_mapping ||= request.env["devise.mapping"] @@ -49,7 +54,12 @@ module Devise # Checks whether it's a devise mapped resource or not. def is_devise_resource? #:nodoc: - raise ActionController::UnknownAction unless devise_mapping + unknown_action!("Could not find devise mapping for #{request.fullpath}.") unless devise_mapping + end + + def unknown_action!(msg) + logger.debug "[Devise] #{msg}" if logger + raise ActionController::UnknownAction, msg end # Sets the resource creating an instance variable @@ -85,12 +95,14 @@ module Devise # # Please refer to README or en.yml locale file to check what messages are # available. - def set_flash_message(key, kind) - flash[key] = I18n.t(:"#{resource_name}.#{kind}", :resource_name => resource_name, - :scope => [:devise, controller_name.to_sym], :default => kind) + def set_flash_message(key, kind, options={}) #:nodoc: + options[:scope] = "devise.#{controller_name}" + options[:default] = Array(options[:default]).unshift(kind.to_sym) + options[:resource_name] = resource_name + flash[key] = I18n.t("#{resource_name}.#{kind}", options) end - def clean_up_passwords(object) + def clean_up_passwords(object) #:nodoc: object.clean_up_passwords if object.respond_to?(:clean_up_passwords) end end diff --git a/lib/devise/controllers/scoped_views.rb b/lib/devise/controllers/scoped_views.rb index aa0a3d42..0736a0f9 100644 --- a/lib/devise/controllers/scoped_views.rb +++ b/lib/devise/controllers/scoped_views.rb @@ -17,17 +17,15 @@ module Devise # Render a view for the specified scope. Turned off by default. # Accepts just :controller as option. - def render_with_scope(action, options={}) - controller_name = options.delete(:controller) || self.controller_name - + def render_with_scope(action, path=self.controller_path) if self.class.scoped_views? begin - render :template => "#{devise_mapping.plural}/#{controller_name}/#{action}" + render :template => "#{devise_mapping.plural}/#{path.split("/").last}/#{action}" rescue ActionView::MissingTemplate - render :template => "#{controller_path}/#{action}" + render :template => "#{path}/#{action}" end else - render :template => "#{controller_path}/#{action}" + render :template => "#{path}/#{action}" end end end diff --git a/lib/devise/controllers/url_helpers.rb b/lib/devise/controllers/url_helpers.rb index c39c11e2..b1bb46da 100644 --- a/lib/devise/controllers/url_helpers.rb +++ b/lib/devise/controllers/url_helpers.rb @@ -19,13 +19,11 @@ module Devise # Those helpers are added to your ApplicationController. module UrlHelpers - Devise::ROUTES.values.uniq.each do |module_name| + Devise::URL_HELPERS.each do |module_name, actions| [:path, :url].each do |path_or_url| - actions = [ nil, :new_ ] - actions << :edit_ if [:password, :registration].include?(module_name) - actions << :destroy_ if [:session].include?(module_name) - actions.each do |action| + action = action ? "#{action}_" : "" + class_eval <<-URL_HELPERS, __FILE__, __LINE__ + 1 def #{action}#{module_name}_#{path_or_url}(resource_or_scope, *args) scope = Devise::Mapping.find_scope!(resource_or_scope) diff --git a/lib/devise/failure_app.rb b/lib/devise/failure_app.rb index c398123a..ada9d295 100644 --- a/lib/devise/failure_app.rb +++ b/lib/devise/failure_app.rb @@ -27,6 +27,7 @@ module Devise elsif warden_options[:recall] recall else + debug! redirect end end @@ -52,6 +53,11 @@ module Devise protected + def debug! + return unless Rails.logger.try(:debug?) + Rails.logger.debug "[Devise] Could not sign in #{scope}: #{i18n_message.inspect}." + end + def i18n_message(default = nil) message = warden.message || warden_options[:message] || default || :unauthenticated @@ -89,7 +95,7 @@ module Devise end def scope - @scope ||= warden_options[:scope] + @scope ||= warden_options[:scope] || Devise.default_scope end def attempted_path @@ -101,7 +107,7 @@ module Devise # yet, but we still need to store the uri based on scope, so different scopes # would never use the same uri to redirect. def store_location! - session[:"#{scope}_return_to"] = attempted_path if request.get? && !http_auth? + session["#{scope}_return_to"] = attempted_path if request.get? && !http_auth? end end end diff --git a/lib/devise/models.rb b/lib/devise/models.rb index 51438f64..7f11e3e3 100644 --- a/lib/devise/models.rb +++ b/lib/devise/models.rb @@ -47,20 +47,6 @@ module Devise def devise(*modules) include Devise::Models::Authenticatable options = modules.extract_options! - - if modules.delete(:authenticatable) - ActiveSupport::Deprecation.warn ":authenticatable as module is deprecated. Please give :database_authenticatable instead.", caller - modules << :database_authenticatable - end - - if modules.delete(:activatable) - ActiveSupport::Deprecation.warn ":activatable as module is deprecated. It's included in your model by default.", caller - end - - if modules.delete(:http_authenticatable) - ActiveSupport::Deprecation.warn ":http_authenticatable as module is deprecated and is on by default. Revert by setting :http_authenticatable => false.", caller - end - self.devise_modules += Devise::ALL & modules.map(&:to_sym).uniq devise_modules_hook! do diff --git a/lib/devise/models/authenticatable.rb b/lib/devise/models/authenticatable.rb index e7f20cee..6c6fb227 100644 --- a/lib/devise/models/authenticatable.rb +++ b/lib/devise/models/authenticatable.rb @@ -4,18 +4,17 @@ module Devise module Models # Authenticable module. Holds common settings for authentication. # - # == Configuration: + # == Options # - # You can overwrite configuration values by setting in globally in Devise, - # using devise method or overwriting the respective instance method. + # Authenticatable adds the following options to devise_for: # - # authentication_keys: parameters used for authentication. By default [:email]. + # * +authentication_keys+: parameters used for authentication. By default [:email]. # - # http_authenticatable: if this model allows http authentication. By default true. - # It also accepts an array specifying the strategies that should allow http. + # * +http_authenticatable+: if this model allows http authentication. By default true. + # It also accepts an array specifying the strategies that should allow http. # - # params_authenticatable: if this model allows authentication through request params. By default true. - # It also accepts an array specifying the strategies that should allow params authentication. + # * +params_authenticatable+: if this model allows authentication through request params. By default true. + # It also accepts an array specifying the strategies that should allow params authentication. # # == Active? # diff --git a/lib/devise/models/confirmable.rb b/lib/devise/models/confirmable.rb index ca2b642e..3cfa6800 100644 --- a/lib/devise/models/confirmable.rb +++ b/lib/devise/models/confirmable.rb @@ -9,22 +9,22 @@ module Devise # it means it won't be able to sign in again without confirming the account # again through the email that was sent. # - # Configuration: + # == Options # - # confirm_within: the time you want the user will have to confirm it's account - # without blocking his access. When confirm_within is zero, the - # user won't be able to sign in without confirming. You can - # use this to let your user access some features of your - # application without confirming the account, but blocking it - # after a certain period (ie 7 days). By default confirm_within is - # zero, it means users always have to confirm to sign in. + # Confirmable adds the following options to devise_for: # - # Examples: + # * +confirm_within+: the time you want to allow the user to access his account + # before confirming it. After this period, the user access is denied. You can + # use this to let your user access some features of your application without + # confirming the account, but blocking it after a certain period (ie 7 days). + # By default confirm_within is zero, it means users always have to confirm to sign in. + # + # == Examples # # User.find(1).confirm! # returns true unless it's already confirmed # User.find(1).confirmed? # true/false # User.find(1).send_confirmation_instructions # manually send instructions - # User.find(1).resend_confirmation! # generates a new token and resent it + # module Confirmable extend ActiveSupport::Concern diff --git a/lib/devise/models/database_authenticatable.rb b/lib/devise/models/database_authenticatable.rb index bd549024..5c65fb3f 100644 --- a/lib/devise/models/database_authenticatable.rb +++ b/lib/devise/models/database_authenticatable.rb @@ -5,21 +5,20 @@ module Devise # Authenticable Module, responsible for encrypting password and validating # authenticity of a user while signing in. # - # Configuration: + # == Options # - # You can overwrite configuration values by setting in globally in Devise, - # using devise method or overwriting the respective instance method. + # DatabaseAuthenticable adds the following options to devise_for: # - # pepper: encryption key used for creating encrypted password. Each time - # password changes, it's gonna be encrypted again, and this key - # is added to the password and salt to create a secure hash. - # Always use `rake secret' to generate a new key. + # * +pepper+: encryption key used for creating encrypted password. Each time + # password changes, it's gonna be encrypted again, and this key is added + # to the password and salt to create a secure hash. Always use `rake secret' + # to generate a new key. # - # stretches: defines how many times the password will be encrypted. + # * +stretches+: defines how many times the password will be encrypted. # - # encryptor: the encryptor going to be used. By default :sha1. + # * +encryptor+: the encryptor going to be used. By default :sha1. # - # Examples: + # == Examples # # User.find(1).valid_password?('password123') # returns true/false # diff --git a/lib/devise/models/lockable.rb b/lib/devise/models/lockable.rb index a5132026..ef58b4bc 100644 --- a/lib/devise/models/lockable.rb +++ b/lib/devise/models/lockable.rb @@ -7,13 +7,14 @@ module Devise # will unlock the user automatically after some configured time (ie 2.hours). # It's also possible to setup lockable to use both email and time strategies. # - # Configuration: + # == Options # - # maximum_attempts: how many attempts should be accepted before blocking the user. - # lock_strategy: lock the user account by :failed_attempts or :none. - # unlock_strategy: unlock the user account by :time, :email, :both or :none. - # unlock_in: the time you want to lock the user after to lock happens. Only - # available when unlock_strategy is :time or :both. + # Lockable adds the following options to devise_for: + # + # * +maximum_attempts+: how many attempts should be accepted before blocking the user. + # * +lock_strategy+: lock the user account by :failed_attempts or :none. + # * +unlock_strategy+: unlock the user account by :time, :email, :both or :none. + # * +unlock_in+: the time you want to lock the user after to lock happens. Only available when unlock_strategy is :time or :both. # module Lockable extend ActiveSupport::Concern diff --git a/lib/devise/models/oauthable.rb b/lib/devise/models/oauthable.rb new file mode 100644 index 00000000..9f1951d6 --- /dev/null +++ b/lib/devise/models/oauthable.rb @@ -0,0 +1,49 @@ +module Devise + module Models + # Adds OAuth support to your model. The whole workflow is deeply discussed in the + # README. This module adds just a class +oauth_access_token+ helper to your model + # which assists you on creating an access token. All the other OAuth hooks in + # Devise must be implemented by yourself in your application. + # + # == Options + # + # Oauthable adds the following options to devise_for: + # + # * +oauth_providers+: Which providers are avaialble to this model. It expects an array: + # + # devise_for :database_authenticatable, :oauthable, :oauth_providers => [:twitter] + # + module Oauthable + extend ActiveSupport::Concern + + module ClassMethods + def oauth_configs #:nodoc: + Devise.oauth_configs.slice(*oauth_providers) + end + + # Pass a token stored in the database to this object to get an OAuth2::AccessToken + # object back, as the one received in your model hook. + # + # For each provider you add, you may want to add a hook to retrieve the token based + # on the column you stored the token in the database. For example, you may want to + # the following for twitter: + # + # def oauth_twitter_token + # @oauth_twitter_token ||= self.class.oauth_access_token(:twitter, twitter_token) + # end + # + # You can call get, post, put and delete in this object to access Twitter's API. + def oauth_access_token(provider, token) + oauth_configs[provider].access_token_by_token(token) + end + + # TODO Implement this method in the future. + # def refresh_oauth_token(provider, refresh_token) + # returns access_token + # end + + Devise::Models.config(self, :oauth_providers) + end + end + end +end \ No newline at end of file diff --git a/lib/devise/models/recoverable.rb b/lib/devise/models/recoverable.rb index 6f5d69e0..1265b003 100644 --- a/lib/devise/models/recoverable.rb +++ b/lib/devise/models/recoverable.rb @@ -1,8 +1,9 @@ module Devise module Models - # Recoverable takes care of reseting the user password and send reset instructions - # Examples: + # Recoverable takes care of reseting the user password and send reset instructions. + # + # == Examples # # # resets the user password and save the record, true if valid passwords are given, otherwise false # User.find(1).reset_password!('password123', 'password123') @@ -13,6 +14,7 @@ module Devise # # # creates a new token and send it with instructions about how to reset the password # User.find(1).send_reset_password_instructions + # module Recoverable extend ActiveSupport::Concern diff --git a/lib/devise/models/registerable.rb b/lib/devise/models/registerable.rb index 9b51d4aa..f6da656c 100644 --- a/lib/devise/models/registerable.rb +++ b/lib/devise/models/registerable.rb @@ -3,6 +3,19 @@ module Devise # Registerable is responsible for everything related to registering a new # resource (ie user sign up). module Registerable + extend ActiveSupport::Concern + + module ClassMethods + # A convenience method that receives both parameters and session to + # initialize an user. This can be used by OAuth, for example, to send + # in the user token and be stored on initialization. + # + # By default discards all information sent by the session by calling + # new with params. + def new_with_session(params, session) + new(params) + end + end end end end diff --git a/lib/devise/models/rememberable.rb b/lib/devise/models/rememberable.rb index 650adbdb..c412f44f 100644 --- a/lib/devise/models/rememberable.rb +++ b/lib/devise/models/rememberable.rb @@ -11,24 +11,23 @@ module Devise # You probably wouldn't use rememberable methods directly, they are used # mostly internally for handling the remember token. # - # Configuration: + # == Options # - # remember_for: the time you want the user will be remembered without - # asking for credentials. After this time the user will be - # blocked and will have to enter his credentials again. - # This configuration is also used to calculate the expires - # time for the cookie created to remember the user. - # 2.weeks by default. + # Rememberable adds the following options in devise_for: # - # remember_across_browsers: if true, a valid remember token can be - # re-used between multiple browsers. - # True by default. + # * +remember_for+: the time you want the user will be remembered without + # asking for credentials. After this time the user will be blocked and + # will have to enter his credentials again. This configuration is als + # used to calculate the expires time for the cookie created to remember + # the user. By default remember_for is 2.weeks. # - # extend_remember_period: if true, extends the user's remember period - # when remembered via cookie. - # False by default. + # * +remember_across_browsers+: if a valid remember token can be re-used + # between multiple browsers. By default remember_across_browsers is true. # - # Examples: + # * +extend_remember_period+: if true, extends the user's remember period + # when remembered via cookie. False by default. + # + # == Examples # # User.find(1).remember_me! # regenerating the token # User.find(1).forget_me! # clearing the token diff --git a/lib/devise/models/timeoutable.rb b/lib/devise/models/timeoutable.rb index a922797b..18463445 100644 --- a/lib/devise/models/timeoutable.rb +++ b/lib/devise/models/timeoutable.rb @@ -7,9 +7,16 @@ module Devise # will be asked for credentials again, it means, he/she will be redirected # to the sign in page. # - # Configuration: + # == Options + # + # Timeoutable adds the following options to devise_for: + # + # * +timeout_in+: the interval to timeout the user session without activity. + # + # == Examples + # + # user.timedout?(30.minutes.ago) # - # timeout_in: the time you want to timeout the user session without activity. module Timeoutable extend ActiveSupport::Concern diff --git a/lib/devise/models/token_authenticatable.rb b/lib/devise/models/token_authenticatable.rb index a012d82f..075f95ed 100644 --- a/lib/devise/models/token_authenticatable.rb +++ b/lib/devise/models/token_authenticatable.rb @@ -8,12 +8,11 @@ module Devise # This module only provides a few helpers to help you manage the token. Creating and resetting # the token is your responsibility. # - # == Configuration: + # == Options # - # You can overwrite configuration values by setting in globally in Devise (+Devise.setup+), - # using devise method, or overwriting the respective instance method. + # TokenAuthenticable adds the following options to devise_for: # - # +token_authentication_key+ - Defines name of the authentication token params key. E.g. /users/sign_in?some_key=... + # * +token_authentication_key+: Defines name of the authentication token params key. E.g. /users/sign_in?some_key=... # module TokenAuthenticatable extend ActiveSupport::Concern diff --git a/lib/devise/models/validatable.rb b/lib/devise/models/validatable.rb index 3977e6a7..36e775f4 100644 --- a/lib/devise/models/validatable.rb +++ b/lib/devise/models/validatable.rb @@ -1,10 +1,17 @@ module Devise module Models - # Validatable creates all needed validations for a user email and password. # It's optional, given you may want to create the validations by yourself. # Automatically validate if the email is present, unique and it's format is - # valid. Also tests presence of password, confirmation and length + # valid. Also tests presence of password, confirmation and length. + # + # == Options + # + # Validatable adds the following options to devise_for: + # + # * +email_regexp+: the regular expression used to validate e-mails; + # * +password_length+: a range expressing password length. Defaults to 6..20. + # module Validatable # All validations used by this module. VALIDATIONS = [ :validates_presence_of, :validates_uniqueness_of, :validates_format_of, diff --git a/lib/devise/modules.rb b/lib/devise/modules.rb index 9935ebd6..1f688c03 100644 --- a/lib/devise/modules.rb +++ b/lib/devise/modules.rb @@ -2,20 +2,26 @@ require 'active_support/core_ext/object/with_options' Devise.with_options :model => true do |d| # Strategies first - d.with_options :strategy => true do |s| - s.add_module :database_authenticatable, :controller => :sessions, :route => :session - s.add_module :token_authenticatable, :controller => :sessions, :route => :session + d.with_options :strategy => true do |s| + routes = [nil, :new, :destroy] + s.add_module :database_authenticatable, :controller => :sessions, :route => { :session => routes } + s.add_module :token_authenticatable, :controller => :sessions, :route => { :session => routes } s.add_module :rememberable end - # Misc after - d.add_module :recoverable, :controller => :passwords, :route => :password - d.add_module :registerable, :controller => :registrations, :route => :registration + # Other authentications + d.add_module :oauthable, :controller => :oauth_callbacks, :route => :oauth_callback + + # Misc after + routes = [nil, :new, :edit] + d.add_module :recoverable, :controller => :passwords, :route => { :password => routes } + d.add_module :registerable, :controller => :registrations, :route => { :registration => (routes << :cancel) } d.add_module :validatable # The ones which can sign out after - d.add_module :confirmable, :controller => :confirmations, :route => :confirmation - d.add_module :lockable, :controller => :unlocks, :route => :unlock + routes = [nil, :new] + d.add_module :confirmable, :controller => :confirmations, :route => { :confirmation => routes } + d.add_module :lockable, :controller => :unlocks, :route => { :unlock => routes } d.add_module :timeoutable # Stats for last, so we make sure the user is really signed in diff --git a/lib/devise/oauth.rb b/lib/devise/oauth.rb new file mode 100644 index 00000000..8b81d0fb --- /dev/null +++ b/lib/devise/oauth.rb @@ -0,0 +1,41 @@ +begin + require "oauth2" +rescue LoadError => e + warn "Could not load 'oauth2'. Please ensure you have the gem installed and listed in your Gemfile." + raise +end + +module Devise + module Oauth + autoload :Config, "devise/oauth/config" + autoload :Helpers, "devise/oauth/helpers" + autoload :InternalHelpers, "devise/oauth/internal_helpers" + autoload :UrlHelpers, "devise/oauth/url_helpers" + autoload :TestHelpers, "devise/oauth/test_helpers" + + class << self + delegate :short_circuit_authorizers!, :unshort_circuit_authorizers!, :to => "Devise::Oauth::TestHelpers" + + def test_mode! + Faraday.default_adapter = :test + ActiveSupport.on_load(:action_controller) { include Devise::Oauth::TestHelpers } + ActiveSupport.on_load(:action_view) { include Devise::Oauth::TestHelpers } + end + + def stub!(provider, stubs=nil, &block) + raise "You either need to pass stubs as a block or as a parameter" unless block_given? || stubs + stubs ||= Faraday::Adapter::Test::Stubs.new(&block) + Devise.oauth_configs[provider].build_connection do |b| + b.adapter :test, stubs + end + end + + def reset_stubs!(*providers) + target = providers.any? ? Devise.oauth_configs.slice(*providers) : Devise.oauth_configs + target.each_value do |v| + v.build_connection { |b| b.adapter Faraday.default_adapter } + end + end + end + end +end \ No newline at end of file diff --git a/lib/devise/oauth/config.rb b/lib/devise/oauth/config.rb new file mode 100644 index 00000000..d9af7878 --- /dev/null +++ b/lib/devise/oauth/config.rb @@ -0,0 +1,33 @@ +require 'active_support/core_ext/array/wrap' + +module Devise + module Oauth + # A configuration object that holds the OAuth2::Client object + # and all configuration values given config.oauth. + class Config + attr_reader :scope, :client + + def initialize(app_id, app_secret, options) + @scope = Array.wrap(options.delete(:scope)) + @client = OAuth2::Client.new(app_id, app_secret, options) + end + + def authorize_url(options) + options[:scope] ||= @scope.join(',') + client.web_server.authorize_url(options) + end + + def access_token_by_code(code, redirect_uri=nil) + client.web_server.get_access_token(code, :redirect_uri => redirect_uri) + end + + def access_token_by_token(token) + OAuth2::AccessToken.new(client, token) + end + + def build_connection(&block) + client.connection.build(&block) + end + end + end +end \ No newline at end of file diff --git a/lib/devise/oauth/helpers.rb b/lib/devise/oauth/helpers.rb new file mode 100644 index 00000000..b825c39d --- /dev/null +++ b/lib/devise/oauth/helpers.rb @@ -0,0 +1,28 @@ +module Devise + module Oauth + # Provides a few helpers that are included in ActionController::Base + # for convenience. + module Helpers + extend ActiveSupport::Concern + + included do + helper_method :oauth_callback + end + + def oauth_callback #:nodoc: + nil + end + alias :oauth_provider :oauth_callback + + protected + + # Overwrite expire_session_data_after_sign_in! so it removes all + # oauth tokens from session ensuring registrations done in a row + # do not try to store the same token in the database. + def expire_session_data_after_sign_in! + super + session.keys.grep(/_oauth_token$/).each { |k| session.delete(k) } + end + end + end +end \ No newline at end of file diff --git a/lib/devise/oauth/internal_helpers.rb b/lib/devise/oauth/internal_helpers.rb new file mode 100644 index 00000000..f62ae651 --- /dev/null +++ b/lib/devise/oauth/internal_helpers.rb @@ -0,0 +1,190 @@ +module Devise + module Oauth + module InternalHelpers + extend ActiveSupport::Concern + + def self.define_oauth_helpers(name) #:nodoc: + alias_method(name, :callback_action) + public name + end + + included do + helpers = %w(oauth_config) + hide_action *helpers + helper_method *helpers + before_filter :valid_oauth_callback?, :oauth_error_happened? + end + + # Returns the oauth_callback (also aliased as oauth_provider) as a symbol. + # For example: :github. + def oauth_callback + @oauth_callback ||= action_name.to_sym + end + alias :oauth_provider :oauth_callback + + # Returns the configuration object for this oauth callback. + def oauth_config + @oauth_client ||= resource_class.oauth_configs[oauth_callback] + end + + protected + + # This method checks three things: + # + # * If the URL being accessed is a valid provider for the given scope; + # * If code or error was streamed back from the server; + # * If the resource class implements the required hook; + # + def valid_oauth_callback? + unless oauth_config + unknown_action! "Skipping #{oauth_callback} OAuth because configuration " << + "could not be found for model #{resource_name}." + end + + unless params[:code] || params[:error] || params[:error_reason] + unknown_action! "Skipping #{oauth_callback} OAuth because code nor error were sent." + end + + unless resource_class.respond_to?(oauth_model_callback) + raise "#{resource_class.name} does not respond to #{oauth_model_callback}. " << + "Check the OAuth section in the README for more information." + end + end + + # Check if an error was sent by the authorizer. If it happened, we redirect + # to url specified by after_oauth_failure_path_for, which defaults to new_session_path. + # + # By default, Devise shows a custom message from I18n saying the user could + # not be authenticated and the reason: + # + # en: + # devise: + # oauth_callbacks: + # failure: 'Could not authorize you from %{kind} because "%{reason}".' + # + # Let's suppose the reason returned by a Github was "access_denied". It will show: + # + # Could not authorize you from Github because "Access denied" + # + # And it will also be logged on console: + # + # github oauth failed: "access_denied". + # + # However, each specific error message can be customized using I18n: + # + # en: + # devise: + # oauth_callbacks: + # access_denied: 'You did not give access to our application on %{kind}.' + # + # Note "access_denied" follows the same lookup rule described in set_oauth_flash_message + # method. Besides, is important to remember most errors are specified by OAuth 2 + # specification. But a few providers do not use them yet. + # + # TODO: Currently, Facebook is returning error_reason=user_denied when + # the user denies, but the specification defines error=access_denied instead. + def oauth_error_happened? + if error = params[:error] || params[:error_reason] + # Some providers returns access-denied instead of access_denied. + error = error.to_s.gsub("-", "_") + logger.warn "[Devise] #{oauth_callback} oauth failed: #{error.inspect}." + + set_oauth_flash_message :alert, error[0,25], :default => :failure, :reason => error.humanize + redirect_to after_oauth_failure_path_for(resource_name) + end + end + + # The model method used as hook. + def oauth_model_callback #:nodoc: + "find_for_#{oauth_callback}_oauth" + end + + # The session key to store the token. + def oauth_session_key #:nodoc: + "#{resource_name}_#{oauth_callback}_oauth_token" + end + + # The callback redirect uri. Used to request the access token. + def oauth_redirect_uri #:nodoc: + oauth_callback_url(resource_name, oauth_callback) + end + + # This is the implementation for all OAuth actions. + def callback_action + access_token = oauth_config.access_token_by_code(params[:code], oauth_redirect_uri) + self.resource = resource_class.send(oauth_model_callback, access_token, signed_in_resource) + + if resource && resource.persisted? && resource.errors.empty? + set_oauth_flash_message :notice, :success + sign_in_and_redirect resource_name, resource, :event => :authentication + elsif resource + session[oauth_session_key] = access_token.token + clean_up_passwords(resource) + render_for_oauth + else + set_oauth_flash_message :alert, :skipped + redirect_to after_oauth_skipped_path_for(resource_name) + end + end + + # Handles oauth flash messages by adding a cascade. The default messages + # are always in the controller namespace: + # + # en: + # devise: + # oauth_callbacks: + # success: 'Successfully authorized from %{kind} account.' + # failure: 'Could not authorize you from %{kind} because "%{reason}".' + # skipped: 'Skipped Oauth authorization for %{kind}.' + # + # But they can also be nested according to the oauth provider: + # + # en: + # devise: + # oauth_callbacks: + # github: + # success: 'Hello coder! Welcome to our app!' + # + # And finally by Devise scope: + # + # en: + # devise: + # oauth_callbacks: + # admin: + # github: + # success: 'Hello coder with high permissions! Can I get a raise?' + # + def set_oauth_flash_message(key, type, options={}) + options[:kind] = oauth_callback.to_s.titleize + options[:default] = Array(options[:default]).unshift(type.to_sym) + set_flash_message(key, "#{oauth_callback}.#{type}", options) + end + + # Choose which template to render when a not persisted resource is + # returned in the find_for_x_oauth. By default, it renders registrations/new. + def render_for_oauth + render_with_scope :new, devise_mapping.controllers[:registrations] + end + + # The default hook used by oauth to specify the redirect url for success. + def after_oauth_success_path_for(resource_or_scope) + after_sign_in_path_for(resource_or_scope) + end + + # The default hook used by oauth to specify the redirect url for skip. + def after_oauth_skipped_path_for(scope) + new_session_path(scope) + end + + # The default hook used by oauth to specify the redirect url for failure. + def after_oauth_failure_path_for(scope) + new_session_path(scope) + end + + # Overwrite redirect_for_sign_in so it takes uses after_oauth_success_path_for. + def redirect_for_sign_in(scope, resource) #:nodoc: + redirect_to stored_location_for(scope) || after_oauth_success_path_for(resource) + end + end + end +end \ No newline at end of file diff --git a/lib/devise/oauth/test_helpers.rb b/lib/devise/oauth/test_helpers.rb new file mode 100644 index 00000000..9082bf63 --- /dev/null +++ b/lib/devise/oauth/test_helpers.rb @@ -0,0 +1,29 @@ +module Devise + module Oauth + module TestHelpers #:nodoc: + def self.short_circuit_authorizers! + module_eval <<-ALIASES, __FILE__, __LINE__ + 1 + def oauth_authorize_url(scope, provider) + oauth_callback_url(scope, provider, :code => "12345") + end + ALIASES + + Devise.mappings.each_value do |m| + next unless m.oauthable? + + module_eval <<-ALIASES, __FILE__, __LINE__ + 1 + def #{m.name}_oauth_authorize_url(provider) + #{m.name}_oauth_callback_url(provider, :code => "12345") + end + ALIASES + end + end + + def self.unshort_circuit_authorizers! + module_eval do + instance_methods.each { |m| remove_method(m) } + end + end + end + end +end \ No newline at end of file diff --git a/lib/devise/oauth/url_helpers.rb b/lib/devise/oauth/url_helpers.rb new file mode 100644 index 00000000..49ac366b --- /dev/null +++ b/lib/devise/oauth/url_helpers.rb @@ -0,0 +1,35 @@ +module Devise + module Oauth + module UrlHelpers + def self.define_helpers(mapping) + return unless mapping.oauthable? + + class_eval <<-URL_HELPERS, __FILE__, __LINE__ + 1 + def #{mapping.name}_oauth_authorize_url(provider, options={}) + if config = Devise.oauth_configs[provider.to_sym] + options[:redirect_uri] ||= #{mapping.name}_oauth_callback_url(provider.to_s) + config.authorize_url(options) + else + raise ArgumentError, "Could not find oauth provider \#{provider.inspect}" + end + end + URL_HELPERS + end + + def oauth_authorize_url(resource_or_scope, *args) + scope = Devise::Mapping.find_scope!(resource_or_scope) + send("#{scope}_oauth_authorize_url", *args) + end + + def oauth_callback_url(resource_or_scope, *args) + scope = Devise::Mapping.find_scope!(resource_or_scope) + send("#{scope}_oauth_callback_url", *args) + end + + def oauth_callback_path(resource_or_scope, *args) + scope = Devise::Mapping.find_scope!(resource_or_scope) + send("#{scope}_oauth_callback_path", *args) + end + end + end +end \ No newline at end of file diff --git a/lib/devise/rails.rb b/lib/devise/rails.rb index 30ba6f4f..c671fe0d 100644 --- a/lib/devise/rails.rb +++ b/lib/devise/rails.rb @@ -1,14 +1,16 @@ require 'devise/rails/routes' require 'devise/rails/warden_compat' -# Include UrlHelpers in ActionController and ActionView as soon as they are loaded. -ActiveSupport.on_load(:action_controller) { include Devise::Controllers::UrlHelpers } -ActiveSupport.on_load(:action_view) { include Devise::Controllers::UrlHelpers } - module Devise class Engine < ::Rails::Engine config.devise = Devise + # Skip eager load of controllers because it is handled by Devise + # to avoid loading unused controllers. + config.paths.app.controllers.autoload! + config.paths.app.controllers.skip_eager_load! + + # Initialize Warden and copy its configurations. config.app_middleware.use Warden::Manager do |config| Devise.warden_config = config end @@ -16,54 +18,33 @@ module Devise # Force routes to be loaded if we are doing any eager load. config.before_eager_load { |app| app.reload_routes! } - config.after_initialize do - Devise.encryptor ||= begin - warn "[WARNING] config.encryptor is not set in your config/initializers/devise.rb. " \ - "Devise will then set it to :bcrypt. If you were using the previous default " \ - "encryptor, please add config.encryptor = :sha1 to your configuration file." if Devise.mailer_sender - :bcrypt - end - end - initializer "devise.add_filters" do |app| app.config.filter_parameters += [:password, :password_confirmation] app.config.filter_parameters.uniq end - unless Rails.env.production? - config.after_initialize do - actions = [:confirmation_instructions, :reset_password_instructions, :unlock_instructions] + initializer "devise.url_helpers" do + Devise.include_helpers(Devise::Controllers) + end - translations = begin - I18n.t("devise.mailer", :raise => true).map { |k, v| k if v.is_a?(String) }.compact - rescue Exception => e # Do not care if something fails - [] - end + initializer "devise.oauth_url_helpers" do + if Devise.oauth_providers.any? + Devise.include_helpers(Devise::Oauth) + end + end - keys = actions & translations + # Check all available mapings and only load related controllers. + def eager_load! + mappings = Devise.mappings.values.map(&:modules).flatten.uniq + controllers = Devise::CONTROLLERS.values_at(*mappings) + path = paths.app.controllers.to_a.first + matcher = /\A#{Regexp.escape(path)}\/(.*)\.rb\Z/ - keys.each do |key| - ActiveSupport::Deprecation.warn "The I18n message 'devise.mailer.#{key}' is deprecated. " \ - "Please use 'devise.mailer.#{key}.subject' instead." - end + Dir.glob("#{path}/devise/{#{controllers.join(',')}}_controller.rb").sort.each do |file| + require_dependency file.sub(matcher, '\1') end - config.after_initialize do - flash = [:unauthenticated, :unconfirmed, :invalid, :invalid_token, :timeout, :inactive, :locked] - - translations = begin - I18n.t("devise.sessions", :raise => true).keys - rescue Exception => e # Do not care if something fails - [] - end - - keys = flash & translations - - if keys.any? - ActiveSupport::Deprecation.warn "The following I18n messages in 'devise.sessions' " \ - "are deprecated: #{keys.to_sentence}. Please move them to 'devise.failure' instead." - end - end + super end end end \ No newline at end of file diff --git a/lib/devise/rails/routes.rb b/lib/devise/rails/routes.rb index 5f93d4e3..6e5a18ee 100644 --- a/lib/devise/rails/routes.rb +++ b/lib/devise/rails/routes.rb @@ -5,7 +5,6 @@ module ActionDispatch::Routing def finalize_with_devise! finalize_without_devise! Devise.configure_warden! - ActionController::Base.send :include, Devise::Controllers::Helpers end alias_method_chain :finalize!, :devise end @@ -116,16 +115,6 @@ module ActionDispatch::Routing def devise_for(*resources) options = resources.extract_options! - if as = options.delete(:as) - ActiveSupport::Deprecation.warn ":as is deprecated, please use :path instead." - options[:path] ||= as - end - - if scope = options.delete(:scope) - ActiveSupport::Deprecation.warn ":scope is deprecated, please use :singular instead." - options[:singular] ||= scope - end - options[:as] ||= @scope[:as] if @scope[:as].present? options[:module] ||= @scope[:module] if @scope[:module].present? options[:path_prefix] ||= @scope[:path] if @scope[:path].present? @@ -227,8 +216,20 @@ module ActionDispatch::Routing end def devise_registration(mapping, controllers) #:nodoc: - resource :registration, :only => [:new, :create, :edit, :update, :destroy], :path => mapping.path_names[:registration], - :path_names => { :new => mapping.path_names[:sign_up] }, :controller => controllers[:registrations] + path_names = { + :new => mapping.path_names[:sign_up], + :cancel => mapping.path_names[:cancel] + } + + resource :registration, :except => :show, :path => mapping.path_names[:registration], + :path_names => path_names, :controller => controllers[:registrations] do + get :cancel + end + end + + def devise_oauth_callback(mapping, controllers) #:nodoc: + get "/oauth/:action/callback", :action => Regexp.union(mapping.to.oauth_providers.map(&:to_s)), + :to => controllers[:oauth_callbacks], :as => :oauth_callback end def with_devise_exclusive_scope(new_path, new_as) #:nodoc: diff --git a/lib/devise/schema.rb b/lib/devise/schema.rb index 409ffe00..f0ff2a14 100644 --- a/lib/devise/schema.rb +++ b/lib/devise/schema.rb @@ -3,11 +3,6 @@ module Devise # and overwrite the apply_schema method. module Schema - def authenticatable(*args) - ActiveSupport::Deprecation.warn "t.authenticatable in migrations is deprecated. Please use t.database_authenticatable instead.", caller - database_authenticatable(*args) - end - # Creates email, encrypted_password and password_salt. # # == Options @@ -21,10 +16,6 @@ module Devise null = options[:null] || false default = options[:default] || "" - if options.delete(:encryptor) - ActiveSupport::Deprecation.warn ":encryptor as option is deprecated, simply remove it." - end - apply_devise_schema :email, String, :null => null, :default => default apply_devise_schema :encrypted_password, String, :null => null, :default => default, :limit => 128 apply_devise_schema :password_salt, String, :null => null, :default => default diff --git a/lib/generators/devise/orm_helpers.rb b/lib/generators/devise/orm_helpers.rb index c20a9864..5e881b74 100644 --- a/lib/generators/devise/orm_helpers.rb +++ b/lib/generators/devise/orm_helpers.rb @@ -4,7 +4,7 @@ module Devise def model_contents <<-CONTENT # Include default devise modules. Others available are: - # :token_authenticatable, :confirmable, :lockable and :timeoutable + # :token_authenticatable, :confirmable, :lockable, :timeoutable and :oauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable diff --git a/lib/generators/devise/templates/devise.rb b/lib/generators/devise/templates/devise.rb index 6878d17b..f3f56f3c 100644 --- a/lib/generators/devise/templates/devise.rb +++ b/lib/generators/devise/templates/devise.rb @@ -35,16 +35,16 @@ Devise.setup do |config| # config.http_authentication_realm = "Application" # ==> Configuration for :database_authenticatable - # For bcrypt, this is the cost for hashing the password and defaults to 10. If - # using other encryptors, it sets how many times you want the password re-encrypted. - config.stretches = 10 - # Define which will be the encryption algorithm. Devise also supports encryptors # from others authentication tools as :clearance_sha1, :authlogic_sha512 (then # you should set stretches above to 20 for default behavior) and :restful_authentication_sha1 # (then you should set stretches to 10, and copy REST_AUTH_SITE_KEY to pepper) config.encryptor = :bcrypt + # For bcrypt, this is the cost for hashing the password and defaults to 10. If + # using other encryptors, it sets how many times you want the password re-encrypted. + config.stretches = 10 + # Setup a pepper to generate the encrypted password. config.pepper = <%= ActiveSupport::SecureRandom.hex(64).inspect %> @@ -126,17 +126,21 @@ Devise.setup do |config| # should add them to the navigational formats lists. Default is [:html] # config.navigational_formats = [:html, :iphone] + # ==> OAuth2 + # Add a new OAuth2 provider. Check the README for more information on setting + # up on your models and hooks. + # config.oauth :github, 'APP_ID', 'APP_SECRET', + # :site => 'https://github.com/', + # :authorize_path => '/login/oauth/authorize', + # :access_token_path => '/login/oauth/access_token', + # :scope => %w(user public_repo) + # ==> Warden configuration - # If you want to use other strategies, that are not (yet) supported by Devise, - # you can configure them inside the config.warden block. The example below - # allows you to setup OAuth, using http://github.com/roman/warden_oauth + # If you want to use other strategies, that are not supported by Devise, or + # change the failure app, you can configure them inside the config.warden block. # # config.warden do |manager| - # manager.oauth(:twitter) do |twitter| - # twitter.consumer_secret = - # twitter.consumer_key = - # twitter.options :site => 'http://twitter.com' - # end - # manager.default_strategies(:scope => :user).unshift :twitter_oauth + # manager.failure_app = AnotherApp + # manager.default_strategies(:scope => :user).unshift :some_external_strategy # end end diff --git a/lib/generators/devise_install_generator.rb b/lib/generators/devise_install_generator.rb deleted file mode 100644 index 1458b047..00000000 --- a/lib/generators/devise_install_generator.rb +++ /dev/null @@ -1,4 +0,0 @@ -# Remove this file after deprecation -if caller.none? { |l| l =~ %r{lib/rails/generators\.rb:(\d+):in `lookup!'$} } - warn "[WARNING] `rails g devise_install` is deprecated, please use `rails g devise:install` instead." -end \ No newline at end of file diff --git a/lib/generators/devise_views_generator.rb b/lib/generators/devise_views_generator.rb deleted file mode 100644 index ef9ded35..00000000 --- a/lib/generators/devise_views_generator.rb +++ /dev/null @@ -1,4 +0,0 @@ -# Remove this file after deprecation -if caller.none? { |l| l =~ %r{lib/rails/generators\.rb:(\d+):in `lookup!'$} } - warn "[WARNING] `rails g devise_views` is deprecated, please use `rails g devise:views` instead." -end \ No newline at end of file diff --git a/test/controllers/helpers_test.rb b/test/controllers/helpers_test.rb index be7da526..81523257 100644 --- a/test/controllers/helpers_test.rb +++ b/test/controllers/helpers_test.rb @@ -1,51 +1,16 @@ require 'test_helper' require 'ostruct' -class MockController < ApplicationController - attr_accessor :env - - def request - self - end - - def path - '' - end - - def index - end - - def host_with_port - "test.host:3000" - end - - def protocol - "http" - end - - def script_name - "" - end - - def symbolized_path_parameters - {} - end -end - class ControllerAuthenticableTest < ActionController::TestCase - tests MockController + tests ApplicationController def setup @mock_warden = OpenStruct.new - @controller.env = { 'warden' => @mock_warden } - end - - test 'setup warden' do - assert_not_nil @controller.warden + @controller.request.env['warden'] = @mock_warden end test 'provide access to warden instance' do - assert_equal @controller.warden, @controller.env['warden'] + assert_equal @mock_warden, @controller.warden end test 'proxy signed_in? to authenticated' do @@ -81,13 +46,13 @@ class ControllerAuthenticableTest < ActionController::TestCase end test 'proxy user_signed_in? to authenticate? with user scope' do - @mock_warden.expects(:authenticate?).with(:scope => :user) - @controller.user_signed_in? + @mock_warden.expects(:authenticate).with(:scope => :user).returns("user") + assert @controller.user_signed_in? end test 'proxy admin_signed_in? to authenticate? with admin scope' do - @mock_warden.expects(:authenticate?).with(:scope => :admin) - @controller.admin_signed_in? + @mock_warden.expects(:authenticate).with(:scope => :admin) + assert_not @controller.admin_signed_in? end test 'proxy user_session to session scope in warden' do diff --git a/test/controllers/internal_helpers_test.rb b/test/controllers/internal_helpers_test.rb index fc08e6aa..cd334507 100644 --- a/test/controllers/internal_helpers_test.rb +++ b/test/controllers/internal_helpers_test.rb @@ -22,16 +22,16 @@ class HelpersTest < ActionController::TestCase end test 'get resource instance variable from env' do - @controller.instance_variable_set(:@user, admin = Admin.new) - assert_equal admin, @controller.resource + @controller.instance_variable_set(:@user, user = User.new) + assert_equal user, @controller.resource end test 'set resource instance variable from env' do - admin = @controller.send(:resource_class).new - @controller.send(:resource=, admin) + user = @controller.send(:resource_class).new + @controller.send(:resource=, user) - assert_equal admin, @controller.send(:resource) - assert_equal admin, @controller.instance_variable_get(:@user) + assert_equal user, @controller.send(:resource) + assert_equal user, @controller.instance_variable_get(:@user) end test 'resources methods are not controller actions' do @@ -39,11 +39,15 @@ class HelpersTest < ActionController::TestCase end test 'require no authentication tests current mapping' do - @controller.expects(:resource_name).returns(:user).twice @mock_warden.expects(:authenticated?).with(:user).returns(true) @controller.expects(:redirect_to).with(root_path) @controller.send :require_no_authentication end + + test 'signed in resource returns signed in resource for current scope' do + @mock_warden.expects(:authenticate).with(:scope => :user).returns(User.new) + assert_kind_of User, @controller.signed_in_resource + end test 'is a devise controller' do assert @controller.devise_controller? diff --git a/test/controllers/url_helpers_test.rb b/test/controllers/url_helpers_test.rb index 00ff5b82..1b82bb8f 100644 --- a/test/controllers/url_helpers_test.rb +++ b/test/controllers/url_helpers_test.rb @@ -20,7 +20,7 @@ class RoutesTest < ActionController::TestCase send(:"#{prepend_path}user_#{name}_url", :param => 123) @request.path = nil - # With an AR object + # With an object assert_equal @controller.send(:"#{prepend_path}#{name}_path", User.new), send(:"#{prepend_path}user_#{name}_path") assert_equal @controller.send(:"#{prepend_path}#{name}_url", User.new), @@ -54,5 +54,6 @@ class RoutesTest < ActionController::TestCase assert_path_and_url :registration assert_path_and_url :registration, :new assert_path_and_url :registration, :edit + assert_path_and_url :registration, :cancel end end diff --git a/test/integration/authenticatable_test.rb b/test/integration/authenticatable_test.rb index 5ee55fad..267c89ab 100644 --- a/test/integration/authenticatable_test.rb +++ b/test/integration/authenticatable_test.rb @@ -1,15 +1,6 @@ require 'test_helper' class AuthenticationSanityTest < ActionController::IntegrationTest - - def setup - Devise.sign_out_all_scopes = false - end - - def teardown - Devise.sign_out_all_scopes = false - end - test 'home should be accessible without sign in' do visit '/' assert_response :success @@ -18,14 +9,12 @@ class AuthenticationSanityTest < ActionController::IntegrationTest test 'sign in as user should not authenticate admin scope' do sign_in_as_user - assert warden.authenticated?(:user) assert_not warden.authenticated?(:admin) end test 'sign in as admin should not authenticate user scope' do sign_in_as_admin - assert warden.authenticated?(:admin) assert_not warden.authenticated?(:user) end @@ -33,59 +22,61 @@ class AuthenticationSanityTest < ActionController::IntegrationTest test 'sign in as both user and admin at same time' do sign_in_as_user sign_in_as_admin - assert warden.authenticated?(:user) assert warden.authenticated?(:admin) end test 'sign out as user should not touch admin authentication if sign_out_all_scopes is false' do - sign_in_as_user - sign_in_as_admin - - get destroy_user_session_path - assert_not warden.authenticated?(:user) - assert warden.authenticated?(:admin) + swap Devise, :sign_out_all_scopes => false do + sign_in_as_user + sign_in_as_admin + get destroy_user_session_path + assert_not warden.authenticated?(:user) + assert warden.authenticated?(:admin) + end end test 'sign out as admin should not touch user authentication if sign_out_all_scopes is false' do - sign_in_as_user - sign_in_as_admin + swap Devise, :sign_out_all_scopes => false do + sign_in_as_user + sign_in_as_admin - get destroy_admin_session_path - assert_not warden.authenticated?(:admin) - assert warden.authenticated?(:user) + get destroy_admin_session_path + assert_not warden.authenticated?(:admin) + assert warden.authenticated?(:user) + end end test 'sign out as user should also sign out admin if sign_out_all_scopes is true' do - Devise.sign_out_all_scopes = true - sign_in_as_user - sign_in_as_admin + swap Devise, :sign_out_all_scopes => true do + sign_in_as_user + sign_in_as_admin - get destroy_user_session_path - assert_not warden.authenticated?(:user) - assert_not warden.authenticated?(:admin) + get destroy_user_session_path + assert_not warden.authenticated?(:user) + assert_not warden.authenticated?(:admin) + end end test 'sign out as admin should also sign out user if sign_out_all_scopes is true' do - Devise.sign_out_all_scopes = true - sign_in_as_user - sign_in_as_admin + swap Devise, :sign_out_all_scopes => true do + sign_in_as_user + sign_in_as_admin - get destroy_admin_session_path - assert_not warden.authenticated?(:admin) - assert_not warden.authenticated?(:user) + get destroy_admin_session_path + assert_not warden.authenticated?(:admin) + assert_not warden.authenticated?(:user) + end end test 'not signed in as admin should not be able to access admins actions' do get admins_path - assert_redirected_to new_admin_session_path assert_not warden.authenticated?(:admin) end test 'not signed in as admin should not be able to access private route restricted to admins' do get private_path - assert_redirected_to new_admin_session_path assert_not warden.authenticated?(:admin) end @@ -94,7 +85,6 @@ class AuthenticationSanityTest < ActionController::IntegrationTest sign_in_as_user assert warden.authenticated?(:user) assert_not warden.authenticated?(:admin) - get private_path assert_redirected_to new_admin_session_path end @@ -236,6 +226,25 @@ class AuthenticationSessionTest < ActionController::IntegrationTest get '/users' assert_equal "Cart", @controller.user_session[:cart] end + + test 'does not explode when invalid user class is stored in session' do + klass = User + paths = ActiveSupport::Dependencies.autoload_paths.dup + + begin + sign_in_as_user + assert warden.authenticated?(:user) + + Object.send :remove_const, :User + ActiveSupport::Dependencies.autoload_paths.clear + + visit "/users" + assert_not warden.authenticated?(:user) + ensure + Object.const_set(:User, klass) + ActiveSupport::Dependencies.autoload_paths.replace(paths) + end + end end class AuthenticationWithScopesTest < ActionController::IntegrationTest @@ -277,18 +286,6 @@ class AuthenticationWithScopesTest < ActionController::IntegrationTest end end end - - test 'uses the mapping from router' do - sign_in_as_user :visit => "/as/sign_in" - assert warden.authenticated?(:user) - assert_not warden.authenticated?(:admin) - end - - test 'uses the mapping from nested devise_for call' do - sign_in_as_user :visit => "/devise_for/sign_in" - assert warden.authenticated?(:user) - assert_not warden.authenticated?(:admin) - end end class AuthenticationOthersTest < ActionController::IntegrationTest @@ -317,28 +314,21 @@ class AuthenticationOthersTest < ActionController::IntegrationTest end end - test 'registration in xml format' do + test 'registration in xml format works when recognizing path' do assert_nothing_raised do post user_registration_path(:format => 'xml', :user => {:email => "test@example.com", :password => "invalid"} ) end end - test 'does not explode when invalid user class is stored in session' do - klass = User - paths = ActiveSupport::Dependencies.autoload_paths.dup + test 'uses the mapping from router' do + sign_in_as_user :visit => "/as/sign_in" + assert warden.authenticated?(:user) + assert_not warden.authenticated?(:admin) + end - begin - sign_in_as_user - assert warden.authenticated?(:user) - - Object.send :remove_const, :User - ActiveSupport::Dependencies.autoload_paths.clear - - visit "/users" - assert_not warden.authenticated?(:user) - ensure - Object.const_set(:User, klass) - ActiveSupport::Dependencies.autoload_paths.replace(paths) - end + test 'uses the mapping from nested devise_for call' do + sign_in_as_user :visit => "/devise_for/sign_in" + assert warden.authenticated?(:user) + assert_not warden.authenticated?(:admin) end end diff --git a/test/integration/oauthable_test.rb b/test/integration/oauthable_test.rb new file mode 100644 index 00000000..23ba64a3 --- /dev/null +++ b/test/integration/oauthable_test.rb @@ -0,0 +1,295 @@ +require 'test_helper' + +class OAuthableIntegrationTest < ActionController::IntegrationTest + FACEBOOK_INFO = { + :username => 'usertest', + :email => 'user@test.com' + } + + ACCESS_TOKEN = { + :access_token => "plataformatec" + } + + setup do + Devise::Oauth.short_circuit_authorizers! + end + + teardown do + Devise::Oauth.unshort_circuit_authorizers! + Devise::Oauth.reset_stubs! + User.singleton_class.remove_possible_method(:find_for_github_oauth) + end + + def stub_github!(times=1) + def User.find_for_github_oauth(*); end + + Devise::Oauth.stub!(:github) do |b| + b.post('/login/oauth/access_token') { [200, {}, ACCESS_TOKEN.to_json] } + end + end + + def stub_facebook!(times=1) + # If times != 1, use invalid data + data = (times != 1) ? FACEBOOK_INFO.except(:email) : FACEBOOK_INFO + + Devise::Oauth.stub!(:facebook) do |b| + b.post('/oauth/access_token') { [200, {}, ACCESS_TOKEN.to_json] } + times.times { + b.get('/me?access_token=plataformatec') { [200, {}, data.to_json] } + } + end + end + + test "[BASIC] setup with persisted user" do + stub_facebook! + + assert_difference "User.count", 1 do + visit "/users/sign_in" + click_link "Sign in with Facebook" + end + + assert_current_url "/" + assert_contain "Successfully authorized from Facebook account." + + assert warden.authenticated?(:user) + assert_not warden.authenticated?(:admin) + assert "plataformatec", warden.user(:user).facebook_token + end + + test "[BASIC] setup with not persisted user and follow up" do + stub_facebook!(2) + + assert_no_difference "User.count" do + visit "/users/sign_in" + click_link "Sign in with Facebook" + end + + assert_contain "1 error prohibited this user from being saved" + assert_contain "Email can't be blank" + + assert_not warden.authenticated?(:user) + assert_not warden.authenticated?(:admin) + + fill_in "Email", :with => "user.form@test.com" + click_button "Sign up" + + assert_current_url "/" + assert_contain "You have signed up successfully." + assert_contain "Hello User user.form@test.com" + + assert warden.authenticated?(:user) + assert_not warden.authenticated?(:admin) + assert "plataformatec", warden.user(:user).facebook_token + end + + test "[BASIC] setup updating an existing user in database" do + stub_facebook! + user = create_user + + assert_no_difference "User.count" do + visit "/users/sign_in" + click_link "Sign in with Facebook" + end + + assert_current_url "/" + assert_contain "Successfully authorized from Facebook account." + + assert_equal user, warden.user(:user) + assert_equal "plataformatec", user.reload.facebook_token + end + + test "[BASIC] setup updating an existing user in session" do + stub_facebook! + + # Create an user and change his e-mail + user = sign_in_as_user + user.update_attribute(:email, "another@test.com") + + assert_no_difference "User.count" do + visit "/" + click_link "Sign in with Facebook" + end + + assert_current_url "/" + assert_contain "Successfully authorized from Facebook account." + + assert_equal user, warden.user(:user) + assert_equal "another@test.com", warden.user(:user).email + assert_equal "plataformatec", user.reload.facebook_token + end + + test "[BASIC] setup skipping oauth callback" do + stub_github! + + assert_no_difference "User.count" do + visit "/users/sign_in" + click_link "Sign in with Github" + end + + assert_current_url "/users/sign_in" + assert_contain "Skipped Oauth authorization for Github." + + assert_not warden.authenticated?(:user) + assert_not warden.authenticated?(:admin) + end + + test "[SESSION CLEANUP] ensures session is cleaned up after sign up" do + stub_facebook!(2) + + assert_no_difference "User.count" do + visit "/users/sign_in" + click_link "Sign in with Facebook" + end + + assert_contain "1 error prohibited this user from being saved" + fill_in "Email", :with => "user.form@test.com" + click_button "Sign up" + + assert_contain "You have signed up successfully." + visit "/users/sign_out" + + user = sign_in_as_user + assert_nil warden.user(:user).facebook_token + assert_equal user, warden.user(:user) + end + + test "[SESSION CLEANUP] ensures session is cleaned up on cancel" do + stub_facebook!(2) + + assert_no_difference "User.count" do + visit "/users/sign_in" + click_link "Sign in with Facebook" + end + + assert_contain "1 error prohibited this user from being saved" + visit "/users/cancel" + + user = sign_in_as_user + assert_nil warden.user(:user).facebook_token + assert_equal user, warden.user(:user) + end + + test "[SESSION CLEANUP] ensures session is cleaned up on sign in" do + stub_facebook!(2) + + assert_no_difference "User.count" do + visit "/users/sign_in" + click_link "Sign in with Facebook" + end + + assert_contain "1 error prohibited this user from being saved" + + user = sign_in_as_user + assert_nil warden.user(:user).facebook_token + assert_equal user, warden.user(:user) + end + + test "[I18N] scopes messages based on oauth callback for success" do + stub_facebook! + + store_translations :en, :devise => { :oauth_callbacks => { + :facebook => { :success => "Welcome facebooker" } } } do + visit "/users/sign_in" + click_link "Sign in with Facebook" + assert_contain "Welcome facebooker" + end + end + + test "[I18N] scopes messages based on oauth callback and resource name for success" do + stub_facebook! + + store_translations :en, :devise => { :oauth_callbacks => { + :user => { :facebook => { :success => "Welcome facebooker user" } }, + :facebook => { :success => "Welcome facebooker" } } } do + visit "/users/sign_in" + click_link "Sign in with Facebook" + assert_contain "Welcome facebooker user" + end + end + + test "[I18N] scopes messages based on oauth callback for skipped" do + stub_github! + + store_translations :en, :devise => { :oauth_callbacks => { + :github => { :skipped => "Skipped github" } } } do + visit "/users/sign_in" + click_link "Sign in with Github" + assert_contain "Skipped github" + end + end + + test "[I18N] scopes messages based on oauth callback and resource name for skipped" do + stub_github! + + store_translations :en, :devise => { :oauth_callbacks => { + :user => { :github => { :skipped => "Skipped github user" } }, + :github => { :skipped => "Skipped github" } } } do + visit "/users/sign_in" + click_link "Sign in with Github" + assert_contain "Skipped github user" + end + end + + test "[FAILURE] shows 404 if no code or error are given as params" do + assert_raise AbstractController::ActionNotFound do + visit "/users/oauth/facebook/callback" + end + end + + test "[FAILURE] raises an error if model does not implement a hook" do + begin + visit "/users/oauth/github/callback?code=123456" + raise "Expected visit to raise an error" + rescue Exception => e + assert_match "User does not respond to find_for_github_oauth", e.message + end + end + + test "[FAILURE] handles callback error parameter according to the specification" do + visit "/users/oauth/facebook/callback?error=access_denied" + assert_current_url "/users/sign_in" + assert_contain 'Could not authorize you from Facebook because "Access denied".' + end + + test "[FAILURE] handles callback error_reason just for Facebook compatibility" do + visit "/users/oauth/facebook/callback?error_reason=access_denied" + assert_current_url "/users/sign_in" + assert_contain 'Could not authorize you from Facebook because "Access denied".' + end + + test "[FAILURE][I18N] uses I18n for custom messages" do + store_translations :en, :devise => { :oauth_callbacks => { :access_denied => "Access denied bro" } } do + visit "/users/oauth/facebook/callback?error=access_denied" + assert_current_url "/users/sign_in" + assert_contain "Access denied bro" + end + end + + test "[FAILURE][I18N] uses I18n with oauth callback scope for custom messages" do + store_translations :en, :devise => { :oauth_callbacks => { + :facebook => { :access_denied => "Access denied bro" } } } do + visit "/users/oauth/facebook/callback?error=access_denied" + assert_current_url "/users/sign_in" + assert_contain "Access denied bro" + end + end + + test "[FAILURE][I18N] uses I18n with oauth callback scope and resource name for custom messages" do + store_translations :en, :devise => { :oauth_callbacks => { + :user => { :facebook => { :access_denied => "Access denied user" } }, + :facebook => { :access_denied => "Access denied bro" } } } do + visit "/users/oauth/facebook/callback?error=access_denied" + assert_current_url "/users/sign_in" + assert_contain "Access denied user" + end + end + + test "[FAILURE][I18N] trim messages to avoid long symbols lookups" do + store_translations :en, :devise => { :oauth_callbacks => { + :facebook => { ("a"*25) => "Access denied bro" } } } do + visit "/users/oauth/facebook/callback?error=#{"a"*100}" + assert_current_url "/users/sign_in" + assert_contain "Access denied bro" + end + end +end \ No newline at end of file diff --git a/test/integration/registerable_test.rb b/test/integration/registerable_test.rb index 0da7db4d..2ef47339 100644 --- a/test/integration/registerable_test.rb +++ b/test/integration/registerable_test.rb @@ -150,4 +150,16 @@ class RegistrationTest < ActionController::IntegrationTest assert User.all.empty? end + + test 'a user should be able to cancel sign up by deleting data in the session' do + get "/set" + assert_equal "something", @request.session["user_provider_oauth_token"] + + get "/users/sign_up" + assert_equal "something", @request.session["user_provider_oauth_token"] + + get "/users/cancel" + assert_nil @request.session["user_provider_oauth_token"] + assert_redirected_to new_user_registration_path + end end diff --git a/test/models/oauthable_test.rb b/test/models/oauthable_test.rb new file mode 100644 index 00000000..58a781ad --- /dev/null +++ b/test/models/oauthable_test.rb @@ -0,0 +1,21 @@ +require 'test_helper' + +class OauthableTest < ActiveSupport::TestCase + teardown { Devise::Oauth.reset_stubs! } + + test "oauth_configs returns all configurations relative to that model" do + swap User, :oauth_providers => [:github] do + assert_equal User.oauth_configs, Devise.oauth_configs.slice(:github) + end + end + + test "oauth_access_token returns the token object for the given provider" do + Devise::Oauth.stub!(:facebook) do |b| + b.get('/me?access_token=plataformatec') { [200, {}, {}.to_json] } + end + + access_token = User.oauth_access_token(:facebook, "plataformatec") + assert_kind_of OAuth2::AccessToken, access_token + assert_equal "{}", access_token.get("/me") + end +end \ No newline at end of file diff --git a/test/oauth/config_test.rb b/test/oauth/config_test.rb new file mode 100644 index 00000000..bb7fdde1 --- /dev/null +++ b/test/oauth/config_test.rb @@ -0,0 +1,44 @@ +require 'test_helper' + +class OauthConfigTest < ActiveSupport::TestCase + ACCESS_TOKEN = { + :access_token => "plataformatec" + } + + setup { @config = Devise.oauth_configs[:facebook] } + teardown { Devise::Oauth.reset_stubs! } + + test "stored OAuth2::Client" do + assert_kind_of OAuth2::Client, @config.client + end + + test "build authorize url" do + url = @config.authorize_url(:redirect_uri => "foo") + assert_match "https://graph.facebook.com/oauth/authorize?", url + assert_match "scope=email%2Coffline_access", url + assert_match "client_id=APP_ID", url + assert_match "type=web_server", url + assert_match "redirect_uri=foo", url + end + + test "retrieves access token object by code" do + Devise::Oauth.stub!(:facebook) do |b| + b.post('/oauth/access_token') { [200, {}, ACCESS_TOKEN.to_json] } + b.get('/me?access_token=plataformatec') { [200, {}, {}.to_json] } + end + + access_token = @config.access_token_by_code("12345") + assert_kind_of OAuth2::AccessToken, access_token + assert_equal "{}", access_token.get("/me") + end + + test "retrieves access token object by token" do + Devise::Oauth.stub!(:facebook) do |b| + b.get('/me?access_token=plataformatec') { [200, {}, {}.to_json] } + end + + access_token = @config.access_token_by_token("plataformatec") + assert_kind_of OAuth2::AccessToken, access_token + assert_equal "{}", access_token.get("/me") + end +end \ No newline at end of file diff --git a/test/oauth/url_helpers_test.rb b/test/oauth/url_helpers_test.rb new file mode 100644 index 00000000..de03ed52 --- /dev/null +++ b/test/oauth/url_helpers_test.rb @@ -0,0 +1,47 @@ +require 'test_helper' + +class OauthRoutesTest < ActionController::TestCase + tests ApplicationController + + def assert_path_and_url(action, provider) + # Resource param + assert_equal @controller.send(action, :user, provider), + @controller.send("user_#{action}", provider) + + # Default url params + assert_equal @controller.send(action, :user, provider, :param => 123), + @controller.send("user_#{action}", provider, :param => 123) + + # With an object + assert_equal @controller.send(action, User.new, provider, :param => 123), + @controller.send("user_#{action}", provider, :param => 123) + end + + test 'should alias oauth_callback to mapped user auth_callback' do + assert_path_and_url :oauth_callback_path, :github + assert_path_and_url :oauth_callback_url, :github + assert_path_and_url :oauth_callback_path, :facebook + assert_path_and_url :oauth_callback_url, :facebook + end + + test 'should alias oauth_authorize to mapped user auth_authorize' do + assert_path_and_url :oauth_authorize_url, :github + assert_path_and_url :oauth_authorize_url, :facebook + end + + test 'should adds scope, provider and redirect_uri to authorize urls' do + url = @controller.oauth_authorize_url(:user, :github) + assert_match "https://github.com/login/oauth/authorize?", url + assert_match "scope=user%2Cpublic_repo", url + assert_match "client_id=APP_ID", url + assert_match "type=web_server", url + assert_match "redirect_uri=http%3A%2F%2Ftest.host%2Fusers%2Foauth%2Fgithub%2Fcallback", url + + url = @controller.oauth_authorize_url(:user, :facebook) + assert_match "https://graph.facebook.com/oauth/authorize?", url + assert_match "scope=email%2Coffline_access", url + assert_match "client_id=APP_ID", url + assert_match "type=web_server", url + assert_match "redirect_uri=http%3A%2F%2Ftest.host%2Fusers%2Foauth%2Ffacebook%2Fcallback", url + end +end diff --git a/test/rails_app/app/active_record/admin.rb b/test/rails_app/app/active_record/admin.rb index 5b36afa6..124bc905 100644 --- a/test/rails_app/app/active_record/admin.rb +++ b/test/rails_app/app/active_record/admin.rb @@ -1,3 +1,6 @@ +require 'shared_admin' + class Admin < ActiveRecord::Base - devise :database_authenticatable, :registerable, :timeoutable, :recoverable, :lockable, :unlock_strategy => :time + include Shim + include SharedAdmin end diff --git a/test/rails_app/app/active_record/user.rb b/test/rails_app/app/active_record/user.rb index 4f5540de..ccb119e2 100644 --- a/test/rails_app/app/active_record/user.rb +++ b/test/rails_app/app/active_record/user.rb @@ -1,7 +1,8 @@ -class User < ActiveRecord::Base - devise :database_authenticatable, :confirmable, :lockable, :recoverable, - :registerable, :rememberable, :timeoutable, :token_authenticatable, - :trackable, :validatable +require 'shared_user' - attr_accessible :username, :email, :password, :password_confirmation +class User < ActiveRecord::Base + include Shim + include SharedUser + + attr_accessible :username, :email, :password, :password_confirmation, :remember_me end diff --git a/test/rails_app/app/controllers/home_controller.rb b/test/rails_app/app/controllers/home_controller.rb index 78d0d799..28412b83 100644 --- a/test/rails_app/app/controllers/home_controller.rb +++ b/test/rails_app/app/controllers/home_controller.rb @@ -4,4 +4,9 @@ class HomeController < ApplicationController def private end + + def set + session["user_provider_oauth_token"] = "something" + head :ok + end end diff --git a/test/rails_app/app/mongoid/admin.rb b/test/rails_app/app/mongoid/admin.rb index 76a5bfd0..acbe2f74 100644 --- a/test/rails_app/app/mongoid/admin.rb +++ b/test/rails_app/app/mongoid/admin.rb @@ -1,6 +1,7 @@ +require 'shared_admin' + class Admin include Mongoid::Document include Shim - - devise :database_authenticatable, :timeoutable, :registerable, :recoverable, :lockable, :unlock_strategy => :time + include SharedAdmin end diff --git a/test/rails_app/app/mongoid/shim.rb b/test/rails_app/app/mongoid/shim.rb index fb01ec0d..5d16fd31 100644 --- a/test/rails_app/app/mongoid/shim.rb +++ b/test/rails_app/app/mongoid/shim.rb @@ -1,6 +1,10 @@ module Shim extend ::ActiveSupport::Concern - include ::Mongoid::Timestamps + + included do + include ::Mongoid::Timestamps + field :created_at, :type => DateTime + end module ClassMethods def last(options={}) diff --git a/test/rails_app/app/mongoid/user.rb b/test/rails_app/app/mongoid/user.rb index a133f459..1a75f930 100644 --- a/test/rails_app/app/mongoid/user.rb +++ b/test/rails_app/app/mongoid/user.rb @@ -1,10 +1,7 @@ +require 'shared_user' + class User include Mongoid::Document include Shim - - field :created_at, :type => DateTime - - devise :database_authenticatable, :confirmable, :lockable, :recoverable, - :registerable, :rememberable, :timeoutable, :token_authenticatable, - :trackable, :validatable + include SharedUser end diff --git a/test/rails_app/app/views/home/index.html.erb b/test/rails_app/app/views/home/index.html.erb index c3942a09..b0d8fcd5 100644 --- a/test/rails_app/app/views/home/index.html.erb +++ b/test/rails_app/app/views/home/index.html.erb @@ -1 +1,5 @@ Home! + +<%- User.oauth_providers.each do |provider| %> + <%= link_to "Sign in with #{provider.to_s.titleize}", user_oauth_authorize_url(provider) %>
+<% end =%> \ No newline at end of file diff --git a/test/rails_app/config/initializers/devise.rb b/test/rails_app/config/initializers/devise.rb index 5a340f43..eea767c8 100644 --- a/test/rails_app/config/initializers/devise.rb +++ b/test/rails_app/config/initializers/devise.rb @@ -123,6 +123,17 @@ Devise.setup do |config| # should add them to the navigational formats lists. Default is [:html] # config.navigational_formats = [:html, :iphone] + # ==> OAuth + config.oauth :github, 'APP_ID', 'APP_SECRET', + :site => 'https://github.com/', + :authorize_path => '/login/oauth/authorize', + :access_token_path => '/login/oauth/access_token', + :scope => 'user,public_repo' + + config.oauth :facebook, 'APP_ID', 'APP_SECRET', + :site => 'https://graph.facebook.com/', + :scope => %w(email offline_access) + # ==> Warden configuration # If you want to use other strategies, that are not (yet) supported by Devise, # you can configure them inside the config.warden block. The example below diff --git a/test/rails_app/config/routes.rb b/test/rails_app/config/routes.rb index b2566cb7..db38ad21 100644 --- a/test/rails_app/config/routes.rb +++ b/test/rails_app/config/routes.rb @@ -39,9 +39,10 @@ Rails.application.routes.draw do :sign_in => "login", :sign_out => "logout", :password => "secret", :confirmation => "verification", :unlock => "unblock", :sign_up => "register", - :registration => "management" + :registration => "management", :cancel => "giveup" } end + match "/set", :to => "home#set" root :to => "home#index" end \ No newline at end of file diff --git a/test/rails_app/db/migrate/20100401102949_create_tables.rb b/test/rails_app/db/migrate/20100401102949_create_tables.rb index 032a4240..13bc8e6b 100644 --- a/test/rails_app/db/migrate/20100401102949_create_tables.rb +++ b/test/rails_app/db/migrate/20100401102949_create_tables.rb @@ -1,27 +1,29 @@ class CreateTables < ActiveRecord::Migration def self.up - [:users, :admins, :accounts].each do |table| - create_table table do |t| - t.database_authenticatable :null => (table == :admins) + create_table :users do |t| + t.string :username + t.string :facebook_token - if table != :admin - t.string :username - t.confirmable - t.recoverable - t.rememberable - t.trackable - t.lockable - t.token_authenticatable - end + t.database_authenticatable :null => false + t.confirmable + t.recoverable + t.rememberable + t.trackable + t.lockable + t.token_authenticatable + t.timestamps + end - t.timestamps - end + create_table :admins do |t| + t.database_authenticatable :null => true, :encryptor => :bcrypt + t.recoverable + t.lockable + t.timestamps end end def self.down - [:users, :admins, :accounts].each do |table| - drop_table table - end + drop_table :users + drop_table :admins end end diff --git a/test/rails_app/db/schema.rb b/test/rails_app/db/schema.rb index a4579fa2..0e4db5c6 100644 --- a/test/rails_app/db/schema.rb +++ b/test/rails_app/db/schema.rb @@ -1,81 +1,47 @@ -# This file is auto-generated from the current state of the database. Instead of editing this file, -# please use the migrations feature of Active Record to incrementally modify your database, and -# then regenerate this schema definition. +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. # -# Note that this schema.rb definition is the authoritative source for your database schema. If you need -# to create the application database on another system, you should be using db:schema:load, not running -# all the migrations from scratch. The latter is a flawed and unsustainable approach (the more migrations +# Note that this schema.rb definition is the authoritative source for your +# database schema. If you need to create the application database on another +# system, you should be using db:schema:load, not running all the migrations +# from scratch. The latter is a flawed and unsustainable approach (the more migrations # you'll amass, the slower it'll run and the greater likelihood for issues). # # It's strongly recommended to check this file into your version control system. ActiveRecord::Schema.define(:version => 20100401102949) do - create_table "accounts", :force => true do |t| - t.string "email", :default => "", :null => false - t.string "encrypted_password", :default => "", :null => false - t.string "password_salt", :default => "", :null => false - t.string "username" - t.string "confirmation_token" - t.datetime "confirmed_at" - t.datetime "confirmation_sent_at" - t.string "reset_password_token" - t.string "remember_token" - t.datetime "remember_created_at" - t.integer "sign_in_count", :default => 0 - t.datetime "current_sign_in_at" - t.datetime "last_sign_in_at" - t.string "current_sign_in_ip" - t.string "last_sign_in_ip" - t.integer "failed_attempts", :default => 0 - t.string "unlock_token" - t.datetime "locked_at" - t.string "authentication_token" - t.datetime "created_at" - t.datetime "updated_at" - end - create_table "admins", :force => true do |t| - t.string "email", :default => "" - t.string "encrypted_password", :default => "" - t.string "password_salt", :default => "" - t.string "username" - t.string "confirmation_token" - t.datetime "confirmed_at" - t.datetime "confirmation_sent_at" + t.string "email", :default => "" + t.string "encrypted_password", :limit => 128, :default => "" + t.string "password_salt", :default => "" t.string "reset_password_token" - t.string "remember_token" - t.datetime "remember_created_at" - t.integer "sign_in_count", :default => 0 - t.datetime "current_sign_in_at" - t.datetime "last_sign_in_at" - t.string "current_sign_in_ip" - t.string "last_sign_in_ip" - t.integer "failed_attempts", :default => 0 + t.integer "failed_attempts", :default => 0 t.string "unlock_token" t.datetime "locked_at" - t.string "authentication_token" t.datetime "created_at" t.datetime "updated_at" end create_table "users", :force => true do |t| - t.string "email", :default => "", :null => false - t.string "encrypted_password", :default => "", :null => false - t.string "password_salt", :default => "", :null => false t.string "username" + t.string "facebook_token" + t.string "email", :default => "", :null => false + t.string "encrypted_password", :limit => 128, :default => "", :null => false + t.string "password_salt", :default => "", :null => false t.string "confirmation_token" t.datetime "confirmed_at" t.datetime "confirmation_sent_at" t.string "reset_password_token" t.string "remember_token" t.datetime "remember_created_at" - t.integer "sign_in_count", :default => 0 + t.integer "sign_in_count", :default => 0 t.datetime "current_sign_in_at" t.datetime "last_sign_in_at" t.string "current_sign_in_ip" t.string "last_sign_in_ip" - t.integer "failed_attempts", :default => 0 + t.integer "failed_attempts", :default => 0 t.string "unlock_token" t.datetime "locked_at" t.string "authentication_token" diff --git a/test/rails_app/lib/shared_admin.rb b/test/rails_app/lib/shared_admin.rb new file mode 100644 index 00000000..baabfc06 --- /dev/null +++ b/test/rails_app/lib/shared_admin.rb @@ -0,0 +1,7 @@ +module SharedAdmin + extend ActiveSupport::Concern + + included do + devise :database_authenticatable, :registerable, :timeoutable, :recoverable, :lockable, :unlock_strategy => :time + end +end \ No newline at end of file diff --git a/test/rails_app/lib/shared_user.rb b/test/rails_app/lib/shared_user.rb new file mode 100644 index 00000000..1f8e4da6 --- /dev/null +++ b/test/rails_app/lib/shared_user.rb @@ -0,0 +1,48 @@ +module SharedUser + extend ActiveSupport::Concern + + included do + devise :database_authenticatable, :confirmable, :lockable, :recoverable, + :registerable, :rememberable, :timeoutable, :token_authenticatable, + :trackable, :validatable, :oauthable + + # They need to be included after Devise is called. + extend ExtendMethods + end + + module ExtendMethods + def find_for_facebook_oauth(access_token, signed_in_resource=nil) + data = ActiveSupport::JSON.decode(access_token.get('/me')) + user = signed_in_resource || User.find_by_email(data["email"]) || User.new + user.update_with_facebook_oauth(access_token, data) + user.save + user + end + + def new_with_session(params, session) + super.tap do |user| + if session[:user_facebook_oauth_token] + access_token = oauth_access_token(:facebook, session[:user_facebook_oauth_token]) + user.update_with_facebook_oauth(access_token) + end + end + end + end + + def update_with_facebook_oauth(access_token, data=nil) + data ||= ActiveSupport::JSON.decode(access_token.get('/me')) + + self.username = data["username"] unless username.present? + self.email = data["email"] unless email.present? + + self.confirmed_at ||= Time.now + self.facebook_token = access_token.token + + unless encrypted_password.present? + self.password = Devise.friendly_token + self.password_confirmation = nil + end + + yield self if block_given? + end +end diff --git a/test/routes_test.rb b/test/routes_test.rb index e5d1ac27..f3e4a430 100644 --- a/test/routes_test.rb +++ b/test/routes_test.rb @@ -86,10 +86,27 @@ class DefaultRoutingTest < ActionController::TestCase assert_recognizes({:controller => 'devise/registrations', :action => 'destroy'}, {:path => 'users', :method => :delete}) end + test 'map cancel user registration' do + assert_recognizes({:controller => 'devise/registrations', :action => 'cancel'}, {:path => 'users/cancel', :method => :get}) + assert_named_route "/users/cancel", :cancel_user_registration_path + end + + test 'map oauth callbacks' do + assert_recognizes({:controller => 'devise/oauth_callbacks', :action => 'facebook'}, {:path => 'users/oauth/facebook/callback', :method => :get}) + assert_named_route "/users/oauth/facebook/callback", :user_oauth_callback_path, :facebook + + assert_recognizes({:controller => 'devise/oauth_callbacks', :action => 'github'}, {:path => 'users/oauth/github/callback', :method => :get}) + assert_named_route "/users/oauth/github/callback", :user_oauth_callback_path, :github + + assert_raise ActionController::RoutingError do + assert_recognizes({:controller => 'devise/oauth_callbacks', :action => 'twitter'}, {:path => 'users/oauth/twitter/callback', :method => :get}) + end + end + protected - def assert_named_route(result, name) - assert_equal result, @routes.url_helpers.send(name) + def assert_named_route(result, *args) + assert_equal result, @routes.url_helpers.send(*args) end end @@ -131,6 +148,11 @@ class CustomizedRoutingTest < ActionController::TestCase test 'map account with custom path name for registration' do assert_recognizes({:controller => 'devise/registrations', :action => 'new', :locale => 'en'}, '/en/accounts/management/register') end + + test 'map account with custom path name for cancel registration' do + assert_recognizes({:controller => 'devise/registrations', :action => 'cancel', :locale => 'en'}, '/en/accounts/management/giveup') + end + end class ScopedRoutingTest < ActionController::TestCase diff --git a/test/support/test_silencer.rb b/test/support/test_silencer.rb deleted file mode 100644 index 274a0aa9..00000000 --- a/test/support/test_silencer.rb +++ /dev/null @@ -1,5 +0,0 @@ -module Devise - module TestSilencer - def test(*args, &block); end - end -end \ No newline at end of file diff --git a/test/test_helper.rb b/test/test_helper.rb index 56d09074..051029f6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -16,6 +16,8 @@ Webrat.configure do |config| config.open_error_files = false end +Devise::Oauth.test_mode! + # Add support to load paths so we can overwrite broken webrat setup $:.unshift File.expand_path('../support', __FILE__) Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } \ No newline at end of file