From 776e246538f4846a680ca346406c08fc497faeb8 Mon Sep 17 00:00:00 2001 From: TheZero0-ctrl Date: Mon, 18 Mar 2024 09:27:11 +0545 Subject: [PATCH] generator-for-devise-jwt: Implement the generator for devise-jwt --- CHANGELOG.md | 2 + README.md | 1 + .../devise/jwt/install/install_generator.rb | 224 ++++++++++++++++++ .../devise_jwt_install_generator_test.rb | 137 +++++++++++ 4 files changed, 364 insertions(+) create mode 100644 lib/generators/boring/devise/jwt/install/install_generator.rb create mode 100644 test/generators/devise/devise_jwt_install_generator_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f6b44e..fc4a7555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## master (unreleased) +* Adds Devise JWT generator. ([@TheZero0-ctrl][]) ## 0.13.0 (March 26th, 2024) * Adds Letter Opener generator. ([@coolprobn][]) @@ -84,3 +85,4 @@ [@luathn]: https://github.com/luathn [@coolprobn]: https://github.com/coolprobn [@aadil]: https://github.com/AdilRT +[@TheZero0-ctrl]: https://github.com/TheZero0-ctrl diff --git a/README.md b/README.md index aee53505..676c9755 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ The boring generator introduces following generators: - Install Whenever: `rails generate boring:whenever:install` - Install Rswag: `rails generate boring:rswag:install --rails_port= --authentication_type= --skip_api_authentication= --api_authentication_options= --enable_swagger_ui_authentication=` - Install Webmock: `rails generate boring:webmock:install --app_test_framework=` +- Install Devise JWT: `rails generate boring:devise:jwt:install` ## Screencasts diff --git a/lib/generators/boring/devise/jwt/install/install_generator.rb b/lib/generators/boring/devise/jwt/install/install_generator.rb new file mode 100644 index 00000000..dff4e6f1 --- /dev/null +++ b/lib/generators/boring/devise/jwt/install/install_generator.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +module Boring + module Devise + module Jwt + class InstallGenerator < Rails::Generators::Base + desc "Add devise-jwt to the application" + + class_option :model_name, type: :string, aliases: "-m", + default: "User", + desc: "Tell us the user model name which will be used for authentication. Defaults to User" + class_option :use_env_variable, type: :boolean, aliases: "-ev", + desc: "Use ENV variable for devise_jwt_secret_key. By default Rails credentials will be used." + class_option :revocation_strategy, type: :string, aliases: "-rs", + enum: %w[JTIMatcher Denylist Allowlist], + default: "Denylist", + desc: "Tell us the revocation strategy to be used. Defaults to Denylist" + class_option :expiration_time_in_days, type: :numeric, aliases: "-et", + default: 15, + desc: "Tell us the expiration time on days for the JWT token. Defaults to 15 days" + + def verify_presence_of_devise_gem + gem_file_content_array = File.readlines("Gemfile") + devise_is_installed = gem_file_content_array.any? { |line| line.include?('devise') } + + return if devise_is_installed + + say "We couldn't find devise gem. Please configure devise gem and run the generator again!", :red + + abort + end + + def verify_presence_of_devise_initializer + return if File.exist?("config/initializers/devise.rb") + + say "We couldn't find devise initializer. Please configure devise gem correctly and run the generator again!", :red + + abort + end + + def verify_presence_of_devise_model + return if File.exist?("app/models/#{options[:model_name].underscore}.rb") + + say "We couldn't find the #{options[:model_name]} model. Maybe there is a typo? Please provide the correct model name and run the generator again.", :red + + abort + end + + def add_devise_jwt_gem + say "Adding devise-jwt gem", :green + gem "devise-jwt" + end + + def add_devise_jwt_config_to_devise_initializer + say "Adding devise-jwt configurations to a file `config/initializers/devise.rb`", :green + + jwt_config = <<~RUBY + config.jwt do |jwt| + jwt.secret = #{devise_jwt_secret_key} + jwt.dispatch_requests = [ + ['POST', %r{^/sign_in$}] + ] + jwt.revocation_requests = [ + ['DELETE', %r{^/sign_out$}] + ] + jwt.expiration_time = #{options[:expiration_time_in_days]}.day.to_i + end + RUBY + + inject_into_file "config/initializers/devise.rb", + optimize_indentation(jwt_config, 2), + before: /^end\s*\Z/m + + say "❗️❗️\nValue for jwt.secret will be used from `#{devise_jwt_secret_key}`. You can change this values if they don't match with your app.\n", + :yellow + end + + def configure_revocation_strategies + say "Configuring #{options[:revocation_strategy]} revocation strategy", + :green + + case options[:revocation_strategy] + when "JTIMatcher" + configure_jti_matcher_strategy + when "Denylist" + configure_denylist_strategy + when "Allowlist" + configure_allowlist_strategy + end + end + + private + + def devise_jwt_secret_key + if options[:use_env_variable] + "ENV['DEVISE_JWT_SECRET_KEY']" + else + "Rails.application.credentials.devise_jwt_secret_key" + end + end + + def configure_jti_matcher_strategy + model_name = options[:model_name].underscore + Bundler.with_unbundled_env do + run "bundle exec rails generate migration add_jti_to_#{model_name.pluralize}" + end + migration_content = <<~RUBY + add_column :users, :jti, :string, null: false + add_index :users, :jti, unique: true + RUBY + + inject_into_file Dir["db/migrate/*_add_jti_to_#{model_name.pluralize}.rb"][0], + optimize_indentation(migration_content, 4), + after: /def change\n/, + verbose: false + + add_devise_jwt_module( + strategy: "self", + include_content: "include Devise::JWT::RevocationStrategies::JTIMatcher" + ) + + end + + def configure_denylist_strategy + Bundler.with_unbundled_env do + run "bundle exec rails generate model jwt_denylist --skip-migration" + run "bundle exec rails generate migration create_jwt_denylist" + end + + migration_content = <<~RUBY + t.string :jti, null: false + t.datetime :exp, null: false + RUBY + + gsub_file Dir["db/migrate/*_create_jwt_denylist.rb"][0], + /create_table :jwt_denylists do \|t\|/, + "create_table :jwt_denylist do |t|", + verbose: false + + inject_into_file Dir["db/migrate/*_create_jwt_denylist.rb"][0], + optimize_indentation(migration_content, 6), + after: /create_table :jwt_denylist do \|t\|\n/, + verbose: false + + inject_into_file Dir["db/migrate/*_create_jwt_denylist.rb"][0], + optimize_indentation("add_index :jwt_denylist, :jti", 4), + before: /^ end/, + verbose: false + + add_devise_jwt_module(strategy: "JwtDenylist") + + jwt_denylist_content = <<~RUBY + include Devise::JWT::RevocationStrategies::Denylist + self.table_name = 'jwt_denylist' + RUBY + + inject_into_file "app/models/jwt_denylist.rb", + optimize_indentation(jwt_denylist_content, 2), + after: /ApplicationRecord\n/, + verbose: false + end + + def configure_allowlist_strategy + model_name = options[:model_name].underscore + Bundler.with_unbundled_env do + run "bundle exec rails generate model allowlisted_jwt" + end + + migration_content = <<~RUBY + t.string :jti, null: false + t.string :aud + # If you want to leverage the `aud` claim, add to it a `NOT NULL` constraint: + # t.string :aud, null: false + t.datetime :exp, null: false + t.references :#{model_name}, foreign_key: { on_delete: :cascade }, null: false + RUBY + + inject_into_file Dir["db/migrate/*_create_allowlisted_jwts.rb"][0], + optimize_indentation(migration_content, 6), + after: /create_table :allowlisted_jwts do \|t\|\n/, + verbose: false + + inject_into_file Dir["db/migrate/*_create_allowlisted_jwts.rb"][0], + optimize_indentation("add_index :allowlisted_jwts, :jti", 4), + before: /^ end/, + verbose: false + + add_devise_jwt_module( + strategy: "self", + include_content: "include Devise::JWT::RevocationStrategies::Allowlist" + ) + end + + def add_devise_jwt_module(strategy:, include_content: nil) + model_name = options[:model_name].underscore + model_content = File.read("app/models/#{model_name}.rb") + devise_module_pattern = /devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)/ + + if model_content.match?(devise_module_pattern) + inject_into_file "app/models/#{model_name}.rb", + ", :jwt_authenticatable, jwt_revocation_strategy: #{strategy}", + after: devise_module_pattern + else + inject_into_file "app/models/#{model_name}.rb", + optimize_indentation( + "devise :jwt_authenticatable, jwt_revocation_strategy: #{strategy}", + 2 + ), + after: /ApplicationRecord\n/ + say "Successfully added the devise-jwt module to #{model_name} model. However, it looks like the devise module is missing from the #{model_name} model. Please configure the devise module to ensure everything functions correctly.", + :yellow + end + + if include_content + inject_into_file "app/models/#{model_name}.rb", + optimize_indentation(include_content, 2), + after: /ApplicationRecord\n/, + verbose: false + end + end + end + end + end +end diff --git a/test/generators/devise/devise_jwt_install_generator_test.rb b/test/generators/devise/devise_jwt_install_generator_test.rb new file mode 100644 index 00000000..7f803512 --- /dev/null +++ b/test/generators/devise/devise_jwt_install_generator_test.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "test_helper" +require "generators/boring/devise/jwt/install/install_generator" + +class DeviseInstallGeneratorTest < Rails::Generators::TestCase + tests Boring::Devise::Jwt::InstallGenerator + setup :build_app + teardown :teardown_app + + include GeneratorHelper + include ActiveSupport::Testing::Isolation + + def destination_root + app_path + end + + def test_should_exit_if_devise_is_not_installed + assert_raises SystemExit do + quietly { generator.verify_presence_of_devise_gem } + end + end + + def test_should_exit_if_devise_initializer_is_not_present + assert_raises SystemExit do + quietly { generator.verify_presence_of_devise_initializer } + end + end + + def test_should_exit_if_devise_model_is_not_present + assert_raises SystemExit do + quietly { generator.verify_presence_of_devise_model } + end + end + + def test_should_configure_devise_jwt + Dir.chdir(app_path) do + setup_devise + quietly { run_generator } + assert_gem "devise-jwt" + assert_file "config/initializers/devise.rb" do |content| + assert_match(/config.jwt do |jwt|/, content) + assert_match(/jwt.secret = Rails.application.credentials.devise_jwt_secret/, content) + assert_match(/jwt\.dispatch_requests\s*=\s*\[\s*/, content) + assert_match(/jwt\.revocation_requests\s*=\s*\[\s*/, content) + assert_match(/jwt\.expiration_time\s*=\s*/, content) + end + assert_migration "db/migrate/create_jwt_denylist.rb" + assert_file "app/models/user.rb" do |content| + + assert_match( + /devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)*(?:,\s*:jwt_authenticatable, jwt_revocation_strategy: JwtDenylist)/, + content + ) + end + + assert_file "app/models/jwt_denylist.rb" do |content| + assert_match(/include Devise::JWT::RevocationStrategies::Denylist/, content) + assert_match(/self\.table_name = 'jwt_denylist'/, content) + end + end + end + + def test_should_use_env_variable_for_devise_jwt_secret + Dir.chdir(app_path) do + setup_devise + quietly { run_generator [destination_root, "--use_env_variable"] } + assert_file "config/initializers/devise.rb" do |content| + assert_match(/jwt\.secret\s*=\s*ENV\['DEVISE_JWT_SECRET_KEY'\]/, content) + end + end + end + + def test_should_configure_jti_matcher_revocation_strategy + Dir.chdir(app_path) do + setup_devise + quietly { run_generator [destination_root, "--revocation_strategy=JTIMatcher"] } + assert_migration "db/migrate/add_jti_to_users.rb" + assert_file "app/models/user.rb" do |content| + assert_match(/include Devise::JWT::RevocationStrategies::JTIMatcher/, content) + assert_match( + /devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)*(?:,\s*:jwt_authenticatable, jwt_revocation_strategy: self)/, + content + ) + end + end + end + + def test_should_configure_allowlist_revocation_strategy + Dir.chdir(app_path) do + setup_devise + quietly { run_generator [destination_root, "--revocation_strategy=Allowlist"] } + assert_migration "db/migrate/create_allowlisted_jwts.rb" + assert_file "app/models/user.rb" do |content| + assert_match(/include Devise::JWT::RevocationStrategies::Allowlist/, content) + assert_match( + /devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)*(?:,\s*:jwt_authenticatable, jwt_revocation_strategy: self)/, + content + ) + end + assert_file "app/models/allowlisted_jwt.rb" + end + end + + private + + def setup_devise(model_name: "User") + Bundler.with_unbundled_env do + `bundle add devise` + end + + create_devise_initializer + create_devise_model(model_name) + end + + def create_devise_initializer + FileUtils.mkdir_p("#{app_path}/config/initializers") + content = <<~RUBY + Devise.setup do |config| + end + RUBY + + File.write("#{app_path}/config/initializers/devise.rb", content) + end + + def create_devise_model(model_name) + FileUtils.mkdir_p("#{app_path}/app/models") + content = <<~RUBY + class #{model_name} < ApplicationRecord + devise :database_authenticatable, :registerable, + :recoverable, :rememberable, :validatable + end + RUBY + + File.write("#{app_path}/app/models/#{model_name.underscore}.rb", content) + end +end