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

Store hashed tokens #81

Closed
wants to merge 3 commits into from
Closed

Conversation

timwis
Copy link

@timwis timwis commented Apr 5, 2020

Stores hashed versions of the tokens in the database per #80. This PR is not quite done yet, but I wanted to share the draft to get feedback in the meantime. I'm not normally a ruby developer so I'm sure there's things we could improve in it.

I used OpenSSL::HMAC like devise does (and basically copied their token generator class) since BCrypt is not idempotent (there's no way to do session = Session.find_by(token_hash: BCrypt::Password.create(token)) as create generates a new salt every time).

I'll leave more comments inline in the code.

I know I still need to:

  • Get the assertion in navigation_test to pass
  • Figure out how to use the rails secret key for generating the key name for HMAC, or whether that's even necessary (devise seems to do it)

Looking forward to your feedback (feel free to edit directly). Hope this is a good start.

break token unless Session.find_by(token: token)
}

if not self.token or not self.token_digest
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't get conditional assignment to work with destructuring 🤷‍♂️

@@ -13,7 +13,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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to modify the existing migration, or should we add a second migration?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should modify the original but provide upgrade instructions for existing installations.

@@ -13,11 +13,13 @@ class Session < ApplicationRecord
:expires_at,
:user_agent,
:remote_addr,
:token,
:token_digest,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought calling the database column token_digest made it more clear that it's not the actual token. The tradeoff is this is a breaking change and assumedly would require a major version bump and running a migration. We can keep the column name the same to make upgrading easier, but you could still call it a breaking change if there are any valid non-hashed tokens in the database when you upgrade.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree we should hint in the column name that it's not the actual token 👍

As we're still pre 1.0, we can do breaking changes like this in a minor release. But I still like to provide all the necessary upgrade info. I think we should:

  1. Rename the field so all new installations have it right
  2. Provide upgrade instructions in the changelog including an example migration

mattr_accessor(:token_generator) { UrlSafeBase64Generator.new }
mattr_accessor(:token_generator) {
Passwordless::TokenGenerator.new(
ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new('secret')),
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied this from devise. I'm not sure whether it's necessary for OpenSSL::HMAC to get a secret "key". If so, should we use rails' SECRET_KEY_BASE or whatever?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd like to keep CachingKeyGenerator out of this block – maybe move it to a Passwordless.digest(token) method? Then use it in a setter on Session like:

def token=(value)
  self.token_digest = Passwordless.digest(value)
end

I think using Rails' secret key is a good idea 👍

module Passwordless
class TokenGenerator
def initialize(key_generator, friendly_token_generator, digest)
@key = key_generator.generate_key('passwordless')
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devise uses the column name but I've just called it 'passwordless'

@mikker
Copy link
Owner

mikker commented Apr 6, 2020

Wonderful work so far ❤️

@timwis
Copy link
Author

timwis commented Apr 6, 2020

Thanks! I’d love your thoughts on the field rename and migration bit when you have a sec (see my inline comments). And the secret key thing if any ideas come to mind, otherwise I’ll do some more research on it.

Copy link
Owner

@mikker mikker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know what you think 😊

@@ -13,11 +13,13 @@ class Session < ApplicationRecord
:expires_at,
:user_agent,
:remote_addr,
:token,
:token_digest,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree we should hint in the column name that it's not the actual token 👍

As we're still pre 1.0, we can do breaking changes like this in a minor release. But I still like to provide all the necessary upgrade info. I think we should:

  1. Rename the field so all new installations have it right
  2. Provide upgrade instructions in the changelog including an example migration

mattr_accessor(:token_generator) { UrlSafeBase64Generator.new }
mattr_accessor(:token_generator) {
Passwordless::TokenGenerator.new(
ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new('secret')),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd like to keep CachingKeyGenerator out of this block – maybe move it to a Passwordless.digest(token) method? Then use it in a setter on Session like:

def token=(value)
  self.token_digest = Passwordless.digest(value)
end

I think using Rails' secret key is a good idea 👍

require 'openssl'

module Passwordless
class TokenGenerator
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should split out the hashing from this. The token generators are supposed to be easy to provide yourself and if you do, I think we should still provide the hashing.

Let's let the token generator still just return a simple, unhashed string.

@timwis
Copy link
Author

timwis commented Apr 21, 2020

Thanks for the feedback! All great notes. I'll work on that over the next week or so as I get time.

Didn't realise there could be breaking changes in a minor version release for this library though! I'll have to update my project's Gemfile to prevent breaking changes coming in 😬

@@ -9,6 +10,8 @@
module Passwordless
mattr_accessor(:default_from_address) { "[email protected]" }
mattr_accessor(:token_generator) { UrlSafeBase64Generator.new }
mattr_accessor(:digest_algorithm) { "SHA256" }
mattr_accessor(:digest_secret) { lambda { Rails.application.secret_key_base } }
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the docs this should get it in all 3 environments. Note that deivse uses a helper class for this to support multiple versions of rails out of the box. We could copy that, or just point them here if they're using an older rails version.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@timwis timwis changed the title Store hashed tokens (WIP) Store hashed tokens May 7, 2020
@timwis timwis requested a review from mikker May 7, 2020 14:00
@timwis timwis marked this pull request as ready for review May 7, 2020 14:01
@timwis
Copy link
Author

timwis commented May 7, 2020

Okay, I think I've incorporated all of your feedback. I dropped the separate TokenGenerator module in order to keep token generation and hashing functionality separate. I moved hashing to a static Passwordless.digest method as you suggested, though the only way I could see to do that was on the Passwordless module - let me know if there's a better way. I also added a couple tests for the configurable bits.

Beyond this, I think it's just a matter of updating the readme to reflect (1) how to upgrade/migrate, and (2) how to customise hashing.

Looking forward to your feedback.

Copy link
Owner

@mikker mikker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry this took a bit. Left a few comments again 😊

@@ -13,7 +13,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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should modify the original but provide upgrade instructions for existing installations.

Comment on lines +61 to +67
if !token || !token_digest
self.token, self.token_digest = loop {
token = Passwordless.token_generator.call(self)
token_digest = Passwordless.digest(token)
break [token, token_digest] unless Session.find_by(token_digest: token_digest)
}
end
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks a bit suspicious to me. I don't think anything's wrong but it's hard to make sense of. Do we need/want to check for both !token || !token_digest. What if we have one but not the other? Will that ever happen? Could we prevent it? Just dumping my brain, sorry 😊

@@ -9,6 +10,8 @@
module Passwordless
mattr_accessor(:default_from_address) { "[email protected]" }
mattr_accessor(:token_generator) { UrlSafeBase64Generator.new }
mattr_accessor(:digest_algorithm) { "SHA256" }
mattr_accessor(:digest_secret) { lambda { Rails.application.secret_key_base } }
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +32 to +38
def self.digest(token)
key_generator = ActiveSupport::CachingKeyGenerator.new(
ActiveSupport::KeyGenerator.new(digest_secret.call)
)
key = key_generator.generate_key("passwordless")
OpenSSL::HMAC.hexdigest(digest_algorithm, key, token)
end
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it would be nice to move this to its own class. Maybe only create the generator once and save it as an ivar.

@xdmx
Copy link

xdmx commented Aug 16, 2020

Any update on this? I'd love to start having hashed values in the db, instead of the real ones.

@timwis were you able to see @mikker's feedback?

@timwis
Copy link
Author

timwis commented Aug 18, 2020

Hi @xdmx, I haven't been able to integrate @mikker's feedback — unfortunately I've just started a new job that's had me quite full-on :/ I'd love to, but I'd certainly welcome the help if you or @mikker can pick it up! Only a few things left to do, I think.

@mikker
Copy link
Owner

mikker commented Aug 18, 2020

I'm quite busy as well. Any help from anyone on bringing this home is very appreciated 😊

@mikker mikker mentioned this pull request Oct 7, 2020
7 tasks
@rickychilcott
Copy link
Collaborator

It's been a while @timwis or @xdmx - interested in seeing this across the finish line?

@mikker
Copy link
Owner

mikker commented Dec 25, 2021

AFAIR the work is already done in the big 1.0 PR. It was short sighted of me to have everything be in one PR. If anyone's willing to split it up into separate PRs it would be greatly appreciated.

@mikker mikker mentioned this pull request Jun 16, 2023
@mikker mikker closed this in #145 Jun 16, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants