Skip to content

Commit 74f3168

Browse files
authored
Merge pull request #37 from epochtalk/delete-session
Delete session
2 parents 558b8a6 + 3ae299f commit 74f3168

File tree

3 files changed

+159
-93
lines changed

3 files changed

+159
-93
lines changed

lib/epochtalk_server/session.ex

Lines changed: 55 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -106,99 +106,66 @@ defmodule EpochtalkServer.Session do
106106
end
107107

108108
@doc """
109-
Gets all sessions for a specific `User`
109+
Gets all session ids for a specific user_id
110110
"""
111-
@spec get_sessions(user :: User.t()) ::
112-
{:ok, user :: User.t(), sessions :: [String.t()]}
111+
@spec get_sessions(user_id :: non_neg_integer) ::
112+
{:ok, sessions_ids :: [String.t()]}
113113
| {:error, atom() | Redix.Error.t() | Redix.ConnectionError.t()}
114-
def get_sessions(%User{} = user) do
114+
def get_sessions(user_id) do
115115
# get session id's from redis under "user:{user_id}:sessions"
116-
session_key = generate_key(user.id, "sessions")
117-
118-
case Redix.command(:redix, ["SMEMBERS", session_key]) do
119-
{:ok, sessions} ->
120-
session_ids =
121-
sessions
122-
|> Enum.map(fn session ->
123-
[session_id, _expiration] = session |> String.split(":")
124-
session_id
125-
end)
126-
127-
{:ok, user, session_ids}
116+
session_key = generate_key(user_id, "sessions")
128117

129-
{:error, error} ->
130-
{:error, error}
131-
end
118+
Redix.command(:redix, ["ZRANGE", session_key, 0, -1])
132119
end
133120

134121
defp is_active_for_user_id(session_id, user_id) do
135-
case get_session_ids_by_user_id(user_id) do
136-
{:error, error} ->
137-
{:error, error}
138-
139-
session_ids ->
140-
Enum.member?(session_ids, session_id)
141-
end
142-
end
143-
144-
defp get_sessions_by_user_id(user_id) do
145-
# get session id's from redis under "user:{user_id}:sessions"
146122
session_key = generate_key(user_id, "sessions")
147-
148-
case Redix.command(:redix, ["SMEMBERS", session_key]) do
149-
{:ok, sessions} -> sessions
150-
{:error, error} -> {:error, error}
151-
end
152-
end
153-
154-
defp get_session_ids_by_user_id(user_id) do
155-
case get_sessions_by_user_id(user_id) do
123+
# check ZSCORE for user_id:session_id pair
124+
# returns score if it is a member of the set
125+
# returns nil if not a member
126+
case Redix.command(:redix, ["ZSCORE", session_key, session_id]) do
156127
{:error, error} -> {:error, error}
157-
sessions -> Enum.map(sessions, &(String.split(&1, ":") |> List.first()))
128+
{:ok, nil} -> false
129+
{:ok, _score} -> true
158130
end
159131
end
160132

161133
@doc """
162-
Deletes a specific `User` session
134+
Deletes the session specified in conn
163135
"""
164-
@spec delete_session(
165-
user :: User.t(),
166-
session_id :: String.t()
167-
) ::
168-
{:ok, user :: User.t()} | {:error, atom() | Redix.Error.t() | Redix.ConnectionError.t()}
169-
def delete_session(%User{} = user, session_id) do
170-
# delete session id from redis under "user:{user_id}:sessions"
171-
session_key = generate_key(user.id, "sessions")
172-
173-
case Redix.command(:redix, ["SREM", session_key, session_id]) do
174-
{:ok, _} -> {:ok, user}
175-
{:error, error} -> {:error, error}
176-
end
177-
end
136+
@spec delete(conn :: Plug.Conn.t()) ::
137+
{:ok, conn :: Plug.Conn.t()}
138+
| {:error, atom() | Redix.Error.t() | Redix.ConnectionError.t()}
139+
def delete(conn) do
140+
# get user from guardian resource
141+
%{id: user_id} = Guardian.Plug.current_resource(conn)
142+
# get session_id from jti in jwt token
143+
%{claims: %{"jti" => session_id}} =
144+
Guardian.Plug.current_token(conn)
145+
|> Guardian.peek()
178146

179-
defp delete_session_by_user_id(user_id, session) do
180-
# delete session from redis under "user:{user_id}:sessions"
147+
# delete session id from redis under "user:{user_id}:sessions"
181148
session_key = generate_key(user_id, "sessions")
182149

183-
case Redix.command(:redix, ["SREM", session_key, session]) do
184-
{:ok, _} -> {:ok, user_id}
185-
{:error, error} -> {:error, error}
150+
case Redix.command(:redix, ["ZREM", session_key, session_id]) do
151+
{:ok, _} ->
152+
# sign user out
153+
conn = Guardian.Plug.sign_out(conn)
154+
{:ok, conn}
155+
156+
{:error, error} ->
157+
{:error, error}
186158
end
187159
end
188160

189161
@doc """
190162
Deletes every session instance for the specified `User`
191163
"""
192-
@spec delete_sessions(user :: User.t()) :: :ok
164+
@spec delete_sessions(user :: User.t()) :: {:ok, non_neg_integer} | Redix.ConnectionError.t()
193165
def delete_sessions(%User{} = user) do
194166
# delete session id from redis under "user:{user_id}:sessions"
195167
session_key = generate_key(user.id, "sessions")
196-
197-
case Redix.command(:redix, ["SPOP", session_key]) do
198-
{:ok, nil} -> :ok
199-
# repeat until redix returns nil
200-
_ -> delete_sessions(user)
201-
end
168+
Redix.command(:redix, ["DEL", session_key])
202169
end
203170

204171
defp save(%User{} = user, session_id, ttl) do
@@ -290,39 +257,39 @@ defmodule EpochtalkServer.Session do
290257
maybe_extend_ttl(ban_key, ttl, old_ttl)
291258
end
292259

293-
# these two rules ensure that sessions will eventually be deleted:
294-
# clean expired sessions and add a new one
295-
# ttl expiry for this key will delete all sessions in the set
260+
# session storage uses "pseudo-expiry" (via redis sorted-set score)
261+
# these rules ensure that session storage will not grow indefinitely:
262+
#
263+
# on every new session add,
264+
# clean (delete) expired sessions by expiry
265+
# add the new session, with expiry
266+
# ttl expiry for this key will delete all sessions in the set
296267
defp add_session(user_id, session_id, ttl) do
297268
# save session id to redis under "user:{user_id}:sessions"
298269
session_key = generate_key(user_id, "sessions")
299-
# current unix time (default :seconds)
300-
now = DateTime.utc_now() |> DateTime.to_unix()
301-
# intended unix expiration of this session
302-
unix_expiration = now + ttl
303270
# delete expired sessions
304-
get_sessions_by_user_id(user_id)
305-
|> Enum.each(fn session ->
306-
[_session_id, expiration] = String.split(session, ":")
307-
308-
if String.to_integer(expiration) < now do
309-
delete_session_by_user_id(user_id, session)
310-
end
311-
end)
271+
delete_expired_sessions(session_key)
272+
# intended unix expiration of this session
273+
unix_expiration = current_time() + ttl
312274

313275
# add new session, noting unix expiration
314-
result =
315-
Redix.command(:redix, [
316-
"SADD",
317-
session_key,
318-
session_id <> ":" <> Integer.to_string(unix_expiration)
319-
])
276+
result = Redix.command(:redix, ["ZADD", session_key, unix_expiration, session_id])
320277

321278
# set ttl
322279
maybe_extend_ttl(session_key, ttl)
323280
result
324281
end
325282

283+
# delete expired sessions by expiration (sorted set score)
284+
defp delete_expired_sessions(session_key) do
285+
Redix.command(:redix, ["ZREMRANGEBYSCORE", session_key, "-inf", current_time()])
286+
end
287+
288+
# current unix time (default :seconds)
289+
defp current_time() do
290+
DateTime.utc_now() |> DateTime.to_unix()
291+
end
292+
326293
defp generate_key(user_id, "user"), do: "user:#{user_id}"
327294
defp generate_key(user_id, type), do: "user:#{user_id}:#{type}"
328295

lib/epochtalk_server_web/controllers/user_controller.ex

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,17 +159,17 @@ defmodule EpochtalkServerWeb.UserController do
159159
Logs out the logged in `User`
160160
161161
- TODO(boka): check if user is on page that requires auth
162-
- TODO(boka): Delete users session
163162
"""
164163
def logout(conn, _attrs) do
165-
with {:auth, true} <- {:auth, Guardian.Plug.authenticated?(conn)} do
166-
user = Guardian.Plug.current_resource(conn)
167-
token = Guardian.Plug.current_token(conn)
164+
with {:auth, true} <- {:auth, Guardian.Plug.authenticated?(conn)},
165+
user <- Guardian.Plug.current_resource(conn),
166+
token <- Guardian.Plug.current_token(conn),
167+
{:ok, conn} <- Session.delete(conn) do
168168
EpochtalkServerWeb.Endpoint.broadcast("user:#{user.id}", "logout", %{token: token})
169-
Guardian.Plug.sign_out(conn)
170169
render(conn, "logout.json", data: %{success: true})
171170
else
172171
{:auth, false} -> ErrorHelpers.render_json_error(conn, 400, "Not logged in")
172+
{:error, error} -> ErrorHelpers.render_json_error(conn, 500, error)
173173
_ -> ErrorHelpers.render_json_error(conn, 500, "There was an issue signing out")
174174
end
175175
end

test/epochtalk_server/session_test.exs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,105 @@ defmodule EpochtalkServerWeb.SessionTest do
3333
end
3434
end
3535

36+
describe "delete/1" do
37+
@tag :authenticated
38+
test "deletes authenticated user's session", %{conn: conn, authed_user: authed_user} do
39+
session_id = conn.private.guardian_default_claims["jti"]
40+
{:ok, resource} = Session.get_resource(authed_user.id, session_id)
41+
%{id: session_user_id, username: session_user_username} = resource
42+
assert session_user_id == authed_user.id
43+
assert session_user_username == authed_user.username
44+
45+
# delete user session indirectly via logout route
46+
# this is done rather than calling Session.delete
47+
# because guardian does not load the resource
48+
# when calling Session.delete directly
49+
unauthed_conn = delete(conn, Routes.user_path(conn, :logout))
50+
assert Guardian.Plug.authenticated?(unauthed_conn) == false
51+
unauthed_resource = Session.get_resource(authed_user.id, session_id)
52+
53+
assert unauthed_resource ==
54+
{:error, "No session for user_id #{authed_user.id} with id #{session_id}"}
55+
end
56+
end
57+
58+
describe "create/3 session expiration" do
59+
test "deletes an expired user session when logging in", %{conn: conn, user: user} do
60+
remember_me = false
61+
# create session that should be deleted
62+
{:ok, authed_user_to_delete, _token, authed_conn_to_delete} =
63+
Session.create(user, remember_me, conn)
64+
65+
session_id_to_delete = authed_conn_to_delete.private.guardian_default_claims["jti"]
66+
# create session that shouldn't be deleted
67+
{:ok, authed_user_to_persist, _token, authed_conn_to_persist} =
68+
Session.create(user, remember_me, conn)
69+
70+
session_id_to_persist = authed_conn_to_persist.private.guardian_default_claims["jti"]
71+
# check that all sessions are active
72+
{:ok, resource_to_delete} = Session.get_resource(user.id, session_id_to_delete)
73+
74+
%{id: session_user_id_to_delete, username: session_user_username_to_delete} =
75+
resource_to_delete
76+
77+
assert session_user_id_to_delete == user.id
78+
assert session_user_username_to_delete == user.username
79+
{:ok, resource_to_persist} = Session.get_resource(user.id, session_id_to_persist)
80+
81+
%{id: session_user_id_to_persist, username: session_user_username_to_persist} =
82+
resource_to_persist
83+
84+
assert session_user_id_to_persist == user.id
85+
assert session_user_username_to_persist == user.username
86+
# change expiration of session to delete to UTC 0
87+
expiration_utc = 0
88+
89+
Redix.command(:redix, [
90+
"ZADD",
91+
"user:#{user.id}:sessions",
92+
expiration_utc,
93+
session_id_to_delete
94+
])
95+
96+
# create a new session (should delete expired sessions)
97+
{:ok, new_authed_user, _token, new_authed_conn} = Session.create(user, remember_me, conn)
98+
new_session_id = new_authed_conn.private.guardian_default_claims["jti"]
99+
# check that active sessions are still active
100+
{:ok, resource_to_persist} = Session.get_resource(user.id, session_id_to_persist)
101+
102+
%{id: session_user_id_to_persist, username: session_user_username_to_persist} =
103+
resource_to_persist
104+
105+
assert session_user_id_to_persist == user.id
106+
assert session_user_username_to_persist == user.username
107+
108+
authenticate_persisted_conn =
109+
get(authed_conn_to_persist, Routes.user_path(authed_conn_to_persist, :authenticate))
110+
111+
assert user.id == json_response(authenticate_persisted_conn, 200)["id"]
112+
{:ok, new_resource} = Session.get_resource(user.id, new_session_id)
113+
%{id: new_session_user_id, username: new_session_user_username} = new_resource
114+
assert new_session_user_id == user.id
115+
assert new_session_user_username == user.username
116+
117+
new_authenticate_conn =
118+
get(new_authed_conn, Routes.user_path(new_authed_conn, :authenticate))
119+
120+
assert user.id == json_response(new_authenticate_conn, 200)["id"]
121+
# check that expired session is not active
122+
unauthed_resource = Session.get_resource(user.id, session_id_to_delete)
123+
124+
assert unauthed_resource ==
125+
{:error, "No session for user_id #{user.id} with id #{session_id_to_delete}"}
126+
127+
authenticate_deleted_conn =
128+
get(authed_conn_to_delete, Routes.user_path(authed_conn_to_delete, :authenticate))
129+
130+
assert %{"error" => "Unauthorized", "message" => "No resource found"} =
131+
json_response(authenticate_deleted_conn, 401)
132+
end
133+
end
134+
36135
describe "create/3 expiration/ttl" do
37136
setup [:flush_redis]
38137

0 commit comments

Comments
 (0)