Devise User's password gets overwritten upon updating any other attributes - ruby-on-rails

Problem
When I use user.update(not_password_attribute: 'value') for the first time in the session the SQL query includes this:
UPDATE "users" SET "encrypted_password" = $1, so the encrypted password gets overwritten, and the password the user had before is no longer valid (checked with #valid_password? method).
However, when I update the same user for the second time within one session the encrypted_password is no longer added to the SQL query.
But when close the rails console and open it again the first scenario happens again.
Here is the output from the rails console: https://i.stack.imgur.com/ktTgJ.jpg
The same happens if I use user.update_without_password
here is the output
I used the console to demo that the problem is not within controllers but it happens whenever the user is updated from any of the controllers including Active Admin or UsersController etc.
I tried using Devise 4.7.2 which I used in a previous project where this issue did not occur. But the outcome was the same.
Question
How do I update the user without the password being overwritten?
Some explanation why this happens would also help.
I am using
Rails 6.0.3.4
Devise 4.7.3
Active Admin 2.9.0 b9076eb (don't think the issue is in Active Admin but trying to provide all the information here)
Devise Config (in /app/config/inintializers/devise.rb)
config.mailer_sender = 'please-change-me-at-config-initializers-devise#example.com'
require 'devise/orm/active_record'
config.case_insensitive_keys = [:email]
config.strip_whitespace_keys = [:email]
config.skip_session_storage = [:http_auth]
config.stretches = Rails.env.test? ? 1 : 12
config.reconfirmable = true
config.expire_all_remember_me_on_sign_out = true
config.password_length = 6..128
config.email_regexp = /\A[^#\s]+#[^#\s]+\z/
config.reset_password_within = 6.hours
config.sign_out_via = :delete
None of these seem relevant to the problem but, again, trying to provide as much information as possible
User model (in /app/models/user.rb)
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
# some methods here but none overwrites Devise or ActiveRecord methods
end

I cannot say I am completely sure what might be going there but I have a few guesses.
So, it seems like your issue is: user.update(first_name: 'one') is triggering a callback that attempts to update encrypted password even when an actual password is not provided or it is considered blank. But there is more.
Why it doesn't get triggered for the subsequent update (user.update(first_name: 'two')?
Possible explanation for that one is - perhaps callback is not triggered if logic behind it figures out that the password is now the same as the one we have set with the first command (ie blank) or the user object we call the second update on has encrypted password field populated after we executed the first update.
Ok, but then, rerunning the console again, and for the same user that we previously set blank password for, we again get encrypted_password update?
Looking at the encrypted password being set in the new console and observing that it is different than the previous one, we can reason that either blank case is handled by assigning random encrypted password, or there is a monkey-patch causing DeviseEncryptor (see https://github.com/heartcombo/devise/blob/98fc5e8e396b66b826528811287ea6680a6d0757/lib/devise/models/database_authenticatable.rb#L71) to somehow generate different result based on current session (this would probably break a lot more things thus highly-unlikely).
The weirdest one is update_without_password still adding encrypted password to query???
Explanation - whatever was previously mentioned seems to affect update_without_password in a similar manner.
The solution
I think you can solve your issue if you use update methods that skip callbacks like: update_column or update_columns (see: https://guides.rubyonrails.org/active_record_callbacks.html#skipping-callbacks)
If you really want to understand what is going on exactly I would suggest to take a look at this file from devise source: https://github.com/heartcombo/devise/blob/master/lib/devise/models/database_authenticatable.rb. Then, after you get a grasp of the code there, you can perhaps find the same source file in your local environment (wherever devise gem is installed), and instrument it with logs (puts) and check / patch it out yourself.
Note: a few nice 'techniques' are described here:
https://tenderlovemaking.com/2016/02/05/i-am-a-puts-debuggerer.html
It would also be a good idea to disable spring while debugging weirdnesses like this. Good luck!
As a bonus - you can get some more context about why things around devise user update work the way they do by reading this discussion: https://github.com/heartcombo/devise/issues/5033 I believe ActiveAdmin was also mentioned in the discussion. Tbh, I originally thought you had the same issue as them but the affected version does not match with the one you shared.

Related

Locking a user using a per-user `maximum_attempts` value with Devise for Rails

As a de-facto standard, we all using Devise for login in our Rails application and will use the Lockable module to lock users after a particular number of failed attempts.
From Devise’s source code and the configuration option config.maximum_attempts = 20, I came to understand how Devise performs locking when the user tries to give wrong login credentials. Configuration is statically defined at Rails application boot time in initializers.
My expectation is to set the maximum_attempts dynamically – is this possible? If so, please guide me.
I have a superadmin and user below each admin. Based on the super admin I would like to set a different failed_attempt value for each user during runtime.
One possible way is to monkey-patch the Devise code that you linked to, where attempts_exceeded? is defined. Here’s a guess at what needs to be overridden:
module Devise::Models::Lockable
# assumes that the User model has a `superadmin` relation
# that has a `maximum_attempts` attribute
def attempts_exceeded?
self.failed_attempts >= self.superadmin.maximum_attempts
end
def last_attempt?
self.failed_attempts == self.superadmin.maximum_attempts - 1
end
end
This should work, but it would mean that whenever you update Devise, there is a risk of related code breaking, with unknown consequences. So you would have to review the changes to Devise before every update. And if you are discouraged from updating Devise because of this, that may eventually cause security problems if you are too slow to update to a version of Devise with a fixed security problem. So beware of those possible problems.
A safer way that requires more work up-front is to lock the user manually from your own code. The documentation for Devise::Models::Lockable mentions a public method lock_access! that locks the user when you call it. You can set the global config.maximum_attempts to some really high value such as 25. Then, in some callback on the model (I’m not sure which callback), call a method lock_access_based_on_superadmin_limit! that calls lock_access! if necessary according to your custom rules. The following definition is adapted from part of Devise’s valid_for_authentication?:
class User
# …
def lock_access_based_on_superadmin_limit!
if failed_attempts >= superadmin.maximum_attempts
lock_access! unless access_locked?
end
end
end

How can I allow Devise users to log in when they're outside my default scope?

I have a Rails 4 app which uses Devise 3.4 for authentication, which I've customized with the ability to ban users (using a simple boolean column users.banned, default false). The User model also has a default_scope which only returns non-banned users.
Here's the problem - I still want my banned users to be able to log in, even though they can't do anything after logging in. (They essentially just see a page saying "you've been banned"). But it seems that the default_scope is tripping up Devise. When you log in or call e.g. authenticate_user!, Devise tries to find the current user using one of the basic ActiveRecord methods like find or find_by, but can't because they lie outside the default scope. Thus Devise concludes that the user doesn't exist, and the login fails.
How can I make Devise ignore the default scope?
After a long time digging around in the Devise and Warden source code, I finally found a solution.
Short Answer:
Add this to the User class:
def self.serialize_from_session(key, salt)
record = to_adapter.klass.unscoped.find(key[0])
record if record && record.authenticatable_salt == salt
end
(Note that I've only tested this for ActiveRecord; if you're using a different ORM adapter you probably need to change the first line of the method... but then I'm not sure if other ORM adapters even have the concept of a "default so
Long Answer:
serialize_from_session is mixed into the User class from -Devise::Models::Authenticatable::ClassMethods. Honestly, I'm not sure what it's actually supposed to do, but it's a public method and documented (very sparsely) in the Devise API, so I don't think there's much chance of it being removed from Devise without warning.
Here's the original source code as of Devise 3.4.1:
def serialize_from_session(key, salt)
record = to_adapter.get(key)
record if record && record.authenticatable_salt == salt
end
The problem lies with to_adapter.get(key). to_adapter returns an instance of OrmAdapter::ActiveRecord wrapped around the User class, and to_adapter.get is essentially the same as calling User.find. (Devise uses the orm_adapter gem to keep it flexible; the above method will work without modification whether you're using ActiveRecord, Mongoid or any other OrmAdapter-compatible ORM.)
But, of course, User.find only searches within the default_scope, which is why it can't find my banned users. Calling to_adapter.klass returns the User class directly, and from then I can call unscoped.find to search all my users and make the banned ones visible to Devise. So the working line is:
record = to_adapter.klass.unscoped.find(key[0])
Note that I'm passing key[0] instead of key, because key is an Array (in this case with one element) and passing an Array to find will return an Array, which isn't what we want.
Also note that calling klass within the real Devise source code would be a bad idea, as it means you lose the advantages of OrmAdapter. But within your own app, where you know with certainty which ORM you're using (something Devise doesn't know), it's safe to be specific.

Devise log in with one more condition to check

I´m using devise gem in a rails 4 app and I have in my user table a column called valid that by default is false, when the user registers in the site it should send me a email with the information about them and approve it, and put that valid column in true. So then in the log in action it will check that valid is true and let them login to the site.
My question is how modify that login action that takes care of the valid column in users table.
You should look at adding :confirmable to your User model, it may take care of most of what you are looking to do.
Otherwise, if you want to modify whether someone can login, look at the wiki on how to customize account validation.
From the wiki:
def active_for_authentication?
# Uncomment the below debug statement to view the properties of the returned
# self model values.
# logger.debug self.to_yaml
super && account_active?
end

Is there a way in Rails to say "run all the validates EXCEPT :password"?

I am using Devise for my authentication. If a hashed_password isn't set, Rails/Devise's validations will require a password to be set, as well as the password_confirmation.
When I invite new users, I obviously don't want to set their password, so when I create the invitation in my system, it fails because user.password is blank.
I can set a temporary hashed_password on the user, but when they enter their own password, the validation checks for :password and :password_confirmation will not happen because hashed_password is set, which is a real problem.
Is there any way to tell Rails that I want to run all the validations except for the ones associated with :password?
I know Rails has :if conditions, which might fix my problem, but Devise declares the :password validation on my behalf, so that essentially is hidden.
How can I get the desired result here?, hopefully in a way that is not a hack.
My current hypothetical solution that is somewhat messy: The only thing I can think of is to create a new Invitation model that is not the User model, and use the Invitation model for the form. When the invitation is submitted I can validate that Invitation and copy over all the values to the new User model. I can save that User without any validations at all.
That's the best solution I dreamed up.
It seems like my solution will be a lot more work than saying something simple like:
user.save(validations => {:except => :password})
EDIT: I have found one part of the solution, but I am still having problems. In our user model, we can override a Devise method to prevent the validation of the password for invitations with this bit of code:
#protected
def password_required?
!is_invited && super
end
The is_invited attribute is just a column I added to the users table/model.
However, there is one gotcha here. When a user accepts an invitation and they arrive to the form where they need to set their password/password_confirmation, valid? will always return true.
This one has me deeply perplexed. I don't see how requires_password? and valid? can be true at the same time. If it requires the password, it should do a validation check and cause the validations to fail.
I'm starting to hate Devise - or just the idea of using gems to build parts of your application in a blackbox. I think the real solution probably is to rip out Devise and just do it all from scratch. That way your app has total control of how all of this works :(
I recently started using this great devise add-on: devise_invitable
It's commonly used so users (or any model) can invite other users to join.
But I adapt it for manually (via an admin panel) invite new potential users to my app.
Hope this helps!

Devise Remember Me and Sessions

I'm confused with the devise gem config settings:
# The time the user will be remembered without asking for credentials again.
config.remember_for = 2.weeks
# The time you want to timeout the user session without activity. After this
# time the user will be asked for credentials again.
config.timeout_in = 10.minutes
I want to have a user select the "Remember Me" checkbox (i.e., keep me logged in), but the default session timeout is 10 minutes. After 10 minutes it asks me to log in again even though I have clicked "Remember me". If this is true then the remember_for is really meaningless. Obviously I'm missing something here.
Ryan is correct in that the default Devise gem does not support both the :rememberable and :timeoutable options. However, like all things Ruby, if you don't like the decision that some other coder has made, especially when it strays from the norm that most users are likely to expect, then you can simply override it.
Thanks to a (rejected) pull request we can override this behaviour by adding the following code to the top of your Devise config file (/config/initializers/devise.rb):
module Devise
module Models
module Timeoutable
# Checks whether the user session has expired based on configured time.
def timedout?(last_access)
return false if remember_exists_and_not_expired?
last_access && last_access <= self.class.timeout_in.ago
end
private
def remember_exists_and_not_expired?
return false unless respond_to?(:remember_expired?)
remember_created_at && !remember_expired?
end
end
end
end
This will now allow you to configure both options and have them work as you would expect.
config.remember_for = 2.weeks
config.timeout_in = 30.minutes
The timeout_in will automatically log you out within 10 minutes of inactivity and is incompatible with the remember_me checkbox. You can have one, but not both.
The information in previous answers is outdated. I've tested my project, which uses Rails 4 and Devise 3.5.1 and also checked devise code to be sure.
Now it looks whether Remember Me checkbox was checked:
if yes, it checks if remember_exists_and_not_expired, so basically uses config.remember_for for session management
if no, it checks if last_access <= timeout_in.ago, using config.timeout_in correspondingly

Resources