A better default authentication system for apis - early draft

51 views
Skip to first unread message

Hamza Mahmood

unread,
Mar 19, 2017, 5:34:14 PM3/19/17
to rails-api-core
I have actually seen a lot of examples on the web of broken "simple" token authentication systems. Device once had a TokenAuthenticatable that was removed because it was insecure - authentication via token can be tricky!

Rails has signed and encrypted cookies since 4.2 ("nobody can read or change what is inside") - and this system is battle tested. Can we use it for our tokens?

[WARNING - code bellow is indicative and may not be ready for production]

1/ One extreme: re-using all the cookie related code for token handling

While this is definitely not the way I want it to look like - I think it illustrates this idea very well:

## re-enable ActionDispatch::Cookies and ActionDispatch::Session::CookieStore (for rails-apis only)
    config.middleware.use ActionDispatch::Cookies
    config
.middleware.use ActionDispatch::Session::CookieStore

## add in an initializer
    ActionDispatch::Cookies::HTTP_HEADER = "Authorization".freeze # was "Set-Cookie"
   
Rack::HTTP_COOKIE = "HTTP_AUTHORIZATION".freeze # was "HTTP_COOKIE"

## disable `protect_from_forgery` in ApplicationController

## In javascript, save responses' header "Authorization" and send them with requests' header "Authorization"

Because we are using different headers in Rails (ie not "Cookie"/"Set-Cookie"), what we send/receive are not cookies but "real tokens".
By construction (because we have sessions behind the scene) - this system is compatible with Devise (and all the systems many people are used to).
(Warning: the option `expire_after` in config/initializers/session_store.rb does not seems to work - there are definitely other issues)


2/ Other extreme: Writing code (highly inspired, but without re-using existing code)

Behind the scene, Rails'cookies use EncryptedCookieJar that uses ActiveSupport::MessageEncryptor.

## in config/initializers/encryptor.rb
  class Rails::Application
   
# based on Rails::Application#message_verifier and ActionDispatch::Cookies::EncryptedCookieJar
   
# note sign_salt is optionnal and has an effect only on first call, for any given encryptor_name
   
def message_encryptor(encryptor_name, sign_salt: nil)
     
@message_encryptors ||= {} # TODO move in initialize, bellow @message_verifiers

     
@message_encryptors[encryptor_name] ||= begin
       
# see https://github.com/rails/rails/pull/28139#issuecomment-283465442 for the choice of the cipher
        cipher
=  ActiveSupport::MessageEncryptor::DEFAULT_CIPHER  # default: "aes-256-cbc", better: "aes-128-gcm"
        serializer
= JSON

        secret
= key_generator.generate_key(encryptor_name.to_s || "")[0, ActiveSupport::MessageEncryptor.key_len(cipher)]
        sign_secret
= key_generator.generate_key(sign_salt || 'signed ' + encryptor_name.to_s)
       
ActiveSupport::MessageEncryptor.new(secret, sign_secret, cipher: cipher, serializer: serializer)
     
end
   
end
 
end

## in config/initializers/token_store.rb
  require "action_dispatch/middleware/session/abstract_store"

 
class TokenStore < ActionDispatch::Session::AbstractStore
    HTTP_HEADER
= "Authorization".freeze
    HTTP_TOKEN
= "HTTP_AUTHORIZATION".freeze
    ENCRYPTED_TOKEN_SALT  
= "encrypted token"
    ENCRYPTOR
= Rails.application.message_encryptor(ENCRYPTED_TOKEN_SALT)

   
def commit_session(req, res)
      session
= req.get_header(Rack::RACK_SESSION)

     
unless session.blank?
        token
= ENCRYPTOR.encrypt_and_sign(session.to_hash)

        res
.set_header(HTTP_HEADER, token)
     
end
   
end

   
# TODO not conforme with ActionDispatch::Session::AbstractStore's interface
   
def find_session(env, sid)
      auth
= env.get_header(HTTP_TOKEN)

      session
= {}
      session
= ENCRYPTOR.decrypt_and_verify(auth) if auth

     
[nil, session]
   
end
 
end



## in config/application.rb, add:
  config.middleware.insert_after "ActiveRecord::Migration::CheckPending", "TokenStore"


## In javascript, save responses' header "Authorization" and send them with requests' header "Authorization"

This code is also compatible with Devise (and others). Depending on utilization, the backend may not send any token - but it should be noted that it will never send the same token twice, even if there is no change in the token's content (think "same user_id but different token per request").
(Warning: missing code to add a "hard" expiration on token - maybe add an extra field?)
(Interesting: you can decrypt an existing cookie with the previous code with: Rails.application.message_encryptor("encrypted token").decrypt_and_verify(encrypted_cookie) )

I am interested to know if something like this can find its way in Rails.
Feedback more than welcome!

Hamza.
Reply all
Reply to author
Forward
0 new messages