├── .github └── workflows │ ├── publish.yml │ ├── rubocop.yml │ └── tests.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── config └── routes.rb ├── lib ├── generators │ ├── templates │ │ ├── routes.rb.erb │ │ ├── tokenable.rb.erb │ │ └── verifier_migration.rb.erb │ └── tokenable │ │ ├── install_generator.rb │ │ └── verifier_generator.rb ├── tokenable-ruby.rb ├── tokenable.rb └── tokenable │ ├── authable.rb │ ├── config.rb │ ├── controllers │ └── tokens_controller.rb │ ├── engine.rb │ ├── railtie.rb │ ├── strategies │ ├── devise.rb │ ├── secure_password.rb │ └── sorcery.rb │ ├── verifier.rb │ └── version.rb ├── spec ├── dummy │ ├── .bundle │ │ └── config │ ├── Gemfile │ ├── README.md │ ├── Rakefile │ ├── app │ │ ├── controllers │ │ │ ├── application_controller.rb │ │ │ └── concerns │ │ │ │ └── .keep │ │ └── models │ │ │ ├── application_record.rb │ │ │ ├── concerns │ │ │ └── .keep │ │ │ ├── user.rb │ │ │ ├── user_with_password.rb │ │ │ └── user_with_verifier.rb │ ├── bin │ │ ├── rails │ │ ├── rake │ │ └── setup │ ├── config.ru │ ├── config │ │ ├── application.rb │ │ ├── boot.rb │ │ ├── credentials.yml.enc │ │ ├── database.yml │ │ ├── environment.rb │ │ ├── environments │ │ │ ├── development.rb │ │ │ ├── production.rb │ │ │ └── test.rb │ │ ├── initializers │ │ │ ├── application_controller_renderer.rb │ │ │ ├── backtrace_silencers.rb │ │ │ ├── cors.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── inflections.rb │ │ │ ├── mime_types.rb │ │ │ └── wrap_parameters.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── puma.rb │ │ └── routes.rb │ ├── db │ │ ├── migrate │ │ │ ├── 20210118001932_create_users.rb │ │ │ └── 20210118014258_create_user_with_verifiers.rb │ │ ├── schema.rb │ │ └── seeds.rb │ ├── lib │ │ └── tasks │ │ │ └── .keep │ ├── public │ │ └── robots.txt │ └── vendor │ │ └── .keep ├── generators │ ├── install_generator_spec.rb │ └── verifier_generator_spec.rb ├── spec_helper.rb ├── tokenable │ ├── authable_spec.rb │ ├── config_spec.rb │ ├── controllers │ │ └── tokens_controller_spec.rb │ ├── strategies │ │ ├── devise_spec.rb │ │ ├── secure_password_spec.rb │ │ └── sorcery_spec.rb │ └── verifier_spec.rb └── tokenable_spec.rb └── tokenable-ruby.gemspec /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Gem 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | name: Build + Publish 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Ruby 2.7 15 | uses: actions/setup-ruby@v1 16 | with: 17 | ruby-version: 2.7 18 | 19 | - name: Publish to GPR 20 | run: | 21 | mkdir -p $HOME/.gem 22 | touch $HOME/.gem/credentials 23 | chmod 0600 $HOME/.gem/credentials 24 | printf -- "---\n:github: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 25 | gem build *.gemspec 26 | gem push --KEY github --host https://rubygems.pkg.github.com/${OWNER} *.gem 27 | env: 28 | GEM_HOST_API_KEY: "Bearer ${{secrets.GITHUB_TOKEN}}" 29 | OWNER: ${{ github.repository_owner }} 30 | 31 | - name: Publish to RubyGems 32 | run: | 33 | mkdir -p $HOME/.gem 34 | touch $HOME/.gem/credentials 35 | chmod 0600 $HOME/.gem/credentials 36 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 37 | gem build *.gemspec 38 | gem push *.gem 39 | env: 40 | GEM_HOST_API_KEY: "${{secrets.RUBYGEMS_AUTH_TOKEN}}" 41 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: Rubocop 2 | 3 | on: push 4 | 5 | jobs: 6 | rubocop: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Ruby 11 | uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: 2.7 14 | bundler-cache: true 15 | 16 | - run: bundle exec rubocop 17 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: push 4 | 5 | jobs: 6 | rspec: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | ruby: [2.5, 2.6, 2.7, 3.0] 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Ruby 17 | uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ${{ matrix.ruby }} 20 | bundler-cache: true 21 | 22 | - run: cd spec/dummy && bundle install 23 | - run: cd spec/dummy && bundle exec rails db:create db:migrate 24 | env: 25 | RAILS_ENV: test 26 | 27 | - run: bundle exec rspec 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | Gemfile.lock 10 | /vendor 11 | 12 | spec/dummy/log 13 | spec/dummy/tmp 14 | spec/dummy/db/*.sqlite3 15 | spec/dummy/config/master.key 16 | 17 | *.gem 18 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rake 3 | - rubocop-rspec 4 | 5 | AllCops: 6 | NewCops: enable 7 | TargetRubyVersion: 2.5 8 | Exclude: 9 | - "spec/dummy/**/*" 10 | - "vendor/**/*" 11 | - "tmp/**/*" 12 | 13 | Naming/FileName: 14 | Exclude: 15 | - 'lib/tokenable-ruby.rb' 16 | 17 | Style/Documentation: 18 | Enabled: false 19 | 20 | Style/StringLiterals: 21 | Enabled: true 22 | EnforcedStyle: single_quotes 23 | 24 | Style/StringLiteralsInInterpolation: 25 | Enabled: true 26 | EnforcedStyle: double_quotes 27 | 28 | Style/TrailingCommaInArrayLiteral: 29 | EnforcedStyleForMultiline: consistent_comma 30 | 31 | Style/TrailingCommaInHashLiteral: 32 | EnforcedStyleForMultiline: consistent_comma 33 | 34 | Layout/LineLength: 35 | Max: 150 36 | Exclude: 37 | - "spec/**/*" 38 | 39 | Lint/EmptyBlock: 40 | Enabled: false 41 | 42 | Metrics/AbcSize: 43 | Enabled: false 44 | 45 | Metrics/MethodLength: 46 | Enabled: false 47 | 48 | Metrics/BlockLength: 49 | Exclude: 50 | - "spec/**/*" 51 | 52 | Style/ClassVars: 53 | Enabled: false 54 | 55 | RSpec/MultipleExpectations: 56 | Enabled: false 57 | 58 | RSpec/ImplicitBlockExpectation: 59 | Enabled: false 60 | 61 | RSpec/DescribedClass: 62 | Enabled: false 63 | 64 | RSpec/StubbedMock: 65 | Enabled: false 66 | 67 | RSpec/MessageSpies: 68 | Enabled: false 69 | 70 | RSpec/NamedSubject: 71 | Enabled: false 72 | 73 | RSpec/ExampleLength: 74 | Enabled: false 75 | 76 | RSpec/FilePath: 77 | Enabled: false 78 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at marc@marcqualie.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] 8 | 9 | gem 'rake' 10 | 11 | group :test do 12 | gem 'bcrypt' 13 | gem 'codecov', require: false, git: 'https://github.com/codecov/codecov-ruby.git' 14 | gem 'database_cleaner-active_record', '1.8.0' 15 | gem 'generator_spec', '0.9.4' 16 | gem 'rails' 17 | gem 'rails-controller-testing', '1.0.5' 18 | gem 'rspec-rails', '4.0.2' 19 | gem 'rubocop', '1.8.1' 20 | gem 'rubocop-rails', '2.9.1' 21 | gem 'rubocop-rake', '0.5.1' 22 | gem 'rubocop-rspec', '2.1.0' 23 | gem 'simplecov', '~> 0.2' 24 | gem 'sqlite3', '1.4.2' 25 | end 26 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Marc Qualie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tokenable 2 | 3 | [![Gem Version](https://badge.fury.io/rb/tokenable-ruby.svg)](https://badge.fury.io/rb/tokenable-ruby) 4 | ![Tests](https://github.com/tokenable/tokenable-ruby/workflows/Tests/badge.svg) 5 | [![codecov](https://codecov.io/gh/tokenable/tokenable-ruby/branch/main/graph/badge.svg?token=URF456H8RI)](https://codecov.io/gh/tokenable/tokenable-ruby) 6 | ![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat) 7 | ![Project Status: WIP – Development is in progress](https://www.repostatus.org/badges/latest/wip.svg) 8 | 9 | Tokenable is a Rails gem that allows API-only applications a way to authenticate users. This can be helpful when building Single Page Applications, or Mobile Applications. It's designed to work with the auth system you are already using, such as Devise, Sorcery and `has_secure_password`. You can also use it with any custom auth systems. 10 | 11 | Simply send a login request to the authentication endpoint, and Tokenable will return a token. This token can then be used to access your API, and any authenticated endpoints. 12 | 13 | ## Installation 14 | 15 | Add this line to your application's Gemfile: 16 | 17 | ```ruby 18 | gem 'tokenable-ruby' 19 | ``` 20 | 21 | And then execute: 22 | 23 | ``` 24 | bundle install 25 | ``` 26 | 27 | ## Usage 28 | 29 | Once you have the gem installed, lets get it setup: 30 | 31 | ```bash 32 | rails generate tokenable:install User --strategy=devise 33 | ``` 34 | 35 | We make it easier for you, by adding out of the box support for some auth libraries. You can pick from the following options for `--strategy`, or leave it empty for a [custom strategy](https://github.com/tokenable/tokenable-ruby/wiki/Create-your-own-statergy): 36 | 37 | - [devise](https://github.com/heartcombo/devise) 38 | - [sorcery](https://github.com/Sorcery/sorcery) 39 | - [secure_password](https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html) 40 | 41 | This will add a route, the configuration file at `config/initializers/tokenable.rb`, and add the required includes to your User model. There are no migrations to run in the default configuration. 42 | 43 | ### Controllers 44 | 45 | To limit access to your controllers/endpoints, you will need to include Tokenable. 46 | 47 | ```ruby 48 | class SomeController < ApplicationController 49 | include Tokenable::Authable 50 | 51 | before_action :require_tokenable_user! 52 | end 53 | ``` 54 | 55 | After you have done this, the following methods are available: 56 | 57 | - `current_user` 58 | - `user_signed_in?` 59 | 60 | ### Invalidate Tokens 61 | 62 | Sometime you want to be able to force a user (or users) to login again. You can do this by adding the Verifier. To install this, run: 63 | 64 | ``` 65 | rails generate tokenable:verifier User 66 | ``` 67 | 68 | And then run your migrations: 69 | 70 | ``` 71 | rails db:migrate 72 | ``` 73 | 74 | You can now invalidate all tokens by calling `user.invalidate_tokens!`. 75 | 76 | ### Token Expiry 77 | 78 | By default, tokens expire after 7 days. If you want to change this, you can set a config option. 79 | 80 | ```ruby 81 | # Expire in 7 days (default) 82 | Tokenable::Config.lifespan = 7.days 83 | 84 | # Tokens will never expire 85 | Tokenable::Config.lifespan = nil 86 | ``` 87 | 88 | ### Example Use Cases 89 | 90 | Once you have this setup, you will then be able to integrate your Rails API with a mobile app, single page application, or any other type of system. Here are some example use cases: 91 | 92 | - [Using Tokenable with Nuxt.js Auth](https://github.com/tokenable/tokenable-ruby/wiki/Integration-with-Nuxt.js-Auth) 93 | - [Using Tokenable with Axios](https://github.com/tokenable/tokenable-ruby/wiki/Integration-with-Axios) 94 | - [Using Tokenable with Curl](https://github.com/tokenable/tokenable-ruby/wiki/Curl-Example) 95 | 96 | ## Development 97 | 98 | After checking out the repo, run `bin/setup` to install dependencies. 99 | 100 | Then, run `bundle exec rspec` to run the tests. 101 | 102 | ## Contributing 103 | 104 | Bug reports and pull requests are welcome on GitHub at . This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/tokenable/tokenable-ruby/blob/main/CODE_OF_CONDUCT.md). 105 | 106 | ## License 107 | 108 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 109 | 110 | ## Code of Conduct 111 | 112 | Everyone interacting in the Tokenable project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/tokenable/tokenable-ruby/blob/main/CODE_OF_CONDUCT.md). 113 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rake/testtask' 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << 'test' 8 | t.libs << 'lib' 9 | t.test_files = FileList['test/**/*_test.rb'] 10 | end 11 | 12 | require 'rubocop/rake_task' 13 | 14 | RuboCop::RakeTask.new 15 | 16 | task default: %i[test rubocop] 17 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'tokenable' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Tokenable::Engine.routes.draw do 4 | post '/', to: 'tokens#create' 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/templates/routes.rb.erb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | end 3 | -------------------------------------------------------------------------------- /lib/generators/templates/tokenable.rb.erb: -------------------------------------------------------------------------------- 1 | # How long should the token be valid for? 2 | # After this time, it will return Tokenable::Unauthorized 3 | # You can set this to nil for tokens to never expire 4 | Tokenable::Config.lifespan = 7.days 5 | 6 | # The class in which your User resides. 7 | Tokenable::Config.user_class = '<%= name %>' 8 | 9 | # The secret used to create these tokens. This is then used to verify the 10 | # token is valid. Note: Tokens are not encrypted, and container the user_id. 11 | # You can change this to any 256-bit string 12 | Tokenable::Config.secret = Rails.application.secret_key_base 13 | -------------------------------------------------------------------------------- /lib/generators/templates/verifier_migration.rb.erb: -------------------------------------------------------------------------------- 1 | class AddTokenableVerifierTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %> 2 | def change 3 | add_column :<%= table_name %>, :tokenable_verifier, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/tokenable/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_record' 4 | 5 | module Tokenable 6 | module Generators 7 | class InstallGenerator < Rails::Generators::NamedBase 8 | source_root File.expand_path('../templates', __dir__) 9 | class_option :strategy, type: :string 10 | 11 | def install_config 12 | template 'tokenable.rb.erb', 'config/initializers/tokenable.rb' 13 | template 'routes.rb.erb', 'config/routes.rb' unless routes_file_exists? 14 | route "mount Tokenable::Engine => '/api/auth'" 15 | end 16 | 17 | def setup_strategy 18 | unless options.strategy 19 | say_status :skip, 'strategy (none provided)', :yellow 20 | return 21 | end 22 | 23 | if options.strategy.in?(list_of_strategies) 24 | invoke 'active_record:model', [name], migration: false unless model_exists? 25 | 26 | strategy_class = options.strategy.classify 27 | model_path = "app/models/#{file_name}.rb" 28 | already_injected = File.open(File.join(destination_root, model_path)).grep(/Tokenable::Strategies/).any? 29 | 30 | if already_injected 31 | say_status :skip, 'a strategy is already in this model', :yellow 32 | else 33 | inject_into_file model_path, " include Tokenable::Strategies::#{strategy_class}\n", after: " < ApplicationRecord\n" 34 | 35 | inject_into_file model_path, " has_secure_password\n", after: " < ApplicationRecord\n" if options.strategy == 'secure_password' 36 | end 37 | else 38 | say_status :failure, "stargery not found (#{options.strategy}). Available: #{list_of_strategies.join(", ")}", :red 39 | end 40 | end 41 | 42 | private 43 | 44 | def model_exists? 45 | File.exist?(File.join(destination_root, "app/models/#{file_name}.rb")) 46 | end 47 | 48 | def routes_file_exists? 49 | File.exist?(File.join(destination_root, 'config/routes.rb')) 50 | end 51 | 52 | def list_of_strategies 53 | Dir.entries(File.expand_path('../../tokenable/strategies', __dir__)) 54 | .reject { |f| File.directory?(f) } 55 | .map { |f| File.basename(f, File.extname(f)) } 56 | .compact 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/generators/tokenable/verifier_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/active_record' 4 | 5 | module Tokenable 6 | module Generators 7 | class VerifierGenerator < ActiveRecord::Generators::Base 8 | source_root File.expand_path('../templates', __dir__) 9 | 10 | def generate_model 11 | invoke 'active_record:model', [name], migration: false unless model_exists? && behavior == :invoke 12 | end 13 | 14 | def add_to_model 15 | model_path = "app/models/#{file_name}.rb" 16 | already_injected = File.open(File.join(destination_root, model_path)).grep(/Tokenable::Verifier/).any? 17 | 18 | if already_injected 19 | say_status :skip, 'verifier is already in this model', :yellow 20 | else 21 | inject_into_file "app/models/#{file_name}.rb", " include Tokenable::Verifier\n", after: " < ApplicationRecord\n" 22 | end 23 | end 24 | 25 | def add_migration 26 | migration_template 'verifier_migration.rb.erb', "db/migrate/add_tokenable_verifier_to_#{table_name}.rb" 27 | end 28 | 29 | private 30 | 31 | def model_exists? 32 | File.exist?(File.join(destination_root, "app/models/#{file_name}.rb")) 33 | end 34 | 35 | def migration_version 36 | "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" if Rails::VERSION::MAJOR >= 5 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/tokenable-ruby.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'tokenable' 4 | -------------------------------------------------------------------------------- /lib/tokenable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'tokenable/version' 4 | require_relative 'tokenable/authable' 5 | require_relative 'tokenable/verifier' 6 | require_relative 'tokenable/config' 7 | require_relative 'tokenable/railtie' if defined?(Rails) 8 | 9 | module Tokenable 10 | class Error < StandardError; end 11 | 12 | class Unauthorized < Error; end 13 | end 14 | -------------------------------------------------------------------------------- /lib/tokenable/authable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # The main controller concern that will be injected to the application 4 | 5 | require 'jwt' 6 | require 'active_support/concern' 7 | 8 | module Tokenable 9 | module Authable 10 | extend ActiveSupport::Concern 11 | 12 | def user_signed_in? 13 | current_user.present? 14 | end 15 | 16 | def current_user 17 | @current_user ||= user_class.find_by(id: jwt_user_id) 18 | rescue Tokenable::Unauthorized 19 | nil 20 | end 21 | 22 | def require_tokenable_user! 23 | raise Tokenable::Unauthorized, 'User not found in JWT token' unless jwt_user_id 24 | raise Tokenable::Unauthorized, 'User is not signed in' unless user_signed_in? 25 | raise Tokenable::Unauthorized, 'Token verifier is invalid' unless valid_token? 26 | end 27 | 28 | private 29 | 30 | def verifier_enabled? 31 | user_class.included_modules.include?(Tokenable::Verifier) 32 | end 33 | 34 | def valid_token? 35 | return true unless verifier_enabled? 36 | 37 | current_user.valid_verifier?(jwt_verifier) 38 | end 39 | 40 | def user_class 41 | Tokenable::Config.user_class 42 | end 43 | 44 | def token_from_header 45 | request.authorization.to_s.split.last 46 | end 47 | 48 | def token_from_user(user) 49 | jwt_data = { 50 | data: { 51 | user_id: user.id, 52 | }, 53 | } 54 | 55 | jwt_data[:exp] = jwt_expiry_time if jwt_expiry_time 56 | 57 | jwt_data[:data][:verifier] = user.current_verifier if verifier_enabled? 58 | 59 | raise Tokenable::Unauthorized, 'No secret key was provided' unless jwt_secret 60 | 61 | JWT.encode(jwt_data, jwt_secret, 'HS256') 62 | end 63 | 64 | def jwt_user_id 65 | jwt.dig('data', 'user_id') 66 | end 67 | 68 | def jwt_verifier 69 | jwt.dig('data', 'verifier') 70 | end 71 | 72 | def jwt 73 | raise Tokenable::Unauthorized, 'Bearer token not provided' unless token_from_header.present? 74 | 75 | @jwt ||= JWT.decode(token_from_header, jwt_secret, true, { algorithm: 'HS256' }).first.to_h 76 | rescue JWT::ExpiredSignature 77 | raise Tokenable::Unauthorized, 'Token has expired' 78 | rescue JWT::VerificationError 79 | raise Tokenable::Unauthorized, 'The tokenable secret used in this token does not match the one supplied in Tokenable::Config.secret' 80 | rescue JWT::DecodeError 81 | raise Tokenable::Unauthorized, 'JWT exception thrown' 82 | end 83 | 84 | def jwt_expiry_time 85 | Tokenable::Config.lifespan ? Tokenable::Config.lifespan.from_now.to_i : nil 86 | end 87 | 88 | def jwt_secret 89 | Tokenable::Config.secret 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/tokenable/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tokenable 4 | class Config 5 | # How long should the token last before it expires? 6 | # E.G: Tokenable::Config.lifespan = 14.days 7 | # You could set this to nil to disable expiring keys 8 | mattr_writer :lifespan, default: -> { 7.days } 9 | 10 | # The secret used by JWT to encode the Token. 11 | # We default to Rails secret_key_base 12 | # This can be any 256 bit string. 13 | mattr_writer :secret, default: -> { Rails.application.secret_key_base } 14 | 15 | # The user model that we will perform actions on 16 | mattr_writer :user_class, default: -> { 'User' } 17 | 18 | def self.user_class 19 | class_name = proc_reader(:user_class) 20 | class_name.is_a?(String) ? class_name.constantize : class_name 21 | end 22 | 23 | # We do this, as some of our defaults need to live in a Proc (as this library is loaded before Rails) 24 | # This means we can return the value when the method is called, instead of the Proc. 25 | def self.method_missing(method_name, *args, &block) 26 | class_variable_defined?("@@#{method_name}") ? proc_reader(method_name) : super 27 | end 28 | 29 | def self.respond_to_missing?(method_name, include_private = false) 30 | class_variable_defined?("@@#{method_name}") || super 31 | end 32 | 33 | def self.proc_reader(key) 34 | value = class_variable_get("@@#{key}") 35 | value.is_a?(Proc) ? value.call : value 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/tokenable/controllers/tokens_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tokenable 4 | class TokensController < ::ActionController::API 5 | include Authable 6 | 7 | rescue_from 'Tokenable::Unauthorized' do |exception| 8 | Rails.logger.error("Tokenable Auth Failure: #{exception.message}") 9 | 10 | render json: { error: 'Login failed, please try again.' }, status: 401 11 | end 12 | 13 | def create 14 | user = Tokenable::Config.user_class.from_tokenable_params(params) 15 | raise Tokenable::Unauthorized, 'No user returned by strategy' unless user 16 | 17 | response = { 18 | data: { 19 | token: token_from_user(user), 20 | user_id: user.id, 21 | }, 22 | } 23 | 24 | render json: response, status: 201 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/tokenable/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'controllers/tokens_controller' 4 | require_relative 'strategies/devise' 5 | require_relative 'strategies/sorcery' 6 | require_relative 'strategies/secure_password' 7 | 8 | module Tokenable 9 | class Engine < ::Rails::Engine 10 | isolate_namespace Tokenable 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/tokenable/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'authable' 4 | require_relative 'engine' 5 | 6 | module Tokebale 7 | class Railtie < ::Rails::Railtie 8 | railtie_name :tokenable 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/tokenable/strategies/devise.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tokenable 4 | module Strategies 5 | module Devise 6 | extend ActiveSupport::Concern 7 | 8 | class_methods do 9 | def from_tokenable_params(params) 10 | email, password = parse_auth_params(params) 11 | 12 | user = Tokenable::Config.user_class.find_by(email: email) 13 | return nil unless user 14 | 15 | return nil unless user.valid_password?(password) 16 | 17 | user 18 | end 19 | 20 | private 21 | 22 | def parse_auth_params(params) 23 | [ 24 | params[:email], 25 | params[:password], 26 | ] 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/tokenable/strategies/secure_password.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tokenable 4 | module Strategies 5 | module SecurePassword 6 | extend ActiveSupport::Concern 7 | 8 | class_methods do 9 | def from_tokenable_params(params) 10 | email, password = parse_auth_params(params) 11 | 12 | user = Tokenable::Config.user_class.find_by(email: email) 13 | return nil unless user 14 | 15 | return nil unless user.authenticate(password) 16 | 17 | user 18 | end 19 | 20 | private 21 | 22 | def parse_auth_params(params) 23 | [ 24 | params[:email], 25 | params[:password], 26 | ] 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/tokenable/strategies/sorcery.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tokenable 4 | module Strategies 5 | module Sorcery 6 | extend ActiveSupport::Concern 7 | 8 | class_methods do 9 | def from_tokenable_params(params) 10 | email, password = parse_auth_params(params) 11 | 12 | user = Tokenable::Config.user_class.find_by(email: email) 13 | return nil unless user 14 | 15 | return nil unless user.valid_password?(password) 16 | 17 | user 18 | end 19 | 20 | private 21 | 22 | def parse_auth_params(params) 23 | [ 24 | params[:email], 25 | params[:password], 26 | ] 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/tokenable/verifier.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tokenable 4 | module Verifier 5 | extend ActiveSupport::Concern 6 | 7 | def valid_verifier?(verifier) 8 | raise Tokenable::Unauthorized, "#{verifier_key} field is missing" unless has_attribute?(verifier_key) 9 | 10 | current_verifier == verifier 11 | end 12 | 13 | def current_verifier 14 | read_attribute(verifier_key) || issue_verifier! 15 | end 16 | 17 | def invalidate_tokens! 18 | issue_verifier! 19 | end 20 | 21 | def issue_verifier! 22 | update!(verifier_key => SecureRandom.uuid) 23 | read_attribute(verifier_key) 24 | end 25 | 26 | private 27 | 28 | def verifier_key 29 | :tokenable_verifier 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/tokenable/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Tokenable 4 | VERSION = '0.3.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: "../../vendor/bundle" 3 | -------------------------------------------------------------------------------- /spec/dummy/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'tokenable-ruby', path: '../../' 6 | 7 | gem 'rails' 8 | gem 'sqlite3' 9 | gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] 10 | -------------------------------------------------------------------------------- /spec/dummy/README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | This README would normally document whatever steps are necessary to get the 4 | application up and running. 5 | 6 | Things you may want to cover: 7 | 8 | * Ruby version 9 | 10 | * System dependencies 11 | 12 | * Configuration 13 | 14 | * Database creation 15 | 16 | * Database initialization 17 | 18 | * How to run the test suite 19 | 20 | * Services (job queues, cache servers, search engines, etc.) 21 | 22 | * Deployment instructions 23 | 24 | * ... 25 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::API 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tokenable/tokenable-ruby/4edb509c34c3d4f0ac79b1a3659e82759336c0a8/spec/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tokenable/tokenable-ruby/4edb509c34c3d4f0ac79b1a3659e82759336c0a8/spec/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /spec/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/models/user_with_password.rb: -------------------------------------------------------------------------------- 1 | class UserWithPassword < ApplicationRecord 2 | self.table_name = 'users' 3 | has_secure_password 4 | 5 | include Tokenable::Strategies::SecurePassword 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/app/models/user_with_verifier.rb: -------------------------------------------------------------------------------- 1 | class UserWithVerifier < ApplicationRecord 2 | include Tokenable::Verifier 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path('..', __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?('config/database.yml') 22 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! 'bin/rails db:prepare' 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! 'bin/rails log:clear tmp:clear' 30 | 31 | puts "\n== Restarting application server ==" 32 | system! 'bin/rails restart' 33 | end 34 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | # require "active_job/railtie" 7 | require "active_record/railtie" 8 | # require "active_storage/engine" 9 | require "action_controller/railtie" 10 | # require "action_mailer/railtie" 11 | # require "action_mailbox/engine" 12 | # require "action_text/engine" 13 | require "action_view/railtie" 14 | # require "action_cable/engine" 15 | # require "sprockets/railtie" 16 | # require "rails/test_unit/railtie" 17 | 18 | # Require the gems listed in Gemfile, including any gems 19 | # you've limited to :test, :development, or :production. 20 | Bundler.require(*Rails.groups) 21 | 22 | require 'tokenable' 23 | 24 | module Dummy 25 | class Application < Rails::Application 26 | # Initialize configuration defaults for originally generated Rails version. 27 | config.load_defaults 6.1 28 | 29 | # Configuration for the application, engines, and railties goes here. 30 | # 31 | # These settings can be overridden in specific environments using the files 32 | # in config/environments, which are processed later. 33 | # 34 | # config.time_zone = "Central Time (US & Canada)" 35 | # config.eager_load_paths << Rails.root.join("extras") 36 | 37 | # Only loads a smaller set of middleware suitable for API only apps. 38 | # Middleware like session, flash, cookies can be added back manually. 39 | # Skip views, helpers and assets when generating a new resource. 40 | config.api_only = true 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | # require "bootsnap/setup" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /spec/dummy/config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | LM/MpzInOFSnOzKealB5sk8cUX3EsGcxPFmCyUtkY93oHUn64/bVGM0idgGsbLnp5oGKNLlkzExAK0veYKnFJp7l2KYvu1Ylb+fJNTbjlRnN4gdYR9QLrP00G3ZugglpCpkUWlqQzvhKM9OV7dtsBRMf1nh6Hcr0VrYgCBd95ynUEe137hkCCBvbwISyqw7viwXnteT6S16T1TB7SfJA0yz16QsZiWjGLQuY3JKAYGn4rCb6kbj7TCbniF2SPOSR/rRXCjAZ61Ee53J/zglkibEG7QBRGpaJzacikdFIomSU+ofn6T8ZvK/GaHl0Cp4iVXDcxUsn01xetRdlQ9VKcIrPmAmHJ36sVm7BxrRIguRvNnp+TgwnpjudosXhvsYBG0IFgNux5uFa8a4JAB02mjhjDcg0q0V7M02p--Avex0AgNpZxVCG/1--mRFPm6hJnwkW63nwgqit+g== -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable/disable caching. By default caching is disabled. 18 | # Run rails dev:cache to toggle caching. 19 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 20 | config.cache_store = :memory_store 21 | config.public_file_server.headers = { 22 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 23 | } 24 | else 25 | config.action_controller.perform_caching = false 26 | 27 | config.cache_store = :null_store 28 | end 29 | 30 | # Print deprecation notices to the Rails logger. 31 | config.active_support.deprecation = :log 32 | 33 | # Raise exceptions for disallowed deprecations. 34 | config.active_support.disallowed_deprecation = :raise 35 | 36 | # Tell Active Support which deprecation messages to disallow. 37 | config.active_support.disallowed_deprecation_warnings = [] 38 | 39 | # Raise an error on page load if there are pending migrations. 40 | config.active_record.migration_error = :page_load 41 | 42 | # Highlight code that triggered database queries in logs. 43 | config.active_record.verbose_query_logs = true 44 | 45 | 46 | # Raises error for missing translations. 47 | # config.i18n.raise_on_missing_translations = true 48 | 49 | # Annotate rendered view with file names. 50 | # config.action_view.annotate_rendered_view_with_filenames = true 51 | 52 | # Use an evented file watcher to asynchronously detect changes in source code, 53 | # routes, locales, etc. This feature depends on the listen gem. 54 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 55 | 56 | # Uncomment if you wish to allow Action Cable access from any origin. 57 | # config.action_cable.disable_request_forgery_protection = true 58 | end 59 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | 18 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 19 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 20 | # config.require_master_key = true 21 | 22 | # Disable serving static files from the `/public` folder by default since 23 | # Apache or NGINX already handles this. 24 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 25 | 26 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 27 | # config.asset_host = 'http://assets.example.com' 28 | 29 | # Specifies the header that your server uses for sending files. 30 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 31 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 32 | 33 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 34 | # config.force_ssl = true 35 | 36 | # Include generic and useful information about system operation, but avoid logging too much 37 | # information to avoid inadvertent exposure of personally identifiable information (PII). 38 | config.log_level = :info 39 | 40 | # Prepend all log lines with the following tags. 41 | config.log_tags = [ :request_id ] 42 | 43 | # Use a different cache store in production. 44 | # config.cache_store = :mem_cache_store 45 | 46 | 47 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 48 | # the I18n.default_locale when a translation cannot be found). 49 | config.i18n.fallbacks = true 50 | 51 | # Send deprecation notices to registered listeners. 52 | config.active_support.deprecation = :notify 53 | 54 | # Log disallowed deprecations. 55 | config.active_support.disallowed_deprecation = :log 56 | 57 | # Tell Active Support which deprecation messages to disallow. 58 | config.active_support.disallowed_deprecation_warnings = [] 59 | 60 | # Use default logging formatter so that PID and timestamp are not suppressed. 61 | config.log_formatter = ::Logger::Formatter.new 62 | 63 | # Use a different logger for distributed setups. 64 | # require "syslog/logger" 65 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 66 | 67 | if ENV["RAILS_LOG_TO_STDOUT"].present? 68 | logger = ActiveSupport::Logger.new(STDOUT) 69 | logger.formatter = config.log_formatter 70 | config.logger = ActiveSupport::TaggedLogging.new(logger) 71 | end 72 | 73 | # Do not dump schema after migrations. 74 | config.active_record.dump_schema_after_migration = false 75 | 76 | # Inserts middleware to perform automatic connection switching. 77 | # The `database_selector` hash is used to pass options to the DatabaseSelector 78 | # middleware. The `delay` is used to determine how long to wait after a write 79 | # to send a subsequent read to the primary. 80 | # 81 | # The `database_resolver` class is used by the middleware to determine which 82 | # database is appropriate to use based on the time delay. 83 | # 84 | # The `database_resolver_context` class is used by the middleware to set 85 | # timestamps for the last write to the primary. The resolver uses the context 86 | # class timestamps to determine how long to wait before reading from the 87 | # replica. 88 | # 89 | # By default Rails will store a last write timestamp in the session. The 90 | # DatabaseSelector middleware is designed as such you can define your own 91 | # strategy for connection switching and pass that into the middleware through 92 | # these configuration options. 93 | # config.active_record.database_selector = { delay: 2.seconds } 94 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver 95 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session 96 | end 97 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | config.cache_classes = true 12 | 13 | # Do not eager load code on boot. This avoids loading your whole application 14 | # just for the purpose of running a single test. If you are using a tool that 15 | # preloads Rails for running tests, you may have to set it to true. 16 | config.eager_load = false 17 | 18 | # Configure public file server for tests with Cache-Control for performance. 19 | config.public_file_server.enabled = true 20 | config.public_file_server.headers = { 21 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 22 | } 23 | 24 | # Show full error reports and disable caching. 25 | config.consider_all_requests_local = true 26 | config.action_controller.perform_caching = false 27 | config.cache_store = :null_store 28 | 29 | # Raise exceptions instead of rendering exception templates. 30 | config.action_dispatch.show_exceptions = false 31 | 32 | # Disable request forgery protection in test environment. 33 | config.action_controller.allow_forgery_protection = false 34 | 35 | # Print deprecation notices to the stderr. 36 | config.active_support.deprecation = :stderr 37 | 38 | # Raise exceptions for disallowed deprecations. 39 | config.active_support.disallowed_deprecation = :raise 40 | 41 | # Tell Active Support which deprecation messages to disallow. 42 | config.active_support.disallowed_deprecation_warnings = [] 43 | 44 | # Raises error for missing translations. 45 | # config.i18n.raise_on_missing_translations = true 46 | 47 | # Annotate rendered view with file names. 48 | # config.action_view.annotate_rendered_view_with_filenames = true 49 | end 50 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code 7 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". 8 | Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/cors.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Avoid CORS issues when API is called from the frontend app. 4 | # Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. 5 | 6 | # Read more: https://github.com/cyu/rack-cors 7 | 8 | # Rails.application.config.middleware.insert_before 0, Rack::Cors do 9 | # allow do 10 | # origins 'example.com' 11 | # 12 | # resource '*', 13 | # headers: :any, 14 | # methods: [:get, :post, :put, :patch, :delete, :options, :head] 15 | # end 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [ 5 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 6 | ] 7 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /spec/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20210118001932_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :users do |t| 4 | t.string :email 5 | t.string :password_digest 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20210118014258_create_user_with_verifiers.rb: -------------------------------------------------------------------------------- 1 | class CreateUserWithVerifiers < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :user_with_verifiers do |t| 4 | t.string :email 5 | t.string :password_digest 6 | t.string :tokenable_verifier 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2021_01_18_014258) do 14 | 15 | create_table "user_with_verifiers", force: :cascade do |t| 16 | t.string "email" 17 | t.string "password_digest" 18 | t.string "tokenable_verifier" 19 | t.datetime "created_at", precision: 6, null: false 20 | t.datetime "updated_at", precision: 6, null: false 21 | end 22 | 23 | create_table "users", force: :cascade do |t| 24 | t.string "email" 25 | t.string "password_digest" 26 | t.datetime "created_at", precision: 6, null: false 27 | t.datetime "updated_at", precision: 6, null: false 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /spec/dummy/db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) 7 | # Character.create(name: 'Luke', movie: movies.first) 8 | -------------------------------------------------------------------------------- /spec/dummy/lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tokenable/tokenable-ruby/4edb509c34c3d4f0ac79b1a3659e82759336c0a8/spec/dummy/lib/tasks/.keep -------------------------------------------------------------------------------- /spec/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /spec/dummy/vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tokenable/tokenable-ruby/4edb509c34c3d4f0ac79b1a3659e82759336c0a8/spec/dummy/vendor/.keep -------------------------------------------------------------------------------- /spec/generators/install_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'generator_spec' 5 | require 'generators/tokenable/install_generator' 6 | 7 | describe Tokenable::Generators::InstallGenerator, type: :generator do 8 | destination File.expand_path('../../tmp', __dir__) 9 | before { prepare_destination } 10 | 11 | describe 'when creating with a strategy' do 12 | it 'creates the all the files etc' do 13 | run_generator %w[SomeUser --strategy=devise] 14 | 15 | assert_file 'config/initializers/tokenable.rb' do |content| 16 | assert_match(/Tokenable::Config.user_class = 'SomeUser'/, content) 17 | assert_match(/Tokenable::Config.lifespan = 7.days/, content) 18 | assert_match(/Tokenable::Config.secret = Rails.application.secret_key_base/, content) 19 | end 20 | 21 | assert_file 'config/routes.rb' do |content| 22 | assert_match(/mount Tokenable::Engine/, content) 23 | end 24 | 25 | assert_file 'app/models/some_user.rb' do |content| 26 | assert_match(/class SomeUser < ApplicationRecord/, content) 27 | assert_match(/include Tokenable::Strategies::Devise/, content) 28 | end 29 | end 30 | 31 | it 'creates the all the files and adds has_secure_password when using secure_password strategy' do 32 | run_generator %w[SomeUser --strategy=secure_password] 33 | 34 | assert_file 'app/models/some_user.rb' do |content| 35 | assert_match(/class SomeUser < ApplicationRecord/, content) 36 | assert_match(/has_secure_password/, content) 37 | assert_match(/include Tokenable::Strategies::SecurePassword/, content) 38 | end 39 | end 40 | end 41 | 42 | describe 'when creating without a strategy' do 43 | it 'does not create the model' do 44 | run_generator %w[SomeOtherUser] 45 | assert_no_file 'app/models/some_other_user.rb' 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/generators/verifier_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'generator_spec' 5 | require 'generators/tokenable/verifier_generator' 6 | 7 | describe Tokenable::Generators::VerifierGenerator, type: :generator do 8 | destination File.expand_path('../../tmp', __dir__) 9 | 10 | before do 11 | prepare_destination 12 | end 13 | 14 | it 'creates the all the files etc' do 15 | run_generator %w[SomeUser] 16 | 17 | assert_migration 'db/migrate/add_tokenable_verifier_to_some_users.rb' 18 | 19 | assert_file 'app/models/some_user.rb' do |content| 20 | assert_match(/class SomeUser < ApplicationRecord/, content) 21 | assert_match(/include Tokenable::Verifier/, content) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | SimpleCov.start do 5 | add_filter 'spec' 6 | 7 | add_group 'Generators', 'lib/generators' 8 | add_group 'Strategies', 'lib/tokenable/strategies' 9 | add_group 'Controllers', 'lib/tokenable/controllers' 10 | end 11 | 12 | if ENV['CI'] 13 | require 'codecov' 14 | SimpleCov.formatter = SimpleCov::Formatter::Codecov 15 | end 16 | 17 | ENV['RAILS_ENV'] ||= 'test' 18 | 19 | require File.expand_path('../spec/dummy/config/environment.rb', __dir__) 20 | ENV['RAILS_ROOT'] ||= "#{File.dirname(__FILE__)}../../../spec/dummy" 21 | 22 | require 'rspec/rails' 23 | 24 | begin 25 | ActiveRecord::Migration.maintain_test_schema! 26 | rescue ActiveRecord::PendingMigrationError => e 27 | puts e.to_s.strip 28 | exit 1 29 | end 30 | 31 | require 'database_cleaner' 32 | 33 | RSpec.configure do |config| 34 | config.before(:suite) do 35 | DatabaseCleaner.strategy = :transaction 36 | DatabaseCleaner.clean_with(:truncation, except: %w[ar_internal_metadata]) 37 | end 38 | 39 | config.around do |example| 40 | DatabaseCleaner.cleaning do 41 | example.run 42 | end 43 | end 44 | 45 | # Keep track of the original config class variables, as we need to reset them after each spec. 46 | config_defaults = Tokenable::Config.class_variables.map do |variable| 47 | [variable, Tokenable::Config.class_variable_get(variable)] 48 | end.to_h 49 | 50 | # Reset Tokenable::Config to it's original state after each spec 51 | config.after do |_example| 52 | config_defaults.each do |key, value| 53 | Tokenable::Config.class_variable_set(key, value) 54 | end 55 | end 56 | 57 | config.expect_with :rspec do |expectations| 58 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 59 | end 60 | 61 | config.mock_with :rspec do |mocks| 62 | mocks.verify_partial_doubles = true 63 | end 64 | 65 | config.shared_context_metadata_behavior = :apply_to_host_groups 66 | 67 | config.order = :random 68 | end 69 | -------------------------------------------------------------------------------- /spec/tokenable/authable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Used for testing when they have included the module, but not ran the migrations 4 | class NormalUserWithVerifier < User 5 | include Tokenable::Verifier 6 | end 7 | 8 | class ControllerWithModule 9 | include Tokenable::Authable 10 | end 11 | 12 | describe Tokenable::Authable, type: :controller do 13 | controller ApplicationController do 14 | include Tokenable::Authable 15 | before_action :require_tokenable_user! 16 | 17 | def index 18 | @user = current_user 19 | render plain: 'Hello World' 20 | end 21 | end 22 | 23 | subject { -> { get :index } } 24 | 25 | describe 'when generating a token' do 26 | let(:user) { User.create! } 27 | let(:token) { ControllerWithModule.new.send(:token_from_user, user) } 28 | let(:jwt) { JWT.decode(token, Tokenable::Config.secret).first } 29 | 30 | it 'contains the user_id' do 31 | expect(jwt['data']['user_id']).to eq(user.id) 32 | end 33 | 34 | describe 'when expiry is enabled' do 35 | before do 36 | Tokenable::Config.lifespan = 1.minute 37 | end 38 | 39 | it 'contains an expiry time' do 40 | expect(jwt).to have_key('exp') 41 | expect(jwt['exp']).to be_between(1.minute.ago.to_i, 1.minute.from_now.to_i) 42 | end 43 | end 44 | 45 | describe 'when expiry is disabled' do 46 | before do 47 | Tokenable::Config.lifespan = nil 48 | end 49 | 50 | it 'does not contain the expiry time' do 51 | expect(jwt).not_to have_key('exp') 52 | end 53 | end 54 | 55 | describe 'when verifier is disabled' do 56 | it 'does not contain the verifier' do 57 | expect(jwt).not_to have_key('verifier') 58 | end 59 | end 60 | 61 | describe 'when verifier is enabled' do 62 | let(:verifier) { SecureRandom.hex } 63 | let(:user) { UserWithVerifier.create!(tokenable_verifier: verifier) } 64 | 65 | before do 66 | Tokenable::Config.user_class = UserWithVerifier 67 | end 68 | 69 | it 'contains the verifier' do 70 | expect(jwt['data']).to have_key('verifier') 71 | expect(jwt['data']['verifier']).to eq(verifier) 72 | end 73 | end 74 | end 75 | 76 | describe 'when no token is provided' do 77 | it { is_expected.to raise_error(Tokenable::Unauthorized, 'Bearer token not provided') } 78 | end 79 | 80 | describe 'when an invalid JWT token is provided' do 81 | before { request.headers['Authorization'] = 'Bearer 123' } 82 | 83 | it { is_expected.to raise_error(Tokenable::Unauthorized, 'JWT exception thrown') } 84 | end 85 | 86 | describe 'when a valid JWT token is provided, but the secret does not match' do 87 | let(:jwt_data) do 88 | { 89 | data: { 90 | user_id: SecureRandom.hex, 91 | }, 92 | } 93 | end 94 | 95 | before do 96 | request.headers['Authorization'] = "Bearer #{JWT.encode(jwt_data, Tokenable::Config.secret, "HS256")}" 97 | Tokenable::Config.secret = SecureRandom.hex 98 | end 99 | 100 | it { is_expected.to raise_error(Tokenable::Unauthorized, 'The tokenable secret used in this token does not match the one supplied in Tokenable::Config.secret') } 101 | end 102 | 103 | describe 'when a valid JWT token is provided, but the user does not exist' do 104 | let(:jwt_data) do 105 | { 106 | data: { 107 | user_id: SecureRandom.hex, 108 | }, 109 | } 110 | end 111 | 112 | before { request.headers['Authorization'] = "Bearer #{JWT.encode(jwt_data, Tokenable::Config.secret, "HS256")}" } 113 | 114 | it { is_expected.to raise_error(Tokenable::Unauthorized, 'User is not signed in') } 115 | end 116 | 117 | describe 'when a valid JWT token is provided, but it has expired' do 118 | let(:jwt_data) do 119 | { 120 | exp: 1.minute.ago.to_i, 121 | data: { 122 | user_id: User.create!.id, 123 | }, 124 | } 125 | end 126 | 127 | before { request.headers['Authorization'] = "Bearer #{JWT.encode(jwt_data, Tokenable::Config.secret, "HS256")}" } 128 | 129 | it { is_expected.to raise_error(Tokenable::Unauthorized, 'Token has expired') } 130 | end 131 | 132 | describe 'when a valid JWT token is provided, and it has not expired, but the tokenable_verifier is missing' do 133 | let(:user) { User.create! } 134 | let(:jwt_data) do 135 | { 136 | exp: 1.minute.from_now.to_i, 137 | data: { 138 | user_id: user.id, 139 | }, 140 | } 141 | end 142 | 143 | before do 144 | Tokenable::Config.user_class = NormalUserWithVerifier 145 | request.headers['Authorization'] = "Bearer #{JWT.encode(jwt_data, Tokenable::Config.secret, "HS256")}" 146 | end 147 | 148 | it { is_expected.to raise_error(Tokenable::Unauthorized, 'tokenable_verifier field is missing') } 149 | end 150 | 151 | describe 'when a valid JWT token is provided, and it has not expired, but the tokenable_verifier is missing in the JWT' do 152 | let(:verifier) { SecureRandom.hex } 153 | let(:user) { UserWithVerifier.create!(tokenable_verifier: verifier) } 154 | 155 | let(:jwt_data) do 156 | { 157 | exp: 1.minute.from_now.to_i, 158 | data: { 159 | user_id: user.id, 160 | }, 161 | } 162 | end 163 | 164 | before do 165 | Tokenable::Config.user_class = UserWithVerifier 166 | request.headers['Authorization'] = "Bearer #{JWT.encode(jwt_data, Tokenable::Config.secret, "HS256")}" 167 | end 168 | 169 | it { is_expected.to raise_error(Tokenable::Unauthorized, 'Token verifier is invalid') } 170 | end 171 | 172 | describe 'when a valid JWT token is provided, and it has not expired, and the verifier is valid' do 173 | render_views 174 | 175 | let(:verifier) { SecureRandom.hex } 176 | let(:user) { UserWithVerifier.create!(tokenable_verifier: verifier) } 177 | 178 | let(:jwt_data) do 179 | { 180 | exp: 1.minute.from_now.to_i, 181 | data: { 182 | user_id: user.id, 183 | verifier: verifier, 184 | }, 185 | } 186 | end 187 | 188 | before do 189 | Tokenable::Config.user_class = UserWithVerifier 190 | request.headers['Authorization'] = "Bearer #{JWT.encode(jwt_data, Tokenable::Config.secret, "HS256")}" 191 | end 192 | 193 | it 'gets the correct current_user' do 194 | get :index 195 | 196 | expect(response).to have_http_status(:ok) 197 | expect(assigns[:user]).to eq(user) 198 | expect(assigns[:user]).to be_a(Tokenable::Config.user_class) 199 | end 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /spec/tokenable/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Tokenable::Config do 6 | it 'has the default values' do 7 | expect(described_class.user_class).to eq(User) 8 | expect(described_class.secret).not_to eq(nil) 9 | expect(described_class.secret).to eq(Rails.application.secret_key_base) 10 | expect(described_class.lifespan).to eq(7.days) 11 | end 12 | 13 | it 'ensures setters are working' do 14 | expect(described_class.secret).to eq(Rails.application.secret_key_base) 15 | 16 | random_string = SecureRandom.hex 17 | described_class.secret = random_string 18 | expect(described_class.secret).to eq(random_string) 19 | end 20 | 21 | it 'ensures method_missing is working as expected' do 22 | expect(described_class.respond_to?(:secret)).to eq(true) 23 | expect(described_class.secret).to eq(Rails.application.secret_key_base) 24 | 25 | expect { described_class.not_a_thing }.to raise_error(NoMethodError) 26 | expect(described_class.respond_to?(:not_a_thing)).to eq(false) 27 | end 28 | 29 | it 'ensures user_class returns a class, when a string is defined' do 30 | described_class.user_class = 'User' 31 | 32 | expect(described_class.class_variable_get('@@user_class')).to be_a(String) 33 | expect(described_class.user_class).to eq(User) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/tokenable/controllers/tokens_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Tokenable::TokensController, type: :controller do 6 | controller Tokenable::TokensController do 7 | end 8 | 9 | before { Tokenable::Config.user_class = UserWithPassword } 10 | 11 | describe 'when no email/password is sent' do 12 | it 'returns the correct header and error' do 13 | expect(Rails.logger).to receive(:error).with('Tokenable Auth Failure: No user returned by strategy') 14 | 15 | post :create 16 | expect(response.status).to eq(401) 17 | expect(response.parsed_body['error']).to eq('Login failed, please try again.') 18 | end 19 | end 20 | 21 | describe 'when the incorrect password is sent' do 22 | let(:password) { SecureRandom.hex } 23 | let(:user) { UserWithPassword.create!(email: 'user@example.com', password: password) } 24 | 25 | it 'returns the correct header and error' do 26 | expect(Rails.logger).to receive(:error).with('Tokenable Auth Failure: No user returned by strategy') 27 | 28 | post :create, params: { email: user.email, password: 'randompassword' } 29 | 30 | expect(response.status).to eq(401) 31 | expect(response.parsed_body['error']).to eq('Login failed, please try again.') 32 | end 33 | end 34 | 35 | describe 'when the correct email/password is sent' do 36 | let(:password) { SecureRandom.hex } 37 | let(:user) { UserWithPassword.create!(email: 'user@example.com', password: password) } 38 | 39 | it 'returns the token and user_id' do 40 | post :create, params: { email: user.email, password: password } 41 | 42 | expect(response.parsed_body['data']['user_id']).to eq(user.id) 43 | expect(response.parsed_body['data']['token']).not_to eq be_nil 44 | 45 | token = response.parsed_body['data']['token'] 46 | jwt = JWT.decode(token, Tokenable::Config.secret).first 47 | expect(jwt['data']['user_id']).to eq(user.id) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/tokenable/strategies/devise_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserWithDeviseStrategy < User 4 | include Tokenable::Strategies::Devise 5 | 6 | def valid_password?(password); end 7 | end 8 | 9 | describe Tokenable::Strategies::Devise do 10 | subject { UserWithDeviseStrategy.from_tokenable_params(params) } 11 | 12 | before { Tokenable::Config.user_class = UserWithDeviseStrategy } 13 | 14 | let(:password) { SecureRandom.hex } 15 | let(:user) { UserWithDeviseStrategy.new(email: 'test@example.com') } 16 | let(:params) do 17 | ActionController::Parameters.new(email: user.email, password: password) 18 | end 19 | 20 | it 'returns nil when the email is incorrect' do 21 | expect(UserWithDeviseStrategy).to receive(:find_by).with(email: user.email).and_return(nil) 22 | expect(user).not_to receive(:valid_password?) 23 | expect(subject).to be_nil 24 | end 25 | 26 | it 'returns nil when the password is incorrect' do 27 | expect(UserWithDeviseStrategy).to receive(:find_by).with(email: user.email).and_return(user) 28 | expect(user).to receive(:valid_password?).with(password).and_return(false) 29 | expect(subject).to be_nil 30 | end 31 | 32 | it 'returns a user when the password is correct' do 33 | expect(UserWithDeviseStrategy).to receive(:find_by).with(email: user.email).and_return(user) 34 | expect(user).to receive(:valid_password?).with(password).and_return(true) 35 | expect(subject).to be(user) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/tokenable/strategies/secure_password_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserWithSecurePasswordStrategy < User 4 | include Tokenable::Strategies::SecurePassword 5 | 6 | def authenticate(password); end 7 | end 8 | 9 | describe Tokenable::Strategies::SecurePassword do 10 | subject { UserWithSecurePasswordStrategy.from_tokenable_params(params) } 11 | 12 | before { Tokenable::Config.user_class = UserWithSecurePasswordStrategy } 13 | 14 | let(:password) { SecureRandom.hex } 15 | let(:user) { UserWithSecurePasswordStrategy.new(email: 'test@example.com') } 16 | let(:params) do 17 | ActionController::Parameters.new(email: user.email, password: password) 18 | end 19 | 20 | it 'returns nil when the email is incorrect' do 21 | expect(UserWithSecurePasswordStrategy).to receive(:find_by).with(email: user.email).and_return(nil) 22 | expect(user).not_to receive(:authenticate) 23 | expect(subject).to be_nil 24 | end 25 | 26 | it 'returns nil when the password is incorrect' do 27 | expect(UserWithSecurePasswordStrategy).to receive(:find_by).with(email: user.email).and_return(user) 28 | expect(user).to receive(:authenticate).with(password).and_return(false) 29 | expect(subject).to be_nil 30 | end 31 | 32 | it 'returns a user when the password is correct' do 33 | expect(UserWithSecurePasswordStrategy).to receive(:find_by).with(email: user.email).and_return(user) 34 | expect(user).to receive(:authenticate).with(password).and_return(true) 35 | expect(subject).to be(user) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/tokenable/strategies/sorcery_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserWithSorceryStrategy < User 4 | include Tokenable::Strategies::Sorcery 5 | 6 | def valid_password?(password); end 7 | end 8 | 9 | describe Tokenable::Strategies::Sorcery do 10 | subject { UserWithSorceryStrategy.from_tokenable_params(params) } 11 | 12 | before { Tokenable::Config.user_class = UserWithSorceryStrategy } 13 | 14 | let(:password) { SecureRandom.hex } 15 | let(:user) { UserWithSorceryStrategy.new(email: 'test@example.com') } 16 | let(:params) do 17 | ActionController::Parameters.new(email: user.email, password: password) 18 | end 19 | 20 | it 'returns nil when the email is incorrect' do 21 | expect(UserWithSorceryStrategy).to receive(:find_by).with(email: user.email).and_return(nil) 22 | expect(user).not_to receive(:valid_password?) 23 | expect(subject).to be_nil 24 | end 25 | 26 | it 'returns nil when the password is incorrect' do 27 | expect(UserWithSorceryStrategy).to receive(:find_by).with(email: user.email).and_return(user) 28 | expect(user).to receive(:valid_password?).with(password).and_return(false) 29 | expect(subject).to be_nil 30 | end 31 | 32 | it 'returns a user when the password is correct' do 33 | expect(UserWithSorceryStrategy).to receive(:find_by).with(email: user.email).and_return(user) 34 | expect(user).to receive(:valid_password?).with(password).and_return(true) 35 | expect(subject).to be(user) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/tokenable/verifier_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | class NormalUserWithVerifier < User 6 | include Tokenable::Verifier 7 | end 8 | 9 | describe Tokenable::Verifier do 10 | let(:user) { UserWithVerifier.new } 11 | 12 | describe '#current_verifier' do 13 | it 'returns the verifier if one is set' do 14 | verifier = SecureRandom.hex 15 | user.tokenable_verifier = verifier 16 | 17 | expect(user.current_verifier).to eq(verifier) 18 | end 19 | 20 | it 'creates a verifier if one is not defined' do 21 | expect(user.tokenable_verifier).to be_blank 22 | user.current_verifier 23 | expect(user.tokenable_verifier).not_to be_blank 24 | end 25 | end 26 | 27 | describe '#invalidate_tokens!' do 28 | it 'creates a new verifier' do 29 | verifier = SecureRandom.hex 30 | user.tokenable_verifier = verifier 31 | expect(user.current_verifier).to eq(verifier) 32 | 33 | user.invalidate_tokens! 34 | expect(user.current_verifier).not_to be_blank 35 | expect(user.current_verifier).not_to eq(verifier) 36 | end 37 | end 38 | 39 | describe '#valid_verifier?' do 40 | it 'throws an exception when using a user without Verifier field in DB' do 41 | expect { NormalUserWithVerifier.new.valid_verifier?('123') }.to raise_error(Tokenable::Unauthorized, 'tokenable_verifier field is missing') 42 | end 43 | 44 | it 'returns false when using an invalid verifier' do 45 | verifier = SecureRandom.hex 46 | user.tokenable_verifier = verifier 47 | expect(user).not_to be_valid_verifier('123') 48 | end 49 | 50 | it 'returns true when using a valid verifier' do 51 | verifier = SecureRandom.hex 52 | user.tokenable_verifier = verifier 53 | expect(user).to be_valid_verifier(verifier) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/tokenable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Tokenable do 6 | it 'has a version number' do 7 | expect(Tokenable::VERSION).not_to eq(nil) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /tokenable-ruby.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/tokenable/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'tokenable-ruby' 7 | spec.version = Tokenable::VERSION 8 | spec.authors = ['Marc Qualie', 'Scott Robertson'] 9 | spec.email = ['marc@marcqualie.com', 'scott@scottrobertson.me'] 10 | 11 | spec.summary = "JWT authentication for Rails API's" 12 | spec.homepage = 'https://github.com/tokenable/tokenable-ruby' 13 | spec.license = 'MIT' 14 | spec.required_ruby_version = Gem::Requirement.new('>= 2.5') 15 | 16 | spec.metadata['homepage_uri'] = spec.homepage 17 | spec.metadata['source_code_uri'] = 'https://github.com/tokenable/tokenable-ruby' 18 | spec.metadata['changelog_uri'] = 'https://github.com/tokenable/tokenable-ruby/releases' 19 | 20 | # Specify which files should be added to the gem when it is released. 21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 22 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 23 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) } 24 | end 25 | 26 | spec.bindir = 'exe' 27 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 28 | spec.require_paths = ['lib'] 29 | 30 | spec.add_dependency 'jwt', '~> 2.2', '< 3' 31 | spec.add_dependency 'rails', '~> 6.0', '< 6.2' 32 | end 33 | --------------------------------------------------------------------------------