From a64d5f98070c92122e5fdd1f9cb899ffbec0a05e Mon Sep 17 00:00:00 2001 From: Mikkel Malmberg Date: Fri, 16 Jun 2023 13:40:20 +0200 Subject: [PATCH] Store encrypted tokens (#145) --- CHANGELOG.md | 14 +++++++ README.md | 41 ++++++++++--------- .../passwordless/sessions_controller.rb | 4 +- app/models/passwordless/session.rb | 14 +++++-- ...1104221735_create_passwordless_sessions.rb | 2 +- lib/passwordless.rb | 5 +++ lib/passwordless/token_digest.rb | 18 ++++++++ test/dummy/db/schema.rb | 2 +- test/fixtures/passwordless/sessions.yml | 6 +-- test/integration/navigation_test.rb | 7 ++-- test/models/passwordless/session_test.rb | 2 + test/passwordless_test.rb | 8 +++- test/token_digest_test.rb | 13 ++++++ 13 files changed, 102 insertions(+), 34 deletions(-) create mode 100644 lib/passwordless/token_digest.rb create mode 100644 test/token_digest_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index aaff9aa..c08371c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased + +### Breaking changes + +Tokens are now encrypted in the database. If you are upgrading from a previous version, you'll need to add a field to your passwordless table: + +```sh +$ bin/rails g migration AddTokenDigestToPasswordlessSessions token_digest:string:index +``` + +### Changed + +- Tokens are now encrypted in the database ([#145](https://github.com/mikker/passwordless/pull/145)) + ## 0.12.0 (2023-06-16) ### Added diff --git a/README.md b/README.md index bdc5058..1f8805e 100644 --- a/README.md +++ b/README.md @@ -12,24 +12,24 @@ Add authentication to your Rails app without all the icky-ness of passwords. ## Table of Contents -* [Installation](#installation) -* [Usage](#usage) - * [Getting the current user, restricting access, the usual](#getting-the-current-user-restricting-access-the-usual) - * [Providing your own templates](#providing-your-own-templates) - * [Registering new users](#registering-new-users) - * [URLs and links](#urls-and-links) - * [Customize the way to send magic link](#customize-the-way-to-send-magic-link) - * [Generate your own magic links](#generate-your-own-magic-links) - * [Overrides](#overrides) -* [Configuration](#configuration) - * [Customising token generation](#generating-tokens) - * [Token and Session Expiry](#token-and-session-expiry) - * [Redirecting back after sign-in](#redirecting-back-after-sign-in) - * [Claiming tokens](#claiming-tokens) - * [Supporting UUID primary keys](#supporting-uuid-primary-keys) -* [Testing helpers](#testing-helpers) -* [E-mail security](#e-mail-security) -* [License](#license) +- [Installation](#installation) +- [Usage](#usage) + - [Getting the current user, restricting access, the usual](#getting-the-current-user-restricting-access-the-usual) + - [Providing your own templates](#providing-your-own-templates) + - [Registering new users](#registering-new-users) + - [URLs and links](#urls-and-links) + - [Customize the way to send magic link](#customize-the-way-to-send-magic-link) + - [Generate your own magic links](#generate-your-own-magic-links) + - [Overrides](#overrides) +- [Configuration](#configuration) + - [Customising token generation](#customizing-token-generation) + - [Token and Session Expiry](#token-and-session-expiry) + - [Redirecting back after sign-in](#redirecting-back-after-sign-in) + - [Claiming tokens](#claiming-tokens) + - [Supporting UUID primary keys](#supporting-uuid-primary-keys) +- [Testing helpers](#testing-helpers) +- [E-mail security](#e-mail-security) +- [License](#license) ## Installation @@ -130,6 +130,7 @@ app/views/passwordless/mailer/magic_link.text.erb ``` If you'd like to let the user know whether or not a record was found, `@resource` is provided to the view. You may override `app/views/passwordless/session/create.html.erb` for example like so: + ```erb <% if @resource.present? %>

User found, check your inbox

@@ -215,6 +216,7 @@ session.save! ``` You can further customize this URL by specifying the destination path to be redirected to after the user has logged in. You can do this by adding the `destination_path` query parameter to the end of the URL. For example + ``` @magic_link = "#{@magic_link}?destination_path=/your-custom-path" ``` @@ -350,6 +352,7 @@ class AddClaimedAtToPasswordlessSessions < ActiveRecord::Migration[5.2] end ``` + ### Supporting UUID primary keys @@ -358,6 +361,7 @@ If your `users` table uses UUIDs for its primary keys, you will need to add a mi to change the type of `passwordless`' `authenticatable_id` field to match your primary key type (this will also involve dropping and recreating associated indices). Here is an example migration you can use: + ```ruby class SupportUuidInPasswordlessSessions < ActiveRecord::Migration[6.0] def change @@ -388,7 +392,6 @@ If you are using TestUnit, add this line to your `test/test_helper.rb`: require "passwordless/test_helpers" ``` - Then in your controller, request, and system tests/specs, you can utilize the following methods: ```ruby diff --git a/app/controllers/passwordless/sessions_controller.rb b/app/controllers/passwordless/sessions_controller.rb index 8a411a4..d69c364 100644 --- a/app/controllers/passwordless/sessions_controller.rb +++ b/app/controllers/passwordless/sessions_controller.rb @@ -31,7 +31,7 @@ def create end end - flash[:notice] = I18n.t('passwordless.sessions.create.email_sent_if_record_found') + flash[:notice] = I18n.t("passwordless.sessions.create.email_sent_if_record_found") redirect_to(sign_in_path) end @@ -119,7 +119,7 @@ def find_authenticatable def passwordless_session @passwordless_session ||= Session.find_by!( authenticatable_type: authenticatable_classname, - token: params[:token] + token_digest: Passwordless.digest(params[:token]) ) end end diff --git a/app/models/passwordless/session.rb b/app/models/passwordless/session.rb index f4da5ba..3912472 100644 --- a/app/models/passwordless/session.rb +++ b/app/models/passwordless/session.rb @@ -16,12 +16,16 @@ class Session < ApplicationRecord :expires_at, :user_agent, :remote_addr, - :token, + :token_digest, presence: true ) before_validation :set_defaults + # save the token in memory so we can put it in emails but only save the + # hashed version in the database + attr_accessor :token + scope( :available, lambda { where("expires_at > ?", Time.current) } @@ -61,9 +65,13 @@ def available? def set_defaults self.expires_at ||= Passwordless.expires_at.call self.timeout_at ||= Passwordless.timeout_at.call - self.token ||= loop { + + return if self.token || self.token_digest + + self.token, self.token_digest = loop { token = Passwordless.token_generator.call(self) - break token unless Session.find_by(token: token) + digest = Passwordless.digest(token) + break [token, digest] unless Session.find_by(token_digest: digest) } end end diff --git a/db/migrate/20171104221735_create_passwordless_sessions.rb b/db/migrate/20171104221735_create_passwordless_sessions.rb index 2b4546d..4b0c913 100644 --- a/db/migrate/20171104221735_create_passwordless_sessions.rb +++ b/db/migrate/20171104221735_create_passwordless_sessions.rb @@ -14,7 +14,7 @@ def change t.datetime(:claimed_at) t.text(:user_agent, null: false) t.string(:remote_addr, null: false) - t.string(:token, null: false) + t.string(:token_digest, null: false) t.timestamps end diff --git a/lib/passwordless.rb b/lib/passwordless.rb index be196f3..08a2f29 100644 --- a/lib/passwordless.rb +++ b/lib/passwordless.rb @@ -3,10 +3,15 @@ require "active_support" require "passwordless/errors" require "passwordless/engine" +require "passwordless/token_digest" require "passwordless/url_safe_base_64_generator" # The main Passwordless module module Passwordless + def self.digest(token) + TokenDigest.new(token).digest + end + mattr_accessor(:parent_mailer) { "ActionMailer::Base" } mattr_accessor(:default_from_address) { "CHANGE_ME@example.com" } mattr_accessor(:token_generator) { UrlSafeBase64Generator.new } diff --git a/lib/passwordless/token_digest.rb b/lib/passwordless/token_digest.rb new file mode 100644 index 0000000..107d967 --- /dev/null +++ b/lib/passwordless/token_digest.rb @@ -0,0 +1,18 @@ +module Passwordless + class TokenDigest + ALGORITHM = "SHA256" + + def initialize(str) + @str = str + end + + def digest + key = self.class.key() + OpenSSL::HMAC.hexdigest(ALGORITHM, key, @str) + end + + def self.key + @key ||= ActiveSupport::KeyGenerator.new(Rails.application.secret_key_base).generate_key("passwordless") + end + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index e719ae5..e227d0a 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -19,7 +19,7 @@ t.datetime "claimed_at", precision: nil t.text "user_agent", null: false t.string "remote_addr", null: false - t.string "token", null: false + t.string "token_digest", null: false t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.index ["authenticatable_type", "authenticatable_id"], name: "authenticatable" diff --git a/test/fixtures/passwordless/sessions.yml b/test/fixtures/passwordless/sessions.yml index 6514b0f..2ed5f1e 100644 --- a/test/fixtures/passwordless/sessions.yml +++ b/test/fixtures/passwordless/sessions.yml @@ -5,19 +5,19 @@ one: expires_at: 2017-11-04 23:17:35 user_agent: MyText remote_addr: MyString - token: MyString + token_digest: MyString two: timeout_at: 2017-11-04 23:17:35 expires_at: 2017-11-04 23:17:35 user_agent: MyText remote_addr: MyString - token: MyString + token_digest: MyString john: timeout_at: 2017-11-04 23:17:35 expires_at: 2017-11-04 23:17:35 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.62 Safari/537.36" remote_addr: 127.0.0.1 - token: 3veDCdFw4VedgZtZZkCJ7um7rsc2bFNCZGB51Iih024 + token_digest: 3veDCdFw4VedgZtZZkCJ7um7rsc2bFNCZGB51Iih024 authenticatable: john (User) diff --git a/test/integration/navigation_test.rb b/test/integration/navigation_test.rb index 9165f75..2f0a5ce 100644 --- a/test/integration/navigation_test.rb +++ b/test/integration/navigation_test.rb @@ -25,9 +25,7 @@ class NavigationTest < ActionDispatch::IntegrationTest # Submit form post( "/users/sign_in", - params: { - passwordless: {email: alice.email} - }, + params: {passwordless: {email: alice.email}}, headers: {"HTTP_USER_AGENT" => "Mosaic v.1"} ) @@ -42,9 +40,10 @@ class NavigationTest < ActionDispatch::IntegrationTest assert_equal 1, ActionMailer::Base.deliveries.count email = ActionMailer::Base.deliveries.first assert_equal alice.email, email.to.first + token = email.body.match(/sign_in\/([\w\-_]+)/)[1] # Expect mail body to include session link - token_sign_in_path = "/users/sign_in/#{user_session.token}" + token_sign_in_path = "/users/sign_in/#{token}" assert email.body.to_s.include?(token_sign_in_path) # Follow link, Expect redirect to /secret path which has been unsuccessfully diff --git a/test/models/passwordless/session_test.rb b/test/models/passwordless/session_test.rb index 98f3516..1aada86 100644 --- a/test/models/passwordless/session_test.rb +++ b/test/models/passwordless/session_test.rb @@ -46,6 +46,7 @@ def create_session(attrs = {}) refute_nil session.expires_at refute_nil session.timeout_at refute_nil session.token + refute_nil session.token_digest end test("with a custom token generator") do @@ -62,6 +63,7 @@ def call(_session) session.validate assert_equal "ALWAYS ME", session.token + assert_equal Passwordless.digest("ALWAYS ME"), session.token_digest Passwordless.token_generator = old_generator end diff --git a/test/passwordless_test.rb b/test/passwordless_test.rb index 2bec0fc..a86b248 100644 --- a/test/passwordless_test.rb +++ b/test/passwordless_test.rb @@ -3,6 +3,12 @@ require "test_helper" module Passwordless - class Test < ActiveSupport::TestCase + class PasswordlessTest < ActiveSupport::TestCase + test(".digest(str)") do + a = Passwordless.digest("string") + b = Passwordless.digest("string") + + assert_equal a, b + end end end diff --git a/test/token_digest_test.rb b/test/token_digest_test.rb new file mode 100644 index 0000000..c686d57 --- /dev/null +++ b/test/token_digest_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +module Passwordless + class DigestTest < ActiveSupport::TestCase + test("generates a digest") do + a = TokenDigest.new("mystring").digest + b = TokenDigest.new("mystring").digest + + assert a.length > 10 + assert_equal a, b + end + end +end