I am in the process of designing my own auth system, and as a result I’ve had a close look at phx.gen.auth to get a feel for best practices.
Most of the design choices are quite straightforward and clear, which is great, but one thing I’ve been wondering about is the choice to use a separate remember_me_cookie to store the user_token outside the Plug session.
For anyone who is unfamiliar, it works like this:
When a user logs in, a user_token (a long random string) is generated via the Accounts context
Assuming “Remember me” is checked, the token is stored in a remember_me_cookie named app_user_remember_me
The token is then copied from the remember_me_cookie into a user_token value stored in the Plug session
That user_token value in the Plug session is used to authenticate and store the current_user via the Plug/LiveView pipelines
Since the Plug session is, by default, stored as a session cookie (noobs beware: this is a completely different meaning of the word session), it will be lost whenever the browser feels like it (probably on restart), at which point we jump to Step 3
What I’m wondering here is why bother with the remember_me_cookie shuffle instead of just storing the token in a cookie directly? A good chunk of the code in auth.ex is dedicated to copying the token from the cookie to the session.
Also, storing the cookie in the session then necessitates clearing the session on login to prevent fixation attacks (as helpfully explained in a comment), but if the token was just stored in its own cookie this would not be necessary. Though, from what I can tell, it’s actually not necessary anyway by default as the default session implementation does not actually have a session id (it just encodes/signs the session data directly into the cookie), meaning a fixation attack would not apply. But I presume it was written this way in case users swap in their own Plug session backend (i.e. store it in Postgres), at which point fixation attacks would apply, and it’s better to handle that from the start.
Anyway, I suspect this separate cookie business is done just because it’s idiomatic to store the token in the session, but I figured I’d inquire in case there’s something I’m missing. I suppose it also handles the case of the user unchecking “Remember me” nicely, but you could just set the expiry of the cookie instead so I don’t think that’s the reason.
It has been a while but I believe everything you said is correct.
The only reason we still write to the session is because the session is also shared with Channels/LiveViews. WebSockets are vulnerable to cross-site hijacking attacks, so cookies should only be read from WebSockets if you do CSRF validation, which we already do for session. Otherwise you have to re-implement that for cookies.
While this is true, I would still renew the session on login even if you write the token to a cookie. Otherwise it can be easy for someone to accidentally write something they did not intend to the fixated session id, which can now be tied to the logged in user.
This is exactly the sort of thing I figured I might be missing. Thanks!
Indeed, after having a closer look I see that LiveView does not expose cookies for exactly this reason, providing the session on mount instead. So I suppose it would be possible to use a Plug to inject a user_id into the session or something like that, but that seems messy and at that point the phx.gen.auth approach starts to make a lot of sense.
This is a good point, and it also makes sense to avoid fixation on the csrf token. Which actually raises another question:
Having a look at the Plug.CSRFProtection module, I see that the csrf token is actually stored in the process dictionary (an unusual choice) and then written back into the session at the end of the Plug pipeline via a callback. Doesn’t that mean that clearing the session is insufficient to prevent fixation on the csrf token, and that delete_csrf_token/0 must be called as well? Otherwise I would expect the csrf token to survive in the process dictionary and be written back into the new session after login. Perhaps I’m missing some other side-effect, though; I haven’t tested this.
Interestingly, the Plug.CSRFProtection docs actually explicitly state that you should call delete_csrf_token/0 after logging a user in.
So I went ahead and actually tested this, and to my surprise the CSRF token is actually cleared after login/logout. What I can’t for the life of me figure out is where it gets cleared. I thought maybe there was some weird interaction with configure_session(:renew) being executed via callback at the end of the pipeline (it would be executed after the CSRF callback because :fetch_session is run first and they run in reverse order). But renewing the session doesn’t clear the data written into it after login (i.e. the user token, which obviously survives). The session is cleared by clear_session/1, which would be run before the CSRF callback. I’m curious if anyone can find the code responsible for resetting the token.
I don’t use GitHub, but I went ahead and tested a one-line patch to call delete_csrf_token/0 when the session is cleared, and it seems to work fine (tests pass etc). The relevant line in the template is here.
As I mentioned above, though, this change is not strictly necessary - I just have no idea why!
This is close to what happens, but not quite right because get_csrf_token/0 is called on template render, so the masked token is not written into the process dictionary until then. Therefore Process.get(:plug_masked_csrf_token) was always nil when that debug Plug was invoked (because it runs before template render).
The exact sequence (for :plug_unmasked_csrf_token) is:
On the first page load, the token is nil because it isn’t generated until get_csrf_token/0 is called during template render.
After reloading the page, the unmasked token is in the process dictionary because it was generated after the first page load and then loaded from the session during the second page load. So a value, let’s say "abc", is printed for the token.
After loading the login form page, the same token, "abc", is again printed.
After logging in, the token printed is nil for the same reason as in Step 1. This is where I get confused as I can’t find the side-effect responsible for resetting this token in Plug, but it does get reset.
After reloading, a new unmasked token is printed (call it "xyz"), having been generated on the previous page load just like in Step 2.
Logging out has the same effect (the token is nil, and regenerated before the next page load).
The behavior was exactly the same if delete_csrf_token/0 was added to the auth.ex code.
I should also note that this was tested with the Controller version of phx.gen.auth, not the LiveView version.