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
faaf902
commit e474e3f
Showing
7 changed files
with
359 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
189 changes: 189 additions & 0 deletions
189
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,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 |
6 changes: 6 additions & 0 deletions
6
lib/generators/boring/devise/jwt/install/templates/add_jti_to_users.rb.tt
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,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 |
15 changes: 15 additions & 0 deletions
15
lib/generators/boring/devise/jwt/install/templates/create_allowlisted_jwts.rb.tt
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,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 |
11 changes: 11 additions & 0 deletions
11
lib/generators/boring/devise/jwt/install/templates/create_jwt_denylist.rb.tt
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,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 |
136 changes: 136 additions & 0 deletions
136
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,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 |