Update the generated phx.gen.auth code to use a separate session token table
One of the pet-peeves that I have with the official phx.gen.auth generator is that it merges all the user tokens into a single table.
I'm not a big fan of this, because I consider it to be a leaky abstraction.
Why would my session tokens need sent_to ? This is for the email-change flow.
What if I want to add extra metadata, like the user-agent, or the IP address? I shouldn't do that in this table.
My password reset token flow doesn't care about that.
I believe it's best to make this change early, before we get to unicorn status, and not have to deal with the migration when it's painful. In this post I'll focus on the session tokens, but the same principle applies to all others.
Step 1: Generate the migration
We keep the same structure, but without context & sent_to. Let's also add a unique index on the token.
There are no surprises here.
Optional Step: Centralize the token generation
Well, here's a second pet-peeve. I don't enjoy having to call :crypto.strong_rand_bytes/1 everywhere in my code.
It's a bit of a code smell. The hash_algorithm and rand_size are also things that are not going to change module to module.
So I like to centralize this in a helper module.
Step 2: Create the schema
OK, back to the juicy stuff. Let's create the SessionToken schema. There's only a single changeset method that generates the token, and prepares the changeset.
Step 3: Update the Accounts context
Finally, let's make small adjustments to the Accounts context. I'm also going to add a few new ones, revoke_other_sessions/2 and list_sessions/2.
Let's also update the reset_user_password/2 method to delete all the session tokens for the user.
I find Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all) to be sub-optimal as well, but I'll leave it as is for now.
Step 4: Update the Accounts context tests
Last but not least, let's update the tests. There's not much to change here (intentionally), we just have to ensure we're referencing the correct schema.
Step 5: Remove old session tokens
One last thing, be sure to remove the leftover session tokens in the users_tokens table, any way you see fit.
I'm not going to provide an example here, as it's a one-time operation. You can do it manually, or write a migration for it.
Optional Step: Add some metadata
Let's make this change useful. We will expand the session token table, to include some metadata about the device that the user is using. The point here is to allow them inspect their active sessions, and see if there's anything suspicious.
We start by generating a migration to add the fields.
Next, we have to update the SessionToken schema to include the device metadata.
Then, update the changeset/1 method (now changeset/2) to accept these fields.
Finally, we update generate_user_session_token/1 method (now generate_user_session_token/2), and the list_sessions/2.
After that, we just have to parse our headers in our session controller, and extract the device info. Even if you're using LiveView for everything, you can't get away without having a session controller. So the same step applies.
In order to collect the device info, we can use various User-Agent parsers (like UAParser). That said, it's not a foolproof method. For example Brave sometimes gets detected as Chrome. You need client-hints to get the real info there.
MacOS has also stopped updating the User-Agent string, so it's not reliable. I believe it's stuck on 10_15_7, and it's not going to change.
So, I won't go into the details of how to parse the User-Agent string, it depends on how serious you want to get with this.
But essentially, you need something along these lines:
Conclusion
That's it, we now have a separate table for session tokens, that can be easily extended with relevant metadata.
Now, let's recap why we did it.
The generator, creates what's effectively a "God Table", it kind of knows too much, and handles different concerns.
When you get a session token, you notice a sent_to. If you are not aware of the generator structure, and you see this, you have every right to be concerned. Session management,
has nothing to do with email delivery.
This is a classic example of a leaky abstraction, where implementation details of one concern are leaking into another.
So we separate them.
I hope you found this post useful. If I have messed something up, feel free to reach out to me on Twitter or BlueSky.