Although Intrepid is primarily a mobile shop, we have a small-but-mighty web team that builds APIs, admin portals, and the like for our clients. For every API we’ve built, some sort of authentication system is required. In this and subsequent posts, I’ll walk through the approach we’ve developed over a number of projects for user sign-up and authentication.
In this post, I'll review signing up users with email and password. In future posts, I'll review:
- Allowing users to sign up via either email/password or an OAuth provider
- Authenticating API endpoints using Warden
- Regenerating API tokens when they expire
Note: This tutorial is designed for someone who has a beginning to intermediate knowledge of Ruby on Rails.
The Sign-Up and Authentication Flow
Before we get started, let’s review the whole sign-up and authentication flow:
- User signs up
- A first-time user submits their email and password to a `POST /users` endpoint.
- They get back a unique `authentication_token` to pass up in subsequent requests.
- User makes a request to an authenticated endpoint in your API
- They pass up their `authentication_token` in an `Authorization` header.
- If a user with that auth token exists and the auth token is not expired, the request proceeds.
- Otherwise, the server returns a `401 - Unauthorized` response.
- User regenerates a token
- If their token has expired, the user submits their email and password to a `POST /authentications` endpoint.
- A new token is generated for that user and is returned for use in subsequent requests.
We'll tackle part one today. Follow along on this repo.
Tools We'll Use
- ActiveModel::Serializers v. 0.8.3
- Monban and Warden
- RSpec and json_spec gem for testing
- Versionist for versioning the API
Let's get started...
Step 1: Write a Test
Because we're good little TDD-ers, we'll start with an integration test for our "happy path" case– that is, the case where everything goes right.
We're making use of a couple of helper methods in our test that we can define in a `Helpers::Requests` module, namely:
- `accept_headers` to generate a header with our vendor prefix and API version number
- `json_value_at_path` for validating that JSON responses contain the correct values
These rely on the `json_spec` gem's helper methods, namely `parse_json`.
Remember to register this helper, as well as the `json_spec` helpers, in your `rails_helper.rb`, or you won’t have access to these methods in your tests:
Step 2: Generate Your User Model
Let's add a `User` model with `email`, `password_digest`, `authentication_token`, and `authentication_token_expires_at` required fields (see commit).
Step 3: Add your Route and Controller Action
We use the versionist gem to enable versioning via an accept header. Adding the route below will give us a route to the `v1/users#create` controller action:
In that action, we'll use a `SignUpUser` service and an `AuthenticationSerializer` to serialize the user into JSON:
If you’re not familiar with serializers, they provide a way of tailoring how you represent your objects as JSON. Without a serializer, if you render JSON in your controller using `render json: my_objects`, Rails will call the `to_json` method on each object and return every attribute belonging to that object in the JSON. Using a serialization library like `active_model_serializers` provides much more control over which attributes you send to your API client and how you include associated records.
Step 4: Add SignUpUser Service
The `SignUpUser` service is responsible for creating a new user given an email and password. We'll be using Monban to sign up users.
In addition to the `SignUpUser` service, we'll need to add:
- A `reset_token!` method on the `User` model to set the authentication token, which we'll be able to use later on when we need to regenerate tokens as well.
- An `AuthenticationToken` service, which will hold the logic for generating new (unique) tokens.
Here’s the code. Let’s take a quick look at each service to see what they are doing.
Our `SignUpUser` service has a class method `perform`, which instantiates a `SignUpUser` object and calls its instance method `perform`. This is a pattern we use frequently because it provides a somewhat nicer syntax than relying on an instance method alone, which would require us to call this method like so: `SignUpUser.new(user_attrs).perform`.
That method first checks to make sure an email and password are provided, then calls `sign_up_user`, which uses Monban to generate the user with a proper password digest. Then it sets the token and saves the user.
In addition, we've got a `User#reset_token!` method that relies on our `AuthenticationToken` service to set the user's token:
Our `AuthenticationToken` service contains the logic for generating a unique token so it doesn't muddy up our `User` model. It generates a token and expiry, checks to make sure the token is not already in use by another user, and updates the user with their new credentials.
Step 5: Add AuthenticationSerializer
Our last step is to add an `AuthenticationSerializer` to serialize our newly-signed-up user.
First let's add a `BaseSerialier` that our other serializers will inherit from, which will include basic attributes and ensure that any associated records are sideloaded rather than embedded.
Now let's add our `AuthenticationSerializer` and return the user's token and expiry:
Our happy path request spec should now be passing!
Step 6: Handle Error Cases
Finally, we need to make sure that error cases (such as a user providing a pre-existing email) are handled properly.
In this case, we want to return a "422 - Unprocessable entity" response along with helpful error messages. Let's add our integration test:
And now let's make it pass:
We could leave it like this and be done with our feature. But since we nearly always use this pattern in `create` actions of trying to save an object, and rendering errors if it fails, let's instead raise an error and rescue that error in our base controller:
Congratulations, you can now sign up users.
Let us know if you have feedback or questions! And stay tuned for our next post in the series.