Skip to content

Commit 87acd4f

Browse files
authored
Merge pull request #366 from danschultzer/eager-delete-bug
Prevent eager deletion of invalid persistent session cookie
2 parents b1d4904 + 2f584be commit 87acd4f

File tree

3 files changed

+123
-27
lines changed

3 files changed

+123
-27
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
### Bug fixes
1010

1111
* [`Pow.Ecto.Schema.Changeset`] Fixed bug where `Pow.Ecto.Schema.Changeset.user_id_field_changeset/3` update with `nil` value caused an exception to be raised
12+
* [`PowPersistentSession.Plug.Cookie`] Now expires the cookie 10 seconds after the last request when authenticating to prevent multiple simultaneous requests deletes the cookie immediately
1213

1314
## v1.0.15 (2019-11-20)
1415

lib/extensions/persistent_session/plug/cookie.ex

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ defmodule PowPersistentSession.Plug.Cookie do
33
This plug will handle persistent user sessions with cookies.
44
55
By default, the cookie will expire after 30 days. The cookie expiration will
6-
be renewed on every request. The token in the cookie can only be used once to
7-
create a session.
6+
be renewed on every request where a user is assigned to the conn. The token
7+
in the cookie can only be used once to create a session.
88
99
If an assigned private `:pow_session_metadata` key exists in the conn with a
1010
keyword list containing a `:fingerprint` key, that fingerprint value will be
@@ -36,11 +36,15 @@ defmodule PowPersistentSession.Plug.Cookie do
3636
* `:persistent_session_ttl` - used for both backend store and max age for
3737
cookie. See `PowPersistentSession.Plug.Base` for more.
3838
39-
* `:persistent_session_cookie_opts` - a keyword list of cookie options, see
39+
* `:persistent_session_cookie_opts` - keyword list of cookie options, see
4040
`Plug.Conn.put_resp_cookie/4` for options. The default options are
4141
`[max_age: max_age, path: "/"]` where `:max_age` is the value defined in
4242
`:persistent_session_ttl`.
4343
44+
* `:persistent_session_cookie_expiration_timeout` - integer value in
45+
seconds for how much time should go by before cookie should expire after
46+
the token is fetched in `authenticate/2`. Defaults to 10.
47+
4448
## Custom metadata
4549
4650
You can assign a private `:pow_persistent_session_metadata` key in the conn
@@ -71,6 +75,7 @@ defmodule PowPersistentSession.Plug.Cookie do
7175
alias Pow.{Config, Plug, UUID}
7276

7377
@cookie_key "persistent_session_cookie"
78+
@cookie_expiration_timeout 10
7479

7580
@doc """
7681
Sets a persistent session cookie with an auto generated token.
@@ -136,41 +141,60 @@ defmodule PowPersistentSession.Plug.Cookie do
136141
@doc """
137142
Expires the persistent session cookie.
138143
139-
If a persistent session cookie exists it'll be expired, and the token in
140-
the persistent session cache will be deleted.
144+
If a persistent session cookie exists it'll be updated to expire immediately,
145+
and the token in the persistent session cache will be deleted.
141146
"""
142147
@spec delete(Conn.t(), Config.t()) :: Conn.t()
143148
def delete(conn, config) do
144149
cookie_key = cookie_key(config)
145150

146151
case conn.req_cookies[cookie_key] do
147-
nil -> conn
148-
key_id -> do_delete(conn, cookie_key, key_id, config)
152+
nil ->
153+
conn
154+
155+
key_id ->
156+
expire_token_in_store(key_id, config)
157+
delete_cookie(conn, cookie_key, config)
149158
end
150159
end
151160

152-
defp do_delete(conn, cookie_key, key_id, config) do
161+
defp expire_token_in_store(key_id, config) do
153162
{store, store_config} = store(config)
154-
value = ""
155-
opts = [max_age: -1, path: "/"]
156163

157164
store.delete(store_config, key_id)
158-
Conn.put_resp_cookie(conn, cookie_key, value, opts)
165+
end
166+
167+
defp delete_cookie(conn, cookie_key, config) do
168+
opts =
169+
config
170+
|> cookie_opts()
171+
|> Keyword.put(:max_age, -1)
172+
173+
Conn.put_resp_cookie(conn, cookie_key, "", opts)
159174
end
160175

161176
@doc """
162177
Authenticates a user with the persistent session cookie.
163178
164179
If a persistent session cookie exists, it'll fetch the credentials from the
165-
persistent session cache, and create a new session and persistent session
166-
cookie. The old persistent session cookie and session cache credentials will
167-
be removed.
180+
persistent session cache.
181+
182+
After the value is fetched from the cookie, it'll be updated to expire after
183+
the value of `:persistent_session_cookie_expiration_timeout` so invalid
184+
cookies will be deleted eventually. This timeout prevents immediate deletion
185+
of the cookie so in case of multiple simultaneous requests, the cache has
186+
time to update the value.
187+
188+
If credentials was fetched successfully, the token in the cache is deleted, a
189+
new session is created, and `create/2` is called to create a new persistent
190+
session cookie. This will override any expiring cookie.
168191
169192
If a `:session_metadata` keyword list is fetched from the persistent session
170193
metadata, all the values will be merged into the private
171194
`:pow_session_metadata` key in the conn.
172195
173-
The cookie expiration will automatically be renewed on every request.
196+
The expiration date for the cookie will be reset on each request where a user
197+
is assigned to the conn.
174198
"""
175199
@spec authenticate(Conn.t(), Config.t()) :: Conn.t()
176200
def authenticate(conn, config) do
@@ -187,23 +211,39 @@ defmodule PowPersistentSession.Plug.Cookie do
187211

188212
case conn.req_cookies[cookie_key] do
189213
nil -> conn
190-
key_id -> do_authenticate(conn, key_id, config)
214+
key_id -> do_authenticate(conn, cookie_key, key_id, config)
191215
end
192216
end
193217
defp maybe_authenticate(conn, _user, _config), do: conn
194218

195-
defp do_authenticate(conn, key_id, config) do
219+
defp do_authenticate(conn, cookie_key, key_id, config) do
196220
{store, store_config} = store(config)
197221
res = store.get(store_config, key_id)
198222
plug = Plug.get_plug(config)
199-
conn = delete(conn, config)
223+
conn = expire_cookie(conn, cookie_key, key_id, config)
200224

201225
case res do
202-
:not_found -> conn
203-
res -> fetch_and_auth_user(conn, res, plug, config)
226+
:not_found ->
227+
conn
228+
229+
res ->
230+
expire_token_in_store(key_id, config)
231+
232+
fetch_and_auth_user(conn, res, plug, config)
204233
end
205234
end
206235

236+
defp expire_cookie(conn, cookie_key, key_id, config) do
237+
max_age = Config.get(config, :persistent_session_cookie_expiration_timeout, @cookie_expiration_timeout)
238+
opts =
239+
config
240+
|> cookie_opts()
241+
|> Keyword.put(:max_age, max_age)
242+
243+
Conn.put_resp_cookie(conn, cookie_key, key_id, opts)
244+
end
245+
246+
207247
defp fetch_and_auth_user(conn, {clauses, metadata}, plug, config) do
208248
clauses
209249
|> filter_invalid!()
@@ -276,11 +316,13 @@ defmodule PowPersistentSession.Plug.Cookie do
276316
end
277317

278318
defp maybe_renew(conn, config) do
279-
cookie_key = cookie_key(config)
319+
cookie_key = cookie_key(config)
280320

281-
case conn.resp_cookies[cookie_key] do
282-
nil -> renew(conn, cookie_key, config)
283-
_any -> conn
321+
with user when not is_nil(user) <- Plug.current_user(conn, config),
322+
nil <- conn.resp_cookies[cookie_key] do
323+
renew(conn, cookie_key, config)
324+
else
325+
_ -> conn
284326
end
285327
end
286328

test/extensions/persistent_session/plug/cookie_test.exs

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ defmodule PowPersistentSession.Plug.CookieTest do
99
alias PowPersistentSession.Test.Users.User
1010

1111
@max_age Integer.floor_div(:timer.hours(24) * 30, 1000)
12+
@custom_cookie_opts [domain: "domain.com", max_age: 1, path: "/path", http_only: false, secure: true, extra: "SameSite=Lax"]
1213

1314
setup do
1415
config = PowPersistentSession.Test.pow_config()
@@ -128,7 +129,7 @@ defmodule PowPersistentSession.Plug.CookieTest do
128129
|> Cookie.call(Cookie.init([]))
129130

130131
refute Plug.current_user(conn)
131-
assert conn.resp_cookies["persistent_session_cookie"] == %{max_age: -1, path: "/", value: ""}
132+
assert conn.resp_cookies["persistent_session_cookie"] == %{max_age: 10, path: "/", value: "test"}
132133
assert PersistentSessionCache.get([backend: ets], id) == :not_found
133134
end
134135

@@ -139,7 +140,26 @@ defmodule PowPersistentSession.Plug.CookieTest do
139140
|> Cookie.call(Cookie.init([]))
140141

141142
refute Plug.current_user(conn)
142-
assert conn.resp_cookies["persistent_session_cookie"] == %{max_age: -1, path: "/", value: ""}
143+
assert conn.resp_cookies["persistent_session_cookie"] == %{max_age: 10, path: "/", value: "test"}
144+
end
145+
146+
test "call/2 when persistent session cache doesn't have credentials with custom cookie options", %{conn: conn, config: config} do
147+
config = Keyword.merge(config, persistent_session_cookie_opts: @custom_cookie_opts, persistent_session_cookie_expiration_timeout: 20)
148+
conn =
149+
conn
150+
|> persistent_cookie("persistent_session_cookie", "test")
151+
|> Cookie.call(Cookie.init(config))
152+
153+
refute Plug.current_user(conn)
154+
assert conn.resp_cookies["persistent_session_cookie"] == %{
155+
domain: "domain.com",
156+
extra: "SameSite=Lax",
157+
http_only: false,
158+
max_age: 20,
159+
path: "/path",
160+
secure: true,
161+
value: "test"
162+
}
143163
end
144164

145165
test "call/2 with invalid stored clauses", %{conn: conn, ets: ets} do
@@ -197,7 +217,7 @@ defmodule PowPersistentSession.Plug.CookieTest do
197217
end
198218

199219
test "create/3 with custom cookie options", %{conn: conn, config: config} do
200-
config = Keyword.put(config, :persistent_session_cookie_opts, [domain: "domain.com", max_age: 1, path: "/path", http_only: false, secure: true, extra: "SameSite=Lax"])
220+
config = Keyword.put(config, :persistent_session_cookie_opts, @custom_cookie_opts)
201221
conn = Cookie.create(conn, %User{id: 1}, config)
202222

203223
assert %{
@@ -236,4 +256,37 @@ defmodule PowPersistentSession.Plug.CookieTest do
236256

237257
assert_received {:ets, :put, [{_key, {[id: 1], session_metadata: [a: 1]}}], _config}
238258
end
259+
260+
test "delete/3", %{conn: conn, ets: ets, config: config} do
261+
id = "test"
262+
conn =
263+
conn
264+
|> store_persistent(ets, id, {[id: 1], []})
265+
|> Cookie.delete(config)
266+
267+
refute Plug.current_user(conn)
268+
assert conn.resp_cookies["persistent_session_cookie"] == %{max_age: -1, path: "/", value: ""}
269+
assert PersistentSessionCache.get([backend: ets], id) == :not_found
270+
end
271+
272+
test "delete/3 with custom cookie options", %{conn: conn, ets: ets, config: config} do
273+
id = "test"
274+
config = Keyword.put(config, :persistent_session_cookie_opts, @custom_cookie_opts)
275+
conn =
276+
conn
277+
|> store_persistent(ets, id, {[id: 1], []})
278+
|> Cookie.delete(config)
279+
280+
refute Plug.current_user(conn)
281+
assert conn.resp_cookies["persistent_session_cookie"] == %{
282+
domain: "domain.com",
283+
extra: "SameSite=Lax",
284+
http_only: false,
285+
max_age: -1,
286+
path: "/path",
287+
secure: true,
288+
value: ""
289+
}
290+
assert PersistentSessionCache.get([backend: ets], id) == :not_found
291+
end
239292
end

0 commit comments

Comments
 (0)