From b055fc7750a48724f8e80cdfb231cc0116aad4a7 Mon Sep 17 00:00:00 2001 From: Steve Polito Date: Fri, 28 Jan 2022 15:39:21 -0500 Subject: [PATCH 1/6] Store session in the database Issues ------- - Closes #68 --- README.md | 63 ++++++++++--------- app/controllers/concerns/authentication.rb | 12 ++-- app/models/active_session.rb | 3 + app/models/user.rb | 3 +- ...211217184706_add_session_token_to_users.rb | 6 -- .../20220129144819_create_active_sessions.rb | 9 +++ db/schema.rb | 12 +++- test/controllers/sessions_controller_test.rb | 36 ++++------- test/fixtures/active_sessions.yml | 1 + test/models/active_session_test.rb | 18 ++++++ test/models/user_test.rb | 21 +++---- test/test_helper.rb | 4 +- 12 files changed, 107 insertions(+), 81 deletions(-) create mode 100644 app/models/active_session.rb delete mode 100644 db/migrate/20211217184706_add_session_token_to_users.rb create mode 100644 db/migrate/20220129144819_create_active_sessions.rb create mode 100644 test/fixtures/active_sessions.yml create mode 100644 test/models/active_session_test.rb diff --git a/README.md b/README.md index c854210..9ed83f2 100644 --- a/README.md +++ b/README.md @@ -1182,7 +1182,7 @@ end <% end %> ``` -## Step 15: Add Friendly Redirects +## Step 17: Add Friendly Redirects 1. Update Authentication Concern. @@ -1301,47 +1301,54 @@ 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 @@ -1349,21 +1356,21 @@ 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 @@ -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 @@ -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. \ No newline at end of file +> - 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. diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 451536c..40f3d8d 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -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) @@ -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 @@ -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 diff --git a/app/models/active_session.rb b/app/models/active_session.rb new file mode 100644 index 0000000..4e570f2 --- /dev/null +++ b/app/models/active_session.rb @@ -0,0 +1,3 @@ +class ActiveSession < ApplicationRecord + belongs_to :user +end diff --git a/app/models/user.rb b/app/models/user.rb index 3e42202..3fdb708 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/db/migrate/20211217184706_add_session_token_to_users.rb b/db/migrate/20211217184706_add_session_token_to_users.rb deleted file mode 100644 index 4f55e39..0000000 --- a/db/migrate/20211217184706_add_session_token_to_users.rb +++ /dev/null @@ -1,6 +0,0 @@ -class AddSessionTokenToUsers < ActiveRecord::Migration[6.1] - def change - add_column :users, :session_token, :string, null: false - add_index :users, :session_token, unique: true - end -end diff --git a/db/migrate/20220129144819_create_active_sessions.rb b/db/migrate/20220129144819_create_active_sessions.rb new file mode 100644 index 0000000..1a36b77 --- /dev/null +++ b/db/migrate/20220129144819_create_active_sessions.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index c1b52bc..b8cbf10 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,14 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_12_17_184706) do +ActiveRecord::Schema.define(version: 2022_01_29_144819) do + + create_table "active_sessions", force: :cascade do |t| + t.integer "user_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["user_id"], name: "index_active_sessions_on_user_id" + end create_table "users", force: :cascade do |t| t.string "email", null: false @@ -20,10 +27,9 @@ t.string "password_digest", null: false t.string "unconfirmed_email" t.string "remember_token", null: false - t.string "session_token", null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["remember_token"], name: "index_users_on_remember_token", unique: true - t.index ["session_token"], name: "index_users_on_session_token", unique: true end + add_foreign_key "active_sessions", "users", on_delete: :cascade end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 27e7671..e19390c 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -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 @@ -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 @@ -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 diff --git a/test/fixtures/active_sessions.yml b/test/fixtures/active_sessions.yml new file mode 100644 index 0000000..f5a7b58 --- /dev/null +++ b/test/fixtures/active_sessions.yml @@ -0,0 +1 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html \ No newline at end of file diff --git a/test/models/active_session_test.rb b/test/models/active_session_test.rb new file mode 100644 index 0000000..3867f6a --- /dev/null +++ b/test/models/active_session_test.rb @@ -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 diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 616e6c3..93fda46 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -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 diff --git a/test/test_helper.rb b/test/test_helper.rb index b8c08c0..7133707 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -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) @@ -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 From 10103704a785b0d74e607ec3ac7b7d2bf306327d Mon Sep 17 00:00:00 2001 From: Steve Polito Date: Tue, 1 Feb 2022 05:37:53 -0500 Subject: [PATCH 2/6] Capture request details for each new session When logged into the application I want to be able to view all my active sessions so that I can determine if my account has been compromised based on the session data, user agent, and IP address. Issues ------ - Closes #69 --- README.md | 85 +++++++++++++++++++ app/controllers/concerns/authentication.rb | 2 +- app/controllers/users_controller.rb | 2 + .../active_sessions/_active_session.html.erb | 3 + app/views/users/edit.html.erb | 17 ++++ ..._add_request_columns_to_active_sessions.rb | 6 ++ db/schema.rb | 4 +- test/integration/user_interface_test.rb | 17 ++++ test/system/logins_test.rb | 18 ++++ 9 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 app/views/active_sessions/_active_session.html.erb create mode 100644 db/migrate/20220201102359_add_request_columns_to_active_sessions.rb create mode 100644 test/integration/user_interface_test.rb create mode 100644 test/system/logins_test.rb diff --git a/README.md b/README.md index 9ed83f2..24a56d9 100644 --- a/README.md +++ b/README.md @@ -1398,3 +1398,88 @@ 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. + +## Step 19: Capture Request Details for Each New Session + +1. Add new columns to the active_sessions table. + +```bash +rails g migration add_request_columns_to_active_sessions user_agent:string ip_address:string +rails db:migrate +``` + +2. Update login method to capture request details. + +```ruby +# app/controllers/concerns/authentication.rb +module Authentication + ... + def login(user) + reset_session + active_session = user.active_sessions.create!(user_agent: request.user_agent, ip_address: request.ip) + session[:current_active_session_id] = active_session.id + end + ... +end +``` + +> **What's Going On Here?** +> +> - We add columns to the `active_sessions` table to store data about when and where these sessions are being created. We are able to do this by tapping into the [request object](https://api.rubyonrails.org/classes/ActionDispatch/Request.html) and returning the [ip](https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-ip) and user agent. The user agent is simply the browser and device. + + +4. Update Users Controller. + +```ruby +# app/controllers/users_controller.rb +class UsersController < ApplicationController + ... + def edit + @user = current_user + @active_sessions = @user.active_sessions.order(created_at: :desc) + end + ... + def update + @user = current_user + @active_sessions = @user.active_sessions.order(created_at: :desc) + ... + end +end +``` + +5. Create active session partial. + +```html+ruby + +<%= active_session.user_agent %> +<%= active_session.ip_address %> +<%= active_session.created_at %> +``` + +6. Update account page. + +```html+ruby + +... +

Current Logins

+<% if @active_sessions.any? %> + + + + + + + + + + <%= render @active_sessions %> + +
User AgentIP AddressSigned In At
+<% end %> +``` + +> **What's Going On Here?** +> +> - 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. + diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 40f3d8d..c1f37e8 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -14,7 +14,7 @@ def authenticate_user! def login(user) reset_session - active_session = user.active_sessions.create! + active_session = user.active_sessions.create!(user_agent: request.user_agent, ip_address: request.ip) session[:current_active_session_id] = active_session.id end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 990501c..d0ce864 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -20,6 +20,7 @@ def destroy def edit @user = current_user + @active_sessions = @user.active_sessions.order(created_at: :desc) end def new @@ -28,6 +29,7 @@ def new def update @user = current_user + @active_sessions = @user.active_sessions.order(created_at: :desc) if @user.authenticate(params[:user][:current_password]) if @user.update(update_user_params) if params[:user][:unconfirmed_email].present? diff --git a/app/views/active_sessions/_active_session.html.erb b/app/views/active_sessions/_active_session.html.erb new file mode 100644 index 0000000..9ed323e --- /dev/null +++ b/app/views/active_sessions/_active_session.html.erb @@ -0,0 +1,3 @@ +<%= active_session.user_agent %> +<%= active_session.ip_address %> +<%= active_session.created_at %> \ No newline at end of file diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 8dbdf99..a4b056b 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -22,4 +22,21 @@ <%= form.password_field :current_password, required: true %> <%= form.submit "Update Account" %> +<% end %> +

Current Logins

+<% if @active_sessions.any? %> + + + + + + + + + + + <%= render @active_sessions %> + + +
User AgentIP AddressSigned In At
<% end %> \ No newline at end of file diff --git a/db/migrate/20220201102359_add_request_columns_to_active_sessions.rb b/db/migrate/20220201102359_add_request_columns_to_active_sessions.rb new file mode 100644 index 0000000..7f956e1 --- /dev/null +++ b/db/migrate/20220201102359_add_request_columns_to_active_sessions.rb @@ -0,0 +1,6 @@ +class AddRequestColumnsToActiveSessions < ActiveRecord::Migration[6.1] + def change + add_column :active_sessions, :user_agent, :string + add_column :active_sessions, :ip_address, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index b8cbf10..0cbb61a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,12 +10,14 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_01_29_144819) do +ActiveRecord::Schema.define(version: 2022_02_01_102359) do create_table "active_sessions", force: :cascade do |t| t.integer "user_id", null: false t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false + t.string "user_agent" + t.string "ip_address" t.index ["user_id"], name: "index_active_sessions_on_user_id" end diff --git a/test/integration/user_interface_test.rb b/test/integration/user_interface_test.rb new file mode 100644 index 0000000..523afef --- /dev/null +++ b/test/integration/user_interface_test.rb @@ -0,0 +1,17 @@ +require "test_helper" + +class UserInterfaceTest < 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 render active sessions on account page" do + login @confirmed_user + @confirmed_user.active_sessions.last.update!(user_agent: "Mozilla", ip_address: "123.457.789") + + get account_path + + assert_match "Mozilla", @response.body + assert_match "123.457.789", @response.body + end +end diff --git a/test/system/logins_test.rb b/test/system/logins_test.rb new file mode 100644 index 0000000..1ce0006 --- /dev/null +++ b/test/system/logins_test.rb @@ -0,0 +1,18 @@ +require "application_system_test_case" + +class LoginsTest < ApplicationSystemTestCase + setup do + @confirmed_user = User.create!(email: "confirmed_user@example.com", password: "password", password_confirmation: "password", confirmed_at: Time.current) + end + + test "should login and create active session if confirmed" do + visit login_path + + fill_in "Email", with: @confirmed_user.email + fill_in "Password", with: @confirmed_user.password + click_on "Sign In" + + assert_not_nil @confirmed_user.active_sessions.last.user_agent + assert_not_nil @confirmed_user.active_sessions.last.ip_address + end +end From b038c4e9f2e8a2fc8789413d96a273c0ddec57c7 Mon Sep 17 00:00:00 2001 From: Steve Polito Date: Wed, 2 Feb 2022 06:13:39 -0500 Subject: [PATCH 3/6] 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 --- README.md | 119 +++++++++++++++++- app/assets/stylesheets/active_sessions.scss | 3 + app/controllers/active_sessions_controller.rb | 25 ++++ app/controllers/concerns/authentication.rb | 2 +- app/helpers/active_sessions_helper.rb | 2 + .../active_sessions/_active_session.html.erb | 9 +- app/views/users/edit.html.erb | 8 +- config/routes.rb | 5 + .../active_sessions_controller_test.rb | 45 +++++++ test/integration/user_interface_test.rb | 18 +++ test/test_helper.rb | 2 +- 11 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 app/assets/stylesheets/active_sessions.scss create mode 100644 app/controllers/active_sessions_controller.rb create mode 100644 app/helpers/active_sessions_helper.rb create mode 100644 test/controllers/active_sessions_controller_test.rb diff --git a/README.md b/README.md index 24a56d9..8e1a3a5 100644 --- a/README.md +++ b/README.md @@ -1451,9 +1451,11 @@ end ```html+ruby -<%= active_session.user_agent %> -<%= active_session.ip_address %> -<%= active_session.created_at %> + + <%= active_session.user_agent %> + <%= active_session.ip_address %> + <%= active_session.created_at %> + ``` 6. Update account page. @@ -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 + +... +

Current Logins

+<% if @active_sessions.any? %> + <%= button_to "Log out of all other sessions", destroy_all_active_sessions_path, method: :delete %> + + + + + + + + + + + <%= render @active_sessions %> + +
User AgentIP AddressSigned In AtSign Out
+<% end %> +``` + +```html+ruby + + <%= active_session.user_agent %> + <%= active_session.ip_address %> + <%= active_session.created_at %> + <%= button_to "Sign Out", active_session_path(active_session), method: :delete %> + +``` + +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. \ No newline at end of file diff --git a/app/assets/stylesheets/active_sessions.scss b/app/assets/stylesheets/active_sessions.scss new file mode 100644 index 0000000..c731c40 --- /dev/null +++ b/app/assets/stylesheets/active_sessions.scss @@ -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/ diff --git a/app/controllers/active_sessions_controller.rb b/app/controllers/active_sessions_controller.rb new file mode 100644 index 0000000..d3f14db --- /dev/null +++ b/app/controllers/active_sessions_controller.rb @@ -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 diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index c1f37e8..a8f57db 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -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 diff --git a/app/helpers/active_sessions_helper.rb b/app/helpers/active_sessions_helper.rb new file mode 100644 index 0000000..a1f14ed --- /dev/null +++ b/app/helpers/active_sessions_helper.rb @@ -0,0 +1,2 @@ +module ActiveSessionsHelper +end diff --git a/app/views/active_sessions/_active_session.html.erb b/app/views/active_sessions/_active_session.html.erb index 9ed323e..0af10cb 100644 --- a/app/views/active_sessions/_active_session.html.erb +++ b/app/views/active_sessions/_active_session.html.erb @@ -1,3 +1,6 @@ -<%= active_session.user_agent %> -<%= active_session.ip_address %> -<%= active_session.created_at %> \ No newline at end of file + + <%= active_session.user_agent %> + <%= active_session.ip_address %> + <%= active_session.created_at %> + <%= button_to "Sign Out", active_session_path(active_session), method: :delete %> + \ No newline at end of file diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index a4b056b..6af136e 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -24,19 +24,19 @@ <%= form.submit "Update Account" %> <% end %>

Current Logins

-<% if @active_sessions.any? %> +<% if @active_sessions.any? %> + <%= button_to "Log out of all other sessions", destroy_all_active_sessions_path, method: :delete %> + - - <%= render @active_sessions %> - + <%= render @active_sessions %>
User Agent IP Address Signed In AtSign Out
<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 2fdbd7c..ab10dae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/test/controllers/active_sessions_controller_test.rb b/test/controllers/active_sessions_controller_test.rb new file mode 100644 index 0000000..3696444 --- /dev/null +++ b/test/controllers/active_sessions_controller_test.rb @@ -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 diff --git a/test/integration/user_interface_test.rb b/test/integration/user_interface_test.rb index 523afef..aa0f03e 100644 --- a/test/integration/user_interface_test.rb +++ b/test/integration/user_interface_test.rb @@ -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 diff --git a/test/test_helper.rb b/test/test_helper.rb index 7133707..dbc9d51 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -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) From 29b88480a295a3428de810f2a5544df7eaf8bd17 Mon Sep 17 00:00:00 2001 From: Steve Polito Date: Fri, 4 Feb 2022 14:58:40 -0500 Subject: [PATCH 4/6] Forget user when deleting an associated active session When deleting an active session I want to ensure the associated session is forgotten so that I can ensure my account it safe. Issues ------ - Closes #78 --- README.md | 122 +++++++++++++++++- app/controllers/active_sessions_controller.rb | 3 + app/controllers/concerns/authentication.rb | 10 +- app/controllers/sessions_controller.rb | 4 +- app/models/active_session.rb | 2 + app/models/user.rb | 1 - ...ber_token_from_users_to_active_sessions.rb | 10 ++ db/schema.rb | 6 +- .../active_sessions_controller_test.rb | 23 ++++ test/test_helper.rb | 7 +- 10 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 db/migrate/20220204201046_move_remember_token_from_users_to_active_sessions.rb diff --git a/README.md b/README.md index 8e1a3a5..1e9efb6 100644 --- a/README.md +++ b/README.md @@ -1595,4 +1595,124 @@ 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. \ No newline at end of file +> - 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. + +## Step 21: Refactor Remember Logic + +Since we're now associating our sessions with an `active_session` and not a `user`, we'll want to remove the `remember_token` token from the `users` table and onto the `active_sessions`. + +1. Move remember_token column from users to active_sessions table. + +```bash +rails g migration move_remember_token_from_users_to_active_sessions +``` + +```ruby +# db/migrate/[timestamp]_move_remember_token_from_users_to_active_sessions.rb +class MoveRememberTokenFromUsersToActiveSessions < ActiveRecord::Migration[6.1] + def change + remove_column :users, :remember_token + add_column :active_sessions, :remember_token, :string, null: false + + add_index :active_sessions, :remember_token, unique: true + end +end +``` + +> **What's Going On Here?** +> +> - We add `null: false` to ensure this column always has a value. +> - We add a [unique index](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/Table.html#method-i-index) to ensure this column has unique data. + +2. Update User Model. + +```diff + class User < ApplicationRecord + ... +- has_secure_password + ... + end +``` + +3. Update Active Session Model. + +```ruby +# app/models/active_session.rb +class ActiveSession < ApplicationRecord + ... + has_secure_token :remember_token +end +``` + +> **What's Going On Here?** +> +> - We call [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token) on the `remember_token`. This ensures that the value for this column will be set when the record is created. This value will be used later to securely identify the user. +> - Note that we remove this from the `user` model. + +4. Refactor the Authentication Concern. + +```ruby +# app/controllers/concerns/authentication.rb +module Authentication + ... + def login(user) + reset_session + active_session = user.active_sessions.create!(user_agent: request.user_agent, ip_address: request.ip) + session[:current_active_session_id] = active_session.id + + active_session + end + + def forget(user) + cookies.delete :remember_token + end + ... + def remember(active_session) + cookies.permanent.encrypted[:remember_token] = active_session.remember_token + end + ... + 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? + ActiveSession.find_by(remember_token: cookies.permanent.encrypted[:remember_token])&.user + end + end + ... +end +``` + +> **What's Going On Here?** +> +> - The `login` method now returns the `active_session`. This will be used later when calling `SessionsController#create`. +> - The `forget` method simply deletes the `cookie`. We don't need to call `active_session.regenerate_remember_token` since the `active_session` will be deleted, and therefor cannot be referenced again. +> - The `remember` method now accepts an `active_session` and not a `user`. We do not need to call `active_session.regenerate_remember_token` since a new `active_session` record will be created each time a user logs in. Note that we now save `active_session.remember_token` to the cookie. +> - The `current_user` method now finds the `active_session` record if the `remember_token` is present and returns the user via the [safe navigation operator](https://ruby-doc.org/core-2.6/doc/syntax/calling_methods_rdoc.html#label-Safe+navigation+operator). + +5. Refactor the Sessions Controller. + +```ruby +# app/controllers/sessions_controller.rb +class SessionsController < ApplicationController + def create + ... + if @user + if @user.unconfirmed? + ... + else + ... + active_session = login @user + remember(active_session) if params[:user][:remember_me] == "1" + end + else + ... + end + end +end +``` + +> **What's Going On Here?** +> +> - Since the `login` method now returns an `active_session`, we can take that value and pass it to `remember`. \ No newline at end of file diff --git a/app/controllers/active_sessions_controller.rb b/app/controllers/active_sessions_controller.rb index d3f14db..61e05a2 100644 --- a/app/controllers/active_sessions_controller.rb +++ b/app/controllers/active_sessions_controller.rb @@ -2,6 +2,7 @@ class ActiveSessionsController < ApplicationController before_action :authenticate_user! def destroy + user = current_user @active_session = current_user.active_sessions.find(params[:id]) @active_session.destroy @@ -9,6 +10,7 @@ def destroy if current_user redirect_to account_path, notice: "Session deleted." else + forget(user) reset_session redirect_to root_path, notice: "Signed out." end @@ -17,6 +19,7 @@ def destroy def destroy_all current_user + forget(current_user) current_user.active_sessions.destroy_all reset_session diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index a8f57db..3c45ea8 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -16,11 +16,12 @@ def login(user) reset_session active_session = user.active_sessions.create!(user_agent: request.user_agent, ip_address: request.ip) session[:current_active_session_id] = active_session.id + + active_session end def forget(user) cookies.delete :remember_token - user.regenerate_remember_token end def logout @@ -33,9 +34,8 @@ def redirect_if_authenticated redirect_to root_path, alert: "You are already logged in." if user_signed_in? end - def remember(user) - user.regenerate_remember_token - cookies.permanent.encrypted[:remember_token] = user.remember_token + def remember(active_session) + cookies.permanent.encrypted[:remember_token] = active_session.remember_token end def store_location @@ -48,7 +48,7 @@ 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]) + ActiveSession.find_by(remember_token: cookies.permanent.encrypted[:remember_token])&.user end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 91743b5..9c4b52c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -9,8 +9,8 @@ def create redirect_to new_confirmation_path, alert: "Incorrect email or password." else after_login_path = session[:user_return_to] || root_path - login @user - remember(@user) if params[:user][:remember_me] == "1" + active_session = login @user + remember(active_session) if params[:user][:remember_me] == "1" redirect_to after_login_path, notice: "Signed in." end else diff --git a/app/models/active_session.rb b/app/models/active_session.rb index 4e570f2..27d939a 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -1,3 +1,5 @@ class ActiveSession < ApplicationRecord belongs_to :user + + has_secure_token :remember_token end diff --git a/app/models/user.rb b/app/models/user.rb index 3fdb708..578e289 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,7 +6,6 @@ class User < ApplicationRecord attr_accessor :current_password has_secure_password - has_secure_token :remember_token has_many :active_sessions, dependent: :destroy diff --git a/db/migrate/20220204201046_move_remember_token_from_users_to_active_sessions.rb b/db/migrate/20220204201046_move_remember_token_from_users_to_active_sessions.rb new file mode 100644 index 0000000..3fdd0ec --- /dev/null +++ b/db/migrate/20220204201046_move_remember_token_from_users_to_active_sessions.rb @@ -0,0 +1,10 @@ +# TODO: Remove comment +# rails g migration move_remember_token_from_users_to_active_sessions +class MoveRememberTokenFromUsersToActiveSessions < ActiveRecord::Migration[6.1] + def change + remove_column :users, :remember_token + add_column :active_sessions, :remember_token, :string, null: false + + add_index :active_sessions, :remember_token, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 0cbb61a..c94db43 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_02_01_102359) do +ActiveRecord::Schema.define(version: 2022_02_04_201046) do create_table "active_sessions", force: :cascade do |t| t.integer "user_id", null: false @@ -18,6 +18,8 @@ t.datetime "updated_at", precision: 6, null: false t.string "user_agent" t.string "ip_address" + t.string "remember_token", null: false + t.index ["remember_token"], name: "index_active_sessions_on_remember_token", unique: true t.index ["user_id"], name: "index_active_sessions_on_user_id" end @@ -28,9 +30,7 @@ t.datetime "confirmed_at" t.string "password_digest", null: false t.string "unconfirmed_email" - t.string "remember_token", null: false t.index ["email"], name: "index_users_on_email", unique: true - t.index ["remember_token"], name: "index_users_on_remember_token", unique: true end add_foreign_key "active_sessions", "users", on_delete: :cascade diff --git a/test/controllers/active_sessions_controller_test.rb b/test/controllers/active_sessions_controller_test.rb index 3696444..9222c18 100644 --- a/test/controllers/active_sessions_controller_test.rb +++ b/test/controllers/active_sessions_controller_test.rb @@ -18,6 +18,18 @@ class ActiveSessionsControllerTest < ActionDispatch::IntegrationTest assert_not_nil flash[:notice] end + test "should destroy all active sessions and forget active sessions" do + login @confirmed_user, remember_user: true + @confirmed_user.active_sessions.create! + + assert_difference("ActiveSession.count", -2) do + delete destroy_all_active_sessions_path + end + + assert_nil current_user + assert cookies[:remember_token].blank? + end + test "should destroy another session" do login @confirmed_user @confirmed_user.active_sessions.create! @@ -42,4 +54,15 @@ class ActiveSessionsControllerTest < ActionDispatch::IntegrationTest assert_nil current_user assert_not_nil flash[:notice] end + + test "should destroy current session and forget current active session" do + login @confirmed_user, remember_user: true + + assert_difference("ActiveSession.count", -1) do + delete active_session_path(@confirmed_user.active_sessions.last) + end + + assert_nil current_user + assert cookies[:remember_token].blank? + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index dbc9d51..7f1b170 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -11,7 +11,12 @@ 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 + if session[:current_active_session_id].present? + ActiveSession.find_by(id: session[:current_active_session_id])&.user + else + cookies[:remember_token].present? + ActiveSession.find_by(remember_token: cookies[:remember_token])&.user + end end def login(user, remember_user: nil) From 66b932f25ea4e1f346a7a55e8b75adc062136957 Mon Sep 17 00:00:00 2001 From: Steve Polito Date: Sat, 5 Feb 2022 10:19:56 -0500 Subject: [PATCH 5/6] Use correct input type when collecting email addresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When filling out a form that collects an email address, I want my email to be validated in the browser so that I don’t find out it was incorrectly formatted when submitting the form. Issues ------ - Closes #73 --- README.md | 6 +++--- app/views/sessions/new.html.erb | 2 +- app/views/users/edit.html.erb | 2 +- app/views/users/new.html.erb | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1e9efb6..bec3b1b 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ end <%= render partial: "shared/form_errors", locals: { object: form.object } %>
<%= form.label :email %> - <%= form.text_field :email, required: true %> + <%= form.email_field :email, required: true %>
<%= form.label :password %> @@ -557,7 +557,7 @@ end <%= form_with url: login_path, scope: :user do |form| %>
<%= form.label :email %> - <%= form.text_field :email, required: true %> + <%= form.email_field :email, required: true %>
<%= form.label :password %> @@ -992,7 +992,7 @@ end <%= render partial: "shared/form_errors", locals: { object: form.object } %>
<%= form.label :email, "Current Email" %> - <%= form.text_field :email, disabled: true %> + <%= form.email_field :email, disabled: true %>
<%= form.label :unconfirmed_email, "New Email" %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 627acd4..2cdc067 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,7 +1,7 @@ <%= form_with url: login_path, scope: :user do |form| %>
<%= form.label :email %> - <%= form.text_field :email, required: true %> + <%= form.email_field :email, required: true %>
<%= form.label :password %> diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 6af136e..e392417 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -2,7 +2,7 @@ <%= render partial: "shared/form_errors", locals: { object: form.object } %>
<%= form.label :email, "Current Email" %> - <%= form.text_field :email, disabled: true %> + <%= form.email_field :email, disabled: true %>
<%= form.label :unconfirmed_email, "New Email" %> diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb index f8f2011..9598c4d 100644 --- a/app/views/users/new.html.erb +++ b/app/views/users/new.html.erb @@ -2,7 +2,7 @@ <%= render partial: "shared/form_errors", locals: { object: form.object } %>
<%= form.label :email %> - <%= form.text_field :email, required: true %> + <%= form.email_field :email, required: true %>
<%= form.label :password %> From 03bc311247e6842a861cf322266a098c8ae54c1c Mon Sep 17 00:00:00 2001 From: Steve Polito Date: Sat, 5 Feb 2022 11:25:56 -0500 Subject: [PATCH 6/6] Proof README --- README.md | 63 +++++++++++++++---- app/controllers/active_sessions_controller.rb | 7 +-- app/controllers/concerns/authentication.rb | 10 +-- app/controllers/sessions_controller.rb | 2 +- 4 files changed, 58 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index bec3b1b..bc0f82b 100644 --- a/README.md +++ b/README.md @@ -855,7 +855,7 @@ end # app/mailers/user_mailer.rb class UserMailer < ApplicationMailer - def confirmation(user) + def confirmation(user, confirmation_token) ... mail to: @user.confirmable_email, subject: "Confirmation Instructions" end @@ -1195,10 +1195,12 @@ module Authentication ... end ... + private + ... def store_location session[:user_return_to] = request.original_url if request.get? && request.local? end - ... + end ``` @@ -1461,7 +1463,7 @@ end 6. Update account page. ```html+ruby - + ...

Current Logins

<% if @active_sessions.any? %> @@ -1512,8 +1514,6 @@ class ActiveSessionsController < ApplicationController end def destroy_all - current_user - current_user.active_sessions.destroy_all reset_session @@ -1566,6 +1566,7 @@ end ``` ```html+ruby + <%= active_session.user_agent %> <%= active_session.ip_address %> @@ -1619,22 +1620,28 @@ class MoveRememberTokenFromUsersToActiveSessions < ActiveRecord::Migration[6.1] end ``` +2. Run migration. + +```bash +rails db:migrate +``` + > **What's Going On Here?** > > - We add `null: false` to ensure this column always has a value. > - We add a [unique index](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/Table.html#method-i-index) to ensure this column has unique data. -2. Update User Model. +3. Update User Model. ```diff class User < ApplicationRecord ... -- has_secure_password +- has_secure_token :remember_token ... end ``` -3. Update Active Session Model. +4. Update Active Session Model. ```ruby # app/models/active_session.rb @@ -1649,7 +1656,7 @@ end > - We call [has_secure_token](https://api.rubyonrails.org/classes/ActiveRecord/SecureToken/ClassMethods.html#method-i-has_secure_token) on the `remember_token`. This ensures that the value for this column will be set when the record is created. This value will be used later to securely identify the user. > - Note that we remove this from the `user` model. -4. Refactor the Authentication Concern. +5. Refactor the Authentication Concern. ```ruby # app/controllers/concerns/authentication.rb @@ -1663,7 +1670,7 @@ module Authentication active_session end - def forget(user) + def forget_active_session cookies.delete :remember_token end ... @@ -1687,11 +1694,11 @@ end > **What's Going On Here?** > > - The `login` method now returns the `active_session`. This will be used later when calling `SessionsController#create`. -> - The `forget` method simply deletes the `cookie`. We don't need to call `active_session.regenerate_remember_token` since the `active_session` will be deleted, and therefor cannot be referenced again. +> - The `forget` method has been renamed to `forget_active_session` and no longer takes any arguments. This method simply deletes the `cookie`. We don't need to call `active_session.regenerate_remember_token` since the `active_session` will be deleted, and therefor cannot be referenced again. > - The `remember` method now accepts an `active_session` and not a `user`. We do not need to call `active_session.regenerate_remember_token` since a new `active_session` record will be created each time a user logs in. Note that we now save `active_session.remember_token` to the cookie. > - The `current_user` method now finds the `active_session` record if the `remember_token` is present and returns the user via the [safe navigation operator](https://ruby-doc.org/core-2.6/doc/syntax/calling_methods_rdoc.html#label-Safe+navigation+operator). -5. Refactor the Sessions Controller. +6. Refactor the Sessions Controller. ```ruby # app/controllers/sessions_controller.rb @@ -1710,9 +1717,39 @@ class SessionsController < ApplicationController ... end end + + def destroy + forget_active_session + ... + end end ``` > **What's Going On Here?** > -> - Since the `login` method now returns an `active_session`, we can take that value and pass it to `remember`. \ No newline at end of file +> - Since the `login` method now returns an `active_session`, we can take that value and pass it to `remember`. +> - We replace `forget(current_user)` with `forget_active_session` to reflect changes to the method name and structure. + +7. Refactor Active Sessions Controller + +```ruby +# app/controllers/active_sessions_controller.rb +class ActiveSessionsController < ApplicationController + ... + def destroy + ... + if current_user + ... + else + forget_active_session + ... + end + end + + def destroy_all + forget_active_session + current_user.active_sessions.destroy_all + ... + end +end +``` \ No newline at end of file diff --git a/app/controllers/active_sessions_controller.rb b/app/controllers/active_sessions_controller.rb index 61e05a2..e085820 100644 --- a/app/controllers/active_sessions_controller.rb +++ b/app/controllers/active_sessions_controller.rb @@ -2,7 +2,6 @@ class ActiveSessionsController < ApplicationController before_action :authenticate_user! def destroy - user = current_user @active_session = current_user.active_sessions.find(params[:id]) @active_session.destroy @@ -10,16 +9,14 @@ def destroy if current_user redirect_to account_path, notice: "Session deleted." else - forget(user) + forget_active_session reset_session redirect_to root_path, notice: "Signed out." end end def destroy_all - current_user - - forget(current_user) + forget_active_session current_user.active_sessions.destroy_all reset_session diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 3c45ea8..67e35d6 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -20,7 +20,7 @@ def login(user) active_session end - def forget(user) + def forget_active_session cookies.delete :remember_token end @@ -38,10 +38,6 @@ def remember(active_session) cookies.permanent.encrypted[:remember_token] = active_session.remember_token end - def store_location - session[:user_return_to] = request.original_url if request.get? && request.local? - end - private def current_user @@ -55,4 +51,8 @@ def current_user def user_signed_in? Current.user.present? end + + def store_location + session[:user_return_to] = request.original_url if request.get? && request.local? + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 9c4b52c..c23c2a3 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -20,7 +20,7 @@ def create end def destroy - forget(current_user) + forget_active_session logout redirect_to root_path, notice: "Signed out." end