Project

slots-jwt

0.0
No release in over 3 years
Low commit activity in last 3 years
Token Authentication for Rails using JWT. Slots is designed to keep JWT stateless and minimize database calls. This is done by storing (none sensitive) data in the JWT and populating current_user with the JWT data. This allows for things like `current_user.teams` or other assocations to be called on the user. Unless explicitly told slots will only load the user from the database when creating (or updating an expired) token.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

Runtime

~> 3.1.7
~> 2.1.0
~> 5.0
 Project Readme

Slots

Token authentication solution for rails 5 API. Slots use JSON Web Tokens for authentication and database session for remembering signed in users.

Table of Contents

  • Getting started
  • Secrets
  • Usage
  • Authorization
  • Sessions
  • Using with GraphQL
  • Testing
  • Configurations
  • Routes
  • Login Hooks
  • Contributing
  • License

Getting started

Slots 0.0.4 works with Rails 5. Add this line to your application's Gemfile:

gem 'slots-jwt'

Then run bundle install.

Next create the slots config file and add the routes using:

$ rails generate slots:install

This will create config/initializers/slots.rb and add the following line to config/routes.rb

mount Slots::JWT::Engine => "/auth"

This will mount all slot routes to auth/*.

Next, the following command can be used to generate the authentication model.

$ rails generate slots:model User

Any rails accepted name can be used for the model but User is the expected default. If a different name is used for the authentication model than it must be defined in the config file for slots (this will automatically be done if the generate slots is used). config/initializers/slots.rb

Slots::JWT.configure do |config|
  ...
  config.authentication_model = 'AnotherModel'
  ...
end

If you are using a model that has already been created than just add the following with the desired plugins:

class MyModel < ApplicationRecord
  ...
  slots :database_authentication
  end
  ...
end

And make sure the table has the necessary columns (if using the default setup, that would be email and password_digest). If other methods are desired for authentication like LDAP do not pass :database_authentication to slots and add a method authenticate(password) in the model. Database Authentication stores a password in the database using Secure Password.

Tokens are expected to be in the header of the request in the following format:

'authorization' => 'Bearer token=TOKEN'

They are also returned in the header in the same way.

Secret

To sign JSON web tokens, a (secret) key is needed. By default Slots will look in the ENV['SLOT_SECRET']. This can be changed in the slots config file.

Usage

To require a user to be authenticated the following methods can be used in the controller.

require_login!

require_login! takes the usual options of a before_action (only, except) and also load_user. The current_user is populated with the information from JWT. This can be a problem because the info in the JWT could become out of date; it would not update until the token has expired. If you want to force the user to be reloaded from the database you can call require_user_load! or pass load_user: true to require_login!. Default is not to load the user to help keep the JWT stateless.

NOTE: Before changes can be made to current_user user must be reloaded. This can be done using the above method or by current_user.valid_in_database?.

WARNING: do not call require_login! twice in one controller. For example if one route, you want with load_user and one without don't do the following, because only the last one will be done.

require_login! only: [:action1]
require_login! load_user: true, only: [:action2]

This is a limitation on rails before_action. In the example above only action2 will require a login. Instead use the following:

require_login! only: [:action1, :action2]
require_user_load! only: [:action2]

These method will raise a Slots::InvalidToken Error. This error can be caught using the helper method catch_invalid_token. If nothing is passed the following will be returned with a unauthorized status:

  'errors' => {
    'authentication' => ['invalid or missing token']
  }

A custom message or status can be returned using the following:

catch_invalid_token(response: {my_message: 'Some custom message'}, status: :im_a_teapot)

It is sometimes easier to always require login and explicitly ignore it when needed. To do this add require_login! and catch_invalid_token to the ApplicationController. Then on routes that you do not want to require authentication use the following method.

ignore_login!

This takes all the same options as require_login!.

Reject new tokens

To not allow a user to sign in the following can be used in the authentication model:

class User < ApplicationRecord
  ...

  reject_new_token do
    !self.approved # Return true if they cannot get a new token
  end
end

This will not allow unapproved users to get a new token (login or update_session_token).

Login Hooks

To run certain methods on failed/successful logins there are hooks:

class User < ApplicationRecord
  ...

  failed_login do
    # This method will be called on failed logins even if they
    # do not have valid login identifier so might need to check
    # if its an actual user by:
    # next if new_record?
    some_failed_login_stuff
  end

  successful_login do
    # Do something with
    some_successful_login_stuff
  end
end

NOTE: failed_login will get called if reject_new_token is true

Authorization

Sometimes when dealing with authentication you also need authorization. While in most cases you should use another gem to handle this, if it is simple (like an admin or approved user) slots can handle it. Just add the following:

class SomeController < ApplicationController
  ...

  reject_token do
    !current_user.admin # Return true to not allow to see resource
  end

  def some_special_action_that_you_must_be_admin_for
  end

  ...
end

reject_token take the same params as rails before_action. This will raise a Slots::AccessDenied Error for users not approved for the routes in this controller. To catch this error you can use the helper method catch_access_denied. If nothing is passed the following will be returned with a forbidden status:

  'errors' => {
    'authorization' => ["can't access"]
  }

A custom message or status can be returned using the following:

catch_invalid_token(response: {my_message: 'Some custom message'}, status: :im_a_teapot)

NOTE: If you want the token to be rejected for all tokens (i.e. require all routes to have an approved user) add the above to the ApplicationController. You can then also add more specific requirements to a controller by also adding it in the controller like requiring an admin. To ignore a reject_token use skip_callback! which again takes the same params as before_action. WARNING: If you do not require the user to be loaded from the DB the admin field will be from the JWT.

Sessions

If sessions are allowed (session_lifetime is not nil) session: true can be passed along when signing in to receive a session token. A session tokens has the session id in the payload of the JWT. This is kept in the JWT so the front-end only has to track one token. There are two ways to get a new token after a session token has expired.

  1. The first is by sending the token to MOUNT_LOCATION/update_session_token. This method will always return a new token even if the token has not expired. This will return the same information as sign_in (user information and with the token in the header).
  2. The second is by adding update_expired_session_tokens! (which takes the usual options of a before_action only, except, etc). This method will allow any route to take a valid expired token and it will return a new token in the headers with usual route information in the body. A token will only be returned in the header if the token passed is expired. When using this method a problem can arise were two request are made at the same time with the same expired token. The first request processed would return a new token but the second request would fail because the expired token does not match the information of the session anymore (since it was just updated) and would therefore return unauthorized. To fix this there is a previous jwt lifetime (which defaults to 5 seconds and can be changed in the config). This will allow the previous token to be valid for 5 seconds (or whatever is set in config). If a previous token is sent that is within the previous lifetime it will be a valid token but it will not return a new token (since one was already returned in the earlier request).

GraphQL

Using graphql-ruby??? Slots has helper modules and classes! It uses the following two feature of graphql-ruby to help with authorization/authentication, extension and limiting visibility. An example can be seen in graph_test.

Authorizing fields

class Types
  AuthorizedField < GraphQL::Schema::Field
    include Slots::JWT::TypeHelper
  end
end
module Types
  class BaseObject < GraphQL::Schema::Object
    field_class AuthorizedField
  end
end
module Types
  class QueryType < Types::BaseObject
    ...

    field :authorized_field, [Types::AuthorizedFieldType], null: false, description: "An authorized field", required_permission: :admin
    def authorized_field
      ...
    end
  end
end

Authorizing Types

module Types
  class BaseObject < GraphQL::Schema::Object
    # field_class AuthorizedField # can be used together
    extend Slots::JWT::TypeHelper
    # required_permission(:default_type)
  end
end
module Types
  class AuthorizedType < Types::BaseObject
    required_permission(:admin)
    ...

    field :field, String, null: false, description: "A string on an authorized field"
    def field
      ...
    end
  end
end

Filter

Filter must be used with at least one of the above.

class PermissionFilter < Slots::JWT::PermissionFilter
  def allowed?
    # available methods schema_member, current_user, required_permission, valid_loaded_user
    return true if required_permission == :anyone
    # loaded user gets it from the DB to help ensure user info is current
    return valid_loaded_user if required_permission == :loaded_user

    return is_admin if required_permission == :admin
    # default to a valid user
    current_user.present?
  end

  def is_admin
    valid_loaded_user && current_user.admin
  end
end
class GraphqlController < ApplicationController
  def execute
    ...
    context = {
      current_user: current_user,# can be nil if current_user bad token or no token
    }
    filter = PermissionFilter.new(current_user)
    result = GraphTestSchema.execute(query, only: filter, variables: variables, context: context, operation_name: operation_name)
    ...
  end
  ...
end

Testing

By adding include Slots::JWT::Tests the following methods can be used within minitest, authorized_get, authorized_post, authorized_put, authorized_patch and authorized_delete. These methods are the same as the usual get, ... delete but the first param in the method must be the user. For example:

authorized_get users(:some_user), some_route_url, params: {one: 'something', ...}, headers: {'info' => 'someInfo', ...}

Configurations

Default configuration:

Slots::JWT.configure do |config|
  config.logins = :email
  config.login_regex_validations = true
  config.authentication_model = 'User'
  config.secret = ENV['SLOT_SECRET']
  config.token_lifetime = 1.hour
  config.session_lifetime = 2.weeks
  config.previous_jwt_lifetime = 5.seconds
  config.secret_yaml = false
end
  • logins: this is the column to use for logins. It must be a symbol or a hash with symbol regex pair where the symbol is the column and the regex is when to use it (hash order matters). An example might is
  config.logins = {email: /@/, username: //}

This would make it if a value for login is passed and it has an @ symbol than check the email column otherwise check the username column.

  • login_regex_validations: This will require the column for login to match the regex passed and no others before it. So for the example above it would not allow username to contain '@'.
  • authentication_model: The model used for authentication.
  • secret: This is the secret used to encode the JWS.
  • token_lifetime: This is the lifetime of the token, it should be kept short (less than one hour).
  • session_lifetime: This is the session lifetime, set to nil if you do not want to use sessions.
  • previous_jwt_lifetime: This is the lifetime of the previous_jwt, for example if two request are sent with an expired token the first one will update the session making the second one invalid (because the iat doesn't match the session). Therefore this is to gives time for all following request to use the new token.
  • secret_yaml: Set to true to load secret from config/slots_secrets.yml. [More](Secret Yaml)

Secret Yaml

config/slots_secrets.yml can be used to store multiple secrets with a date (this way secrets can be updated without invalidating current tokens). The format for the file is:

---
- CREATED_AT: EPOCH TIME IN SECONDS
  SECRET: new_secret
- CREATED_AT: EPOCH TIME IN SECONDS
  SECRET: old_secret
...

The order should be newer to older secrets. This file can be created/updated manually or using rake slots:new_secret. If using rake slots:new_secret secrets that are older than session_lifetime will be removed. When updating manually remember to restart the server rake restart.

Routes

All these routes will be mounted at the route used above in mount Slots::JWT::Engine =>.

Route Helper Route Token
slots.sign_in GET/POST /sign_in Does not require Token This is used to sign in. login and password are expected as params. If the credentials are valid the user is returned with the token in header in the following format: 'authorization' => 'Bearer token=TOKEN' (same as sending)
slots.sign_out DELETE /sign_out Requires Token This is used to sign out. This will delete the session if one exist for the token.
slots.update_session_token GET /update_session_token Requires Token (token can be expired). This is used to force a new token to be returned from an expired token using the session in the JWT. The token is returned in the same way as sign_in.

Why use session token inside a JWS?

Good question, first it's important to talk about some of the reasons (well maybe just one of the reasons) for using JWS:

  • They are stateless. The nice thing is you don't have to go query a database to see if the session exist. Also if you have two different services that don't share a database they can validate the request by having the same secret.

Some of the problems with JWS:

  • Since the tokens are stateless its hard to revoke a token before it expires. In the case of this gem revoking a token is important for signing out. Some solutions suggested are:
Solutions Problems
Set long expatriation and ignore signing out (Have front end handle it by saving the token if the user wants to stay signed in) If the token is compromised the token is still valid for X time. You can only revoke it by creating a new secret, which would require all users to get a new tokens.
Set long expiration and Store JWS in database Not stateless.
Set long expiration and Blacklist JWS to revoke (or when a user signs out) Better... but still not stateless.
Set short expiration and have a refresher/session token When user signs out tokens are still valid for X time (which should be short). If the user info is changed (like a user is deactivated) the token is still valid until it expires. If a token with a session is compromised it can be revoked by removing that session (or all sessions if needed).

The last solution I feel is the best because for most API calls (within the expiration time) the token remains stateless. The downsides can be negligible by setting the expiration time to something small (less than an hour). .

Why the name???

Last but not least the most important question of them all... why slots??? or better yet slots-jwt??? well I'll start with the first, a slot machine takes tokens... yep that's it, all other authentication names had been taken so this is it. So why slots-jwt? Well hopefully it helps clarify a little what it does but most of all rubygems wouldn't let me name it slots because it was to close to another name..?..? so I added -jwt.

Contributing

License

The gem is available as open source under the terms of the MIT License.