Skip to content

Commit bc33089

Browse files
Store session in the database
Issues ------- - Closes #68
1 parent 152e986 commit bc33089

12 files changed

+107
-81
lines changed

README.md

+35-28
Original file line numberDiff line numberDiff line change
@@ -1182,7 +1182,7 @@ end
11821182
<% end %>
11831183
```
11841184

1185-
## Step 15: Add Friendly Redirects
1185+
## Step 17: Add Friendly Redirects
11861186

11871187
1. Update Authentication Concern.
11881188

@@ -1301,69 +1301,76 @@ end
13011301
>
13021302
> - 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.
13031303
1304-
## Step 18: Account for Session Replay Attacks
1304+
## Step 18: Store Session in the Database
13051305

1306-
**Note that this refactor prevents a user from being logged into multiple devices and browsers at one time.**
1306+
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.
13071307

1308-
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.
1308+
The solution we will implement is to set a rotating value to identify the user and store that value in the database.
13091309

1310-
You can read more about session replay attacks [here](https://binarysolo.chapter24.blog/avoiding-session-replay-attacks-in-rails/).
1311-
1312-
1. Add a session_token column to the users table.
1310+
1. Generate ActiveSession model.
13131311

13141312
```bash
1315-
rails g migration add_session_token_to_users session_token:string
1313+
rails g model active_session user:references
13161314
```
13171315

1318-
2. Update migration.
1316+
2. Update the migration.
1317+
13191318
```ruby
1320-
# db/migrate/[timestamp]_add_session_token_to_users.rb
1321-
class AddSessionTokenToUsers < ActiveRecord::Migration[6.1]
1319+
class CreateActiveSessions < ActiveRecord::Migration[6.1]
13221320
def change
1323-
add_column :users, :session_token, :string, null: false
1324-
add_index :users, :session_token, unique: true
1321+
create_table :active_sessions do |t|
1322+
t.references :user, null: false, foreign_key: {on_delete: :cascade}
1323+
1324+
t.timestamps
1325+
end
13251326
end
13261327
end
13271328
```
13281329

13291330
> **What's Going On Here?**
13301331
>
1331-
> - Similar to the `remember_token` column, we prevent the `session_token` from being null and enforce that it has a unique value.
1332+
> - 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.
1333+
1334+
3. Run migration.
1335+
1336+
```bash
1337+
rails db:migrate
1338+
```
13321339

1333-
3. Update User Model.
1340+
4. Update User model.
13341341

13351342
```ruby
13361343
# app/models/user.rb
13371344
class User < ApplicationRecord
13381345
...
1339-
has_secure_token :session_token
1346+
has_many :active_sessions, dependent: :destroy
13401347
...
13411348
end
13421349
```
13431350

1344-
4. Update Authentication Concern.
1351+
5. Update Authentication Concern
13451352

13461353
```ruby
13471354
# app/controllers/concerns/authentication.rb
13481355
module Authentication
13491356
...
13501357
def login(user)
13511358
reset_session
1352-
user.regenerate_session_token
1353-
session[:current_user_session_token] = user.reload.session_token
1359+
active_session = user.active_sessions.create!
1360+
session[:current_active_session_id] = active_session.id
13541361
end
13551362
...
13561363
def logout
1357-
user = current_user
1364+
active_session = ActiveSession.find_by(id: session[:current_active_session_id])
13581365
reset_session
1359-
user.regenerate_session_token
1366+
active_session.destroy! if active_session.present?
13601367
end
13611368
...
13621369
private
13631370

13641371
def current_user
1365-
Current.user ||= if session[:current_user_session_token].present?
1366-
User.find_by(session_token: session[:current_user_session_token])
1372+
Current.user = if session[:current_active_session_id].present?
1373+
ActiveSession.find_by(id: session[:current_active_session_id]).user
13671374
elsif cookies.permanent.encrypted[:remember_token].present?
13681375
User.find_by(remember_token: cookies.permanent.encrypted[:remember_token])
13691376
end
@@ -1374,11 +1381,11 @@ end
13741381

13751382
> **What's Going On Here?**
13761383
>
1377-
> - 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.
1378-
> - 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.
1379-
> - 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.
1384+
> - 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]`.
1385+
> - 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.
1386+
> - 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]`.
13801387
1381-
5. Force SSL.
1388+
6. Force SSL.
13821389

13831390
```ruby
13841391
# config/environments/production.rb
@@ -1390,4 +1397,4 @@ end
13901397

13911398
> **What's Going On Here?**
13921399
>
1393-
> - 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.
1400+
> - 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.

app/controllers/concerns/authentication.rb

+6-6
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ def authenticate_user!
1414

1515
def login(user)
1616
reset_session
17-
user.regenerate_session_token
18-
session[:current_user_session_token] = user.reload.session_token
17+
active_session = user.active_sessions.create!
18+
session[:current_active_session_id] = active_session.id
1919
end
2020

2121
def forget(user)
@@ -24,9 +24,9 @@ def forget(user)
2424
end
2525

2626
def logout
27-
user = current_user
27+
active_session = ActiveSession.find_by(id: session[:current_active_session_id])
2828
reset_session
29-
user.regenerate_session_token
29+
active_session.destroy! if active_session.present?
3030
end
3131

3232
def redirect_if_authenticated
@@ -45,8 +45,8 @@ def store_location
4545
private
4646

4747
def current_user
48-
Current.user ||= if session[:current_user_session_token].present?
49-
User.find_by(session_token: session[:current_user_session_token])
48+
Current.user = if session[:current_active_session_id].present?
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/models/active_session.rb

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class ActiveSession < ApplicationRecord
2+
belongs_to :user
3+
end

app/models/user.rb

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ class User < ApplicationRecord
77

88
has_secure_password
99
has_secure_token :remember_token
10-
has_secure_token :session_token
10+
11+
has_many :active_sessions, dependent: :destroy
1112

1213
before_save :downcase_email
1314
before_save :downcase_unconfirmed_email

db/migrate/20211217184706_add_session_token_to_users.rb

-6
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class CreateActiveSessions < ActiveRecord::Migration[6.1]
2+
def change
3+
create_table :active_sessions do |t|
4+
t.references :user, null: false, foreign_key: {on_delete: :cascade}
5+
6+
t.timestamps
7+
end
8+
end
9+
end

db/schema.rb

+9-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/controllers/sessions_controller_test.rb

+13-23
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,17 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
1818
assert_redirected_to root_path
1919
end
2020

21-
test "should login if confirmed" do
22-
post login_path, params: {
23-
user: {
24-
email: @confirmed_user.email,
25-
password: @confirmed_user.password
21+
test "should login and create active session if confirmed" do
22+
assert_difference("@confirmed_user.active_sessions.count") do
23+
post login_path, params: {
24+
user: {
25+
email: @confirmed_user.email,
26+
password: @confirmed_user.password
27+
}
2628
}
27-
}
29+
end
2830
assert_redirected_to root_path
29-
assert_equal @confirmed_user.email, current_user.email
31+
assert_equal @confirmed_user, current_user
3032
end
3133

3234
test "should remember user when logging in" do
@@ -82,10 +84,12 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
8284
assert_nil current_user
8385
end
8486

85-
test "should logout if authenticated" do
87+
test "should logout and delete current active session if authenticated" do
8688
login @confirmed_user
8789

88-
delete logout_path
90+
assert_difference("@confirmed_user.active_sessions.count", -1) do
91+
delete logout_path
92+
end
8993

9094
assert_nil current_user
9195
assert_redirected_to root_path
@@ -98,18 +102,4 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
98102
delete logout_path
99103
assert_redirected_to root_path
100104
end
101-
102-
test "should reset session_token when logging out" do
103-
login @confirmed_user
104-
105-
assert_changes "@confirmed_user.reload.session_token" do
106-
delete logout_path
107-
end
108-
end
109-
110-
test "should reset session_token when logging in" do
111-
assert_changes "@confirmed_user.reload.session_token" do
112-
login @confirmed_user
113-
end
114-
end
115105
end

test/fixtures/active_sessions.yml

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html

test/models/active_session_test.rb

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
require "test_helper"
2+
3+
class ActiveSessionTest < ActiveSupport::TestCase
4+
setup do
5+
@user = User.new(email: "unique_email@example.com", password: "password", password_confirmation: "password")
6+
@active_session = @user.active_sessions.build
7+
end
8+
9+
test "should be valid" do
10+
assert @active_session.valid?
11+
end
12+
13+
test "should have a user" do
14+
@active_session.user = nil
15+
16+
assert_not @active_session.valid?
17+
end
18+
end

test/models/user_test.rb

+9-12
Original file line numberDiff line numberDiff line change
@@ -164,23 +164,20 @@ class UserTest < ActiveSupport::TestCase
164164
end
165165
end
166166

167-
test "should set session_token on create" do
167+
test "should create active session" do
168168
@user.save!
169169

170-
assert_not_nil @user.reload.session_token
171-
end
172-
173-
test "should generate confirmation token" do
174-
@user.save!
175-
confirmation_token = @user.generate_confirmation_token
176-
177-
assert_equal @user, User.find_signed(confirmation_token, purpose: :confirm_email)
170+
assert_difference("@user.active_sessions.count", 1) do
171+
@user.active_sessions.create!
172+
end
178173
end
179174

180-
test "should generate password reset token" do
175+
test "should destroy associated active session when destryoed" do
181176
@user.save!
182-
password_reset_token = @user.generate_password_reset_token
177+
@user.active_sessions.create!
183178

184-
assert_equal @user, User.find_signed(password_reset_token, purpose: :reset_password)
179+
assert_difference("@user.active_sessions.count", -1) do
180+
@user.destroy!
181+
end
185182
end
186183
end

test/test_helper.rb

+2-2
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_user_session_token] && User.find_by(session_token: session[:current_user_session_token])
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)
@@ -25,6 +25,6 @@ def login(user, remember_user: nil)
2525
end
2626

2727
def logout
28-
session.delete(:current_user_id)
28+
session.delete(:current_active_session_id)
2929
end
3030
end

0 commit comments

Comments
 (0)