Skip to content

Commit

Permalink
Store encrypted tokens (#145)
Browse files Browse the repository at this point in the history
  • Loading branch information
mikker committed Jun 16, 2023
1 parent aff1803 commit a64d5f9
Show file tree
Hide file tree
Showing 13 changed files with 102 additions and 34 deletions.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
41 changes: 22 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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? %>
<p>User found, check your inbox</p>
Expand Down Expand Up @@ -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"
```
Expand Down Expand Up @@ -350,6 +352,7 @@ class AddClaimedAtToPasswordlessSessions < ActiveRecord::Migration[5.2]
end

```

</details>

### Supporting UUID primary keys
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/passwordless/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions app/models/passwordless/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion db/migrate/20171104221735_create_passwordless_sessions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions lib/passwordless.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) { "[email protected]" }
mattr_accessor(:token_generator) { UrlSafeBase64Generator.new }
Expand Down
18 changes: 18 additions & 0 deletions lib/passwordless/token_digest.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion test/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions test/fixtures/passwordless/sessions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
7 changes: 3 additions & 4 deletions test/integration/navigation_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions test/models/passwordless/session_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion test/passwordless_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions test/token_digest_test.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit a64d5f9

Please sign in to comment.