mattIshida
Posted on March 3, 2023
Most things in Rails are remarkably easy. Surprisingly easy. But authentication and authorization are not. Not that Rails isn't doing a lot under the hood to make them easier. But it can be a bit of shock to go from setting up a simple API in literally minutes, to puzzling over how to implement signup, authentication, authorization, auto-login, and logout using a number of interconnected controllers, objects, and methods, many of them custom.
In this point, I'm going to try to "think above the code" to understand what is really going on and hopefully bring a modicum of order to the code itself. The goal is to see the syntactic complexity as the surface layer of something that is more abstract, but also simpler and therefore easier to remember and reason about.
Starting from zero: auth as a conversation
If we were designing "auth" for websites from scratch, what would we need?
To make things more vivid, let's use an analogy. If we were designing a humanoid robot that was capable of rich and meaningful conversations with people, what would we need?
The idea here is that authentication on website should feel like having a conversation with someone who knows who you are and responds to your individual characteristics.
User data
Most obviously, we would need to know about users. We would need to store data about them, including but not necessarily limited to their identifying characteristics, of which username and password are the most obvious.
Going back to our metaphor, if our robot is going to be capable of carrying on rich and meaningful conversations with people, it will need a store of user data like name, facial appearance, age, likes and dislikes, last conversation, etc.
In Rails terms, we're going to need a User
resource. We're going to want to be able to add and retrieve users as needed, so we'll want users#create and users#show
routes, at a minimum.
Back-end State
Is it enough simply to know about users? It's kind of an interesting question. Go back to our robot analogy. Is it enough for our robot to have access to user data?
No.
The goal is not just to have a store of user data but to have robot that can converse meaningfully on a user-by-user basis. All the user data in the world would be of no use unless the robot also knew who it was talking to at any particular moment.
Our robot therefore needs some representation of "the here and now." The user table by itself doesn't pick out any user as the one being spoken to. It's just a series of hundreds maybe thousands of rows, each resembling the last. The table by itself is completely neutral whereas our here-and-now representation needs to be highly particular.
So we'll need some representation of the here and now state that picks out particular user as a conversational partner. For now, I'll simply refer to this representation as the state.
In Rails terms, is state simply another resource? Well, we may want to create, update, and destroy state, which will mean creating routes and a state controller. But state can't be just another resource. It has a special role to play. Creating another table with rows mapped to state instances isn't enough, just as having a bunch of acquaintances stored in memory doesn't tell you who you are talking to right now.
State needs to be it's own thing! In Rails, state is represented as an object named "session" with a :user_id
attribute.
Next steps
We've actually made progress. We've established a foundation. We know that we're going to be basing all of our auth activities on two entities:
- A store of user data in the form of a user table that represents the individuals we know about
- A back-end state representation in the form of session object with
:user_id
attribute that represents who we are talking to in the here and now. By convention, if there isn't any such person:user_id
will benil
.
Now, when we are trying to implement a certain functionality, we can think in terms of these two entities. The first question is what do we want to do to these two entities, then which Rails methods do we need to do that.
Let's walk through some standard auth functions one by one and see how the map to our entities.
Signup
Here we are creating a new user and then allowing that user to actually start using the website.
In terms of our robot, we are introducing a new acquaintance and having the robot start a conversation with that acquaintance.
So we'll want to 1) add a user to the user table and 2) update the session[:user_id] attribute to start the "conversation".
Login
Here we are checking if a user's credentials match anything in our store of user data. If the check is successful, the user will start using the website.
In terms of our robot, the robot is recognizing a previous conversation partner, then initiating a conversation with that individual.
So we'll want to 1) check the contents of user table against the submitted credentials and 2) update the session[:user_id]
attribute to start the "conversation."
In Rails, authentication per se is really just a part of our concept of checking the user table against credentials. It can be accomplished with a simple call to the authenticate
method.
user = User.find_by(username: params[:username])
user&.authenticate(params[:password])
Authorization
Authorization is distinct from signup and login insofar as it involves checking the properties of a user who has already logged in.
In terms of our robot, we can think of this as the robot retrieving information about its current conversational partner and checking that information for guidance about how to respond.
Here the session[:user_id] has already been set as the conversation can be assumed to initiated. The problem is that the :user_id
is just a reference to a record in table. The session object doesn't contain the user instance itself. We still need to retrieve the data in the record so we can decide how to act on it.
Hence, we need to set an instance variable @user
based on the :user id
.
@user = User.find_by(id: session[:user_id])
This points up one limitation of how Rails represents our concept of the 'here and now' back-end state. It doesn't contain the full user instance. So we need to explicitly retrieve that information as a step in the authorization process before any action that retrieves user-specific information.
Logout
Logout is analogous to ending a conversation. Ending a conversation doesn't mean forgetting that individual, so we know we'll be leaving the users table alone. All we really need to do is update the session[:user_id]
attribute to reflect the change to a null state.
Since a nil :user_id
is equivalent to a state of 'no-conversation' we can accomplish this simply by deleting the attribute:
session.delete :user_id
Auto-Login
Auto-login is an interesting case. For present purposes, auto-login is analogous to a situation where a conversation with our robot is already in place. The user table and session[:user_id]
are just as they should be. We simply need to check if there is non-nil :user_id
attribute and, if there is, send that information for that user to the front end.
Recap
Since the point of this post is to reduce complexity, we can start to organize our auth functionality by what kinds of things we are doing to which of our two key entities. As a shorthand:
- Signup: Table update, back-end state update
- Login: Table check, back-end state update
- Authorization: back-end state check
- Logout: back-end state update
- Auto-Login: back-end state check
Again, by table here we mean our store of user data and by state we mean our representation of the "conversation" taking place.
One of the important caveats is that our state representation can't hold full user data, so checking state will typically mean setting an instance variable @user
which contains the relevant data.
Mapping to MVC
The next difficult thing about auth in Rails is mapping these concepts to routes and controllers.
Unfortunately, here is where some of the line start to get a little blurry. It becomes harder to maintain a traditional separation of concerns.
We've seen that auth is based on a users table and session object. As a first pass, it is tempting to assume that we will need Rails resources for both of these, so that each have their own table, model, controller, and RESTFUL routes.
However, we need to depart from the typical Rails setup for a number of reasons.
1) The session object is not just another Rails resource. As we've seen it has an important role to play as the representation of back-end state.
2) User controller actions like create
(for user signup) and show
(for user auto-login) will need to update the session object.
3) Session controller actions like create
(for user login) and destroy
(for logout) do not actually create or destroy the session. Instead, they update the session[:user_id]
attribute.
4) There is an important action of setting a @user
instance variable based on our back-end state. While this crucially involves the session[:user_id]
attribute, it is not an action in the session controller. Instead, it needs to be set again before every action (regardless of controller) that requires authorization. This is typically done with a Rails before_action
filter.
Conclusion
Implementing auth can be better understood by thinking of it in terms of more abstract but more elementary concepts. To be sure, the fit is not altogether clean, especially on some of the finer details. But seeing those mismatches can itself be a useful tool in remembering the ins-and-outs of this complicated topic.
Posted on March 3, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.