Skip to content

Commit

Permalink
Allow user to manually destroy specific sessions
Browse files Browse the repository at this point in the history
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
  • Loading branch information
stevepolitodesign committed Feb 5, 2022
1 parent 8005f1b commit e68248c
Show file tree
Hide file tree
Showing 11 changed files with 226 additions and 12 deletions.
119 changes: 116 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1451,9 +1451,11 @@ end

```html+ruby
<!-- app/views/active_sessions/_active_session.html.erb -->
<td><%= active_session.user_agent %></td>
<td><%= active_session.ip_address %></td>
<td><%= active_session.created_at %></td>
<tr>
<td><%= active_session.user_agent %></td>
<td><%= active_session.ip_address %></td>
<td><%= active_session.created_at %></td>
</tr>
```

6. Update account page.
Expand Down Expand Up @@ -1483,3 +1485,114 @@ end
> - 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.
> - Note that we also instantiate `@active_sessions` in the `update` method. This is because the `update` method renders the `edit` method during failure cases.
## Step 20: Allow User to Sign Out Specific Active Sessions

1. Generate the Active Sessions Controller and update routes.

```
rails g controller active_sessions
```

```ruby
# app/controllers/active_sessions_controller.rb
class ActiveSessionsController < ApplicationController
before_action :authenticate_user!

def destroy
@active_session = current_user.active_sessions.find(params[:id])

@active_session.destroy

if current_user
redirect_to account_path, notice: "Session deleted."
else
reset_session
redirect_to root_path, notice: "Signed out."
end
end

def destroy_all
current_user

current_user.active_sessions.destroy_all
reset_session

redirect_to root_path, notice: "Signed out."
end
end
```

```ruby
# config/routes.rb
Rails.application.routes.draw do
...
resources :active_sessions, only: [:destroy] do
collection do
delete "destroy_all"
end
end
end
```

> **What's Going On Here?**
>
> - We ensure only users who are logged in can access these endpoints by calling `before_action :authenticate_user!`.
> - 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.
> - 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.
> - 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.
2. Update views by adding buttons to destroy sessions.

```html+ruby
<!-- app/views/users/edit.html.erb -->
...
<h2>Current Logins</h2>
<% if @active_sessions.any? %>
<%= button_to "Log out of all other sessions", destroy_all_active_sessions_path, method: :delete %>
<table>
<thead>
<tr>
<th>User Agent</th>
<th>IP Address</th>
<th>Signed In At</th>
<th>Sign Out</th>
</tr>
</thead>
<tbody>
<%= render @active_sessions %>
</tbody>
</table>
<% end %>
```

```html+ruby
<tr>
<td><%= active_session.user_agent %></td>
<td><%= active_session.ip_address %></td>
<td><%= active_session.created_at %></td>
<td><%= button_to "Sign Out", active_session_path(active_session), method: :delete %></td>
</tr>
```

3. Update Authentication Concern.

```ruby
# app/controllers/concerns/authentication.rb
module Authentication
...
private

def current_user
Current.user = if session[:current_active_session_id].present?
ActiveSession.find_by(id: session[:current_active_session_id])&.user
elsif cookies.permanent.encrypted[:remember_token].present?
User.find_by(remember_token: cookies.permanent.encrypted[:remember_token])
end
end
...
end
```

> **What's Going On Here?**
>
> - 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.
3 changes: 3 additions & 0 deletions app/assets/stylesheets/active_sessions.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Place all the styles related to the active_sessions controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: https://sass-lang.com/
25 changes: 25 additions & 0 deletions app/controllers/active_sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
class ActiveSessionsController < ApplicationController
before_action :authenticate_user!

def destroy
@active_session = current_user.active_sessions.find(params[:id])

@active_session.destroy

if current_user
redirect_to account_path, notice: "Session deleted."
else
reset_session
redirect_to root_path, notice: "Signed out."
end
end

def destroy_all
current_user

current_user.active_sessions.destroy_all
reset_session

redirect_to root_path, notice: "Signed out."
end
end
2 changes: 1 addition & 1 deletion app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def store_location

def current_user
Current.user = if session[:current_active_session_id].present?
ActiveSession.find_by(id: session[:current_active_session_id]).user
ActiveSession.find_by(id: session[:current_active_session_id])&.user
elsif cookies.permanent.encrypted[:remember_token].present?
User.find_by(remember_token: cookies.permanent.encrypted[:remember_token])
end
Expand Down
2 changes: 2 additions & 0 deletions app/helpers/active_sessions_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module ActiveSessionsHelper
end
9 changes: 6 additions & 3 deletions app/views/active_sessions/_active_session.html.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
<td><%= active_session.user_agent %></td>
<td><%= active_session.ip_address %></td>
<td><%= active_session.created_at %></td>
<tr>
<td><%= active_session.user_agent %></td>
<td><%= active_session.ip_address %></td>
<td><%= active_session.created_at %></td>
<td><%= button_to "Sign Out", active_session_path(active_session), method: :delete %></td>
</tr>
8 changes: 4 additions & 4 deletions app/views/users/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,19 @@
<%= form.submit "Update Account" %>
<% end %>
<h2>Current Logins</h2>
<% if @active_sessions.any? %>
<% if @active_sessions.any? %>
<%= button_to "Log out of all other sessions", destroy_all_active_sessions_path, method: :delete %>
<table>
<thead>
<tr>
<th>User Agent</th>
<th>IP Address</th>
<th>Signed In At</th>
<th>Sign Out</th>
</tr>
</thead>
<tbody>
<tr>
<%= render @active_sessions %>
</tr>
<%= render @active_sessions %>
</tbody>
</table>
<% end %>
5 changes: 5 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,9 @@
delete "logout", to: "sessions#destroy"
get "login", to: "sessions#new"
resources :passwords, only: [:create, :edit, :new, :update], param: :password_reset_token
resources :active_sessions, only: [:destroy] do
collection do
delete "destroy_all"
end
end
end
45 changes: 45 additions & 0 deletions test/controllers/active_sessions_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
require "test_helper"

class ActiveSessionsControllerTest < ActionDispatch::IntegrationTest
setup do
@confirmed_user = User.create!(email: "confirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: Time.current)
end

test "should destroy all active sessions" do
login @confirmed_user
@confirmed_user.active_sessions.create!

assert_difference("ActiveSession.count", -2) do
delete destroy_all_active_sessions_path
end

assert_redirected_to root_path
assert_nil current_user
assert_not_nil flash[:notice]
end

test "should destroy another session" do
login @confirmed_user
@confirmed_user.active_sessions.create!

assert_difference("ActiveSession.count", -1) do
delete active_session_path(@confirmed_user.active_sessions.last)
end

assert_redirected_to account_path
assert_not_nil current_user
assert_not_nil flash[:notice]
end

test "should destroy current session" do
login @confirmed_user

assert_difference("ActiveSession.count", -1) do
delete active_session_path(@confirmed_user.active_sessions.last)
end

assert_redirected_to root_path
assert_nil current_user
assert_not_nil flash[:notice]
end
end
18 changes: 18 additions & 0 deletions test/integration/user_interface_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,22 @@ class UserInterfaceTest < ActionDispatch::IntegrationTest
assert_match "Mozilla", @response.body
assert_match "123.457.789", @response.body
end

test "should render buttons to delete specific active sessions" do
login @confirmed_user

get account_path

assert_select "input[type='submit']" do
assert_select "[value=?]", "Log out of all other sessions"
end
assert_match destroy_all_active_sessions_path, @response.body

assert_select "table" do
assert_select "input[type='submit']" do
assert_select "[value=?]", "Sign Out"
end
end
assert_match active_session_path(@confirmed_user.active_sessions.last), @response.body
end
end
2 changes: 1 addition & 1 deletion test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class ActiveSupport::TestCase

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

def login(user, remember_user: nil)
Expand Down

0 comments on commit e68248c

Please sign in to comment.