forked from abhaynikam/boring_generators
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
generator-for-devise-jwt: Implement the generator for devise-jwt
- Loading branch information
1 parent
c5743cb
commit 5fd962b
Showing
4 changed files
with
363 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
224 changes: 224 additions & 0 deletions
224
lib/generators/boring/devise/jwt/install/install_generator.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
137 changes: 137 additions & 0 deletions
137
test/generators/devise/devise_jwt_install_generator_test.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |