Skip to content

Commit

Permalink
Store session in the database
Browse files Browse the repository at this point in the history
Issues
-------
- Closes #68
  • Loading branch information
stevepolitodesign committed Jan 29, 2022
1 parent 152e986 commit f02ae60
Show file tree
Hide file tree
Showing 12 changed files with 107 additions and 81 deletions.
63 changes: 35 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1182,7 +1182,7 @@ end
<% end %>
```

## Step 15: Add Friendly Redirects
## Step 17: Add Friendly Redirects

1. Update Authentication Concern.

Expand Down Expand Up @@ -1301,69 +1301,76 @@ end
>
> - We refactor the `create` method to always start by finding and authenticating the user. Not only does this prevent timing attacks, but it also prevents accidentally leaking email addresses. This is because we were originally checking if a user was confirmed before authenticating them. That means a bad actor could try and sign in with an email address to see if it exists on the system without needing to know the password.
## Step 18: Account for Session Replay Attacks
## Step 18: Store Session in the Database

**Note that this refactor prevents a user from being logged into multiple devices and browsers at one time.**
We're currently setting the user's ID in the session. Even though that value is encrypted, the encrypted value doesn't change since it's based on the user id which doesn't change. This means that if a bad actor were to get a copy of the session they would have access to a victim's account in perpetuity. One solution is to [rotate encrypted and signed cookie configurations](https://guides.rubyonrails.org/security.html#rotating-encrypted-and-signed-cookies-configurations). Another option is to configure the [Rails session store](https://guides.rubyonrails.org/configuring.html#config-session-store) to use `mem_cache_store` to store session data.

We're currently setting the user's ID in the session. Even though that value is encrypted, the encrypted value doesn't change since it's based on the user id which doesn't change. This means that if a bad actor were to get a copy of the session they would have access to a victim's account in perpetuity. One solution is to [rotate encrypted and signed cookie configurations](https://guides.rubyonrails.org/security.html#rotating-encrypted-and-signed-cookies-configurations). Another solution is to use a rotating value to identify the user (which is what we'll be doing). A third option is to configure the [Rails session store](https://guides.rubyonrails.org/configuring.html#config-session-store) to use `mem_cache_store` to store session data.
The solution we will implement is to set a rotating value to identify the user and store that value in the database.

You can read more about session replay attacks [here](https://binarysolo.chapter24.blog/avoiding-session-replay-attacks-in-rails/).

1. Add a session_token column to the users table.
1. Generate ActiveSession model.

```bash
rails g migration add_session_token_to_users session_token:string
rails g model active_session user:references
```

2. Update migration.
2. Update the migration.

```ruby
# db/migrate/[timestamp]_add_session_token_to_users.rb
class AddSessionTokenToUsers < ActiveRecord::Migration[6.1]
class CreateActiveSessions < ActiveRecord::Migration[6.1]
def change
add_column :users, :session_token, :string, null: false
add_index :users, :session_token, unique: true
create_table :active_sessions do |t|
t.references :user, null: false, foreign_key: {on_delete: :cascade}

t.timestamps
end
end
end
```

> **What's Going On Here?**
>
> - Similar to the `remember_token` column, we prevent the `session_token` from being null and enforce that it has a unique value.
> - We update the `foreign_key` option from `true` to `{on_delete: :cascade}`. The [on_delete](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_foreign_key-label-Creating+a+cascading+foreign+key) option will delete any `active_session` record if its associated `user` is deleted from the database.
3. Run migration.

```bash
rails db:migrate
```

3. Update User Model.
4. Update User model.

```ruby
# app/models/user.rb
class User < ApplicationRecord
...
has_secure_token :session_token
has_many :active_sessions, dependent: :destroy
...
end
```

4. Update Authentication Concern.
5. Update Authentication Concern

```ruby
# app/controllers/concerns/authentication.rb
module Authentication
...
def login(user)
reset_session
user.regenerate_session_token
session[:current_user_session_token] = user.reload.session_token
active_session = user.active_sessions.create!
session[:current_active_session_id] = active_session.id
end
...
def logout
user = current_user
active_session = ActiveSession.find_by(id: session[:current_active_session_id])
reset_session
user.regenerate_session_token
active_session.destroy! if active_session.present?
end
...
private

def current_user
Current.user ||= if session[:current_user_session_token].present?
User.find_by(session_token: session[:current_user_session_token])
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
Expand All @@ -1374,11 +1381,11 @@ end

> **What's Going On Here?**
>
> - We update the `login` method by adding a call to `user.regenerate_session_token`. This will reset the value of the `session_token` through the [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token) API. We then store that value in the session.
> - We updated the `logout` method by first setting the `current_user` as a variable. This is because once we call `reset_session`, we lose access to the `current_user`. We then call `user.regenerate_session_token` which will update the value of the `session_token` on the user that just signed out.
> - Finally we update the `current_user` method to look for the `session[:current_user_session_token]` instead of the `session[:current_user_id]` and to query for the User by the `session_token` value.
> - We update the `login` method by creating a new `active_session` record and then storing it's ID in the `session`. Note that we replaced `session[:current_user_id]` with `session[:current_active_session_id]`.
> - We update the `logout` method by first finding the `active_session` record from the `session`. After we call `reset_session` we then delete the `active_session` record if it exists. We need to check if it exists because in a future section we will allow a user to log out all current active sessions.
> - We update the `current_user` method by finding the `active_session` record from the `session`, and then returning its associated `user`. Note that we've replaced all instances of `session[:current_user_id]` with `session[:current_active_session_id]`.
5. Force SSL.
6. Force SSL.

```ruby
# config/environments/production.rb
Expand All @@ -1390,4 +1397,4 @@ end

> **What's Going On Here?**
>
> - We force SSL in production to prevent [session hijacking](https://guides.rubyonrails.org/security.html#session-hijacking). Even though the session is encrypted we want to prevent the cookie from being exposed through an insecure network. If it were exposed, a bad actor could sign in as the victim.
> - We force SSL in production to prevent [session hijacking](https://guides.rubyonrails.org/security.html#session-hijacking). Even though the session is encrypted we want to prevent the cookie from being exposed through an insecure network. If it were exposed, a bad actor could sign in as the victim.
12 changes: 6 additions & 6 deletions app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ def authenticate_user!

def login(user)
reset_session
user.regenerate_session_token
session[:current_user_session_token] = user.reload.session_token
active_session = user.active_sessions.create!
session[:current_active_session_id] = active_session.id
end

def forget(user)
Expand All @@ -24,9 +24,9 @@ def forget(user)
end

def logout
user = current_user
active_session = ActiveSession.find_by(id: session[:current_active_session_id])
reset_session
user.regenerate_session_token
active_session.destroy! if active_session.present?
end

def redirect_if_authenticated
Expand All @@ -45,8 +45,8 @@ def store_location
private

def current_user
Current.user ||= if session[:current_user_session_token].present?
User.find_by(session_token: session[:current_user_session_token])
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
Expand Down
3 changes: 3 additions & 0 deletions app/models/active_session.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class ActiveSession < ApplicationRecord
belongs_to :user
end
3 changes: 2 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ class User < ApplicationRecord

has_secure_password
has_secure_token :remember_token
has_secure_token :session_token

has_many :active_sessions, dependent: :destroy

before_save :downcase_email
before_save :downcase_unconfirmed_email
Expand Down
6 changes: 0 additions & 6 deletions db/migrate/20211217184706_add_session_token_to_users.rb

This file was deleted.

9 changes: 9 additions & 0 deletions db/migrate/20220129144819_create_active_sessions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class CreateActiveSessions < ActiveRecord::Migration[6.1]
def change
create_table :active_sessions do |t|
t.references :user, null: false, foreign_key: {on_delete: :cascade}

t.timestamps
end
end
end
12 changes: 9 additions & 3 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 13 additions & 23 deletions test/controllers/sessions_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to root_path
end

test "should login if confirmed" do
post login_path, params: {
user: {
email: @confirmed_user.email,
password: @confirmed_user.password
test "should login and create active session if confirmed" do
assert_difference("@confirmed_user.active_sessions.count") do
post login_path, params: {
user: {
email: @confirmed_user.email,
password: @confirmed_user.password
}
}
}
end
assert_redirected_to root_path
assert_equal @confirmed_user.email, current_user.email
assert_equal @confirmed_user, current_user
end

test "should remember user when logging in" do
Expand Down Expand Up @@ -82,10 +84,12 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert_nil current_user
end

test "should logout if authenticated" do
test "should logout and delete current active session if authenticated" do
login @confirmed_user

delete logout_path
assert_difference("@confirmed_user.active_sessions.count", -1) do
delete logout_path
end

assert_nil current_user
assert_redirected_to root_path
Expand All @@ -98,18 +102,4 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
delete logout_path
assert_redirected_to root_path
end

test "should reset session_token when logging out" do
login @confirmed_user

assert_changes "@confirmed_user.reload.session_token" do
delete logout_path
end
end

test "should reset session_token when logging in" do
assert_changes "@confirmed_user.reload.session_token" do
login @confirmed_user
end
end
end
1 change: 1 addition & 0 deletions test/fixtures/active_sessions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
18 changes: 18 additions & 0 deletions test/models/active_session_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
require "test_helper"

class ActiveSessionTest < ActiveSupport::TestCase
setup do
@user = User.new(email: "unique_email@example.com", password: "password", password_confirmation: "password")
@active_session = @user.active_sessions.build
end

test "should be valid" do
assert @active_session.valid?
end

test "should have a user" do
@active_session.user = nil

assert_not @active_session.valid?
end
end
21 changes: 9 additions & 12 deletions test/models/user_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -164,23 +164,20 @@ class UserTest < ActiveSupport::TestCase
end
end

test "should set session_token on create" do
test "should create active session" do
@user.save!

assert_not_nil @user.reload.session_token
end

test "should generate confirmation token" do
@user.save!
confirmation_token = @user.generate_confirmation_token

assert_equal @user, User.find_signed(confirmation_token, purpose: :confirm_email)
assert_difference("@user.active_sessions.count", 1) do
@user.active_sessions.create!
end
end

test "should generate password reset token" do
test "should destroy associated active session when destryoed" do
@user.save!
password_reset_token = @user.generate_password_reset_token
@user.active_sessions.create!

assert_equal @user, User.find_signed(password_reset_token, purpose: :reset_password)
assert_difference("@user.active_sessions.count", -1) do
@user.destroy!
end
end
end
4 changes: 2 additions & 2 deletions 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_user_session_token] && User.find_by(session_token: session[:current_user_session_token])
session[:current_active_session_id] && ActiveSession.find_by(id: session[:current_active_session_id]).user
end

def login(user, remember_user: nil)
Expand All @@ -25,6 +25,6 @@ def login(user, remember_user: nil)
end

def logout
session.delete(:current_user_id)
session.delete(:current_active_session_id)
end
end

0 comments on commit f02ae60

Please sign in to comment.