diff --git a/CHANGELOG.md b/CHANGELOG.md index a9135699..41a41466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * Adds Honeybadger generator. ([@mausamp][]) * Adds Rails ERD generator. ([@mausamp][]) * Adds Annotate Generator. ([@TheZero0-ctrl][]) +* Adds Devise JWT generator. ([@TheZero0-ctrl][]) ## 0.13.0 (March 26th, 2024) * Adds Letter Opener generator. ([@coolprobn][]) diff --git a/README.md b/README.md index a771077c..ff53f1da 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ The boring generator introduces following generators: - Install Honeybadger: `rails generate boring:honeybadger:install` - Install Rails ERD: `rails generate boring:rails_erd:install` - Install Annotate: `rails generate boring:annotate:install` +- 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..b268552c --- /dev/null +++ b/lib/generators/boring/devise/jwt/install/install_generator.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'boring_generators/generator_helper' + +module Boring + module Devise + module Jwt + class InstallGenerator < Rails::Generators::Base + include Rails::Generators::Migration + include BoringGenerators::GeneratorHelper + + desc "Add devise-jwt to the application" + source_root File.expand_path('templates', __dir__) + + 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 self.next_migration_number(dirname) + next_migration_number = current_migration_number(dirname) + 1 + ActiveRecord::Migration.next_migration_number(next_migration_number) + end + + def verify_presence_of_devise_gem + return if gem_installed?("devise") + + say "We couldn't find devise gem. Please configure devise gem and rerun the generator. Consider running `rails generate boring:devise:install` to set up Devise.", + :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 and rerun the generator. Consider running `rails generate boring:devise:install` to set up Devise.", + :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 + check_and_install_gem("devise-jwt") + bundle_install + 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_db_table = options[:model_name].tableize + @model_class = @model_db_table.camelcase + + migration_template "add_jti_to_users.rb", "db/migrate/add_jti_to_#{@model_db_table}.rb" + + 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" + end + + migration_template "create_jwt_denylist.rb", "db/migrate/create_jwt_denylist.rb" + + 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_underscore = options[:model_name].underscore + Bundler.with_unbundled_env do + run "bundle exec rails generate model allowlisted_jwt --skip-migration" + end + + migration_template "create_allowlisted_jwts.rb", "db/migrate/create_allowlisted_jwts.rb" + + 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/lib/generators/boring/devise/jwt/install/templates/add_jti_to_users.rb.tt b/lib/generators/boring/devise/jwt/install/templates/add_jti_to_users.rb.tt new file mode 100644 index 00000000..e075689b --- /dev/null +++ b/lib/generators/boring/devise/jwt/install/templates/add_jti_to_users.rb.tt @@ -0,0 +1,6 @@ +class AddJtiTo<%= @model_class %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + add_column :<%= @model_db_table %>, :jti, :string, null: false + add_index :<%= @model_db_table %>, :jti, unique: true + end +end diff --git a/lib/generators/boring/devise/jwt/install/templates/create_allowlisted_jwts.rb.tt b/lib/generators/boring/devise/jwt/install/templates/create_allowlisted_jwts.rb.tt new file mode 100644 index 00000000..ea3bc095 --- /dev/null +++ b/lib/generators/boring/devise/jwt/install/templates/create_allowlisted_jwts.rb.tt @@ -0,0 +1,15 @@ +class CreateAllowlistedJwts < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + create_table :allowlisted_jwts do |t| + 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_underscore %>, foreign_key: { on_delete: :cascade }, null: false + + t.timestamps + end + add_index :allowlisted_jwts, :jti + end +end diff --git a/lib/generators/boring/devise/jwt/install/templates/create_jwt_denylist.rb.tt b/lib/generators/boring/devise/jwt/install/templates/create_jwt_denylist.rb.tt new file mode 100644 index 00000000..88b61dbb --- /dev/null +++ b/lib/generators/boring/devise/jwt/install/templates/create_jwt_denylist.rb.tt @@ -0,0 +1,11 @@ +class CreateJwtDenylist < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + create_table :jwt_denylist do |t| + t.string :jti, null: false + t.datetime :exp, null: false + + t.timestamps + end + add_index :jwt_denylist, :jti + 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..0958b3d2 --- /dev/null +++ b/test/generators/devise/devise_jwt_install_generator_test.rb @@ -0,0 +1,136 @@ +# 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