Ten days ago, 37signals published an article: A vanilla Rails stack is plenty, this article reinforced my long-standing belief: If Rails provides a solution, it’s best to stick with Rails’ solution. Maintaining minimal dependencies and reducing upgrade barriers is certainly important; more importantly, it is to learn the “optimal solution” after top developers think and practice.
Therefore, when Rails 8 introduced the Authentication solution, I wanted to learn its implementation details at the first opportunity and take this chance to understand the authentication, which I have always found to be a mysterious black box.
A vanilla Rails stack is plenty: https://dev.37signals.com/a-vanilla-rails-stack-is-plenty/
1. How Should Passwords Be Stored
To understand authentication, we first need to determine how to store passwords during development. There are four possible methods: Plaintext, Hashing, Encrypting, Signing, below I will introduce these four methods that you may already be familiar with.
1.1 Plaintext
Storing user passwords in plaintext means that we save the user’s password as it is in the database. The disadvantage of this method is quite obvious, and the advantage can be said to be nonexistent:
-
• If the database is hacked, then both the username and password are leaked… just think about the scenario where a hacker obtains your Alipay account and password -
• Even if there is no hacking, internal developers can directly see the user’s password through the database, which is not much different from being hacked
1.2 Encrypting
Encryption is the process of converting a given string into a garbled string. The problem is that as long as you have the encryption key, encryption is reversible, so who will save this key? DBA, CTO, CEO, UFO?
Saving the key itself is a big challenge, and this method is obviously not advisable.
1.3 Signing
Signing is the process of generating a key pair consisting of a private key and a public key using asymmetric encryption technology, creating a signature for the data with the private key, allowing anyone with the corresponding public key to verify the signature. It is mainly used to verify the authenticity and integrity of data. It is important to note that the data itself is not encrypted, just signed with your private key. So, upon careful consideration, it is indeed good for verifying the authenticity and integrity of data, but it is unreasonable for encryption because the data itself is still stored in plaintext. We can use the <span>ActiveSupport::MessageVerifier</span>
class in Rails for verification:
# 1. This is your private key
verifier = ActiveSupport::MessageVerifier.new("secret key")
# 2. You sign the user's password with the private key
signed_string = verifier.generate("user's password")
# => "InVzZXIncyBwYXNzd29yZCI=--8c5d09fcec50a63bad5ffe303a307be10d412615"
# 3. The result of this signature seems quite good, looks like it's encrypted, and can also be verified
verifier.verify("InVzZXIncyBwYXNzd29yZCI=--8c5d09fcec50a63bad5ffe303a307be10d412615")
# => "user's password"
# 4. But don't be fooled by the surface of this string; this string is divided into two parts by `--`: the first part is the payload, which is public,
# just encoded with Base64; the second part is the signature
payload, signature = signed_string.split("--")
# 5. The data part is public, which means that if you adopt this scheme, the user's password is public
JSON.parse(Base64.decode64(payload))
# => "user's password"
# 6. But the signature is private; if the data part is modified, it will cause verification to fail, so the signature ensures the integrity of the data
bad_payload = Base64.encode64("fake data").strip
# => "ZmFrZSBkYXRh"
bad_signed_string = [bad_payload, signature].join("--")
# => "ZmFrZSBkYXRh--8c5d09fcec50a63bad5ffe303a307be10d412615"
verifier.verify(bad_signed_string)
# raises ActiveSupport::MessageVerifier::InvalidSignature
<span>ActiveSupport::MessageVerifier</span>
uses a key derived from <span>secret_key_base</span>
, this configuration in Rails 6+ is located at <span>config/credentials.yml.enc</span>
. Rails’ <span>cookies.signed</span>
uses this method to verify that “the cookie sent from the browser is indeed the cookie you sent out”.
1.4 Hashing
Hashing is the process of converting a given string into a fixed-size garbled string, and it has two great features:
-
• Deterministic: Given input will always be converted to the same hash value -
• Irreversible: Hash functions are one-way; it is computationally infeasible to derive the original input from the hash value
This is tailor-made for storing passwords; we convert the user’s password into a hash value using a hash algorithm. Even if the database is attacked, or even if internal personnel obtain the hash value, they cannot retrieve the user’s original password. In <span>rails console</span><span> try this:</span>
Digest::SHA256.hexdigest("hello")
# => "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
There are many hashing algorithms, such as the well-known SHA1, SHA256, but not all hashing algorithms are suitable for processing user passwords. In fact, SHA256 should never be used to hash user passwords because they are “too fast” and can easily be subjected to brute force attacks, which refer to attackers hashing many random strings one by one to see if any of them match the password’s hash value. Obviously, in this case, the faster the algorithm, the easier it is for hackers to crack the password.
To store user passwords, a hashing algorithm specifically designed for generating user passwords must be used. This algorithm is specifically designed to be “slow and consume a lot of memory,” such as the Bcrypt algorithm that Rails uses by default. Bcrypt can also control speed and memory consumption through the cost factor; the higher the cost factor, the slower the hash computation. Some newer algorithms of this type are more secure, such as Argon2id, but setting Bcrypt’s cost factor to 10 or higher is safe, and the default value in Rails is 12.
However, this is not enough; if an attacker already has a huge database of strings mapping to hash values, they only need to query this database to obtain the original password. This database is called a rainbow table, and this attack is known as a rainbow table attack. To prevent rainbow table attacks, passwords need to be salted: salting means generating a random string when saving the user’s password, concatenating this string with the user’s password for hashing. This way, even if two users have the same password, because each person’s “salt” is different, the hash values stored for them will also be different. This makes it almost impossible to use rainbow table attacks to crack user passwords.
Rails’ salting operation is automatically completed through <span>has_secure_password</span>
:
-
1. When creating a new user, Rails automatically generates a random salt for this user -
2. Rails uses Bcrypt to combine the user’s password and salt to generate the hash value -
3. The generated hash value is stored in the user’s <span>xxx_digest</span>
field, where<span>xxx</span>
is generally<span>password</span>
When the user logs in and needs to verify the password, Rails uses the <span>authenticate</span>
method to complete the following operations:
-
1. Look up the corresponding <span>password_digest</span>
hash value by email or other username -
2. Bcrypt extracts the salt and the hash value itself from <span>password_digest</span>
-
3. Bcrypt regenerates a hash value using the user’s input password and the extracted salt -
4. Bcrypt compares the newly generated hash value with the hash value stored in <span>password_digest</span>
; if they are the same, the verification is successful
OWASP’s Password Storage Cheat Sheet section provides a fairly complete explanation of the hashing algorithms related to password storage, but besides salting, there is also the operation of peppering, which I had no idea about. 🥲
Password Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
2. A Little Experiment in Rails Console
2.1 Learning a Bit About Yaml
After running <span>rails g authentication</span>
in the root directory of the Rails 8 project, although you can directly open <span>bin/rails console</span><code><span> to create user operations, I want to record two tips for extracting variables in </span><span><span>fixtures</span></span><span>.</span>
The first is to use ERB to extract variables:
# test/fixtures/users.yml
<% password_digest = BCrypt::Password.create("password") %>
one:
email_address: [email protected]
password_digest: <%= password_digest %>
two:
email_address: [email protected]
password_digest: <%= password_digest %>
The second is to use Yaml’s anchors and aliases:
# test/fixtures/users.yml
DEFAULTS: &defaults
password_digest: <%= BCrypt::Password.create("password") %>
one:
email_address: [email protected]
<<: *defaults
two:
email_address: [email protected]
<<: *defaults
This writing style is common in the <span>config</span>
directory’s Yaml configuration files, which I previously found somewhat confusing. It’s actually quite simple:
-
1. Use <span>&</span>
to define an anchor, which contains the<span>password_digest</span>
key-value pair -
2. Use <span>*</span>
to reference this anchor, which inserts all the values under the anchor into the reference position -
3. <span><<</span>
is called the Merge Key, which is often used together with<span>*</span>
to merge the content of an anchor into the current node and allows new values to override the values in the anchor -
4. If you do not use the merge key, the Yaml parser will directly replace the current node with the entire content of the anchor, and during the replacement process, if there are key conflicts, most parsers will report an error
YAML anchors: https://support.atlassian.com/bitbucket-cloud/docs/yaml-anchors/
2.2 Some Small Rails Tips
Now that the <span>test/fixtures/users.yml</span>
file already has the data needed for practice, how do we write it into the database for use in Rails Console? There are two ways:
-
1. Write the data into the test database and then run Rails Console in the test environment: -
1. <span>bin/rails test</span>
-
2. <span>RAILS_ENV=test bin/rails console</span>
-
2. Write the data into the development database and then run Rails Console in the development environment: -
1. <span>bin/rails db:fixtures:load</span>
-
2. <span>bin/rails console</span>
No matter which way, we can now use Rails Console to try the Bcrypt algorithm.
2.3 Trying in Rails Console
user = User.first
# => #<User:0x000070c0cf51c880...
# 1. The password_digest column is indeed stored as a hash
user.password_digest
# => "$2a$12$cFn5jqnTfWVbQzxyfplWuexuKbhOw9fq9aKsNun5PU.GoORlaYqlG"
# 2. Create a BCrypt::Password object using BCrypt::Password.new, which contains all information of the original
# hash string, including the salt, cost factor, and the hash value itself
hash = BCrypt::Password.new(user.password_digest)
# => "$2a$12$cFn5jqnTfWVbQzxyfplWuexuKbhOw9fq9aKsNun5PU.GoORlaYqlG"
# 3. Since hash is deterministic, it can be compared with the password provided by the user, note that `==` is a method
hash == "password"
# => true
# 4. Of course, you can also use the is_password? method
hash.is_password? "password"
# => true
# 5. Learn some information about the hash object, including algorithm version, cost factor, and salt
hash.version
# => "2a"
hash.cost
# => 12
hash.salt
# => "$2a$12$cFn5jqnTfWVbQzxyfplWue"
It is worth noting the difference between <span>BCrypt::Password.create</span>
and <span>BCrypt::Password.new</span>
:
-
1. <span>create</span>
is used to create a new hash value; it takes a plaintext password as a parameter and returns a Bcrypt hash string -
2. <span>new</span>
is used to verify an existing hash value; it takes a Bcrypt hash string as a parameter and returns a<span>BCrypt::Password</span>
object for comparing with the password provided by the user
Note that we should always compare hash strings using <span>BCrypt::Password</span>
objects, not directly comparing strings. The <span>==</span>
method here can be misleading, making it seem like we are comparing two strings for equality; in fact, we are calling the <span>==</span>
method on the <span>BCrypt::Password</span>
object.
You can also configure the cost factor yourself, although it is unlikely that you need to do this:
# config/initializers/bcrypt.rb
BCrypt::Engine.cost = 15
2.4 Understanding <span>has_secure_password</span>
A large part of the Authentication solution is built on the <span>has_secure_password</span>
method, so it is difficult not to understand this method. Roughly speaking, <span>has_secure_password</span>
:
-
1. Requires you to specify a property name to be hashed; if not specified, the default is <span>:password</span>
-
2. Based on the property name, it creates a <span>xxx_confirmation</span>
property, which is generally used for “re-entering the password” when creating a password (used for client-side validation, unrelated to password hashing) -
3. It also creates a <span>xxx_challenge</span>
property, which is generally used to verify the “current password entered” when modifying the password (requiring the current password when changing the password is extremely necessary) -
4. It generates a <span>xxx_reset_token</span>
method using the<span>generates_token_for</span>
method, which generates a reset password token that expires in 15 minutes by default and can be verified using the<span>find_xxx_reset_token</span>
method
In which <span>xxx_reset_token</span>
uses <span>generates_token_for</span>
like this:
# https://github.com/rails/rails/blob/cf6ff17e9a3c6c1139040b519a341f55f0be16cf/activemodel/lib/active_model/secure_password.rb#L163
generates_token_for :"#{attribute}_reset", expires_in: 15.minutes do
public_send(:"#{attribute}_salt")&.last(10)
end
Here, the last 10 characters of the salt are used as part of the reset password token; although these 10 characters are stored in plaintext, since the salt will change after the password reset, it is safe to use them as one-time use (although the security is relatively weak, but for a small website with many vulnerabilities, achieving functionality is already a challenge, worrying about this is somewhat unnecessary 🤪). And the <span>generates_token_for</span>
method itself is implemented based on the above <span>ActiveSupport::MessageVerifier</span>
.
So far, as DHH said in PR rails#50446: Rails now includes all the key building blocks needed to do basic authentication.
Add basic authentication generator PR
<span>rails#50446</span>
: https://github.com/rails/rails/issues/50446Add a default password reset token to has_secure_password PR
<span>rails#52483</span>
: https://github.com/rails/rails/pull/52483generates_token_for: https://blog.siami.fr/generate-magic-tokens-in-rails