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 handlingWhile 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.