Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new type encrypted_string for preferences #3676

Merged
merged 4 commits into from
Aug 21, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<% label = local_assigns[:label].presence %>
<% html_options = {class: 'input_string fullwidth'}.merge(local_assigns[:html_options] || {}) %>

<div class="field">
<% if local_assigns[:form] %>
<%= form.label attribute, label %>
<%= form.text_field attribute, html_options %>
<% else %>
<%= label_tag name, label %>
<%= text_field_tag name, value, html_options %>
<% end %>
</div>
25 changes: 25 additions & 0 deletions core/lib/spree/encryptor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module Spree
# Spree::Encryptor is a thin wrapper around ActiveSupport::MessageEncryptor.
class Encryptor
# @param key [String] the 256 bits signature key
def initialize(key)
@crypt = ActiveSupport::MessageEncryptor.new(key)
end

# Encrypt a value
# @param value [String] the value to encrypt
# @return [String] the encrypted value
def encrypt(value)
@crypt.encrypt_and_sign(value)
end

# Decrypt an encrypted value
# @param encrypted_value [String] the value to decrypt
# @return [String] the decrypted value
def decrypt(encrypted_value)
@crypt.decrypt_and_verify(encrypted_value)
end
end
end
4 changes: 3 additions & 1 deletion core/lib/spree/preferences/preferable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,13 @@ def admin_form_preference_names

private

def convert_preference_value(value, type)
def convert_preference_value(value, type, preference_encryptor = nil)
return nil if value.nil?
case type
when :string, :text
value.to_s
when :encrypted_string
preference_encryptor.encrypt(value.to_s)
when :password
value.to_s
when :decimal
Expand Down
25 changes: 22 additions & 3 deletions core/lib/spree/preferences/preferable_class_methods.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'spree/encryptor'

module Spree::Preferences
module PreferableClassMethods
DEFAULT_ADMIN_FORM_PREFERENCE_TYPES = %i(
Expand All @@ -9,14 +11,21 @@ module PreferableClassMethods
password
string
text
encrypted_string
)

def defined_preferences
[]
end

def preference(name, type, options = {})
options.assert_valid_keys(:default)
options.assert_valid_keys(:default, :encryption_key)

if type == :encrypted_string
preference_encryptor = preference_encryptor(options)
options[:default] = preference_encryptor.encrypt(options[:default])
end

default = options[:default]
default = ->{ options[:default] } unless default.is_a?(Proc)

Expand All @@ -34,13 +43,15 @@ def preference(name, type, options = {})
# cache_key will be nil for new objects, then if we check if there
# is a pending preference before going to default
define_method preference_getter_method(name) do
preferences.fetch(name) do
value = preferences.fetch(name) do
default.call
end
value = preference_encryptor.decrypt(value) if preference_encryptor.present?
value
end

define_method preference_setter_method(name) do |value|
value = convert_preference_value(value, type)
value = convert_preference_value(value, type, preference_encryptor)
preferences[name] = value

# If this is an activerecord object, we need to inform
Expand Down Expand Up @@ -72,6 +83,14 @@ def preference_type_getter_method(name)
"preferred_#{name}_type".to_sym
end

def preference_encryptor(options)
key = options[:encryption_key] ||
ENV['SOLIDUS_PREFERENCES_MASTER_KEY'] ||
Rails.application.credentials.secret_key_base

Spree::Encryptor.new(key)
end

# List of preference types allowed as form fields in the Solidus admin
#
# Overwrite this method in your class that includes +Spree::Preferable+
Expand Down
25 changes: 25 additions & 0 deletions core/spec/lib/spree/encryptor_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

require 'rails_helper'
require 'spree/encryptor'

RSpec.describe Spree::Encryptor do
let(:key) { 'p3s6v9y$B?E(H+MbQeThWmZq4t7w!z%C' }
let(:value) { 'payment_system_sdk_id' }

describe '#encrypt' do
it 'returns the encrypted value' do
encryptor = described_class.new(key)
expect(encryptor.encrypt(value)).not_to eq(value)
end
end

describe '#decrypt' do
it 'returns the original decrypted value' do
encryptor = described_class.new(key)
encrypted_value = encryptor.encrypt(value)

expect(encryptor.decrypt(encrypted_value)).to eq(value)
end
end
end
51 changes: 51 additions & 0 deletions core/spec/models/spree/preferences/preferable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,45 @@ def self.allowed_admin_form_preference_types
expect(@a.preferences[:product_attributes]).to eq({ id: 1, name: 2 })
end
end

context "converts encrypted_string preferences to encrypted values" do
it "with string, encryption key provided as option" do
A.preference :secret, :encrypted_string,
encryption_key: 'VkYp3s6v9y$B?E(H+MbQeThWmZq4t7w!'

@a.set_preference(:secret, 'secret_client_id')
expect(@a.get_preference(:secret)).to eq('secret_client_id')
expect(@a.preferences[:secret]).not_to eq('secret_client_id')
end

it "with string, encryption key provided as env variable" do
expect(ENV).to receive(:[]).with("SOLIDUS_PREFERENCES_MASTER_KEY").and_return("VkYp3s6v9y$B?E(H+MbQeThWmZq4t7w!")

A.preference :secret, :encrypted_string

@a.set_preference(:secret, 'secret_client_id')
expect(@a.get_preference(:secret)).to eq('secret_client_id')
expect(@a.preferences[:secret]).not_to eq('secret_client_id')
end

it "with string, encryption key provided as option, set using syntactic sugar method" do
A.preference :secret, :encrypted_string,
encryption_key: 'VkYp3s6v9y$B?E(H+MbQeThWmZq4t7w!'

@a.preferred_secret = 'secret_client_id'
expect(@a.preferred_secret).to eq('secret_client_id')
expect(@a.preferences[:secret]).not_to eq('secret_client_id')
end

it "with string, default value" do
A.preference :secret, :encrypted_string,
default: 'my_default_secret',
encryption_key: 'VkYp3s6v9y$B?E(H+MbQeThWmZq4t7w!'

expect(@a.get_preference(:secret)).to eq('my_default_secret')
expect(@a.preferences[:secret]).not_to eq('my_default_secret')
end
end
end

describe "persisted preferables" do
Expand All @@ -290,6 +329,7 @@ def self.down
class PrefTest < Spree::Base
preference :pref_test_pref, :string, default: 'abc'
preference :pref_test_any, :any, default: []
preference :pref_test_encrypted_string, :encrypted_string, encryption_key: 'VkYp3s6v9y$B?E(H+MbQeThWmZq4t7w!'
end
end

Expand Down Expand Up @@ -318,6 +358,17 @@ class PrefTest < Spree::Base
pr.save!
expect(pr.get_preference(:pref_test_any)).to eq([1, 2])
end

it "saves encrypted preferences for serialized object" do
pr = PrefTest.new
pr.set_preference(:pref_test_encrypted_string, 'secret_client_id')
expect(pr.get_preference(:pref_test_encrypted_string)).to eq('secret_client_id')
pr.save!
preferences_value_on_db = ActiveRecord::Base.connection.execute(
"SELECT preferences FROM pref_tests WHERE id=#{pr.id}"
).first
expect(preferences_value_on_db).not_to include('secret_client_id')
end
end

it "clear preferences when record is deleted" do
Expand Down
117 changes: 106 additions & 11 deletions guides/source/developers/preferences/add-model-preferences.html.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Add model preferences

Solidus comes with many model-specific preferences. They are configured to have
default values that are appropriate for typical stores. Preferences can be set
on any model that inherits from [`Spree::Base`][spree-base].
default values that are appropriate for typical stores. Additional preferences
can be added by your application or included extensions.

Preferences can be set on any model that inherits from [`Spree::Base`][spree-base].

Note that model preferences apply only to the current model. To learn more about
application-wide preferences, see the [App configuration][app-configuration]
Expand All @@ -18,32 +20,63 @@ You can define preferences for a model within the model itself:

```ruby
module MyStore
class SubscriptionRules < Spree::Base
class User < Spree::Base
preference :hot_salsa, :boolean
preference :dark_chocolate, :boolean, default: true
preference :color, :string
preference :favorite_number, :integer
preference :language, :string, default: "English"
end
end
```

This will work because User is a subclass of Spree::Base. If found,
the preferences attribute gets serialized into a Hash and merged with the default values.

<!-- TODO:
Let's replace this example code with something a little more realistic. What
kind of object would a store want multiple custom preferences on?
-->

## Supported type for preferences

For each preference you define, a data type should be provided. The available
types are:

- `boolean`
- `string`
- `encrypted_string`
- `password`
- `integer`
- `text`
- `array`
- `hash`

An optional default value may be defined. (See the `:dark_chocolate` preference
in the block above.) This is the value used unless another value has been set.
in the block above.) This is the value used unless another value has been set.

### Details for encrypted_string type

We encourage the usage of environment variables for keeping your secrets,
but in case when this is not possible you can use a preference of type
`encrypted_string`.

A preference of type `encrypted_string` accept an option named `encryption_key`,
the value of the option will be used as key for the encryption of the preference.

If no `encryption_key` is passed the application would use the value of the
environment variable `SOLIDUS_PREFERENCES_MASTER_KEY` as encryption key.

If no environment variable `SOLIDUS_PREFERENCES_MASTER_KEY` is set the application
would use the Rails master key as encryption key.

Solidus will NOT manage the rotation, or secure storage, of the key, this things
need to be handled by hand.

To access the unencrypted value of a preference of type `encrypted_string` use the method generated
by Solidus, see [Access your preferences](#access-your-preferences).

If you try to fetch the value directly from the preferences hash, you'll get the encrypted string.

### Add columns for your preferences

Expand All @@ -53,7 +86,7 @@ relevant model using a migration:
```ruby
class AddPreferencesToSubscriptionRules < ActiveRecord::Migration[5.0]
def change
add_column :my_store_subscription_rules, :preferences, :text
add_column :my_store_users, :preferences, :text
end
end
```
Expand All @@ -68,18 +101,80 @@ bundle exec rails db:migrate

### Access your preferences

Now you can access their values from the model they are set on:
Once preferences have been defined for a model, they can be accessed either using the shortcut methods that are generated for each preference or the generic methods that are not specific to a particular preference.

#### Shortcut Methods

There are several shortcut methods that are generated. They are shown below.

Reader methods:

```ruby
user.preferred_color # => nil
user.preferred_language # => "English"
```

Writer methods:

```ruby
user.preferred_hot_salsa = false # => false
user.preferred_language = "English" # => "English"
```

Check if a preference is available:

```ruby
user.has_preference? :hot_salsa # => True
```

#### Generic Methods

Each shortcut method is essentially a wrapper for the various generic methods shown below:

Query method:

```ruby
user.prefers?(:hot_salsa) # => false
user.prefers?(:dark_chocolate) # => false
```

Reader methods:

```ruby
user.get_preference :color # => nil
user.get_preference :language # => English
user.preferences.fetch(:dark_chocolate) # => false
```

Writer method:

```ruby
user.set_preference(:hot_salsa, false) # => false
user.set_preference(:language, "English") # => "English"
```

#### Accessing All Preferences

You can get a hash of all stored preferences by accessing the `preferences` helper:

```ruby
user.preferences # => {"language"=>"English", "color"=>nil}
```

This hash will contain the value for every preference that has been defined for the model instance, whether the value is the default or one that has been previously stored.

#### Default and Type

You can access the default value for a preference:

```ruby
MyStore::SubscriptionRules.find(1).preferences
# => {:hot_salsa => nil, :dark_chocolate => true, :color => "grey"}
user.preferred_color_default # => 'blue'
```

Or, the value of a specific preference:
Types are used to generate forms or display the preference. You can also get the type defined for a preference:

```ruby
MyStore::SubscriptionRules.find(1).preferences.fetch(:dark_chocolate)
# => true
user.preferred_color_type # => :string
```

[app-configuration]: app-configuration.html
Expand Down