|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +module Boring |
| 4 | + module Devise |
| 5 | + module Jwt |
| 6 | + class InstallGenerator < Rails::Generators::Base |
| 7 | + desc "Add devise-jwt to the application" |
| 8 | + |
| 9 | + class_option :model_name, type: :string, aliases: "-m", |
| 10 | + default: "User", |
| 11 | + desc: "Tell us the user model name which will be used for authentication. Defaults to User" |
| 12 | + class_option :use_env_variable, type: :boolean, aliases: "-ev", |
| 13 | + desc: "Use ENV variable for devise_jwt_secret_key. By default Rails credentials will be used." |
| 14 | + class_option :revocation_strategy, type: :string, aliases: "-rs", |
| 15 | + enum: %w[JTIMatcher Denylist Allowlist], |
| 16 | + default: "Denylist", |
| 17 | + desc: "Tell us the revocation strategy to be used. Defaults to Denylist" |
| 18 | + class_option :expiration_time_in_days, type: :numeric, aliases: "-et", |
| 19 | + default: 15, |
| 20 | + desc: "Tell us the expiration time on days for the JWT token. Defaults to 15 days" |
| 21 | + |
| 22 | + def verify_presence_of_devise_gem |
| 23 | + gem_file_content_array = File.readlines("Gemfile") |
| 24 | + devise_is_installed = gem_file_content_array.any? { |line| line.include?('devise') } |
| 25 | + |
| 26 | + return if devise_is_installed |
| 27 | + |
| 28 | + say "We couldn't find devise gem. Please configure devise gem and run the generator again!", :red |
| 29 | + |
| 30 | + abort |
| 31 | + end |
| 32 | + |
| 33 | + def verify_presence_of_devise_initializer |
| 34 | + return if File.exist?("config/initializers/devise.rb") |
| 35 | + |
| 36 | + say "We couldn't find devise initializer. Please configure devise gem correctly and run the generator again!", :red |
| 37 | + |
| 38 | + abort |
| 39 | + end |
| 40 | + |
| 41 | + def verify_presence_of_devise_model |
| 42 | + return if File.exist?("app/models/#{options[:model_name].underscore}.rb") |
| 43 | + |
| 44 | + 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 |
| 45 | + |
| 46 | + abort |
| 47 | + end |
| 48 | + |
| 49 | + def add_devise_jwt_gem |
| 50 | + say "Adding devise-jwt gem", :green |
| 51 | + gem "devise-jwt" |
| 52 | + end |
| 53 | + |
| 54 | + def add_devise_jwt_config_to_devise_initializer |
| 55 | + say "Adding devise-jwt configurations to a file `config/initializers/devise.rb`", :green |
| 56 | + |
| 57 | + jwt_config = <<~RUBY |
| 58 | + config.jwt do |jwt| |
| 59 | + jwt.secret = #{devise_jwt_secret_key} |
| 60 | + jwt.dispatch_requests = [ |
| 61 | + ['POST', %r{^/sign_in$}] |
| 62 | + ] |
| 63 | + jwt.revocation_requests = [ |
| 64 | + ['DELETE', %r{^/sign_out$}] |
| 65 | + ] |
| 66 | + jwt.expiration_time = #{options[:expiration_time_in_days]}.day.to_i |
| 67 | + end |
| 68 | + RUBY |
| 69 | + |
| 70 | + inject_into_file "config/initializers/devise.rb", |
| 71 | + optimize_indentation(jwt_config, 2), |
| 72 | + before: /^end\s*\Z/m |
| 73 | + |
| 74 | + 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", |
| 75 | + :yellow |
| 76 | + end |
| 77 | + |
| 78 | + def configure_revocation_strategies |
| 79 | + say "Configuring #{options[:revocation_strategy]} revocation strategy", |
| 80 | + :green |
| 81 | + |
| 82 | + case options[:revocation_strategy] |
| 83 | + when "JTIMatcher" |
| 84 | + configure_jti_matcher_strategy |
| 85 | + when "Denylist" |
| 86 | + configure_denylist_strategy |
| 87 | + when "Allowlist" |
| 88 | + configure_allowlist_strategy |
| 89 | + end |
| 90 | + end |
| 91 | + |
| 92 | + private |
| 93 | + |
| 94 | + def devise_jwt_secret_key |
| 95 | + if options[:use_env_variable] |
| 96 | + "ENV['DEVISE_JWT_SECRET_KEY']" |
| 97 | + else |
| 98 | + "Rails.application.credentials.devise_jwt_secret_key" |
| 99 | + end |
| 100 | + end |
| 101 | + |
| 102 | + def configure_jti_matcher_strategy |
| 103 | + model_name = options[:model_name].underscore |
| 104 | + Bundler.with_unbundled_env do |
| 105 | + run "bundle exec rails generate migration add_jti_to_#{model_name.pluralize}" |
| 106 | + end |
| 107 | + migration_content = <<~RUBY |
| 108 | + add_column :users, :jti, :string, null: false |
| 109 | + add_index :users, :jti, unique: true |
| 110 | + RUBY |
| 111 | + |
| 112 | + inject_into_file Dir["db/migrate/*_add_jti_to_#{model_name.pluralize}.rb"][0], |
| 113 | + optimize_indentation(migration_content, 4), |
| 114 | + after: /def change\n/, |
| 115 | + verbose: false |
| 116 | + |
| 117 | + add_devise_jwt_module( |
| 118 | + strategy: "self", |
| 119 | + include_content: "include Devise::JWT::RevocationStrategies::JTIMatcher" |
| 120 | + ) |
| 121 | + |
| 122 | + end |
| 123 | + |
| 124 | + def configure_denylist_strategy |
| 125 | + Bundler.with_unbundled_env do |
| 126 | + run "bundle exec rails generate model jwt_denylist --skip-migration" |
| 127 | + run "bundle exec rails generate migration create_jwt_denylist" |
| 128 | + end |
| 129 | + |
| 130 | + migration_content = <<~RUBY |
| 131 | + t.string :jti, null: false |
| 132 | + t.datetime :exp, null: false |
| 133 | + RUBY |
| 134 | + |
| 135 | + gsub_file Dir["db/migrate/*_create_jwt_denylist.rb"][0], |
| 136 | + /create_table :jwt_denylists do \|t\|/, |
| 137 | + "create_table :jwt_denylist do |t|", |
| 138 | + verbose: false |
| 139 | + |
| 140 | + inject_into_file Dir["db/migrate/*_create_jwt_denylist.rb"][0], |
| 141 | + optimize_indentation(migration_content, 6), |
| 142 | + after: /create_table :jwt_denylist do \|t\|\n/, |
| 143 | + verbose: false |
| 144 | + |
| 145 | + inject_into_file Dir["db/migrate/*_create_jwt_denylist.rb"][0], |
| 146 | + optimize_indentation("add_index :jwt_denylist, :jti", 4), |
| 147 | + before: /^ end/, |
| 148 | + verbose: false |
| 149 | + |
| 150 | + add_devise_jwt_module(strategy: "JwtDenylist") |
| 151 | + |
| 152 | + jwt_denylist_content = <<~RUBY |
| 153 | + include Devise::JWT::RevocationStrategies::Denylist |
| 154 | + self.table_name = 'jwt_denylist' |
| 155 | + RUBY |
| 156 | + |
| 157 | + inject_into_file "app/models/jwt_denylist.rb", |
| 158 | + optimize_indentation(jwt_denylist_content, 2), |
| 159 | + after: /ApplicationRecord\n/, |
| 160 | + verbose: false |
| 161 | + end |
| 162 | + |
| 163 | + def configure_allowlist_strategy |
| 164 | + model_name = options[:model_name].underscore |
| 165 | + Bundler.with_unbundled_env do |
| 166 | + run "bundle exec rails generate model allowlisted_jwt" |
| 167 | + end |
| 168 | + |
| 169 | + migration_content = <<~RUBY |
| 170 | + t.string :jti, null: false |
| 171 | + t.string :aud |
| 172 | + # If you want to leverage the `aud` claim, add to it a `NOT NULL` constraint: |
| 173 | + # t.string :aud, null: false |
| 174 | + t.datetime :exp, null: false |
| 175 | + t.references :#{model_name}, foreign_key: { on_delete: :cascade }, null: false |
| 176 | + RUBY |
| 177 | + |
| 178 | + inject_into_file Dir["db/migrate/*_create_allowlisted_jwts.rb"][0], |
| 179 | + optimize_indentation(migration_content, 6), |
| 180 | + after: /create_table :allowlisted_jwts do \|t\|\n/, |
| 181 | + verbose: false |
| 182 | + |
| 183 | + inject_into_file Dir["db/migrate/*_create_allowlisted_jwts.rb"][0], |
| 184 | + optimize_indentation("add_index :allowlisted_jwts, :jti", 4), |
| 185 | + before: /^ end/, |
| 186 | + verbose: false |
| 187 | + |
| 188 | + add_devise_jwt_module( |
| 189 | + strategy: "self", |
| 190 | + include_content: "include Devise::JWT::RevocationStrategies::Allowlist" |
| 191 | + ) |
| 192 | + end |
| 193 | + |
| 194 | + def add_devise_jwt_module(strategy:, include_content: nil) |
| 195 | + model_name = options[:model_name].underscore |
| 196 | + model_content = File.read("app/models/#{model_name}.rb") |
| 197 | + devise_module_pattern = /devise\s*(?:(?:(?::\w+)|(?:\w+:\s*\w+))(?:(?:,\s*:\w+)|(?:,\s*\w+:\s*\w+))*)/ |
| 198 | + |
| 199 | + if model_content.match?(devise_module_pattern) |
| 200 | + inject_into_file "app/models/#{model_name}.rb", |
| 201 | + ", :jwt_authenticatable, jwt_revocation_strategy: #{strategy}", |
| 202 | + after: devise_module_pattern |
| 203 | + else |
| 204 | + inject_into_file "app/models/#{model_name}.rb", |
| 205 | + optimize_indentation( |
| 206 | + "devise :jwt_authenticatable, jwt_revocation_strategy: #{strategy}", |
| 207 | + 2 |
| 208 | + ), |
| 209 | + after: /ApplicationRecord\n/ |
| 210 | + 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.", |
| 211 | + :yellow |
| 212 | + end |
| 213 | + |
| 214 | + if include_content |
| 215 | + inject_into_file "app/models/#{model_name}.rb", |
| 216 | + optimize_indentation(include_content, 2), |
| 217 | + after: /ApplicationRecord\n/, |
| 218 | + verbose: false |
| 219 | + end |
| 220 | + end |
| 221 | + end |
| 222 | + end |
| 223 | + end |
| 224 | +end |
0 commit comments