GithubHelp home page GithubHelp logo

stas / otp-jwt Goto Github PK

View Code? Open in Web Editor NEW
97.0 6.0 7.0 77 KB

One time password (email, SMS) authentication support for HTTP APIs.

Home Page: https://rubygems.org/gems/otp-jwt

License: MIT License

Ruby 99.55% HTML 0.45%
rails otp-jwt jwt-authentication jwt-tokens jwt-authorization rails-auth api-auth

otp-jwt's Introduction

OTP JWT ⎆

One time password (email, SMS) authentication support for HTTP APIs.

The man who wrote the book on password management has a confession to make: He blew it.

WSJ.com

This project provides a couple of mixins to help you build applications/HTTP APIs without asking your users to provide passwords.

Your browser probably can work seamlessly with OTPs!!! 😍

About

The goal of this project is to provide support for one time passwords which are delivered via different channels (email, SMS), along with a simple and easy to use JWT authentication.

Main goals:

  • No magic please
  • No DSLs please
  • Less code, less maintenance
  • Good docs and test coverage
  • Keep it up-to-date (or at least tell people this is no longer maintained)

The available features include:

  • Flexible models support for counter based OTP
  • Flexible JWT token generation helpers for models and arbitrary data
  • Pluggable authentication flow using the OTP and JWT
  • Pluggable OTP mailer
  • Pluggable OTP SMS background processing job

This little project wouldn't be possible without the previous work on ROTP and JWT.

Thanks to everyone who worked on these amazing projects!

Sponsors

I'm grateful for the following companies for supporting this project!

Installation

Add this line to your application's Gemfile:

gem 'otp-jwt'

And then execute:

$ bundle

Or install it yourself as:

$ gem install otp-jwt

Usage


To start using it with Rails, add this to an initializer and configure your keys:

# config/initializers/otp-jwt.rb
require 'otp'
# To load the JWT related support.
require 'otp/jwt'

# Set to 'none' to disable verification at all.
# OTP::JWT::Token.jwt_algorithm = 'HS256'

# How long the token will be valid.
# OTP::JWT::Token.jwt_lifetime = 60 * 60 * 24

OTP::JWT::Token.jwt_signature_key = ENV['YOUR-SIGN-KEY']

OTP for Active Record models

To add support for OTP to your models, use the OTP::ActiveRecord concern:

class User < ActiveRecord::Base
  include OTP::ActiveRecord

  ...
end

This will provide two new methods which you can use to generate and verify one time passwords:

  • User#otp
  • User#verify_otp

This concern expects two attributes to be provided by the model, the:

  • otp_secret: of type string, used to store the OTP signature key
  • otp_counter: of type integer, used to store the OTP counter
  • expire_jwt_at: of type datetime, optional and used to force a token to expire

A migration to add these two looks like this:

$ rails g migration add_otp_to_users otp_secret:string otp_counter:integer

Generate opt_secret by running the following in rails console if you have preexisting user data:

User.all.each do |u|
  u.save()
end
Force a token to expire

If there's an expire_jwt_at value that is in the past, the user token will be reset and it will require a new authentication to receive a working token.

This is handy if the user access needs to be scheduled and/or removed.

Mailer support

You can use the built-in mailer to deliver the OTP, just require it and overwrite the helper method:

require 'otp/mailer'

class User < ActiveRecord::Base
  include OTP::ActiveRecord

  def email_otp
    OTP::Mailer.otp(email, otp, self).deliver_later
  end
end

To customize the mailer subject, address and template, update the defaults:

require 'otp/mailer'

OTP::Mailer.default subject: 'Your App magic password 🗝️'
OTP::Mailer.default from: ENV['DEFAUL_MAILER_FROM']
# Tell mailer to use the template from app/views/otp/mailer/otp.html.erb
OTP::Mailer.prepend_view_path(Rails.root.join('app', 'views'))

SMS delivery support

You can use the built-in job to deliver the OTP via SMS, just require it and overwrite the helper method:

require 'otp/sms_otp_job'

class User < ActiveRecord::Base
  include OTP::ActiveRecord

  SMS_TEMPLATE = '%{otp} is your APP magic password 🗝️'

  def sms_otp
    OTP::SMSOTPJob.perform_later(
      phone_number,
      otp,
      SMS_TEMPLATE # <-- Optional text message template.
    ) if phone_number.present?
  end
end

You will have to provide your model with the phone number attribute if you want to deliver the OTPs via SMS.

This job requires aws-sdk-sns gem to work. You will have to add it manually and configure to use the correct keys. The SNS region is fetched from the environment variable AWS_SMS_REGION.

JWT for Active Record models

To add support for JWT to your models, use the OTP::JWT::ActiveRecord concern:

class User < ActiveRecord::Base
  include OTP::JWT::ActiveRecord

  ...
end

This will provide two new methods which you can use to generate and verify JWT tokens:

  • User#from_jwt
  • User#to_jwt

JWT authorization

To add support for JWT to your controllers, use the OTP::JWT::ActionController concern:

class ApplicationController < ActionController::Base
  include OTP::JWT::ActionController

  private

  def current_user
    @jwt_user ||= User.from_jwt(request_authorization_header)
  end

  def current_user!
    current_user || raise('User authentication failed')
  rescue
    head(:unauthorized)
  end
end

The example from above includes helpers you can use interact with the currently authenticated user or just use as part of before_action callback.

The request_authorization_header method is also provided by the concern and allows you to customize from where the token is received. A query parameter based alternative would look like this:

class ApplicationController < ActionController::Base
  include OTP::JWT::ActionController

  private

  def current_user
    @jwt_user ||= User.from_jwt(params[:token])
  end

  ...
end

JWT authentication

The OTP::JWT::ActionController concern provides support for handling the authentication requests and token generation by using the jwt_from_otp method.

Here's an example of a tokens controller:

class TokensController < ApplicationController
  def create
    user = User.find_by(email: params[:email])

    jwt_from_otp(user, params[:otp]) do |auth_user|
      # Let's update the last login date before we send the token...
      # auth_user.update_column(:last_login_at, DateTime.current)

      render json: { token: auth_user.to_jwt }, status: :created
    end
  end
end

The jwt_from_otp does a couple of things here:

  • It will try to authenticate the user you found by email and respond with a valid JWT token
  • It will try to schedule an email or SMS delivery of the OTP and it will respond with the 400 HTTP status
  • It will respond with the 403 HTTP status if there's no user or the OTP is wrong

The OTP delivery is handled by the User#deliver_otp method and can be customized. By default it will call the sms_otp method and if nothing is returned, it will proceed with the email_otp method.

JWT Tokens

To help sign any sort of data, a lightweight JWT Token wrapper is provided.

Signing a payload will follow the pre-defined settings like the lifetime and the encryption key. Decoding a token will validate any claims as well. Finally there's a safe wrapper to help you with the JWT specific exceptions handling.

require 'otp/jwt/token'

token = OTP::JWT::Token.sign(sub: 'my subject')
OTP::JWT::Token.decode(token) == {'sub' => 'my subject'}
OTP::JWT::Token.decode('bad token') == nil

Development

After checking out the repo, run bundle to install dependencies.

Then, run rake to run the tests.

To install this gem onto your local machine, run bundle exec rake install.

To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/stas/otp-jwt

This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

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

otp-jwt's People

Contributors

almeida-samuel avatar ayerdines avatar bardo0910 avatar stas avatar yininge avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar

otp-jwt's Issues

Encrypting OTP Secret in the database

Hey @stas
First off thanks so much for your work on this project. I want to use it on a small project that I am working on and just wanted to get your thoughts on if you think its worthwhile to encrypt the the OTP secret/counter in the database.

I know most of the time that sensitive information such as one time use tokens or passwords are stored in the database it is usually hashed (I think devise does this with its reset password links), but that wont be possible with this project as we would need to get the data back, so was thinking encryption was another option.

Was just thinking that if anyone was to get read access to the database (for example if a database backup was leaked) they would be able to get the secrets/counters for all the users and possibly log in as anyone.

Was thinking i could use a project like lockbox to handle the encryption for me which should work seamlessly with this project. Do you think this would be worthwhile or unnecessary and wont really improve anything.

otp_secret is not being saved for preexisting data

I have a User table that has preexisting data, and I start using otp-jwt recently.
It is not working at the beginning because otp_secret column is always empty for preexisting User record

The active record does give the User record a default otp_secret

def setup_otp
self.otp_secret ||= ROTP::Base32.random_base32
self.otp_counter ||= 0

My workaround is to go through each user record and save the default otp_secret

User.all.each do |u|
  u.save()
end

Not sure whether we can make it better by updating the documentation, or always save the default secret if there are otp_secret column is empty

to_jwt and from_jwt multiple user models

I don't see anything about this gem supporting two user models, however, the documentation does seem to indicate the use on multiple active record models. So this could be a bug if used on multiple models.

I have a User and Admin model
I generate a JWT for each instance and then can find that instance using from_jwt as expected.
However, what I didn't expect was to be able to use the same JWT for an Admin instance and find a User instance. Either the JWT is not unique, or the from_jwt method has a bug.

Here is a JWT for a particular User instance eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImV4cCI6MTU2MjcxNDcxNX0.gZvAxDmz4eBQQLmc_-Zxj0bWUkvaWnWKzKOLHQw-uRM

I can find an Admin using that same JWT as shown below

[47] pry(#<Api::V1::Admins::UsersController>)> Admin.from_jwt("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImV4cCI6MTU2MjcxNDcxNX0.gZvAxDmz4eBQQLmc_-Zxj0bWUkvaWnWKzKOLHQw-uRM")

  CACHE Admin Load (0.1ms)  SELECT  "admins".* FROM "admins" WHERE "admins"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> #<Admin id: 1, email: "[email protected]", created_at: "2019-07-04 22:27:33", updated_at: "2019-07-05 21:06:25", account_id: 1, role: "super", first_name: "Super", last_name: "Test", title: "yes", authentication_token: "zavi5k8_5HLNxqLSYN2N", otp_secret: "4bpzyy7ugnsb5x3v3u2d634cpbsnztjd", otp_counter: 0>



[48] pry(#<Api::V1::Admins::UsersController>)> User.from_jwt("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImV4cCI6MTU2MjcxNDcxNX0.gZvAxDmz4eBQQLmc_-Zxj0bWUkvaWnWKzKOLHQw-uRM")

  CACHE User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, email: "[email protected]", created_at: "2019-07-04 22:27:35", updated_at: "2019-07-05 21:06:34", account_id: 1, first_name: "Sherill", last_name: "test", employee_number: nil, tenure: nil, title: "GM", department: nil, gender: nil, region: nil, location: nil, brand: nil, ancestry: "2", manager_id: nil, hire_date: nil, relocate: nil, short_term_plan: nil, long_term_plan: nil, last_email_sent: nil, authentication_token: "WpYq7HypQzytMp1GxkiE", otp_secret: "7r6ooydbzlsodbxluyhswcuxpubfybfj", otp_counter: 0>
[49] pry(#<Api::V1::Admins::UsersController>)>

Here is a complete terminal output to highlight each instances and their respective JWT's.

[39] pry(#<Api::V1::Admins::UsersController>)> @user
=> #<User id: 1, email: "[email protected]", created_at: "2019-07-04 22:27:35", updated_at: "2019-07-05 21:06:34", account_id: 1, first_name: "Sherill", last_name: "test", employee_number: nil, tenure: nil, title: "GM", department: nil, gender: nil, region: nil, location: nil, brand: nil, ancestry: "2", manager_id: nil, hire_date: nil, relocate: nil, short_term_plan: nil, long_term_plan: nil, last_email_sent: nil, authentication_token: "WpYq7HypQzytMp1GxkiE", otp_secret: "7r6ooydbzlsodbxluyhswcuxpubfybfj", otp_counter: 0>
[40] pry(#<Api::V1::Admins::UsersController>)> @admin
=> #<Admin id: 1, email: "[email protected]", created_at: "2019-07-04 22:27:33", updated_at: "2019-07-05 21:06:25", account_id: 1, role: "super", first_name: "Super", last_name: "Test", title: "yes", authentication_token: "zavi5k8_5HLNxqLSYN2N", otp_secret: "4bpzyy7ugnsb5x3v3u2d634cpbsnztjd", otp_counter: 0>
[41] pry(#<Api::V1::Admins::UsersController>)> @admin.to_jwt
=> "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImV4cCI6MTU2MjcxNDcxNX0.gZvAxDmz4eBQQLmc_-Zxj0bWUkvaWnWKzKOLHQw-uRM"
[42] pry(#<Api::V1::Admins::UsersController>)> @user.to_jwt
=> "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImV4cCI6MTU2MjcxNDcxOH0._Uqisk_hpYYdIgS4MNfl8mwW-_lfCprzhOXkJ7OI8FM"
[43] pry(#<Api::V1::Admins::UsersController>)> Admin.from_jwt("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImV4cCI6MTU2MjcxNDcxNX0.gZvAxDmz4eBQQLmc_-Zxj0bWUkvaWnWKzKOLHQw-uRM")

  CACHE Admin Load (0.1ms)  SELECT  "admins".* FROM "admins" WHERE "admins"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> #<Admin id: 1, email: "[email protected]", created_at: "2019-07-04 22:27:33", updated_at: "2019-07-05 21:06:25", account_id: 1, role: "super", first_name: "Super", last_name: "Test", title: "yes", authentication_token: "zavi5k8_5HLNxqLSYN2N", otp_secret: "4bpzyy7ugnsb5x3v3u2d634cpbsnztjd", otp_counter: 0>
[44] pry(#<Api::V1::Admins::UsersController>)> User.from_jwt("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImV4cCI6MTU2MjcxNDcxOH0._Uqisk_hpYYdIgS4MNfl8mwW-_lfCprzhOXkJ7OI8FM")
  CACHE User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, email: "[email protected]", created_at: "2019-07-04 22:27:35", updated_at: "2019-07-05 21:06:34", account_id: 1, first_name: "Sherill", last_name: "test", employee_number: nil, tenure: nil, title: "GM", department: nil, gender: nil, region: nil, location: nil, brand: nil, ancestry: "2", manager_id: nil, hire_date: nil, relocate: nil, short_term_plan: nil, long_term_plan: nil, last_email_sent: nil, authentication_token: "WpYq7HypQzytMp1GxkiE", otp_secret: "7r6ooydbzlsodbxluyhswcuxpubfybfj", otp_counter: 0>
[45] pry(#<Api::V1::Admins::UsersController>)> Admin.from_jwt("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImV4cCI6MTU2MjcxNDcxOH0._Uqisk_hpYYdIgS4MNfl8mwW-_lfCprzhOXkJ7OI8FM")
  CACHE Admin Load (0.1ms)  SELECT  "admins".* FROM "admins" WHERE "admins"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> #<Admin id: 1, email: "[email protected]", created_at: "2019-07-04 22:27:33", updated_at: "2019-07-05 21:06:25", account_id: 1, role: "super", first_name: "Super", last_name: "Test", title: "yes", authentication_token: "zavi5k8_5HLNxqLSYN2N", otp_secret: "4bpzyy7ugnsb5x3v3u2d634cpbsnztjd", otp_counter: 0>
[46] pry(#<Api::V1::Admins::UsersController>)> User.from_jwt("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImV4cCI6MTU2MjcxNDcxNX0.gZvAxDmz4eBQQLmc_-Zxj0bWUkvaWnWKzKOLHQw-uRM")
  CACHE User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, email: "[email protected]", created_at: "2019-07-04 22:27:35", updated_at: "2019-07-05 21:06:34", account_id: 1, first_name: "Sherill", last_name: "test", employee_number: nil, tenure: nil, title: "GM", department: nil, gender: nil, region: nil, location: nil, brand: nil, ancestry: "2", manager_id: nil, hire_date: nil, relocate: nil, short_term_plan: nil, long_term_plan: nil, last_email_sent: nil, authentication_token: "WpYq7HypQzytMp1GxkiE", otp_secret: "7r6ooydbzlsodbxluyhswcuxpubfybfj", otp_counter: 0>
[47] pry(#<Api::V1::Admins::UsersController>)>

verify_otp returns otp_counter or nil

First of all, awesome gem! :-)

In the docs is stated that verify_otp will return true on success and false on failure. In my case if it's true it returns the otp_counter and if its false nil. I made a local patch that return !otp_status.nil? instead of otp_status.

Is retuning the counter or nil intended?

Fix docs

I think include OTP::ActiveRecord should be this include OTP::JWT::ActiveRecord.

Sponsors

Just a placeholder issue where I can upload logos lol

Provided `jwt_algorithm` isn't being considered on `OTP::JWT::Token.verify`

I set the jwt_algorithm to RS256 and provided a valid RSA private key to it, but for some reason, the from_jwt started to return a nil value. I managed to track down a possible issue. The jwt_algorithm isn't being considered in the current implementation of the OTP::JWT::Token.verify method and the from_jwt doesn't allow us to pass otps to the verify method.

The block bellow should solve the issue.

      def self.verify(token, opts = nil)
        verify = self.jwt_algorithm != 'none'
        default_opts = verify ? { algorithm: self.jwt_algorithm } : {}

        ::JWT.decode(token.to_s, self.jwt_signature_key, verify, opts || default_opts)
      end

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.