From simple Devise to Oauth on Angular
Override default devise views with my views with locales, follow instructions on https://github.com/duleorlovic/devise-views-i18n
You can add facebook auth below.
Read config/initializers/devise.rb about default configuration. You need to set default sender:
# devise
sed -i config/initializers/devise.rb -e '/mailer_sender/c \
config.mailer_sender = Rails.application.secrets.mailer_sender'
You can use before_action :authenticate_user!
in controllers that will
redirect to /users/sign_in
. You need to set up
emails
to actually receive registration email.
If you enable lockable
than user will be locked when number of failed login
attempts reaches 20. You can send internal notification when that happens by
overriding devise mailer and method def unlock_instructions(record, token,
opts={})
When user is logged in, than in session there is a id of current_user
session['warden.user.user.key'] # => [[9], "$2a$10$TUfyHaPAWV.A1/6JLuCTGO"]
Errors like ActionView::Template::Error (undefined method new_confirmation_path
for Did you mean? new_user_confirmation_path user_confirmation_path):
occurs
when you add, for example confirmable
in model, but gem are already loaded in
spring, so you need to spring stop
and restart rails server.
To enable additional fields to be permited attributes for new columns, to allow
new params for user, for example :username
class ApplicationController < ActionController::Base
before_action :configure_permitted_parameters, if: :devise_controller?
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:username])
end
end
To pass some values to devise controller, you can use hidden field
<%= f.text_field :username, value: f.object.username || params[:username] %>
Errors like NoMethodError (undefined method 'users_url' for
#<DeviseRegistrationsController:0x007ff6068d92b8>):
IF you need to check user.authenticate_with password you can use valid password
user.valid_password? 'new_password'
There are some modules which you can use
# app/models/user.rb
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable, :confirmable,
:omniauthable
Sign in using username or phone
https://github.com/heartcombo/devise/wiki/How-To:-Allow-users-to-sign-in-using-their-username-or-email-address
Enable login with phone or email
# app/controllers/application_controller.rb
before_action :configure_permitted_parameters, if: :devise_controller?
protected
# https://github.com/heartcombo/devise/wiki/How-To:-Allow-users-to-sign-in-using-their-username-or-email-address
def configure_permitted_parameters
devise_parameter_sanitizer.permit :sign_in, keys: %i[login password]
end
# app/models/user.rb
attr_writer :login
# https://github.com/heartcombo/devise/wiki/How-To:-Allow-users-to-sign-in-using-their-username-or-email-address
def login
@login || mobile || email
end
def self.find_for_database_authentication(warden_conditions)
conditions = warden_conditions.dup
if login = conditions.delete(:login) # rubocop:todo Lint/AssignmentInCondition
where(conditions.to_h).where(['lower(mobile) = :value OR lower(email) = :value', { value: login.downcase }]).first
elsif conditions.key?(:mobile) || conditions.key?(:email)
where(conditions.to_h).first
end
end
# config/initializers/devise.rb
config.authentication_keys = [:login]
# app/views/devise/sessions/new.html.erb
<%= f.text_field :login, autofocus: true, autocomplete: "email", placeholder: 'Enter Mobile No. / Email ID', skip_label: true %>
For enabling forgot password with mobile or email you need to add
User.find_first_by_auth_conditions
method
https://github.com/heartcombo/devise/wiki/How-To:-Allow-users-to-sign-in-using-their-username-or-email-address#allow-users-to-recover-their-password-or-confirm-their-account-using-their-username
# app/models/user.rb
def self.find_for_database_authentication(warden_conditions)
conditions = warden_conditions.dup
if login = conditions.delete(:login) # rubocop:todo Lint/AssignmentInCondition
where(conditions.to_h).where(['lower(mobile) = :value OR lower(email) = :value', { value: login.downcase }]).first
elsif conditions.key?(:mobile) || conditions.key?(:email)
where(conditions.to_h).first
end
end
def self.find_first_by_auth_conditions(warden_conditions)
conditions = warden_conditions.dup
if login = conditions.delete(:login) # rubocop:todo Lint/AssignmentInCondition
where(conditions).where(['lower(mobile) = :value OR lower(email) = :value', { value: login.downcase }]).first
elsif conditions[:mobile].nil?
where(conditions).first
else
where(mobile: conditions[:mobile]).first
end
end
# config/initializers/devise.rb
config.reset_password_keys = %i[login]
# app/views/devise/passwords/new.html.erb
<%= f.text_field :login, autofocus: true, autocomplete: "email", placeholder: 'Enter Mobile No. / Email ID', skip_label: true %>
Hotwire issue
Rails/Devise: Nil location provided. Can't build URI. on Password reset
you need to disable hotwire with data-turbo='false'
Devise and Omniauth
Read wiki to add facebook and google authentication. It’s easy installation. For facebook we use omniauth-facebook and excellent rails casts #360
cat >> Gemfile <<HERE_DOC
gem 'omniauth-facebook'
gem 'omniauth-google-oauth2', '0.2.5'
# https://github.com/zquestz/omniauth-google-oauth2/issues/204
# fix to 1.3.1 because of redirect_uri_mismatch
gem "omniauth-oauth2", '1.3.1'
# 1.2.1 to skip_jwt issue
# Could not find a valid mapping for path "/omniauth/google_oauth2/callback"
HERE_DOC
bundle
rails g migration AddOmniauthToUsers provider:index uid:index
rake db:migrate
sed -i config/initializers/devise.rb -e '/APP_ID/a \
config.omniauth :facebook,\
Rails.application.secrets.facebook_key,\
Rails.application.secrets.facebook_secret,\
scope: "public_profile,email",\
info_fields: "email,name"\
config.omniauth :google_oauth2,\
Rails.application.secrets.google_client_id,\
Rails.application.secrets.google_client_secret,\
{} # skip_jwt: true'
# skip_jwt only needed for omniauth > 1.2.2
# https://github.com/zquestz/omniauth-google-oauth2/issues/197
sed -i config/secrets.yml -e '/^test:/i \
# Facebook Autentication\
facebook_key: <%= ENV["FACEBOOK_KEY"] %>\
facebook_secret: <%= ENV["FACEBOOK_SECRET"] %>\
# Google signup\
google_client_id: <%= ENV["GOOGLE_CLIENT_ID"] %>\
google_client_secret: <%= ENV["GOOGLE_CLIENT_SECRET"] %>\
'
If you need to send some params to callback (for example current user, or some
other state) you can do it and access using params['omniauth.params']
env
field. There are also: ["omniauth.strategy", "omniauth.origin",
"omniauth.params", "omniauth.auth"]
. omniauth.origin
is usefull to redirect
back to the page on which he logs in.
sed -i app/views/layouts/application.html.erb -e '/<body>/a \
<%= link_to "Sign in with Facebook",
user_facebook_omniauth_authorize_path(my_param: 1) %>'
vi app/models/user.rb # add :omniauthable
# no need for , :omniauth_providers => [:facebook], but :omniauthable is needed
# config/routes.rb
devise_for :users, controllers: {
omniauth_callbacks: 'devise/my_omniauth_callbacks',
confirmations: 'devise/my_confirmations',
registrations: 'devise/my_registrations',
}
# app/controllers/devise/my_omniauth_callbacks_controller.rb
module Devise
class MyOmniauthCallbacksController < OmniauthCallbacksController
# https://github.com/plataformatec/devise/wiki/OmniAuth:-Overview
# data is in request.env["omniauth.auth"]
%i[facebook google_oauth2].each do |provider| # yaho_oauth2 twitter
define_method provider do
# to get params use request.env["omniauth.params"]["my_param"]
@user = User.from_omniauth(request.env['omniauth.auth'])
if @user.persisted?
sign_in_and_redirect @user, event: :authentication
# this will throw if @user is not activated
set_flash_message(:notice, :success, kind: provider) if is_navigational_format?
else
session['devise.facebook_data'] = request.env['omniauth.auth']
redirect_to new_user_registration_url
end
end
end
def failure
# this could be: no_authorization_code # when we did not whitelisted domain
# on facebook app settings
alert = t('can_sign_in_reason_name',
reason_name: "#{request.env['omniauth.error'].try(:message)} #{request.env['omniauth.error.type']}")
redirect_to root_path, alert: alert
end
end
end
# app/models/user.rb
def self.from_omniauth(auth)
user = find_by(email: auth.info.email)
return user if user
# user changed his email on facebook
user = find_by(provider: auth.provider, uid: auth.uid)
return user if user
# create new user with some passwor
user = User.create!(
email: auth.info.email,
password: Devise.friendly_token[0, 20],
provider: auth.provider,
uid: auth.uid,
)
user.skip_confirmation! # this will just add confirmed_at = Time.now
# if you change email than you need to run
user.skip_reconfirmation!
# user.name = auth.info.name # assuming the user model has a name
# user.image = auth.info.image # assuming the user model has an image
user
end
If you need multiple identities per account than create separate table for identity.
Note this situations:
-
user changed email address (lost password for old one) and updated on facebook profile. He should be able to add new email address, not just to change old one. Maybe he used the same password which he can not recover. If user can recover password only using original email than they are blocked. Hackerrank has a button to “Add another email”. So we should check if his facebook email address was updated and add add that email to our database as well.
- user click forgot password, it change password, than it is asked to login, than he should not see again forgot password page
- when user change the password it should stay logged in
unless current_user.valid_password? params[:user][:current_password] current_user.errors.add(:current_password, t('my_devise.current_password_is_not_correct')) end current_user.password='asdfasdf' current_user.password_confirmation='asdfasdf' # current_user.save! current_user.errors.blank? && current_user.save! bypass_sign_in current_user if current_user.errors.blank?
Facebook app
Is you use omniauth-facebook
alone than there are problems with devise (it
double redirect and raise csrf exception). So do not use with devise, or
configure devise to use facebook auth.
-
Create app on developers.facebook.com. By default fb app is in development mode and can only be used by app admins, developers and testers. Add Contact Email if not already there on https://developers.facebook.com/apps/FACEBOOK_APP_ID/settings/
-
For production you need to go App Review https://developers.facebook.com/apps/FACEBOOK_APP_ID/review-status/ and toggle switch on
-
Add Facebook Login product and go to settings https://developers.facebook.com/apps/FACEBOOK_APP_ID/fb-login/ (before it was inside advance settings https://developers.facebook.com/apps/FACEBOOK_APP_ID/settings/advanced) Find input for Valid OAuth redirect URIs and fill with all links that are redirected. You can find the link on facebook error page url
www.facebook.com/dialog/oauth?client_id=...&redirect_uri=...
. You can add just domain (/omniauth/facebook/callback
is not needed) Note that this are server url (not frontend url):http://localhost.local:3003
http://localhost:9000
and for https alsohttps://localhost.local:3003
Changes are visible immediatelly. More on blog facebook share buttons.
Notes:
- no need to check if email is verified, since facebook will not allow unverified accounts to use oauth
Google console
Create a project in https://console.developers.google.com/apis/ and enable Google+ API. Create OAuth 2.0 client ID (or edit exists one clicking on its name) and set Authorized redirect URIs to all urls that will be used (not just domain like for facebook, we need whole url path), like redirect_uri
https://myapp.com/users/auth/google_oauth2/callback
- you can add also for http and https
- you can add localhost
http://localhost:3003/users/auth/google_oauth2/callback
but can not add subdomain of localhost, likemyapp.localhost
- Authorized JavaScript origins will be automatically filled with domain like
myapp.com
https://stackoverflow.com/questions/10456174/oauth-how-to-test-with-local-urls
Dont forget to save Changes needs 5 min to propagate
For API keys, you need to add website restrictions: myapp.com, localhost:3003
Twitter app
On https://apps.twitter.com/ you can create application.
Setup callbacks urls to point to you server, but this is not required with
twitter.
app//keys
will give you TWITTER_API_KEY and TWITTER_API_SECRET
Twitter response do not provide user’s email address.
Create app on https://www.linkedin.com/developer/apps and save to
LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET.
Add gem 'omniauth-linkedin'
Angular authentication
I tried two approaches for authentication
For demo usage checkout my repository angular-devise-ng-token-auth
ng-token-auth demo example
Angular ng-token-auth is used with devise-token-auth.
Run client with:
cd ng-token-auth
npm install
cd test && bower install
vi config/default.yml # edit API_URL to match server port
# SITE_DOMAIN is only required for sitemap
cd ..
gem install sass
gulp dev
gnome-open http://localhost:7777/
For server side you can see devise_token_auth_demo compare
cd devise_token_auth_demo
vi config/database.yml # change to your username
echo -e 'gem "letter_opener", :group => :development' >> Gemfile
sed -i '/end$/i \ config.action_mailer.delivery_method = :letter_opener' config/environments/development.rb
# hosts should be the same as in ng-token-auth/test/config/default.html
# config/environments/development.rb redirection url
# OmniAuth.config.full_host = "http://localhost.local:3003"
export GITHUB_KEY=asd GITHUB_SECRET=asd GOOGLE_KEY=$GOOGLE_CLIENT_ID GOOGLE_SECRET=$GOOGLE_CLIENT_SECRET FACEBOOK_KEY=$FACEBOOK_KEY FACEBOOK_SECRET=$FACEBOOK_SECRET
rails s
ng-token-auth from scratch with Yeoman
When use signin, he get access-token
in Response Header. Than he uses that
access-token
for next request (Request Header), and for it he gets new
access-token
in response Token Header
Format
IMPORTANT:
Mount path is important. mount_devise_token_auth_for 'User', at: '/auth'
so if
you access api/v1
and api/v2/...
it will send headers. If you mount under
api/v1/auth
than headers will not be send for api/v2/articles
Rails 4.2.5 use uppercase header names (Access-Token), but that does not affect the app. Also the method you fetch the resource does not matter, it could be $http, angular-rails-resource… headers will be send with ng-token-auth
rails new my_app
cd my_app
cat >> Gemfile <<HERE_DOC
# user auth with devise and ng-token-auth
gem 'devise_token_auth', '=0.1.37.beta4' # fix version because of url localhost:3000//api/ issue
# github: 'lynndylanhurley/devise_token_auth'
gem 'omniauth'
HERE_DOC
bundle
rails g devise_token_auth:install User auth
rake db:migrate
git add . && git commit -m "rails g devise_token_auth:install User auth"
rails g devise:install
git add . && git commit -m "rails g devise:install"
# leave original route auth for mount_devise_token_auth_for
# config/environments/development.rb
# OmniAuth.config.full_host = "http://localhost:9000/" # this is url for google callback, not needed since it will read from request
# on registration, do not raise exception
sed -i app/controllers/application_controller.rb \
-e '/end$/i\
protect_from_forgery with: :null_session\
respond_to :json'
# allow unconfirmed access
sed -i config/initializers/devise.rb -e '/config.allow_unconfirmed_access_for/a \
config.allow_unconfirmed_access_for = 7.days'
Perform some common bootstrap stuff for secrets and emails and add oauth installation as above.
For client start with steps at 2015-11-26-angular-and-ruby-on-rails.
We will use md-dialog to show signup buttons or login form in another tab.
I have some problems to stay logged in immediatelly after log in and than hit refresh (while some params in url). We need to remove those params.
cd client
bower install ng-token-auth --save
# add module dependency
sed -i src/app/index.module.coffee -e "/]/i \ 'ng-token-auth',"
# inject dependencies
sed -i src/app/components/navbar/navbar.directive.coffee -e ":a;N;\$!ba;s/ NavbarController.*return/$(sed ':a;N;$!ba;s/\n/ћ/g;s:\\:\\\\:g;s:/:\\/:g;' <<'MY_TEXT'
NavbarController = (moment, $mdDialog, $auth, $rootScope) ->
'ngInject'
dialog = null
vm = this
# "vm.creation" is avaible by directive option "bindToController: true"
vm.relativeDate = moment(vm.creationDate).fromNow()
vm.showLoginDialog = (ev) ->
dialog = $mdDialog.show
controller: 'LoginController'
controllerAs: 'vm'
templateUrl: 'app/login/login.html'
parent: angular.element(document.body)
targetEvent: ev
clickOutsideToClose: true
vm.signOut = ->
$auth.signOut()
$rootScope.$on 'auth:login-success', (ev, user) ->
$mdDialog.hide dialog
return
MY_TEXT
)/g;s/ћ/\n/g"
# add links
sed -i src/app/components/navbar/navbar.html \
-e '/Contact/a \
<md-button href="#" ng-show="!$root.user.id" ng-click="vm.showLoginDialog()">Try For Free</md-button>\
<div ng-show="!!$root.user.id">Hello { { $root.user.email }}</div>\
<md-button href="#" ng-show="!!$root.user.id" ng-click="vm.signOut()">Sign Out</md-button>'
# config routes since default /api/auth/facebook does not match rails /auth/:provider
sed -i src/app/index.config.coffee \
-e 's/config (/config ($authProvider, /' \
-e '$a\
$authProvider.configure\
apiUrl: "/"\
authProviderPaths:\
google: "/auth/google_oauth2"\
facebook: "/auth/facebook"'
mkdir src/app/login
cat > src/app/login/login.html <<\HERE_DOC
<md-dialog aria-label="login" ng-cloak>
<md-toolbar>
<div class="md-toolbar-tools">
<h2>Try for free!</h2>
</div>
</md-toolbar>
<md-dialog-content>
<md-tabs md-selected="vm.tabsData.selectedIndex" md-align-tabs="bottom"
md-dynamic-height>
<md-tab label="defalt">
<md-content class="md-padding">
<md-button ng-click="$root.authenticate('google')">Sign in with Gmail</md-button>
<md-button ng-click="$root.authenticate('facebook')">Sign in with Facebook</md-button>
<md-button ng-click="vm.tabsData.selectedIndex=1">
Sign in with email
</md-button>
</md-content>
</md-tab>
<md-tab label="Email">
<md-content class="md-padding">
<form ng-submit="vm.submitLogin(vm.login, loginForm)" name="loginForm" role="form">
<md-input-container class="md-block">
<label>Email</label>
<input ng-model="vm.login.email" ng-required="true" name="email" placeholder="Email" type="email">
<div ng-messages="loginForm.email.$error">
<div ng-message="required">Email is required.</div>
<div ng-message="server">
{ { vm.serverErrors.login.email }}.
</div>
</div>
</md-input-container>
<md-input-container class="md-block">
<label>Password</label>
<input ng-model="vm.login.password" name="password"
placeholder="Password" type="password" ng-required="true">
<div ng-messages="loginForm.password.$error">
<div ng-message="required">Password is required.</div>
<div ng-message="server">
{ { vm.serverErrors.login.join(', ') }}.
</div>
</div>
</md-input-container>
<md-button type="submit" class="md-raised md-primary">Sign in</md-button>
or
<md-button ng-click="vm.tabsData.selectedIndex=2">
Register with email
</md-button>
</form>
</md-content>
</md-tab>
<md-tab label="Registration">
<md-content>
<form name="registrationForm"
ng-submit="vm.handleRegBtnClick(vm.registration, registrationForm)" role="form">
<md-input-container class="md-block">
<md-icon>email</md-icon>
<input ng-model="vm.registration.email" type="email"
placeholder="Email (required)" ng-required="true" name="email"
ng-email="true">
<div ng-messages="registrationForm.email.$error">
<div ng-message="required">Email is required.</div>
<div ng-message="email">Must look like email.</div>
<div ng-message="server">
{ { vm.serverErrors.registration.email.join(', ') }}.
</div>
</div>
</md-input-container>
<md-input-container md-no-float class="md-block">
<input ng-model="vm.registration.password" type="password"
placeholder="password" ng-required="true" ng-minlength=6
ng-maxlength=20 name="password">
<div ng-messages="registrationForm.password.$error">
<div ng-message="required">Password is required.</div>
<div ng-message="minlength,maxlength">Password should be between 6 and 20
chars.</div>
<div ng-message="server">
{ { vm.serverErrors.registration.password.join(', ') }}.
</div>
</div>
</md-input-container>
<md-input-container md-no-float class="md-block">
<input ng-model="vm.registration.password_confirmation"
type="password" placeholder="Password confirmation"
name="password_confirmation" ng-required="true">
<div ng-messages="registrationForm.password_confirmation.$error">
<div ng-message="required">Password confirmation is required.</div>
<div ng-message="password_match">Passwords don't match.</div>
</div>
<div
ng-messages="vm.serverErrors.registration.password_confirmation">
<div>{ { vm.serverErrors.registration.password_confirmation.join(', ') }}</div>
</div>
</md-input-container>
<md-button type="submit" class="md-raised md-primary"
>Register</md-button>
</form>
</md-content>
</md-tab>
</md-tabs>
</md-dialog-content>
</md-dialog>
HERE_DOC
cat > src/app/login/login.controller.coffee <<\HERE_DOC
angular.module 'client'
.controller 'LoginController', ($mdDialog, $auth, $log, $scope) ->
'ngInject'
vm = this
vm.login = {}
vm.registration = {}
# vm.registration =
# email: '[email protected]'
# password: 'asdasd'
# password_confirmation: 'asdasd'
# vm.login =
# email: '[email protected]'
# password: 'asdasd'
vm.tabsData = {
selectedIndex: 0,
}
vm.submitLogin = (login, loginForm) ->
handleError = (resp) ->
loginForm.password.$setValidity('server',false)
$scope.vm.serverErrors =
login: resp.errors
$log.debug loginController: 'submitLogin handleError', resp_errors: resp.errors
$auth.submitLogin(login)
.then (resp) ->
$log.debug loginController: 'submitLogin then', resp: resp
.catch handleError
return
vm.handleRegBtnClick = (registration, registrationForm) ->
handleError = (resp) ->
for field of resp.data.errors
if registrationForm[field]
registrationForm[field].$setValidity('server',false)
$scope.vm.serverErrors =
registration: resp.data.errors
$log.debug 'handleError'
$auth.submitRegistration(registration)
.then (resp) ->
$auth.submitLogin
email: $scope.vm.registration.email
password: $scope.vm.registration.password
$log.debug 'submitRegistration then'
.catch handleError
return
HERE_DOC
When you want to override default initial behavior, for example create another
table that will hold identities, you need to override a lot of things
#23
#453 Another
approach (plan B) is to put your business logic in another model let say,
participant
thas has many users
. This has a problem of assigning oauth
account to the current email-user participant (we need to pass current
email-user token to oauth session so we know that we can connect those two users
to the same participant). Also adds new unnecessary model.
Easiest solution is to add uniqueness to email field and rescue with signing in.
Then we just repeat whole proccess from omniauth_success
from
devise_token_auth gem.
Similar approach is in this opensource rails angular app
manshar
rails g migration add_uniq_index_to_users
sed -i db/migrate/*add_uniq_index_to_users* -e '/change/a\
remove_index :users, :email\
add_index :users, :email, unique: true'
rake db:migrate
sed -i config/routes.rb -e "s^\(mount_devise_token_auth_for.*$\)^\1, controllers: {\n\
omniauth_callbacks: 'users/omniauth_callbacks',\n }^"
mkdir app/controllers/users
cat > app/controllers/users/omniauth_callbacks_controller.rb <<\HERE_DOC
module Users
class OmniauthCallbacksController <
DeviseTokenAuth::OmniauthCallbacksController
rescue_from ActiveRecord::RecordNotUnique,
with: :user_already_registered_with_this_email
def user_already_registered_with_this_email
# repeat whole omniauth_success method for this user
@resource = User.find_by_email(@resource.email)
# get_resource_from_auth_hash
create_token_info
set_token_on_resource
create_auth_params
if resource_class.devise_modules.include?(:confirmable)
# don't send confirmation email!!!
@resource.skip_confirmation!
end
sign_in(:user, @resource, store: false, bypass: false)
unless @resource.image.present?
@resource.image = auth_hash['info']['image']
end
@resource.save!
render_data_or_redirect(
'deliverCredentials',
@auth_params.as_json,
@resource.as_json
)
end
end
end
HERE_DOC
Also override session and password controller to use
email
field instead of uid
field. That way oauth users can use email login
and email users can use oauth login without rescue exceptions (only first time)
Some links for Ionic authentication:
- Ionic does not like ipCookies #222 #90
- ionic-rails-sample
- ionic ng-token-auth
- ionic rails
Angular-devise
IMPORTANT:
For angular_devise, path could be default users/sign_in.json
(no need to place
it on the root).
In order to send headers and cookies, you need to add
$httpProvider.defaults.withCredentials =
true
to the index.config.coffee
.
If protect_from_forgery is enabled, you need to pass XSRF-TOKEN
token as
cookie, and it will be returned later. We will read from that cookie (not from
hidden input fields as rails does).
Sometime rails responds multiple times, and last cookie is used.
Note that Set-cookie could be
missing if you use Set-Cookie
is only of GET requestprotect_from_forgery with: :null_session
. Best way is to
always rise exception, so you know when xsrf happens.
Note that cookies are stored per domain. In ajax, if you request two different
domains a.local and b.local, they will receive different sessions cookies (in
rails for example session[:customer_id]
will show different values)
Chrome Developer Tools hides cookies for other domains…
I do not know how to clear cookies for other domain since I can not see them in
Developer Tools Resources…
So it is imporant to have same AuthProvider loginPath and logoutPath (login
at a.local and logout at b.local will not work).
Protect from forgery is for all requests except HEAD and GET link. link2
It is important if you Set-Cookie
on before OR after action. If it is
after_action
than if request is not authorized (before_action
:authenticate_user!
) than that after_action will not be done, so NO XSRF-TOKEN
will be set. So better is to use before_action
.
request.xhr?
is strange. It returns nil for angular requests. Also, when there
are not cookies and login POST is sent, it passes before_action
:set_csrf_cookie_for_ng, if: -> { request.xhr? }
but puts request.xhr?
'XHR=true' : 'XHR=nil'
shows nil.
Example application angular-devise-ng-token-auth
# app/controllers/application_controller.rb
protect_from_forgery with: :exception
# http://stackoverflow.com/questions/14734243/rails-csrf-protection-angular-js-protect-from-forgery-makes-me-to-log-out-on
after_action :set_csrf_cookie_for_ng # , if: -> { request.xhr? }
def set_csrf_cookie_for_ng
cookies['XSRF-TOKEN'] = form_authenticity_token if protect_against_forgery?
end
rescue_from ActionController::InvalidAuthenticityToken do |exception|
respond_to do |format|
format.html { raise exception }
format.json do
set_csrf_cookie_for_ng
render json: { error: 'Invalid authenticity token' }, status: :unprocessable_entity
end
end
end
protected
def verified_request?
super || valid_authenticity_token?(session, cookies['XSRF-TOKEN'])
end
Here is example login controller for ionic
bower install --save angular-devise
# www/index.html
<!-- Devise https://github.com/cloudspace/angular_devise -->
<script src="lib/AngularDevise/lib/devise-min.js"></script>
# www/js/app.js
angular.module('starter', ['ionic', 'Devise'])
# www/js/app.config.cofee
angular.module 'starter'
.config (AuthProvider, $httpProvider, CONSTANT, AuthInterceptProvider) ->
AuthProvider.loginPath CONSTANT.SERVER_URL + '/users/sign_in.json'
AuthProvider.logoutPath CONSTANT.SERVER_URL + '/users/sign_out.json'
$httpProvider.defaults.withCredentials = true
$httpProvider.interceptors.unshift 'csrfInterceptor'
AuthInterceptProvider.interceptAuth true
console.log 'config'
# www/js/interceptors/csrf.interceptor.coffee
# http://stackoverflow.com/questions/14734243/rails-csrf-protection-angular-js-protect-from-forgery-makes-me-to-log-out-on
window.csrfRepeated = false
angular.module 'starter'
.factory 'csrfInterceptor', ($q, $injector) ->
responseError: (rejection) ->
if rejection.status == 422 &&
rejection.data.error == 'Invalid authenticity token'
console.log "CSRF error so try again and only one time"
if ! window.csrfRepeated
window.csrfRepeated = true
deferred = $q.defer()
successCallback = (resp) ->
deferred.resolve(resp)
errorCallback = (resp) ->
deferred.reject(resp)
$http = $http || $injector.get('$http')
$http(rejection.config).then(successCallback, errorCallback)
return deferred.promise
$q.reject(rejection)
# www/js/login/login.jade
ion-view(view-title="Sign In")
ion-content
.list
form(ng-submit='vm.handleSubmitLogin(vm.login)')
label.item.item-input.item-stacked-label
span.input-label Username
input(type="text" placeholder="Your username"
ng-model="vm.login.email")
label.item.item-input.item-stacked-label
span.input-label Password
input(type="password" placeholder="Your password"
ng-model="vm.login.password")
.padding
button.button.button-block.button-positive Sign In
# www/js/login/login.controller.coffee
angular.module 'starter'
.controller 'LoginController', (Auth, $state) ->
vm = this
vm.login =
email: '[email protected]'
password: 'asdfasdf'
init = ->
Auth.currentUser().then(
(user) ->
console.log user
$state.go 'tab.dashboard'
(error) ->
console.log error
)
vm.handleSubmitLogin = (login) ->
Auth.login(login).then(
(user) ->
console.log user
$state.go 'tab.dashboard'
(error) ->
console.log error
)
init()
return
# www/js/app.router.coffee
angular.module 'starter'
.config ($stateProvider, $urlRouterProvider) ->
$stateProvider
.state 'login',
url: '/login'
templateUrl: 'jade_build/js/login/login.html'
controller: 'LoginController'
controllerAs: 'vm'
.state 'tab',
url: '/tab'
abstract: true
templateUrl: 'jade_build/js/tabs/tabs.html'
controller: 'TabsController'
.state 'tab.dashboard',
url: '/dashboard'
views:
'tab-dashboard':
templateUrl: 'jade_build/js/dashboard/dashboard.html'
controller: 'DashboardController'
controllerAs: 'vm'
.state 'tab.account',
url: '/account'
views:
'tab-account':
templateUrl: 'jade_build/js/account/account.html'
controller: 'AccountController'
controllerAs: 'vm'
$urlRouterProvider.otherwise '/login'
# www/js/app.run.coffee
angular.module 'starter'
.run ($rootScope, NotifyService, $state) ->
$rootScope.$on 'devise:login', (event, currentUser) ->
console.log 'devise:login'
$rootScope.$on 'devise:new-session', (event, currentUser) ->
console.log 'devise:new-session user logs in with Auth.login'
$rootScope.$on 'devise:unauthorized', (event, xhr, deferred) ->
NotifyService.toast "Unauthorized. Please log in again"
$state.go 'login'
return
# www/js/tabs/tabs.controller.coffee
angular.module 'starter'
.controller 'TabsController', (Auth, $state, $rootScope) ->
init = ->
Auth.currentUser().then(
(user) ->
$rootScope.user = user
(error) ->
console.log error
$state.go 'login'
)
init()
console.log 'TabsController'
return
Resend confirmation email on login
When user has not confirmed email and config.allow_unconfirmed_access_for =
3.days
has expired, and when he login there will be an error:
Failed to login because A confirmation email was sent to your account at [email protected]. You must follow the instructions in the email before your account can be activated
We can resend confirmation in this failed login attempt (don’t resend email in
case allow_unconfirmed_access_for
is not set or zero and you automatically log
in user after registration). You can also check if confirmation is send more
than 1.day ago and update @resource.confirmation_sent_at = Time.now.utc -
Devise.allow_unconfirmed_access_for
(this substraction is needed because
someone can try to login right after confirmation email is send).
# config/routes.rb
# mount_devise_token_auth_for( 'User', controllers: { sessions: 'users/sessions' })
# config/initializers/devise_token_auth.rb define url or use from secrets
# config.default_confirm_success_url = 'http://localhost:9000'
# config.default_confirm_success_url = \
# "http://#{Rails.application.secrets.default_url['host']}" \
# ":#{Rails.application.secrets.default_url['port']}"
cat > app/controllers/users/sessions_controller.rb <<\HERE_DOC
module Users
class SessionsController < DeviseTokenAuth::SessionsController
def render_create_error_not_confirmed
@redirect_url = DeviseTokenAuth.default_confirm_success_url
@resource.send_confirmation_instructions(
redirect_url: @redirect_url,
)
super
end
end
end
HERE_DOC
Problem with ng-token
is that is returns user object with snake
user.first_name
instead of camelCase user.firstName
as it is for Rails
Resources
Sign in after user click on confirmation link
# app/controllers/confirmations_controller.rb
class ConfirmationsController < Devise::ConfirmationsController
def show
super do
sign_in resource if resource.confirmed?
end
def after_confirmation_path_for(resource_name, resource)
profile_edit_path
end
end
end
# config/routes.rb
devise_for :users, controllers: {
confirmations: :confirmations
}
Admin sign in as another user
If admin wants to become some other user login_as
he can use sign_in(:user,
@user, { :bypass => true })
.
# app/views/layouts/application.html.erb
<% if Rails.env.development? %>
<small>
only_on_development
<% User.first(10).each do |user| %>
<%= link_to user.email, sign_in_as_path(user_id: user.id) %>
<% end %>
</small>
<% end %>
# config/routes.rb
get 'sign_in_as', to: 'application#sign_in_as'
# app/controllers/application_controller.rb
before_action :authenticate_user!, except: [:sign_in_as]
def sign_in_as
return unless Rails.env.development?
user = User.find params[:user_id]
request.env['devise.skip_trackable'] = true
sign_in :user, user, bypass: true
redirect_to root_path
end
With ng-token is similar
def sign_in_as
@resource = @user
# copy original code
# https://github.com/lynndylanhurley/devise_token_auth/blob/master/app/controllers/devise_token_auth/sessions_controller.rb#L33
# create client id
@client_id = SecureRandom.urlsafe_base64(nil, false)
@token = SecureRandom.urlsafe_base64(nil, false)
@resource.tokens[@client_id] = {
token: BCrypt::Password.create(@token),
expiry: (Time.zone.now + DeviseTokenAuth.token_lifespan).to_i
}
@resource.save
sign_in(:user, @resource, store: false, bypass: false)
render json: @user
end
just note that user need to be user.active_for_authentication?
. In seed you
need to have user.skip_confirmation!
since you will not be able to log in as.
Serialization
Devise will show only id, email, create_at and updated_at. To add more fields override user as_json
# app/models/user.rb
def as_json(options={})
r = super(options)
r["domains"] = 'trk.in.rs'
r
end
Redirection after sign in
Stored location for resource is usually kept in session[:user_return_to]
. Yo
enable it you have to add before action
https://github.com/plataformatec/devise/wiki/How-To:-Redirect-back-to-current-page-after-sign-in,-sign-out,-sign-up,-update
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :_store_user_location!, if: :_storable_location?
# The callback which stores the current location must be added before you authenticate the user
# as `authenticate_user!` (or whatever your resource is) will halt the filter chain and redirect
# before the location can be stored.
# Its important that the location is NOT stored if:
# - The request method is not GET (non idempotent)
# - The request is handled by a Devise controller such as Devise::SessionsController as that could cause an
# infinite redirect loop.
# - The request is an Ajax request as this can lead to very unexpected behaviour.
def _storable_location?
request.get? && is_navigational_format? && !devise_controller? && !request.xhr?
end
def _store_user_location!
# :user is the scope we are authenticating
# this path can be fetched with path = stored_location_for(resource)
store_location_for(:user, request.path)
end
# https://www.rubydoc.info/github/plataformatec/devise/Devise/Controllers/Helpers:after_sign_in_path_for
# override in ApplicationController since if you override in
# SessionsController and it wont be used in RegistrationController (for example
# user already signed in and needs to be redirected)
def after_sign_in_path_for(resource)
# we need save stored location in variable since it is not idempotent
# if we call twice stored_location_for(:user) than second will be nil
# so do not use stored_location_for in views or anywhere else... you can use
# session[:user_return_to] if you really need
redirect_url = stored_location_for(resource)
return redirect_url if redirect_url.present?
# calculate default paths for user
end
# app/controllers/users/registrations_controller.rb
note that inside Devise controllers and also in application controller you can
use `stored_location_for :user`
If you need to store landing page for future analitics or you need to redirect users after specific actions, you can implement your own
# app/controllers/application_controller.rb
before_action :set_back_variable_into_session_if_exists
before_action :save_landing_page, unless: :current_user
def set_back_variable_into_session_if_exists
session[:_back] = params[:_back] if params[:_back].present?
# facebook login overwrite params and querystring so we need to use request.env to get _back param
# https://github.com/intridea/omniauth-oauth2/issues/28
session[:_back] = request.env['omniauth.params'].try(:[],'_back') if request.env['omniauth.params'].try(:[],'_back').present?
end
def save_landing_page
if ! session[:landing_page].present?
session[:landing_page] = request.fullpath
end
end
# this overrides devise default
def after_sign_in_path_for(resource)
view_context.after_log_in_path_for(resource)
end
# app/helpets/application_helper.rb
def after_log_in_path_for(user)
track_sign_in(user) if user.customer?
origin = request.env['omniauth.origin'] unless [new_user_session_url, signup_jobseeker_url, signup_employer_url, user_omniauth_authorize_url(:linkedin), user_omniauth_authorize_url(:facebook)].include? "#{request.env['omniauth.origin']}".split('?').first
session[:_back] || origin || controller.stored_location_for(user) || if user.user?
job_seeker_first_step_path
elsif user.customer?
employer_after_signup_path(:subscribe)
elsif user.admin?
users_path
elsif user.superadmin?
users_path
else
root_path
end
end
Testing
You can use test login helpers
In spec/rails_helper.rb
uncomment line that require all spec/support/**/*.rb
files and create that login helper.
And you can use in your acceptance tests login_as user
where user =
User.create email: '[email protected]', password: 'asdasd'
To suppress error in tests ERROR -- omniauth: (facebook) Authentication
failure! invalid_credentials encountered.
you can use this helper
def silence_omniauth
previous_logger = OmniAuth.config.logger
OmniAuth.config.logger = Logger.new("/dev/null")
yield
ensure
OmniAuth.config.logger = previous_logger
end
silence_omniauth { click_link 'Sign in using Facebook' }
HTTP Basic auth
You can use basic http auth but you should config.force_ssl = true
for
production env.
# app/controllers/admin/admin_controller.rb
module Admin
class AdminController < ApplicationController
http_basic_authenticate_with(
name: Rails.application.credentials.admin_username,
password: Rails.application.credentials.admin_password
)
end
end
# app/controllers/admin/users_controller.rb
module Admin
class UsersController < Admin::AdminController
end
end
If you want different password for different request type
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
skip_before_action :verify_authenticity_token, if: :json_request?
before_action :authenticate
def authenticate
return true if Rails.env.test?
authenticate_or_request_with_http_basic do |username, password|
return true if request.format.html? && username == Rails.application.secrets.admin_username && password == Rails.application.secrets.admin_password
return true if request.format.json? && username == Rails.application.secrets.admin_json_username && password == Rails.application.secrets.admin_json_password
false
end
end
end
Sorcery
https://github.com/Sorcery/sorcery
Devise send email in background
Read this gem https://github.com/plataformatec/devise/wiki/How-To:-Send-devise-emails-in-background-(Resque,-Sidekiq-and-Delayed::Job) or you can add manually (just put lines in User model, so it overrides devise’s)
# app/models/user.rb
devise :database_authenticatable, :device_async
# config/initializers/devise.rb
# Register devise-async model in Devise
Devise.add_module(:devise_async, model: 'devise_async')
# app/models/concerns/devise_async.rb
module DeviseAsync
extend ActiveSupport::Concern
included do
# This method overwrites devise's own `send_devise_notification`
# message = devise_mailer.send(notification, self, *args)
# message.deliver_now
# also need to fetch user in MyDeviseMailer
# protected is required, or ActionView::Template::Error: undefined method `main_app'
protected
def send_devise_notification(notification, *args)
message = devise_mailer.send(notification, self, *args)
message.deliver_later
end
end
end
or oneliner instead of all above
# app/models/user.rb
# https://github.com/plataformatec/devise#activejob-integration
def send_devise_notification(notification, *args)
devise_mailer.send(notification, self, *args).deliver_later
end
Look below if you have problem with serilization
Custom devise mailer
https://github.com/plataformatec/devise/wiki/How-To:-Use-custom-mailer I override becaus I wante dto use delive_later but have a problem with serilization (Neo4j objects)
ActiveJob::SerializationError: Unsupported argument type: User
so I send id
instead of self
# app/models/user.rb
# This method overwrites devise's own `send_devise_notification`
# message = devise_mailer.send(notification, self, *args)
# message.deliver_now
# also need to fetch user in MyDeviseMailer
# protected is required, or ActionView::Template::Error: undefined method `main_app'
protected
def send_devise_notification(notification, *args)
message = devise_mailer.send(notification, id, *args)
message.deliver_later
end
So My devise mailer just call super with same arguments
# app/mailers/my_devise_mailer.rb
class MyDeviseMailer < Devise::Mailer
helper :application # gives access to all helpers defined within `application_helper`.
include Devise::Controllers::UrlHelpers # Optional. eg. `confirmation_url`
default template_path: 'devise/mailer' # to make sure that your mailer uses the devise views
def confirmation_instructions(record_id, token, opts = {})
record = User.find record_id
super record, token, opts
end
def reset_password_instructions(record_id, token, opts = {})
record = User.find record_id
super record, token, opts
end
def unlock_instructions(record_id, token, opts = {})
record = User.find record_id
super record, token, opts
end
def email_changed(record_id, opts = {})
record = User.find record_id
super record, opts
end
def password_change(record_id, opts = {})
record = User.find record_id
super record, opts
end
end
Session expired
For ajax requests when session is expired we need to redirect https://coderwall.com/p/zxe6dq/devise-redirect-ajax-request-when-session-timed-out-timeoutable-module
Detect if already signed in to social sites
http://www.tomanthony.co.uk/blog/detect-visitor-social-networks/
Warden
Devise uses warden which is rack middleware. Rack application is convection how
server (puma/unicorn) talk to rails or sinatra, it needs a method .call(env)
env is: rack_version, server_protocol, http_user_agent…
# config.ru
class MyRackApp
def call(env)
status = 200
headers = { "Content-Type" => "application/json" }
body = ['{ "text": "Hello" }']
[status, headers, body]
end
end
run MyRackApp.new
and run this rackapp with rackup
command (default server is puma). Note that
you can also run rails using whit rackup
command.
To test how it works:
curl -i localhost:9292
HTTP/1.1 200 OK
Content-Type: application/json
Transfer-Encoding: chunked
{ "text": "Hello" }
Middleware is like rack application but it receive rack application instance so
it can modify env
hash (add some objects, set cookie header in response etc)
and stop further execution of middlewares (response with 401 unauthorized).
class MyRackMiddleware
def initialize(app)
@app = app
end
def call(env)
# do something with env
@app.call(env)
end
end
Warden does not handle session store, but it depends on Rack::Session::Cookie
middleware that sets env['rack.session']
and at the end it will generate
env['warden']
.
https://github.com/wardencommunity/warden/wiki
Strategies will be tried one after another until: one succees, or no strategies
are found relevant, or strategy fails. Inside it you need to define .valid?
(acts as guard, so do not fail strategy if params does not exists) and
.authenticate!
methods, you can use params
, env
, session
object and
should call success!
, fail!
. halt!
.
To use strategy, pass it as env['warden'].authenticate :password
or define
manager.default_stategies :password
and call without param. You can use bang
version to call failure_app
if user is not authenticated.
Scope is used to enable multiple users to be logged in in the same time, for
example env['rack.session'] = { 'warden.user.user.key' => 1,
'warden.user.customer.key' => 1 }
. You can use by passign as hash argument
env['warden'].authenticate :api_token, scope: :customer
, check with
env['warden'].authenticated? :customer
, fetch the customer
env['warden'].user :customer
, log out env['warden'].logout :customer
. Or you
can define default_scope and scope_defaults strategies config.default_scope =
:customer
and config.scope_defaults :customer, strategies: :api_token
.
# Gemfile
source 'http://rubygems.org'
gem 'byebug'
gem 'warden'
# config.ru
require 'warden'
use Rack::Session::Cookie, secret: 'warden'
use Warden::Manager do |manager|
manager.default_strategies :password
manager.failure_app = FailureApp.new
end
class MyRackApp
def call(env)
env['warden'].authenticate!
user = env['warden'].user
status = 200
headers = { 'Content-Type' => 'application/json' }
body = ["{ \"text\": \"Hello #{user[:email]}\" }"]
[status, headers, body]
end
end
USERS = [
{ id: 1, email: '[email protected]', password: 'mypassword' },
{ id: 2, email: '[email protected]', password: 'yourpassword' },
].freeze
Warden::Manager.serialize_into_session do |user|
user[:id]
end
Warden::Manager.serialize_from_session do |id|
USERS.find { |user| user[:id] == id }
end
Warden::Strategies.add(:password) do
def valid?
params['email'] && params['password']
end
def authenticate!
user = USERS.find { |u| u[:email] == params['email'] }
fail!('Invalid email') and return if user.nil?
fail!('Invalid password') and return if user[:password] != params['password']
success!(user)
end
end
class FailureApp
def call(env)
status = 401
headers = { 'Content-Type' => 'application/json' }
body = ["{ \"error\" : \"Something went wrong. #{env['warden'].message}\" }"]
[status, headers, body]
end
end
run MyRackApp.new
You can test
curl -i 'localhost:[email protected]&password=mypassword'
HTTP/1.1 200 OK
Content-Type: application/json
Transfer-Encoding: chunked
Server: WEBrick/1.4.2 (Ruby/2.5.1/2018-03-29)
Date: Thu, 01 Aug 2019 10:11:49 GMT
Connection: Keep-Alive
Set-Cookie: rack.session=BAh7B0kiD3Nlc3Npb25faWQGOgZFVEkiRWE5YzA3ZjFmYTgyNTg1NTk0YTY5%0AOWMzMmVkNmU3NWM4YjIzOTVkNGFiOTU5MWQxMzdkOGI1MDU2MTIzMDAxNTcG%0AOwBGSSIcd2FyZGVuLnVzZXIuZGVmYXVsdC5rZXkGOwBUaQY%3D%0A--3ad8682341ce7faff8b2140264f03932344cbb7b; path=/; HttpOnly
{ "text": "Hello [email protected]" }