Skip to content

Commit 2b74b3e

Browse files
Allow user to manually destroy specific sessions
When viewing my active sessions I want to be able to destroy a specific session so that I can keep my account secure. Issues ------ - Closes #70
1 parent d153138 commit 2b74b3e

File tree

11 files changed

+226
-12
lines changed

11 files changed

+226
-12
lines changed

README.md

+116-3
Original file line numberDiff line numberDiff line change
@@ -1451,9 +1451,11 @@ end
14511451

14521452
```html+ruby
14531453
<!-- app/views/active_sessions/_active_session.html.erb -->
1454-
<td><%= active_session.user_agent %></td>
1455-
<td><%= active_session.ip_address %></td>
1456-
<td><%= active_session.created_at %></td>
1454+
<tr>
1455+
<td><%= active_session.user_agent %></td>
1456+
<td><%= active_session.ip_address %></td>
1457+
<td><%= active_session.created_at %></td>
1458+
</tr>
14571459
```
14581460

14591461
6. Update account page.
@@ -1483,3 +1485,114 @@ end
14831485
> - We're simply showing any `active_session` associated with the `current_user`. By rendering the `user_agent`, `ip_address`, and `created_at` values we're giving the `current_user` all the information they need to know if there's any suspicious activity happening with their account. For example, if there's an `active_session` with a unfamiliar IP address or browser, this could indicate that the user's account has been compromised.
14841486
> - Note that we also instantiate `@active_sessions` in the `update` method. This is because the `update` method renders the `edit` method during failure cases.
14851487
1488+
## Step 20: Allow User to Sign Out Specific Active Sessions
1489+
1490+
1. Generate the Active Sessions Controller and update routes.
1491+
1492+
```
1493+
rails g controller active_sessions
1494+
```
1495+
1496+
```ruby
1497+
# app/controllers/active_sessions_controller.rb
1498+
class ActiveSessionsController < ApplicationController
1499+
before_action :authenticate_user!
1500+
1501+
def destroy
1502+
@active_session = current_user.active_sessions.find(params[:id])
1503+
1504+
@active_session.destroy
1505+
1506+
if current_user
1507+
redirect_to account_path, notice: "Session deleted."
1508+
else
1509+
reset_session
1510+
redirect_to root_path, notice: "Signed out."
1511+
end
1512+
end
1513+
1514+
def destroy_all
1515+
current_user
1516+
1517+
current_user.active_sessions.destroy_all
1518+
reset_session
1519+
1520+
redirect_to root_path, notice: "Signed out."
1521+
end
1522+
end
1523+
```
1524+
1525+
```ruby
1526+
# config/routes.rb
1527+
Rails.application.routes.draw do
1528+
...
1529+
resources :active_sessions, only: [:destroy] do
1530+
collection do
1531+
delete "destroy_all"
1532+
end
1533+
end
1534+
end
1535+
```
1536+
1537+
> **What's Going On Here?**
1538+
>
1539+
> - We ensure only users who are logged in can access these endpoints by calling `before_action :authenticate_user!`.
1540+
> - The `destroy` method simply looks for an `active_session` associated with the `current_user`. This ensures that a user can only delete sessions associated with their account.
1541+
> - Once we destroy the `active_session` we then redirect back to the account page or to the homepage. This is because a user may not be deleting a session for the device or browser they're currently logged into. Note that we only call [reset_session](https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-reset_session) if the user has deleted a session for the device or browser they're currently logged into, as this is the same as logging out.
1542+
> - The `destroy_all` method is a [collection route](https://guides.rubyonrails.org/routing.html#adding-collection-routes) that will destroy all `active_session` records associated with the `current_user`. Note that we call `reset_session` because we will be logging out the `current_user` during this request.
1543+
1544+
2. Update views by adding buttons to destroy sessions.
1545+
1546+
```html+ruby
1547+
<!-- app/views/users/edit.html.erb -->
1548+
...
1549+
<h2>Current Logins</h2>
1550+
<% if @active_sessions.any? %>
1551+
<%= button_to "Log out of all other sessions", destroy_all_active_sessions_path, method: :delete %>
1552+
<table>
1553+
<thead>
1554+
<tr>
1555+
<th>User Agent</th>
1556+
<th>IP Address</th>
1557+
<th>Signed In At</th>
1558+
<th>Sign Out</th>
1559+
</tr>
1560+
</thead>
1561+
<tbody>
1562+
<%= render @active_sessions %>
1563+
</tbody>
1564+
</table>
1565+
<% end %>
1566+
```
1567+
1568+
```html+ruby
1569+
<tr>
1570+
<td><%= active_session.user_agent %></td>
1571+
<td><%= active_session.ip_address %></td>
1572+
<td><%= active_session.created_at %></td>
1573+
<td><%= button_to "Sign Out", active_session_path(active_session), method: :delete %></td>
1574+
</tr>
1575+
```
1576+
1577+
3. Update Authentication Concern.
1578+
1579+
```ruby
1580+
# app/controllers/concerns/authentication.rb
1581+
module Authentication
1582+
...
1583+
private
1584+
1585+
def current_user
1586+
Current.user = if session[:current_active_session_id].present?
1587+
ActiveSession.find_by(id: session[:current_active_session_id])&.user
1588+
elsif cookies.permanent.encrypted[:remember_token].present?
1589+
User.find_by(remember_token: cookies.permanent.encrypted[:remember_token])
1590+
end
1591+
end
1592+
...
1593+
end
1594+
```
1595+
1596+
> **What's Going On Here?**
1597+
>
1598+
> - This is a very subtle change, but we've added a [safe navigation operator](https://ruby-doc.org/core-2.6/doc/syntax/calling_methods_rdoc.html#label-Safe+navigation+operator) via the `&.user` call. This is because `ActiveSession.find_by(id: session[:current_active_session_id])` can now return `nil` since we're able to delete other `active_session` records.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Place all the styles related to the active_sessions controller here.
2+
// They will automatically be included in application.css.
3+
// You can use Sass (SCSS) here: https://sass-lang.com/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
class ActiveSessionsController < ApplicationController
2+
before_action :authenticate_user!
3+
4+
def destroy
5+
@active_session = current_user.active_sessions.find(params[:id])
6+
7+
@active_session.destroy
8+
9+
if current_user
10+
redirect_to account_path, notice: "Session deleted."
11+
else
12+
reset_session
13+
redirect_to root_path, notice: "Signed out."
14+
end
15+
end
16+
17+
def destroy_all
18+
current_user
19+
20+
current_user.active_sessions.destroy_all
21+
reset_session
22+
23+
redirect_to root_path, notice: "Signed out."
24+
end
25+
end

app/controllers/concerns/authentication.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def store_location
4646

4747
def current_user
4848
Current.user = if session[:current_active_session_id].present?
49-
ActiveSession.find_by(id: session[:current_active_session_id]).user
49+
ActiveSession.find_by(id: session[:current_active_session_id])&.user
5050
elsif cookies.permanent.encrypted[:remember_token].present?
5151
User.find_by(remember_token: cookies.permanent.encrypted[:remember_token])
5252
end

app/helpers/active_sessions_helper.rb

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
module ActiveSessionsHelper
2+
end
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1-
<td><%= active_session.user_agent %></td>
2-
<td><%= active_session.ip_address %></td>
3-
<td><%= active_session.created_at %></td>
1+
<tr>
2+
<td><%= active_session.user_agent %></td>
3+
<td><%= active_session.ip_address %></td>
4+
<td><%= active_session.created_at %></td>
5+
<td><%= button_to "Sign Out", active_session_path(active_session), method: :delete %></td>
6+
</tr>

app/views/users/edit.html.erb

+4-4
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,19 @@
2424
<%= form.submit "Update Account" %>
2525
<% end %>
2626
<h2>Current Logins</h2>
27-
<% if @active_sessions.any? %>
27+
<% if @active_sessions.any? %>
28+
<%= button_to "Log out of all other sessions", destroy_all_active_sessions_path, method: :delete %>
2829
<table>
2930
<thead>
3031
<tr>
3132
<th>User Agent</th>
3233
<th>IP Address</th>
3334
<th>Signed In At</th>
35+
<th>Sign Out</th>
3436
</tr>
3537
</thead>
3638
<tbody>
37-
<tr>
38-
<%= render @active_sessions %>
39-
</tr>
39+
<%= render @active_sessions %>
4040
</tbody>
4141
</table>
4242
<% end %>

config/routes.rb

+5
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,9 @@
1010
delete "logout", to: "sessions#destroy"
1111
get "login", to: "sessions#new"
1212
resources :passwords, only: [:create, :edit, :new, :update], param: :password_reset_token
13+
resources :active_sessions, only: [:destroy] do
14+
collection do
15+
delete "destroy_all"
16+
end
17+
end
1318
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
require "test_helper"
2+
3+
class ActiveSessionsControllerTest < ActionDispatch::IntegrationTest
4+
setup do
5+
@confirmed_user = User.create!(email: "[email protected]", password: "password", password_confirmation: "password", confirmed_at: Time.current)
6+
end
7+
8+
test "should destroy all active sessions" do
9+
login @confirmed_user
10+
@confirmed_user.active_sessions.create!
11+
12+
assert_difference("ActiveSession.count", -2) do
13+
delete destroy_all_active_sessions_path
14+
end
15+
16+
assert_redirected_to root_path
17+
assert_nil current_user
18+
assert_not_nil flash[:notice]
19+
end
20+
21+
test "should destroy another session" do
22+
login @confirmed_user
23+
@confirmed_user.active_sessions.create!
24+
25+
assert_difference("ActiveSession.count", -1) do
26+
delete active_session_path(@confirmed_user.active_sessions.last)
27+
end
28+
29+
assert_redirected_to account_path
30+
assert_not_nil current_user
31+
assert_not_nil flash[:notice]
32+
end
33+
34+
test "should destroy current session" do
35+
login @confirmed_user
36+
37+
assert_difference("ActiveSession.count", -1) do
38+
delete active_session_path(@confirmed_user.active_sessions.last)
39+
end
40+
41+
assert_redirected_to root_path
42+
assert_nil current_user
43+
assert_not_nil flash[:notice]
44+
end
45+
end

test/integration/user_interface_test.rb

+18
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,22 @@ class UserInterfaceTest < ActionDispatch::IntegrationTest
1414
assert_match "Mozilla", @response.body
1515
assert_match "123.457.789", @response.body
1616
end
17+
18+
test "should render buttons to delete specific active sessions" do
19+
login @confirmed_user
20+
21+
get account_path
22+
23+
assert_select "input[type='submit']" do
24+
assert_select "[value=?]", "Log out of all other sessions"
25+
end
26+
assert_match destroy_all_active_sessions_path, @response.body
27+
28+
assert_select "table" do
29+
assert_select "input[type='submit']" do
30+
assert_select "[value=?]", "Sign Out"
31+
end
32+
end
33+
assert_match active_session_path(@confirmed_user.active_sessions.last), @response.body
34+
end
1735
end

test/test_helper.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class ActiveSupport::TestCase
1111

1212
# Add more helper methods to be used by all tests here...
1313
def current_user
14-
session[:current_active_session_id] && ActiveSession.find_by(id: session[:current_active_session_id]).user
14+
session[:current_active_session_id] && ActiveSession.find_by(id: session[:current_active_session_id])&.user
1515
end
1616

1717
def login(user, remember_user: nil)

0 commit comments

Comments
 (0)