Securing a Ruby Web App With JSON Web Tokens
For securing APIs I usually turn to JSON Web Tokens. However, when setting up quick projects that are not APIs, it's tempting to just use basic auth.
In Rack, that's really simple
But there are some issues with Basic auth:
- Credentials are sent in plain text with every request
- More vulnerable to brute force attacks or DOS
- Encourages storing credentials in plain text
- Authentication doesn't expire
Eventually you get to a point where you need to migrate to a more robust solution.
Implementing JWT Authentication:
JSON Web tokens provide a good way to authenticate users once, store some basic (publicly available) information, set expiration, and send a token which is signed by a secret and provided on subsequent requests.
This is much more flexible than Basic Auth, for instance, a persistent token that remembers the user could be implemented, and permissions could be elevated for a short amount of time when a user wants to access more sensitive areas of the application.
Having signed information provided about the authenticated user also means that you can avoid loading a user on every request from the database.
JWTs are typically sent with every authenticated request in the Authorization
header similarly to Basic Auth. However the browser doesn't provide a baked in authentication form in the same way it does with Basic Auth.
Setting up the dependencies
The main dependency is the jwt
gem. In my case, I'm also using Sinatra, and the sinatra-activerecord
extensions.
# Gemfile
# In my case, I'm using active record with Sinatra
gem "sinatra-activerecord"
gem "sqlite3"
gem 'bcrypt', '~> 3.1.7' # Use ActiveModel has_secure_password
gem 'jwt' # JSON web token parsing
Then, of course: bundle install
Authentication with a JWT
I like to abstract the Authentication Token into its own class which takes care of the JWT settings, encoding, signing, decoding, and verification. The good news is that this code is very portable, and I find myself reusing it for many projects.
# authentication.rb
# Abstracts JWTs storing headers and body (payload) in hashes
# Takes care of signature verification based on JWT_OPTIONS
class Authentication
class Error < StandardError;end
class UnsupportedAlgError < Error;end
ISSUER = "your-domain".freeze
JWT_OPTIONS = {
algorithms: ['HS256'],
nbf_leeway: 10,
verify_iat: true,
verify_iss: true,
iss: [ISSUER]
}.freeze
# Sets the secret used for signing the token at the class level
def self.hmac_secret=(value)
@secret = value
end
# Retrieves the secret used for signing. defaults to the `SECRET_KEY_BASE` env variable.
def self.hmac_secret
@secret || ENV['SECRET_KEY_BASE']
end
# Accepts a url safe base64, signed token. The token is decoded and the key is found to verify the sig
def self.from_token(auth_token)
body, headers = JWT.decode(auth_token, nil, true, JWT_OPTIONS) do |headers|
headers.transform_keys(&:to_sym)
case headers['alg']
when 'HS256'
Authentication.hmac_secret
else
raise UnsupportedAlgError, 'This algorithm is not supported'
end
end
new(body: body.transform_keys(&:to_sym), headers: headers.transform_keys(&:to_sym))
end
attr_accessor :body, :headers
def initialize(body: {}, headers: {})
@body = default_body.merge(body)
@headers = default_headers.merge(headers)
end
# Encodes to base64, signed, '.' delimated token
def to_token
case @headers[:alg]
when 'HS256'
JWT.encode(@body, Authentication.hmac_secret, @headers[:alg], @headers)
else
raise UnsupportedAlgError, 'This algorithm is not supported'
end
end
alias_method :to_s, :to_token
def expires_at
Time.at(@body[:exp])
end
private
def default_headers
{
alg: 'HS256'
}
end
def default_body
{
exp: 1.days.from_now.to_i,
iss: ISSUER,
nbf: Time.now.to_i,
iat: Time.now.to_i
}
end
end
One important thing to note is that the JWT spec allows for flexibility in key finding, algorithm definition, and other things which could open your app to a variety of exploitation vectors. The main thing to look out for is alg: 'none'
which could be used to bypass verification altogether.
Writing tests is especially important for authentication. Let's write a test to cover this issue:
# authentication_spec.rb
RSpec.describe Authentication do
it "does not decode unsafe 'none' alg" do
forged_token = [{ alg: 'none' }, { sub: 'Hacker' }]
.map{ |h| Base64.urlsafe_encode64(JSON.dump(h), padding: false).chomp }
.join('.') << '.fakesig'
expect{ Authentication.from_token(forged_token) }.to raise_error Authentication::UnsupportedAlgError
end
end
Great! now we can create and verify JWTs.
Adding authentication to a Ruby app
Now it's just a matter of getting the token from the Authorization header, and verifying it.
class Application < Sinatra::Base
# The `Authorization` header comes through as HTTP_AUTHORIZATION
def auth_header
env['HTTP_AUTHORIZATION']
end
# Strip the `Bearer` from the token part, and return the token
def raw_auth_token
auth_header&.split(/\s+/, 2)&.last
end
# Initialize and verify the token. This wall raise an error if anything is amiss
def verified_auth_token
Authentication.from_token(raw_auth_token)
end
# This isn't strictly necessary, but reads nicer when defining routes
def protected!
verified_auth_token
end
end
Let's create a route which is authenticated:
# application.rb
get '/protected' do
protected!
"Properly authenticated!"
end
In the case where a user is not authenticated, it should return an Unauthorized response. Sinatra makes this easy with error handling routes.
# application.rb
error JWT::DecodeError do
status 401
response['WWW-Authenticate'] = %(Bearer realm="The secret clubhouse")
erb :login # Show the login screen
end
The login screen should look something like this:
<%# login.erb %>
<section id="main">
<form action="/login" method="POST">
<input type="hidden" name="redirect" value="<%= env['REQUEST_URI'] %>">
<label>
Username: <input name="user[name]">
</label>
<label>
Password: <input type="password" name="user[password]">
</label>
<button type="submit">Log in!</button>
</form>
</section>
Now, let's add a test so we don't have to do this manually
# application_spec.rb
it "secures the /protected route" do
get '/protected'
expect(last_response.status).to eq(401)
end
it "allows access with a proper token" do
token = Authentication.new(body: { sub: 'alex' }).to_token
header 'Authorization', "Bearer #{token}"
get '/protected'
expect(last_response).to be_ok
end
Adding a user
We also want our application to supply the user with a token which can be used for authentication. Adding a users table is simple. Create a migration, and define 2 columns:
name
password_digest
Note: using password digest is important - We're not storing the actual password. Luckily the bcrypt gem which was added earlier takes care of authentication, hashing, and storing the password with ActiveRecord.
# db/migrate/0000_create_users.rb
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
t.string :name, null: false
t.string :password_digest, null: false
end
end
end
# user.rb
class User < ActiveRecord::Base
has_secure_password # Provided by the bycrpt gem
end
Now we can migrate, and add a user for development. the racksh
gem gives access to a rails like console for rack applications. This could also be done in some sort of seed, or rake task.
$ rake db:migrate
...
$ racksh
> User.create name: 'Alex', password: '...'
Create a login action
To send a token back to the user, they must provide us with the correct credentials. Let's create an action that accepts a user with the two fields defined earlier, except, they will be sending the password.
I've also added a redirect parameter which is provided by the login form. It works like this
- User tries to visit
/protected
- Auth token is checked, and fails
login.erb
is rendered with the redirect url (/protected
)- Credentials are posted to
/login
- A token is created and returned to the user
- The user is redirected to
/protected
post '/login' do
@user = User.find_by(name: params.dig('user', 'name'))
if @user&.authenticate(params.dig('user', 'password'))
auth = Authentication.new(body: { sub: params.dig('user', 'name') })
response.set_cookie('Authorization', value: "Bearer #{auth.to_token}", expires: auth.expires_at)
end
redirect params['redirect']
end
There's still one issue with this. This isn't an api and the browser won't include the token with the Authorization header like it would with basic authentication. This is easily solved by storing the token in a cookie:
response.set_cookie('Authorization', value: "Bearer #{auth.to_token}", expires: auth.expires_at)
But our application expects an Authorization header and I'd prefer not to modify my existing code.
This is a perfect scenario for some simple Rack middleware, which will take a cookie named 'Authorization' and convert it into a header.
# cookie_to_auth.rb
class CookieToAuth
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
auth_value = request.cookies['Authorization']
env['HTTP_AUTHORIZATION'] ||= auth_value if auth_value
status, headers, body = @app.call(env)
end
end
This can be added to the config.ru
# config.ru
use CookieToAuth
# ...
run MainApp
Now you should have a fully working and flexible authentication system!