├── .rspec ├── lib ├── tiddle │ ├── version.rb │ ├── rails.rb │ ├── model.rb │ ├── model_name.rb │ ├── token_issuer.rb │ └── strategy.rb └── tiddle.rb ├── spec ├── rails_app_mongoid │ ├── app │ │ ├── models │ │ │ ├── admin_user.rb │ │ │ ├── namespace │ │ │ │ └── namespaced_user.rb │ │ │ ├── authentication_token.rb │ │ │ └── user.rb │ │ └── controllers │ │ │ ├── secrets_controller.rb │ │ │ ├── long_secrets_controller.rb │ │ │ ├── namespaced_users_controller.rb │ │ │ └── application_controller.rb │ └── config │ │ ├── boot.rb │ │ ├── mongoid.yml │ │ ├── secrets.yml │ │ ├── environment.rb │ │ ├── routes.rb │ │ └── application.rb ├── rails_app_active_record │ ├── app │ │ ├── models │ │ │ ├── authentication_token.rb │ │ │ ├── user.rb │ │ │ ├── admin_user.rb │ │ │ └── namespace │ │ │ │ └── namespaced_user.rb │ │ └── controllers │ │ │ ├── secrets_controller.rb │ │ │ ├── long_secrets_controller.rb │ │ │ ├── namespaced_users_controller.rb │ │ │ └── application_controller.rb │ ├── config │ │ ├── boot.rb │ │ ├── secrets.yml │ │ ├── environment.rb │ │ ├── routes.rb │ │ └── application.rb │ └── db │ │ └── migrate │ │ └── 20150217000000_create_tables.rb ├── support │ ├── fake_request.rb │ └── backend.rb ├── tiddle_spec.rb ├── spec_helper.rb └── strategy_spec.rb ├── config └── locales │ └── en.yml ├── gemfiles ├── rails6.1.gemfile ├── rails7.0.gemfile └── rails7.1.gemfile ├── Rakefile ├── .gitignore ├── Dockerfile ├── Makefile ├── docker-compose.yml ├── .rubocop.yml ├── CONTRIBUTING.md ├── LICENSE.txt ├── .github └── workflows │ └── ruby.yml ├── tiddle.gemspec ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /lib/tiddle/version.rb: -------------------------------------------------------------------------------- 1 | module Tiddle 2 | VERSION = "1.8.1".freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/app/models/admin_user.rb: -------------------------------------------------------------------------------- 1 | class AdminUser < User 2 | end 3 | -------------------------------------------------------------------------------- /lib/tiddle/rails.rb: -------------------------------------------------------------------------------- 1 | module Tiddle 2 | class Engine < ::Rails::Engine 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | devise: 3 | failure: 4 | invalid_token: "Invalid email or token" 5 | -------------------------------------------------------------------------------- /lib/tiddle/model.rb: -------------------------------------------------------------------------------- 1 | module Devise 2 | module Models 3 | module TokenAuthenticatable 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/app/models/authentication_token.rb: -------------------------------------------------------------------------------- 1 | class AuthenticationToken < ActiveRecord::Base 2 | end 3 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/app/models/namespace/namespaced_user.rb: -------------------------------------------------------------------------------- 1 | module Namespace 2 | class NamespacedUser < User 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] = File.expand_path('../../../Gemfile', __dir__) 2 | require 'bundler/setup' 3 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] = File.expand_path('../../../Gemfile', __dir__) 2 | require 'bundler/setup' 3 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/config/mongoid.yml: -------------------------------------------------------------------------------- 1 | test: 2 | clients: 3 | default: 4 | database: tiddle_test 5 | hosts: 6 | - 0.0.0.0:27017 -------------------------------------------------------------------------------- /gemfiles/rails6.1.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", "~> 6.1.0" 4 | gem "mongoid" 5 | gem "sqlite3", "~> 1.4" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails7.0.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", "~> 7.0.2" 4 | gem "mongoid" 5 | gem "sqlite3", "~> 1.4" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails7.1.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", "~> 7.1.3" 4 | gem "mongoid" 5 | gem "sqlite3", "~> 1.4" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/config/secrets.yml: -------------------------------------------------------------------------------- 1 | test: 2 | secret_key_base: 01c37cff57639eef8aa511ae6ab64298c1da89dc32dfdda363473716f49e25d2473e48b6253c69d17c8ae8c9b6a027ec5a4ac0ffbd6c06defe1b70dd2ef32df8 3 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/app/controllers/secrets_controller.rb: -------------------------------------------------------------------------------- 1 | class SecretsController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def index 5 | head :ok 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/config/secrets.yml: -------------------------------------------------------------------------------- 1 | test: 2 | secret_key_base: 01c37cff57639eef8aa511ae6ab64298c1da89dc32dfdda363473716f49e25d2473e48b6253c69d17c8ae8c9b6a027ec5a4ac0ffbd6c06defe1b70dd2ef32df8 3 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('application', __dir__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/app/controllers/secrets_controller.rb: -------------------------------------------------------------------------------- 1 | class SecretsController < ApplicationController 2 | before_action :authenticate_user! 3 | 4 | def index 5 | head :ok 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('application', __dir__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'rspec/core/rake_task' 3 | require 'rubocop/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(spec: :rubocop) 6 | RuboCop::RakeTask.new(:rubocop) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/app/controllers/long_secrets_controller.rb: -------------------------------------------------------------------------------- 1 | class LongSecretsController < ApplicationController 2 | before_action :authenticate_admin_user! 3 | 4 | def index 5 | head :ok 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/app/controllers/long_secrets_controller.rb: -------------------------------------------------------------------------------- 1 | class LongSecretsController < ApplicationController 2 | before_action :authenticate_admin_user! 3 | 4 | def index 5 | head :ok 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/app/controllers/namespaced_users_controller.rb: -------------------------------------------------------------------------------- 1 | class NamespacedUsersController < ApplicationController 2 | before_action :authenticate_namespaced_user! 3 | 4 | def index 5 | head :ok 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/app/controllers/namespaced_users_controller.rb: -------------------------------------------------------------------------------- 1 | class NamespacedUsersController < ApplicationController 2 | before_action :authenticate_namespaced_user! 3 | 4 | def index 5 | head :ok 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | .ruby-gemset 16 | .ruby-version 17 | *.sqlite3 18 | *.log 19 | *.gemfile.lock 20 | .idea 21 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | end 6 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | devise :database_authenticatable, :registerable, 3 | :recoverable, :trackable, :validatable, 4 | :token_authenticatable 5 | 6 | has_many :authentication_tokens, as: :authenticatable 7 | end 8 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/app/models/admin_user.rb: -------------------------------------------------------------------------------- 1 | class AdminUser < ActiveRecord::Base 2 | devise :database_authenticatable, :registerable, 3 | :recoverable, :trackable, :validatable, 4 | :token_authenticatable 5 | 6 | has_many :authentication_tokens, as: :authenticatable 7 | end 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.3-alpine 2 | 3 | RUN apk add build-base sqlite-dev tzdata git bash 4 | RUN gem update --system && gem install bundler 5 | 6 | WORKDIR /library 7 | 8 | ENV BUNDLE_PATH=/vendor/bundle \ 9 | BUNDLE_BIN=/vendor/bundle/bin \ 10 | GEM_HOME=/vendor/bundle 11 | 12 | ENV PATH="${BUNDLE_BIN}:${PATH}" 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build bundle test bash cleanup 2 | 3 | build: 4 | docker-compose build 5 | 6 | bundle: 7 | docker-compose run --rm library bundle install 8 | 9 | test: 10 | docker-compose run --rm library bundle exec rake 11 | 12 | bash: 13 | docker-compose run --rm library bash 14 | 15 | cleanup: 16 | docker-compose down 17 | -------------------------------------------------------------------------------- /spec/support/fake_request.rb: -------------------------------------------------------------------------------- 1 | class FakeRequest 2 | def initialize( 3 | remote_ip: "23.12.54.111", 4 | user_agent: "I am not a bot", 5 | headers: {} 6 | ) 7 | self.remote_ip = remote_ip 8 | self.user_agent = user_agent 9 | self.headers = headers 10 | end 11 | 12 | attr_accessor :remote_ip, :user_agent, :headers 13 | end 14 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/app/models/namespace/namespaced_user.rb: -------------------------------------------------------------------------------- 1 | module Namespace 2 | class NamespacedUser < ActiveRecord::Base 3 | devise :database_authenticatable, :registerable, 4 | :recoverable, :trackable, :validatable, 5 | :token_authenticatable 6 | 7 | has_many :authentication_tokens, as: :authenticatable 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/app/models/authentication_token.rb: -------------------------------------------------------------------------------- 1 | class AuthenticationToken 2 | include Mongoid::Document 3 | 4 | belongs_to :user 5 | 6 | field :body, type: String 7 | field :last_used_at, type: Time 8 | field :ip_address, type: String 9 | field :user_agent, type: String 10 | field :expires_in, type: Integer, default: 0 11 | field :metadata_attr1, type: String 12 | end 13 | -------------------------------------------------------------------------------- /lib/tiddle/model_name.rb: -------------------------------------------------------------------------------- 1 | module Tiddle 2 | class ModelName 3 | def with_underscores(model) 4 | colon_to_underscore(model).underscore.upcase 5 | end 6 | 7 | def with_dashes(model) 8 | with_underscores(model).dasherize 9 | end 10 | 11 | private 12 | 13 | def colon_to_underscore(model) 14 | model.model_name.to_s.tr(':', '_') 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | devise_for :users 3 | devise_for :admin_users 4 | devise_for :namespaced_user, class_name: 'Namespace::NamespacedUser' 5 | resources :secrets, only: [:index], defaults: { format: 'json' } 6 | resources :long_secrets, only: [:index], defaults: { format: 'json' } 7 | resources :namespaced_users, only: [:index], defaults: { format: 'json' } 8 | end 9 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | devise_for :users 3 | devise_for :admin_users 4 | devise_for :namespaced_user, class_name: 'Namespace::NamespacedUser' 5 | resources :secrets, only: [:index], defaults: { format: 'json' } 6 | resources :long_secrets, only: [:index], defaults: { format: 'json' } 7 | resources :namespaced_users, only: [:index], defaults: { format: 'json' } 8 | end 9 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('boot', __dir__) 2 | 3 | require "active_model/railtie" 4 | require "active_job/railtie" 5 | require "action_controller/railtie" 6 | require "action_mailer/railtie" 7 | require "action_view/railtie" 8 | 9 | module RailsApp 10 | class Application < Rails::Application 11 | config.eager_load = true 12 | config.root = File.expand_path('..', __dir__) 13 | config.consider_all_requests_local = true 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | library: 4 | platform: linux/x86_64 5 | build: 6 | context: . 7 | stdin_open: true 8 | tty: true 9 | volumes: 10 | - ".:/library" 11 | - vendor:/vendor 12 | depends_on: 13 | - redis 14 | environment: 15 | - REDIS_URL=redis://redis:6379/1 16 | - BUNDLE_GEMFILE=gemfiles/rails7.1.gemfile 17 | redis: 18 | image: "redis:6-alpine" 19 | command: redis-server 20 | volumes: 21 | - "redis:/data" 22 | volumes: 23 | vendor: 24 | redis: 25 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('boot', __dir__) 2 | 3 | require "active_model/railtie" 4 | require "active_record/railtie" 5 | require "action_controller/railtie" 6 | require "action_view/railtie" 7 | require "action_mailer/railtie" 8 | 9 | module RailsApp 10 | class Application < Rails::Application 11 | config.eager_load = true 12 | config.root = File.expand_path('..', __dir__) 13 | config.consider_all_requests_local = true 14 | config.active_record.sqlite3.represent_boolean_as_integer = true if config.active_record.sqlite3 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.6 3 | Include: 4 | - 'lib/**/*.rb' 5 | - 'spec/**/*.rb' 6 | Exclude: 7 | - 'spec/rails_app/**/*' 8 | - 'spec/spec_helper.rb' 9 | - 'vendor/bundle/**/*' 10 | SuggestExtensions: false 11 | NewCops: enable 12 | Style/StringLiterals: 13 | Enabled: false 14 | Style/Documentation: 15 | Enabled: false 16 | Style/FrozenStringLiteralComment: 17 | Enabled: false 18 | Style/SignalException: 19 | Enabled: false 20 | Layout/LineLength: 21 | Max: 100 22 | Gemspec/OrderedDependencies: 23 | Enabled: false 24 | Metrics/BlockLength: 25 | Exclude: 26 | - 'spec/**/*' 27 | Metrics/MethodLength: 28 | Max: 15 29 | -------------------------------------------------------------------------------- /spec/rails_app_mongoid/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User 2 | include Mongoid::Document 3 | 4 | devise :database_authenticatable, 5 | :registerable, 6 | :recoverable, 7 | :trackable, 8 | :validatable, 9 | :token_authenticatable 10 | 11 | has_many :authentication_tokens 12 | 13 | field :email, type: String, default: '' 14 | field :encrypted_password, type: String, default: '' 15 | field :reset_password_token, type: String 16 | field :reset_password_sent_at, type: Time 17 | field :sign_in_count, type: Integer, default: 0 18 | field :current_sign_in_at, type: Time 19 | field :last_sign_in_at, type: Time 20 | field :current_sign_in_ip, type: String 21 | field :nick_name, type: String 22 | end 23 | -------------------------------------------------------------------------------- /lib/tiddle.rb: -------------------------------------------------------------------------------- 1 | require "tiddle/version" 2 | require "tiddle/model" 3 | require "tiddle/strategy" 4 | require "tiddle/rails" 5 | require "tiddle/token_issuer" 6 | 7 | module Tiddle 8 | def self.create_and_return_token(resource, request, options = {}) 9 | TokenIssuer.build.create_and_return_token(resource, request, **options) 10 | end 11 | 12 | def self.expire_token(resource, request) 13 | TokenIssuer.build.expire_token(resource, request) 14 | end 15 | 16 | def self.purge_old_tokens(resource) 17 | TokenIssuer.build.purge_old_tokens(resource) 18 | end 19 | end 20 | 21 | Devise.add_module :token_authenticatable, 22 | model: 'tiddle/model', 23 | strategy: true, 24 | no_input: true 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 1. Fork the repo. 4 | 2. Run the tests: 5 | 6 | ``` 7 | BUNDLE_GEMFILE=gemfiles/.gemfile rake 8 | ``` 9 | 3. Introduce your change. If it's a new feature then write a test for it as well. 10 | 4. Make sure that tests are passing. 11 | 5. Push to your fork and submit a pull request. 12 | 13 | #### Docker for development 14 | 15 | Alternatively you can use Docker for the development setup. This requires Docker 16 | and Docker Compose installed. 17 | 18 | ``` 19 | make build 20 | make bundle 21 | ``` 22 | 23 | And in order to run the tests and linter checks: 24 | 25 | ``` 26 | make test 27 | ``` 28 | 29 | After you're done, cleanup leftover containers: 30 | 31 | ``` 32 | make cleanup 33 | ``` 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Adam Niedzielski 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | gemfile: 15 | - rails6.1 16 | - rails7.0 17 | - rails7.1 18 | ruby: 19 | - "3.0" 20 | - "3.1" 21 | - "3.2" 22 | - "3.3" 23 | backend: 24 | - active_record 25 | - mongoid 26 | name: ${{ matrix.gemfile }}, ruby ${{ matrix.ruby }}, ${{ matrix.backend }} 27 | runs-on: ubuntu-latest 28 | env: 29 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 30 | BACKEND: ${{ matrix.backend }} 31 | 32 | steps: 33 | - uses: actions/checkout@v2 34 | - name: Set up Ruby 35 | uses: ruby/setup-ruby@v1 36 | with: 37 | bundler-cache: true 38 | ruby-version: ${{ matrix.ruby }} 39 | - name: Start MongoDB 40 | uses: supercharge/mongodb-github-action@1.3.0 41 | if: ${{ matrix.backend == 'mongoid' }} 42 | - name: Run tests 43 | run: | 44 | bundle exec rake spec 45 | -------------------------------------------------------------------------------- /spec/support/backend.rb: -------------------------------------------------------------------------------- 1 | module Backend 2 | def self.from_name(name) 3 | puts "Backend: #{name}" 4 | case name 5 | when 'mongoid' 6 | MongoidBackend.new 7 | else 8 | ActiveRecordBackend.new 9 | end 10 | end 11 | 12 | class ActiveRecordBackend 13 | def load! 14 | require 'devise/orm/active_record' 15 | require 'rails_app_active_record/config/environment' 16 | end 17 | 18 | def setup_database_cleaner 19 | # Not necessary 20 | end 21 | 22 | def migrate! 23 | # Do initial migration 24 | path = File.expand_path("../rails_app_active_record/db/migrate/", File.dirname(__FILE__)) 25 | 26 | ActiveRecord::MigrationContext.new( 27 | path, 28 | ActiveRecord::SchemaMigration 29 | ).migrate 30 | end 31 | end 32 | 33 | class MongoidBackend 34 | def load! 35 | require 'mongoid' 36 | require 'devise/orm/mongoid' 37 | require 'rails_app_mongoid/config/environment' 38 | require 'database_cleaner-mongoid' 39 | end 40 | 41 | def setup_database_cleaner 42 | DatabaseCleaner.allow_remote_database_url = true 43 | end 44 | 45 | def migrate! 46 | # Not necessary 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /tiddle.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'tiddle/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "tiddle" 7 | spec.version = Tiddle::VERSION 8 | spec.authors = ["Adam Niedzielski"] 9 | spec.email = ["adamsunday@gmail.com"] 10 | spec.summary = "Token authentication for Devise which supports multiple tokens per model" 11 | spec.homepage = "" 12 | spec.license = "MIT" 13 | 14 | spec.files = `git ls-files -z`.split("\x0") 15 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 16 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 17 | spec.require_paths = ["lib"] 18 | 19 | spec.required_ruby_version = '>= 3.0.0' 20 | 21 | spec.add_dependency "devise", ">= 4.0.0.rc1", "< 5" 22 | spec.add_dependency "activerecord", ">= 6.1.0" 23 | spec.add_development_dependency "rake" 24 | spec.add_development_dependency "rspec-rails" 25 | spec.add_development_dependency "simplecov" 26 | spec.add_development_dependency "rubocop" 27 | spec.add_development_dependency "database_cleaner-active_record" 28 | spec.add_development_dependency "database_cleaner-mongoid" 29 | end 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.8.1 2 | 3 | Remove Rails 5.2 and 6.0 support 4 | 5 | Remove Ruby 2.7 support 6 | 7 | Add Rails 7.1 support 8 | 9 | Add Ruby 3.2 and 3.3 support 10 | 11 | ### 1.8.0 12 | 13 | Support different touch interval based on expiration time (Daniel André da Silva) 14 | 15 | ### 1.7.1 16 | 17 | Fix invalid headers generated when model is namespaced (Ariel Agne da Silveira) 18 | 19 | Add Rails 7.0 support 20 | 21 | Add Ruby 3.1 support 22 | 23 | Remove Ruby 2.6 support 24 | 25 | ### 1.7.0 26 | 27 | Add ability to track additional info in tokens (Marcelo Silveira) 28 | 29 | Remove Ruby 2.5 support 30 | 31 | ### 1.6.0 32 | 33 | Add Rails 6.1 support 34 | 35 | Add Ruby 3.0 support 36 | 37 | Remove Rails 4.2 support 38 | 39 | Remove Ruby 2.4 support 40 | 41 | ### 1.5.0 42 | 43 | Add Rails 6 support 44 | 45 | Fix warning on Ruby 2.7 (Andy Klimczak) 46 | 47 | Skip CSRF clean up (Marcelo Silveira) 48 | 49 | ### 1.4.0 50 | 51 | Support for Devise 4.6. 52 | 53 | Relax dependency on Devise. 54 | 55 | ### 1.3.0 56 | 57 | Support for Devise 4.5 58 | 59 | ### 1.2.0 60 | 61 | Adds support for MongoDB. 62 | 63 | ### 1.1.0 64 | 65 | New feature: optional token expiration after period of inactivity - #37 66 | 67 | You have to add `expires_in` field to the database table holding the tokens 68 | to benefit from this feature. 69 | 70 | ### 1.0.0 71 | 72 | No major changes - just a stable version release. 73 | 74 | ### 0.7.0 75 | 76 | Adds support for Rails 5. Requires Devise 4. 77 | 78 | ### 0.6.0 79 | 80 | Adds support for authentication keys other than email. 81 | 82 | ### 0.5.0 83 | 84 | Breaking changes. Token digest is stored in the database, not the actual token. This will invalidate all your existing tokens (logging users out) unless you migrate existing tokens. In order to migrate execute: 85 | 86 | ```ruby 87 | AuthenticationToken.find_each do |token| 88 | token.body = Devise.token_generator.digest(AuthenticationToken, :body, token.body) 89 | token.save! 90 | end 91 | ``` 92 | 93 | assuming that your model which stores tokens is called ```AuthenticationToken```. 94 | -------------------------------------------------------------------------------- /lib/tiddle/token_issuer.rb: -------------------------------------------------------------------------------- 1 | require 'tiddle/model_name' 2 | 3 | module Tiddle 4 | class TokenIssuer 5 | MAXIMUM_TOKENS_PER_USER = 20 6 | 7 | def self.build 8 | new(MAXIMUM_TOKENS_PER_USER) 9 | end 10 | 11 | def initialize(maximum_tokens_per_user) 12 | self.maximum_tokens_per_user = maximum_tokens_per_user 13 | end 14 | 15 | def create_and_return_token(resource, request, expires_in: nil, metadata: {}) 16 | token_class = authentication_token_class(resource) 17 | token, token_body = Devise.token_generator.generate(token_class, :body) 18 | 19 | resource.authentication_tokens.create!( 20 | token_attributes( 21 | token_body: token_body, 22 | request: request, 23 | expires_in: expires_in, 24 | metadata: metadata 25 | ) 26 | ) 27 | 28 | token 29 | end 30 | 31 | def expire_token(resource, request) 32 | find_token(resource, request.headers["X-#{ModelName.new.with_dashes(resource)}-TOKEN"]) 33 | .try(:destroy) 34 | end 35 | 36 | def find_token(resource, token_from_headers) 37 | token_class = authentication_token_class(resource) 38 | token_body = Devise.token_generator.digest(token_class, :body, token_from_headers) 39 | # 'find_by' behaves differently in AR vs Mongoid, so using 'where' instead 40 | resource.authentication_tokens.where(body: token_body).first 41 | end 42 | 43 | def purge_old_tokens(resource) 44 | resource.authentication_tokens 45 | .order(last_used_at: :desc) 46 | .offset(maximum_tokens_per_user) 47 | .destroy_all 48 | end 49 | 50 | private 51 | 52 | attr_accessor :maximum_tokens_per_user 53 | 54 | def authentication_token_class(resource) 55 | if resource.respond_to?(:association) # ActiveRecord 56 | resource.association(:authentication_tokens).klass 57 | elsif resource.respond_to?(:relations) # Mongoid 58 | resource.relations['authentication_tokens'].klass 59 | else 60 | raise 'Cannot determine authentication token class, unsupported ORM/ODM?' 61 | end 62 | end 63 | 64 | def token_attributes(token_body:, request:, expires_in:, metadata: {}) 65 | attributes = { 66 | body: token_body, 67 | last_used_at: Time.current, 68 | ip_address: request.remote_ip, 69 | user_agent: request.user_agent 70 | }.merge(metadata) 71 | 72 | if expires_in 73 | attributes.merge(expires_in: expires_in) 74 | else 75 | attributes 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/tiddle/strategy.rb: -------------------------------------------------------------------------------- 1 | require 'devise/strategies/authenticatable' 2 | require 'tiddle/model_name' 3 | require 'tiddle/token_issuer' 4 | 5 | module Devise 6 | module Strategies 7 | class TokenAuthenticatable < Authenticatable 8 | def authenticate! 9 | env["devise.skip_trackable"] = true 10 | 11 | resource = mapping.to.find_for_authentication(authentication_keys_from_headers) 12 | return fail(:invalid_token) unless resource 13 | 14 | token = Tiddle::TokenIssuer.build.find_token(resource, token_from_headers) 15 | if token && unexpired?(token) 16 | touch_token(token) 17 | return success!(resource) 18 | end 19 | 20 | fail(:invalid_token) 21 | end 22 | 23 | def valid? 24 | authentication_keys_from_headers.present? && token_from_headers.present? 25 | end 26 | 27 | def store? 28 | false 29 | end 30 | 31 | # Avoid CSRF clean up for token authentication as it might trigger session creation in API 32 | # environments even if CSRF prevention is not being used. 33 | # Devise provides a `clean_up_csrf_token_on_authentication` option but it's not always viable 34 | # in applications with multiple user models and authentication strategies. 35 | def clean_up_csrf? 36 | false 37 | end 38 | 39 | private 40 | 41 | def authentication_keys_from_headers 42 | authentication_keys.map do |key| 43 | { key => env["HTTP_X_#{model_name}_#{key.upcase}"] } 44 | end.reduce(:merge) 45 | end 46 | 47 | def token_from_headers 48 | env["HTTP_X_#{model_name}_TOKEN"] 49 | end 50 | 51 | def model_name 52 | Tiddle::ModelName.new.with_underscores(mapping.to) 53 | end 54 | 55 | def authentication_keys 56 | mapping.to.authentication_keys 57 | end 58 | 59 | def touch_token(token) 60 | return unless token.last_used_at < touch_token_interval(token).ago 61 | 62 | token.update_attribute(:last_used_at, Time.current) 63 | end 64 | 65 | def unexpired?(token) 66 | return true if expiration_disabled?(token) 67 | 68 | Time.current <= token.last_used_at + token.expires_in 69 | end 70 | 71 | def touch_token_interval(token) 72 | return 1.hour if expiration_disabled?(token) || token.expires_in >= 24.hours 73 | 74 | return 5.minutes if token.expires_in >= 1.hour 75 | 76 | 1.minute 77 | end 78 | 79 | def expiration_disabled?(token) 80 | !token.respond_to?(:expires_in) || 81 | token.expires_in.blank? || 82 | token.expires_in.zero? 83 | end 84 | end 85 | end 86 | end 87 | 88 | Warden::Strategies.add(:token_authenticatable, Devise::Strategies::TokenAuthenticatable) 89 | -------------------------------------------------------------------------------- /spec/rails_app_active_record/db/migrate/20150217000000_create_tables.rb: -------------------------------------------------------------------------------- 1 | class CreateTables < ActiveRecord::Migration[4.2] 2 | # rubocop:disable Metrics/AbcSize 3 | # rubocop:disable Metrics/MethodLength 4 | def change 5 | create_table(:users) do |t| 6 | ## Database authenticatable 7 | t.string :email, null: false, default: "" 8 | t.string :encrypted_password, null: false, default: "" 9 | 10 | ## Recoverable 11 | t.string :reset_password_token 12 | t.datetime :reset_password_sent_at 13 | 14 | ## Trackable 15 | t.integer :sign_in_count, default: 0, null: false 16 | t.datetime :current_sign_in_at 17 | t.datetime :last_sign_in_at 18 | t.string :current_sign_in_ip 19 | t.string :last_sign_in_ip 20 | 21 | t.string :nick_name 22 | 23 | t.timestamps null: false 24 | end 25 | 26 | add_index :users, :email, unique: true 27 | add_index :users, :reset_password_token, unique: true 28 | 29 | create_table(:admin_users) do |t| 30 | ## Database authenticatable 31 | t.string :email, null: false, default: "" 32 | t.string :encrypted_password, null: false, default: "" 33 | 34 | ## Recoverable 35 | t.string :reset_password_token 36 | t.datetime :reset_password_sent_at 37 | 38 | ## Trackable 39 | t.integer :sign_in_count, default: 0, null: false 40 | t.datetime :current_sign_in_at 41 | t.datetime :last_sign_in_at 42 | t.string :current_sign_in_ip 43 | t.string :last_sign_in_ip 44 | 45 | t.timestamps null: false 46 | end 47 | 48 | add_index :admin_users, :email, unique: true 49 | add_index :admin_users, :reset_password_token, unique: true 50 | 51 | create_table :authentication_tokens do |t| 52 | t.string :body, null: false 53 | t.references :authenticatable, null: false, polymorphic: true 54 | t.datetime :last_used_at, null: false 55 | t.integer :expires_in, null: false, default: 0 56 | t.string :ip_address 57 | t.string :user_agent 58 | t.string :metadata_attr1 59 | 60 | t.timestamps null: false 61 | end 62 | 63 | create_table(:namespaced_users) do |t| 64 | ## Database authenticatable 65 | t.string :email, null: false, default: "" 66 | t.string :encrypted_password, null: false, default: "" 67 | 68 | ## Recoverable 69 | t.string :reset_password_token 70 | t.datetime :reset_password_sent_at 71 | 72 | ## Trackable 73 | t.integer :sign_in_count, default: 0, null: false 74 | t.datetime :current_sign_in_at 75 | t.datetime :last_sign_in_at 76 | t.string :current_sign_in_ip 77 | t.string :last_sign_in_ip 78 | 79 | t.string :nick_name 80 | 81 | t.timestamps null: false 82 | end 83 | end 84 | # rubocop:enable Metrics/AbcSize 85 | # rubocop:enable Metrics/MethodLength 86 | end 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tiddle 2 | 3 | Tiddle provides Devise strategy for token authentication in API-only Ruby on Rails applications. Its main feature is **support for multiple tokens per user**. 4 | 5 | Tiddle is lightweight and non-configurable. It does what it has to do and leaves some manual implementation to you. 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | gem 'tiddle' 13 | ``` 14 | 15 | And then execute: 16 | 17 | $ bundle 18 | 19 | 20 | ## Usage 21 | 22 | 1) Add ```:token_authenticatable``` inside your Devise-enabled model: 23 | 24 | ```ruby 25 | class User < ActiveRecord::Base 26 | devise :database_authenticatable, :registerable, 27 | :recoverable, :trackable, :validatable, 28 | :token_authenticatable 29 | end 30 | ``` 31 | 32 | 2) Generate the model which stores authentication tokens. The model name is not important, but the Devise-enabled model should have association called ```authentication_tokens```. 33 | 34 | ``` 35 | rails g model AuthenticationToken body:string:index user:references last_used_at:datetime expires_in:integer ip_address:string user_agent:string 36 | ``` 37 | 38 | ```ruby 39 | class User < ActiveRecord::Base 40 | has_many :authentication_tokens 41 | end 42 | ``` 43 | 44 | ```body```, ```last_used_at```, ```ip_address``` and ```user_agent``` fields are required. 45 | 46 | 3) Customize ```Devise::SessionsController```. You need to create and return token in ```#create``` and expire the token in ```#destroy```. 47 | 48 | ```ruby 49 | class Users::SessionsController < Devise::SessionsController 50 | 51 | def create 52 | user = warden.authenticate!(auth_options) 53 | token = Tiddle.create_and_return_token(user, request) 54 | render json: { authentication_token: token } 55 | end 56 | 57 | def destroy 58 | Tiddle.expire_token(current_user, request) if current_user 59 | render json: {} 60 | end 61 | 62 | private 63 | 64 | # this is invoked before destroy and we have to override it 65 | def verify_signed_out_user 66 | end 67 | end 68 | ``` 69 | 70 | 4) Require authentication for some controller: 71 | 72 | ```ruby 73 | class PostsController < ApplicationController 74 | before_action :authenticate_user! 75 | 76 | def index 77 | render json: Post.all 78 | end 79 | end 80 | ``` 81 | 82 | 5) Send ```X-USER-EMAIL``` and ```X-USER-TOKEN``` as headers of every request which requires authentication. 83 | 84 | You can read more in a blog post dedicated to Tiddle - https://blog.sundaycoding.com/blog/2015/04/04/token-authentication-with-tiddle/ 85 | 86 | ## Note on Rails session 87 | 88 | The safest solution in API-only application is not to rely on Rails session at all and disable it. Put this line in your ```application.rb```: 89 | 90 | ```ruby 91 | config.middleware.delete ActionDispatch::Session::CookieStore 92 | ``` 93 | 94 | More: https://blog.sundaycoding.com/blog/2015/04/04/token-authentication-with-tiddle/#rails-session 95 | 96 | ## Using field other than email 97 | 98 | Change ```config.authentication_keys``` in Devise intitializer and Tiddle will use this value. 99 | 100 | 101 | ## Security 102 | 103 | Usually it makes sense to remove user's tokens after a password change. Depending on the project and on your taste, this can be done using various methods like running `user.authentication_tokens.destroy_all` after the password change or with an `after_save` callback in your model which runs `authentication_tokens.destroy_all if encrypted_password_changed?`. 104 | 105 | In case of a security breach, remove all existing tokens. 106 | 107 | Tokens are expiring after certain period of inactivity. This behavior is optional. If you want your token to expire, create it passing `expires_in` option: 108 | 109 | ```ruby 110 | token = Tiddle.create_and_return_token(user, request, expires_in: 1.month) 111 | ``` 112 | -------------------------------------------------------------------------------- /spec/tiddle_spec.rb: -------------------------------------------------------------------------------- 1 | describe Tiddle do 2 | describe "create_and_return_token" do 3 | before do 4 | @user = User.create!(email: "test@example.com", password: "12345678") 5 | end 6 | 7 | it "returns string with token" do 8 | result = Tiddle.create_and_return_token(@user, FakeRequest.new) 9 | expect(result).to be_present 10 | expect(result).to be_kind_of(String) 11 | end 12 | 13 | it "stores a different string to the database" do 14 | result = Tiddle.create_and_return_token(@user, FakeRequest.new) 15 | expect(result).to_not eq @user.authentication_tokens.last.body 16 | end 17 | 18 | it "creates new token in the database" do 19 | expect do 20 | Tiddle.create_and_return_token(@user, FakeRequest.new) 21 | end.to change { @user.authentication_tokens.count }.by(1) 22 | end 23 | 24 | it "sets last_used_at field" do 25 | Tiddle.create_and_return_token(@user, FakeRequest.new) 26 | expect(@user.authentication_tokens.last.last_used_at.to_time) 27 | .to be_within(1).of(Time.current) 28 | end 29 | 30 | it "saves ip address" do 31 | Tiddle.create_and_return_token @user, 32 | FakeRequest.new(remote_ip: "123.101.54.1") 33 | expect(@user.authentication_tokens.last.ip_address).to eq "123.101.54.1" 34 | end 35 | 36 | it "saves user agent" do 37 | Tiddle.create_and_return_token @user, 38 | FakeRequest.new(user_agent: "Internet Explorer 4.0") 39 | expect(@user.authentication_tokens.last.user_agent).to eq "Internet Explorer 4.0" 40 | end 41 | 42 | it "saves additional metadata" do 43 | Tiddle.create_and_return_token @user, FakeRequest.new, metadata: { metadata_attr1: "abc" } 44 | expect(@user.authentication_tokens.last.metadata_attr1).to eq "abc" 45 | end 46 | end 47 | 48 | describe "find_token" do 49 | before do 50 | @admin_user = AdminUser.create!(email: "test@example.com", password: "12345678") 51 | @token = Tiddle.create_and_return_token(@admin_user, FakeRequest.new) 52 | end 53 | 54 | it "returns a token from the database" do 55 | result = Tiddle::TokenIssuer.build.find_token(@admin_user, @token) 56 | expect(result).to eq @admin_user.authentication_tokens.last 57 | end 58 | 59 | it 'only returns tokens belonging to the resource' do 60 | other_user = AdminUser.create!(email: "test-other@example.com", password: "12345678") 61 | result = Tiddle::TokenIssuer.build.find_token(other_user, @token) 62 | expect(result).to be_nil 63 | end 64 | end 65 | 66 | describe "expire_token" do 67 | before do 68 | @admin_user = AdminUser.create!(email: "test@example.com", password: "12345678") 69 | token = Tiddle.create_and_return_token(@admin_user, FakeRequest.new) 70 | @request = FakeRequest.new(headers: { "X-ADMIN-USER-TOKEN" => token }) 71 | end 72 | 73 | it "deletes token from the database" do 74 | expect do 75 | Tiddle.expire_token(@admin_user, @request) 76 | end.to change { @admin_user.authentication_tokens.count }.by(-1) 77 | end 78 | end 79 | 80 | describe "purge_old_tokens" do 81 | before do 82 | @user = User.create!(email: "test@example.com", password: "12345678") 83 | Tiddle.create_and_return_token(@user, FakeRequest.new) 84 | @old = @user.authentication_tokens.last 85 | @old.update_attribute(:last_used_at, 2.hours.ago) 86 | 87 | Tiddle.create_and_return_token(@user, FakeRequest.new) 88 | @new = @user.authentication_tokens.last 89 | @new.update_attribute(:last_used_at, 10.minutes.ago) 90 | end 91 | 92 | it "deletes old tokens which are over the limit" do 93 | expect do 94 | Tiddle::TokenIssuer.new(1).purge_old_tokens(@user) 95 | end.to change { @user.authentication_tokens.count }.from(2).to(1) 96 | 97 | expect(@user.authentication_tokens.last).to eq @new 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'simplecov' 3 | 4 | SimpleCov.start do 5 | add_filter "/spec/" 6 | end 7 | 8 | ENV["RAILS_ENV"] = 'test' 9 | ENV["DATABASE_URL"] = "sqlite3:db/test.sqlite3" 10 | 11 | Dir[__dir__ + "/support/**/*.rb"].each { |f| require f } 12 | 13 | require 'devise' 14 | require 'tiddle' 15 | 16 | backend = Backend.from_name(ENV['BACKEND']) 17 | backend.load! 18 | 19 | require 'rspec/rails' 20 | 21 | # This file was generated by the `rspec --init` command. Conventionally, all 22 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 23 | # The generated `.rspec` file contains `--require spec_helper` which will cause 24 | # this file to always be loaded, without a need to explicitly require it in any 25 | # files. 26 | # 27 | # Given that it is always loaded, you are encouraged to keep this file as 28 | # light-weight as possible. Requiring heavyweight dependencies from this file 29 | # will add to the boot time of your test suite on EVERY test run, even for an 30 | # individual file that may not need all of that loaded. Instead, consider making 31 | # a separate helper file that requires the additional dependencies and performs 32 | # the additional setup, and require it from the spec files that actually need 33 | # it. 34 | # 35 | # The `.rspec` file also contains a few flags that are not defaults but that 36 | # users commonly want. 37 | # 38 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 39 | RSpec.configure do |config| 40 | # rspec-expectations config goes here. You can use an alternate 41 | # assertion/expectation library such as wrong or the stdlib/minitest 42 | # assertions if you prefer. 43 | config.expect_with :rspec do |expectations| 44 | # This option will default to `true` in RSpec 4. It makes the `description` 45 | # and `failure_message` of custom matchers include text for helper methods 46 | # defined using `chain`, e.g.: 47 | # be_bigger_than(2).and_smaller_than(4).description 48 | # # => "be bigger than 2 and smaller than 4" 49 | # ...rather than: 50 | # # => "be bigger than 2" 51 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 52 | end 53 | 54 | # rspec-mocks config goes here. You can use an alternate test double 55 | # library (such as bogus or mocha) by changing the `mock_with` option here. 56 | config.mock_with :rspec do |mocks| 57 | # Prevents you from mocking or stubbing a method that does not exist on 58 | # a real object. This is generally recommended, and will default to 59 | # `true` in RSpec 4. 60 | mocks.verify_partial_doubles = true 61 | end 62 | 63 | config.before(:suite) do 64 | backend.setup_database_cleaner 65 | backend.migrate! 66 | end 67 | 68 | config.before(:each) do 69 | DatabaseCleaner.clean if defined?(DatabaseCleaner) 70 | end 71 | 72 | config.use_transactional_fixtures = true 73 | 74 | # The settings below are suggested to provide a good initial experience 75 | # with RSpec, but feel free to customize to your heart's content. 76 | =begin 77 | # These two settings work together to allow you to limit a spec run 78 | # to individual examples or groups you care about by tagging them with 79 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples 80 | # get run. 81 | config.filter_run :focus 82 | config.run_all_when_everything_filtered = true 83 | 84 | # Limits the available syntax to the non-monkey patched syntax that is 85 | # recommended. For more details, see: 86 | # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax 87 | # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 88 | # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching 89 | config.disable_monkey_patching! 90 | 91 | # This setting enables warnings. It's recommended, but in some cases may 92 | # be too noisy due to issues in dependencies. 93 | config.warnings = true 94 | 95 | # Many RSpec users commonly either run the entire suite or an individual 96 | # file, and it's useful to allow more verbose output when running an 97 | # individual spec file. 98 | if config.files_to_run.one? 99 | # Use the documentation formatter for detailed output, 100 | # unless a formatter has already been configured 101 | # (e.g. via a command-line flag). 102 | config.default_formatter = 'doc' 103 | end 104 | 105 | # Print the 10 slowest examples and example groups at the 106 | # end of the spec run, to help surface which specs are running 107 | # particularly slow. 108 | config.profile_examples = 10 109 | 110 | # Run specs in random order to surface order dependencies. If you find an 111 | # order dependency and want to debug it, you can fix the order by providing 112 | # the seed, which is printed after each run. 113 | # --seed 1234 114 | config.order = :random 115 | 116 | # Seed global randomization in this process using the `--seed` CLI option. 117 | # Setting this allows you to use `--seed` to deterministically reproduce 118 | # test failures related to randomization by passing the same `--seed` value 119 | # as the one that triggered the failure. 120 | Kernel.srand config.seed 121 | =end 122 | end 123 | -------------------------------------------------------------------------------- /spec/strategy_spec.rb: -------------------------------------------------------------------------------- 1 | describe "Authentication using Tiddle strategy", type: :request do 2 | context "with valid email and token" do 3 | before do 4 | @user = User.create!(email: "test@example.com", password: "12345678") 5 | @token = Tiddle.create_and_return_token(@user, FakeRequest.new) 6 | end 7 | 8 | it "allows to access endpoints which require authentication" do 9 | get( 10 | secrets_path, 11 | headers: { 12 | "X-USER-EMAIL" => "test@example.com", 13 | "X-USER-TOKEN" => @token 14 | } 15 | ) 16 | expect(response.status).to eq 200 17 | end 18 | 19 | describe "touching token" do 20 | context "when token was last used more than hour ago" do 21 | before do 22 | @user.authentication_tokens.last 23 | .update_attribute(:last_used_at, 2.hours.ago) 24 | end 25 | 26 | it "updates last_used_at field" do 27 | expect do 28 | get( 29 | secrets_path, 30 | headers: { 31 | "X-USER-EMAIL" => "test@example.com", 32 | "X-USER-TOKEN" => @token 33 | } 34 | ) 35 | end.to(change { @user.reload.authentication_tokens.last.last_used_at }) 36 | end 37 | end 38 | 39 | context "when token was last used less than hour ago" do 40 | before do 41 | @user.authentication_tokens.last.update_attribute(:last_used_at, 30.minutes.ago) 42 | end 43 | 44 | it "does not update last_used_at field" do 45 | expect do 46 | get( 47 | secrets_path, 48 | headers: { 49 | "X-USER-EMAIL" => "test@example.com", 50 | "X-USER-TOKEN" => @token 51 | } 52 | ) 53 | end.not_to(change { @user.authentication_tokens.last.last_used_at }) 54 | end 55 | end 56 | end 57 | 58 | context "when email contains uppercase letters" do 59 | it "converts email to lower case and authenticates user" do 60 | get( 61 | secrets_path, 62 | headers: { 63 | "X-USER-EMAIL" => "TEST@example.com", 64 | "X-USER-TOKEN" => @token 65 | } 66 | ) 67 | expect(response.status).to eq 200 68 | end 69 | end 70 | end 71 | 72 | context "with invalid email and valid token" do 73 | before do 74 | @user = User.create!(email: "test@example.com", password: "12345678") 75 | @token = Tiddle.create_and_return_token(@user, FakeRequest.new) 76 | end 77 | 78 | it "does not allow to access endpoints which require authentication" do 79 | get( 80 | secrets_path, 81 | headers: { 82 | "X-USER-EMAIL" => "wrong@example.com", 83 | "X-USER-TOKEN" => @token 84 | } 85 | ) 86 | expect(response.status).to eq 401 87 | end 88 | end 89 | 90 | context "with valid email and invalid token" do 91 | before do 92 | @user = User.create!(email: "test@example.com", password: "12345678") 93 | @token = Tiddle.create_and_return_token(@user, FakeRequest.new) 94 | end 95 | 96 | it "does not allow to access endpoints which require authentication" do 97 | get( 98 | secrets_path, 99 | headers: { 100 | "X-USER-EMAIL" => "test@example.com", 101 | "X-USER-TOKEN" => "wrong" 102 | } 103 | ) 104 | expect(response.status).to eq 401 105 | end 106 | end 107 | 108 | context "when no headers are passed" do 109 | it "does not allow to access endpoints which require authentication" do 110 | get secrets_path, headers: {} 111 | expect(response.status).to eq 401 112 | end 113 | end 114 | 115 | context "when model name consists of two words" do 116 | before do 117 | @admin_user = AdminUser.create!(email: "test@example.com", password: "12345678") 118 | @token = Tiddle.create_and_return_token(@admin_user, FakeRequest.new) 119 | end 120 | 121 | it "allows to access endpoints which require authentication" do 122 | get( 123 | long_secrets_path, 124 | headers: { 125 | "X-ADMIN-USER-EMAIL" => "test@example.com", 126 | "X-ADMIN-USER-TOKEN" => @token 127 | } 128 | ) 129 | expect(response.status).to eq 200 130 | end 131 | end 132 | 133 | context "when the model name is composed of a namespace" do 134 | before do 135 | @user = Namespace::NamespacedUser.create!( 136 | email: "test@example.com", 137 | password: "12345678" 138 | ) 139 | @token = Tiddle.create_and_return_token(@user, FakeRequest.new) 140 | end 141 | 142 | it "allows to access endpoints which require authentication" do 143 | get( 144 | namespaced_users_path, 145 | headers: { 146 | "X-NAMESPACE--NAMESPACED-USER-EMAIL" => "test@example.com", 147 | "X-NAMESPACE--NAMESPACED-USER-TOKEN" => @token 148 | } 149 | ) 150 | expect(response.status).to eq 200 151 | end 152 | end 153 | 154 | describe "using field other than email" do 155 | before do 156 | Devise.setup do |config| 157 | config.authentication_keys = [:nick_name] 158 | end 159 | 160 | @user = User.create!( 161 | email: "test@example.com", 162 | password: "12345678", 163 | nick_name: "test" 164 | ) 165 | @token = Tiddle.create_and_return_token(@user, FakeRequest.new) 166 | end 167 | 168 | after do 169 | Devise.setup do |config| 170 | config.authentication_keys = [:email] 171 | end 172 | end 173 | 174 | it "allows to access endpoints which require authentication with valid \ 175 | nick name and token" do 176 | get( 177 | secrets_path, 178 | headers: { "X-USER-NICK-NAME" => "test", "X-USER-TOKEN" => @token } 179 | ) 180 | expect(response.status).to eq 200 181 | end 182 | end 183 | 184 | context "when token has expires_in set up" do 185 | before do 186 | @user = User.create!(email: "test@example.com", password: "12345678") 187 | @token = Tiddle.create_and_return_token(@user, FakeRequest.new, expires_in: 1.week) 188 | end 189 | 190 | describe "token is not expired" do 191 | it "does allow to access endpoints which require authentication" do 192 | get( 193 | secrets_path, 194 | headers: { 195 | "X-USER-EMAIL" => "test@example.com", 196 | "X-USER-TOKEN" => @token 197 | } 198 | ) 199 | expect(response.status).to eq 200 200 | end 201 | end 202 | 203 | describe "token is expired" do 204 | before do 205 | token = @user.authentication_tokens.max_by(&:id) 206 | token.update_attribute(:last_used_at, 1.month.ago) 207 | end 208 | 209 | it "does not allow to access endpoints which require authentication" do 210 | get( 211 | secrets_path, 212 | headers: { 213 | "X-USER-EMAIL" => "test@example.com", 214 | "X-USER-TOKEN" => @token 215 | } 216 | ) 217 | expect(response.status).to eq 401 218 | end 219 | end 220 | 221 | context "with value lower than 24 hours" do 222 | before do 223 | @token = Tiddle.create_and_return_token(@user, FakeRequest.new, expires_in: 1.hour) 224 | end 225 | 226 | context "and token was last used a minute ago" do 227 | before do 228 | @user.authentication_tokens.last.update_attribute(:last_used_at, 1.minute.ago) 229 | end 230 | 231 | it "does not update last_used_at field" do 232 | expect do 233 | get( 234 | secrets_path, 235 | headers: { 236 | "X-USER-EMAIL" => "test@example.com", 237 | "X-USER-TOKEN" => @token 238 | } 239 | ) 240 | end.not_to(change { @user.authentication_tokens.last.reload.last_used_at }) 241 | end 242 | end 243 | 244 | context "and token was last used 5 minutes ago" do 245 | before do 246 | @user.authentication_tokens.last.update_attribute(:last_used_at, 5.minute.ago) 247 | end 248 | 249 | it "updates last_used_at field" do 250 | expect do 251 | get( 252 | secrets_path, 253 | headers: { 254 | "X-USER-EMAIL" => "test@example.com", 255 | "X-USER-TOKEN" => @token 256 | } 257 | ) 258 | end.to(change { @user.authentication_tokens.last.reload.last_used_at }) 259 | end 260 | end 261 | end 262 | 263 | context "with value lower than 1 hour" do 264 | before do 265 | @token = Tiddle.create_and_return_token(@user, FakeRequest.new, expires_in: 30.minutes) 266 | end 267 | 268 | context "and token was last used less than a minute ago" do 269 | before do 270 | @user.authentication_tokens.last.update_attribute(:last_used_at, 30.seconds.ago) 271 | end 272 | 273 | it "does not update last_used_at field" do 274 | expect do 275 | get( 276 | secrets_path, 277 | headers: { 278 | "X-USER-EMAIL" => "test@example.com", 279 | "X-USER-TOKEN" => @token 280 | } 281 | ) 282 | end.not_to(change { @user.authentication_tokens.last.reload.last_used_at }) 283 | end 284 | end 285 | 286 | context "and token was last used a minute ago" do 287 | before do 288 | @user.authentication_tokens.last.update_attribute(:last_used_at, 1.minute.ago) 289 | end 290 | 291 | it "updates last_used_at field" do 292 | expect do 293 | get( 294 | secrets_path, 295 | headers: { 296 | "X-USER-EMAIL" => "test@example.com", 297 | "X-USER-TOKEN" => @token 298 | } 299 | ) 300 | end.to(change { @user.authentication_tokens.last.reload.last_used_at }) 301 | end 302 | end 303 | end 304 | end 305 | end 306 | --------------------------------------------------------------------------------