Knowing how to properly safeguard valuable and sensitive data against malicious actors is an especially critical aspect of web development. In an ever-changing, perpetually evolving digital landscape, threats to security and privacy are legion. Thus, in order to more meaningfully anticipate and proactively neutralize these threats, and consequently ensure the safety and privacy of our data, it is essential that we familiarize ourselves with core security practices and tools that can be implemented in our code.
During Phase 2 at Flatiron School, I attempted to cobble together a crude semblance of login security and user authentication with state in React.js. I sought to toggle between states of showLogin (true or false) to conditionally render a login form on page load and thereby create an illusion of user authentication by allowing users to input data and seemingly persist it via form submission. However, in actuality, this data was never being persisted or stored in any manner by which it could be cross-referenced or utilized for authentication purposes. Instead, login merely resulted in a single user object being created in my JSON server, the presence of which (or lack thereof) would toggle a change in the showLogin state and prompt rendering of either a login form or a home page.
Moreover, I directed logout to delete the user object, so that only one user object ever existed on the JSON server at any time. So, even though my logged user might have a user object containing their inputted username and password details, none of this mattered because it was not being used for authentication. Rather, I was only relying on the presence of a single user object to update state in my app and mimic login, which meant that my Phase 2 project only allowed for a single logged user at a time and, even worse, accepted any and every conceivable user input for username and password fields. At that time, I was not versed in validations and had only the faintest notion of what real user authentication entailed.
Thankfully, by contrast, Phase 4 has imparted a much greater and more robust approach to implementing true user authentication in my apps. For this phase, I was tasked with coding out a full-stack app (utilizing React.js and Ruby on Rails) that satisfies a number of different criteria. And, of course, user authentication was included as a core requirement. Unlike my Phase 2 project, though, I have been able to access newfound knowledge and tools to properly implement user authentication in my Phase 4 app, Book-It.
Securing data and handling model validations, error messages, and db persistence is far easier when working with a fully featured backend framework like Ruby on Rails. Specifically, its pre-built features and conventions make building, maintaining, and scaling web applications with Rails a more streamlined and manageable process, while simultaneously ensuring data integrity and security throughout. For me, this means being able to safely and securely exercise far greater control and precision over data and how its used and accessed.
Additionally, Rails has access to gem dependencies like Bcrypt that help to create impressively strong foundations for building secure systems. The Bcrypt gem describes its purpose and functionality thusly:
bcrypt() is a hashing algorithm... [that] take[s] a chunk of data (e.g., your user's password) and create[s] a "digital fingerprint," or hash, of it.
On its own, Rails could store user passwords as attributes in a user object. Of course, this is tremendously insecure, as every user password would therefore be available in a database unprotected and unencrypted. This is where Bcrypt comes in, hashing passwords. As opposed to storing passwords in plain text, Brcypt lets us store passwords as hashed versions of the original input.
This hashed version is tied to the original plain text input in that "a hash is a fixed-length output computed by feeding a string to a hash function... and will always produce the same output given the same input." This is very important in that the same password input will always produce the same hashed output. If a user submits the same password at login as they submitted and stored to their user when the user was created, the hashes will match. Thus, Rails stores user passwords as hashed versions of the plain text values entered on signup and subsequently uses these hashes as the basis for authentication by cross-checking them against hashes of potentially valid password inputs on login.
Illustrating this hashing process, Flatiron School [Flatiron School course modules] explains that hashing is like:
making a smoothie. If I put the exact same ingredients into the blender, I'll get the exact same smoothie every time. But there's no way to reverse the operation, and get back the original ingredients from the smoothie.
Hash functions work in a similar way: given the same input, they'll always produce the same output; and there's no way to reverse the output and recreate the original input.
There is a very important caveat to this, however.
Malicious actors who are trying to crack passwords can "run lists of possible passwords through the same algorithm, store the results in a big database, and then look up the passwords by their hash." These mapped collections of strings to hash outputs are known as rainbow tables. In addition to rainbow tables, hackers can and will try to crack passwords through brute-force attacks, whereby the attacker systematically tries every possible combination of characters until they arrive at the right password. Unsurprisingly, these methods can take a significant amount of time, but are likely to decode password hashes. This is where having Bcrypt hash password data is doubly essential.
Bcrypt gives us a convenient and powerful solution to the threat of malicious action. By _salting _passwords, or prepending random strings to passwords before hashing them, the complexity and computational cost of running hash algorithms to guess and crack passwords increases astronomically. Even though the salt is stored in plain text beside a password, a malicious actor is unlikely to have constructed a rainbow table with the salt already prepended and must therefore rerun and reformulate their password combinations. In this way, Bcrypt is intentionally expensive computationally. The entire point is to make the process of running these hashes slower and therefore less susceptible to hacks.
Refer to the following Bcrypt gem documentation for a summary of salting:
The solution to this is to add a small chunk of random data -- called a salt -- to the password before it's hashed:
hash(salt + p) #=> <really unique gibberish>
The salt is then stored along with the hash in the database, and used to check potentially valid passwords:
<really unique gibberish> =? hash(salt + just_entered_password)
bcrypt-ruby automatically handles the storage and generation of these salts for you.
Adding a salt means that an attacker has to have a gigantic database for each unique salt -- for a salt made of 4 letters, that's 456,976 different databases. Pretty much no one has that much storage space, so attackers try a different, slower method -- throw a list of potential passwords at each individual password:
hash(salt + "aadvark") =? <really unique gibberish>
hash(salt + "abacus") =? <really unique gibberish>
etc.
This is much slower than the big database approach, but most hash algorithms are pretty quick -- and therein lies the problem. Hash algorithms aren't usually designed to be slow, they're designed to turn gigabytes of data into secure fingerprints as quickly as possible. bcrypt(), though, is designed to be computationally expensive:
Ten thousand iterations:
user system total real
md5 0.070000 0.000000 0.070000 ( 0.070415)
bcrypt 22.230000 0.080000 22.310000 ( 22.493822)
> If an attacker was using Ruby to check each password, they could check ~140,000 passwords a second with MD5 but only ~450 passwords a second with bcrypt().
So, now the question is how do we use and integrate all of this hashing and salting and password safeguarding into our applications? Simply, Rails streamlines all of this for us. Rails provides access to a method called has_secure_password. To access this method, I had to first download and install the 'bcrypt' gem, including it to my gem file and running bundle install. Next, I used has_secure_password in my User model.
class User
has_secure_password
end
Following this, I gain access to two new instance methods in my User model, password and password_confirmation. However, these methods do not correspond to similarly named database columns, but rather a single 'password_digest' column.
In my database schema, this looks like:
create_table 'users', force: :cascade do |t|
t.string 'email'
t.string 'password_digest'
t.string 'first_name'
t.string 'last_name'
t.string 'phone_number'
t.string 'address'
t.string 'city'
t.string 'state'
t.string 'country'
t.string 'passport_number'
t.integer 'age'
t.date 'date_of_birth'
t.string 'nationality'
t.string 'avatar_url'
t.datetime 'created_at', precision: 6, null: false
t.datetime 'updated_at', precision: 6, null: false
end
With these things in place, I can set up my routes, models, and controllers to fully implement user authentication and authorization.
In quick summation of how this request-response flow cycle looks in my project:
Sign Up
A user visits the page for the first time and does not have an account
User submits an email, password, and password confirmation as form data
After clicking create account, a POST request is sent from the frontend to the signup route
post '/signup', to: 'users#create'
in the backendthis route is matches to the users controller, wherein the create action facilitates the request as such:
def create
new_user = User.create!(user_params)
session[:user_id] = new_user.id
render json: new_user, status: :created
end
private
def user_params
params.permit(:email, :password, :password_confirmation)
end
- the database gets accessed and manipulates data as directed in the controller. it conditionally creates a new user object pending validations I have set in the User model
validates :email, presence: true, uniqueness: true
validates :password, confirmation: true, presence: true,
length: { minimum: 8 }, on: :create
validates :password_confirmation, presence: true, on: :create
- Assuming these requirements are met, the User object is created in the database, saved to the current session, and returned as json data to the frontend.
Login
A user visits the app and is not currently logged in. The user is prompted to log on via submitting email and password.
Form data is submitted as a POST request, or
post '/login', to: 'sessions#create'
This route path matches to the sessions controller wherein the create action facilitates the user authentication process as such:
def create
if params[:email].present? && params[:password].present?
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
session[:user_id] = user.id
render json: user, include: ['bookings', 'bookings.room', 'bookings.room.hotel'], status: :created
else
render json: { error: 'Invalid email or password' }, status: :unauthorized
end
else
render json: { error: 'Email and password are required' }, status: :unprocessable_entity
end
end
Here, I am checking first that there is a valid email and password present, and, if so, find the user that has an email matching the form data. If these values are missing, I return an error message as json informing the user that an email and password are required.
My User model additionally validates the email and password presence and length and returns the matching user object based on email.
Next, my controller facilitates authentication via
user&.authenticate(params[:password])
(another method given to us via bcrypt) to compare the salted hashes and confirm that the submitted user password is the same hashed value as the stored user password digestIf either the password values do not match, or a user is not found via the email params supplied by the user, an error is returned as json stating "Invalid email or password"
If the password params and password digest hash values match, the user is "logged in" and stored in the session as seen in the code block above.
Moreover, all database associations are included with the user json (see multi-level nesting and json in rails docs for more details)
Authorizing Current User
Aside from logging in and signing up, a user may also stay logged in as a current user. This current user status is saved and confirmed via Session data.
If a user has yet to log out and chooses to navigate away from/return to the app or refresh the page, I check that the user is logged in as a current user via sending a GET request at page load via the useEffect hook in React.
get '/me', to: 'users#show'
class Api::UsersController < ApplicationController
before_action :set_current_user, only: %i[show update destroy]
def show
if @current_user
render json: @current_user, include: ['bookings', 'bookings.room', 'bookings.room.hotel'], status: :created
else
render json: { error: 'Not authorized' }, status: :unauthorized
end
end
private
def set_current_user
@current_user ||= User.find_by(id: session[:user_id])
end
end
Here, I have a custom private method called set_current_user running before the show action takes place. This method looks for a User object by searching for an id value taken from the user_id attribute in the current session. If there is an user object associated with that id in the database, it will be set as the current user.
- If that conditional is satisfied, the Show action will return the current user as json, replete with all its database associations.
Logout
Logout is handled via a DELETE request sent from the frontend and handled by the backend route
delete '/logout', to: 'sessions#destroy'
It is carried out as:
class Api::SessionsController < ApplicationController
before_action :authorize, only: [:destroy]
def destroy
session.delete(:user_id)
head :no_content
end
private
def authorize
return render json: { error: 'Not authorized' }, status: :unauthorized unless session[:user_id]
end
end
Before action takes place, I confirm that the session has a user_id attribute value, or return an error of "Not authorized" back to the frontend
If there is a user stored in sessions, I execute the delete method on the session to remove the :user_id from session storage. This will mean that returning to the web page will show that no user is stored in session and therefore prompt login
With this setup, I have successfully implemented secure user authentication and authorization for my Phase 4 project, Book-It. This not only allows for multiple users to be registered, logged in, and maintain their individual sessions but also securely stores and verifies passwords using Bcrypt hashing and salting. In doing so, I have significantly improved upon my initial attempt at user authentication during Phase 2.
Moving forward, I will continue to prioritize security and implement best practices to ensure that my applications maintain the highest level of data protection and user privacy possible. As the digital landscape evolves and new threats emerge, I am committed to staying up-to-date with the latest tools and techniques to protect my applications and the data they store. Now, I will move onward to my graduation capstone and the ultimate, eventual goal of finding a job!
Sources:
Flatiron School. (n.d.). Ruby on Rails. Retrieved [4/13/2023], from [unlisted course modules].
bcrypt-ruby. (n.d.). bcrypt-ruby documentation. Retrieved [4/13/2023], from [https://github.com/bcrypt-ruby/bcrypt-ruby].
Top comments (1)
Another way to avoid weak password methods is to use database authentication. The great thing then is that there is no need at all to store any part of the username or password. The other great thing is that the datamodel has no columns for username and password. Just a reference to the database-role. Rdbms-vendors have much better ways to ensure safety of whatever credentials are used, don't try this yourself. Bonus: other clients can access the database without compromising authorization; your ruby-server is no longer a monopoly.