diff --git a/.allow_skipping_tests b/.allow_skipping_tests index b8ba3680e2..fb3988957b 100644 --- a/.allow_skipping_tests +++ b/.allow_skipping_tests @@ -65,6 +65,7 @@ models/concerns/by_organization_scope.rb models/concerns/roles.rb models/fund_request.rb models/notification.rb +models/jwt_denylist.rb notifications/base_notification.rb notifications/delivery_methods/sms.rb notifications/emancipation_checklist_reminder_notification.rb diff --git a/Gemfile b/Gemfile index 7227ab5b31..2734cf129c 100644 --- a/Gemfile +++ b/Gemfile @@ -15,6 +15,8 @@ gem "cssbundling-rails", "~> 1.1" # compiles css gem "delayed_job_active_record" gem "devise" # for authentication gem "devise_invitable" +gem "devise-jwt" +gem "rack-cors" gem "httparty" # for making HTTP network requests 🥳 gem "twilio-ruby" # twilio helper functions gem "draper" # adds decorators for cleaner presentation logic diff --git a/Gemfile.lock b/Gemfile.lock index aed61511ca..513612e1ba 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -148,7 +148,10 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) - devise_invitable (2.0.8) + devise-jwt (0.10.0) + devise (~> 4.0) + warden-jwt_auth (~> 0.6) + devise_invitable (2.0.7) actionmailer (>= 5.0) devise (>= 4.6) diff-lcs (1.5.0) @@ -166,7 +169,16 @@ GEM activesupport (>= 5.0) request_store (>= 1.0) ruby2_keywords - erb_lint (0.4.0) + dry-auto_inject (1.0.1) + dry-core (~> 1.0) + zeitwerk (~> 2.6) + dry-configurable (1.0.1) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-core (1.0.0) + concurrent-ruby (~> 1.0) + zeitwerk (~> 2.6) + erb_lint (0.3.1) activesupport better_html (>= 2.0.1) parser (>= 2.7.1.4) @@ -319,7 +331,9 @@ GEM rack (2.2.7) rack-attack (6.6.1) rack (>= 1.0, < 3) - rack-test (2.1.0) + rack-cors (2.0.0) + rack (>= 2.0.0) + rack-test (2.0.2) rack (>= 1.3) rails (7.0.5) actioncable (= 7.0.5) @@ -452,6 +466,11 @@ GEM method_source (~> 1.0) warden (1.2.9) rack (>= 2.0.9) + warden-jwt_auth (0.8.0) + dry-auto_inject (>= 0.8, < 2) + dry-configurable (>= 0.13, < 2) + jwt (~> 2.1) + warden (~> 1.2) web-console (4.2.0) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -502,6 +521,7 @@ DEPENDENCIES database_cleaner-active_record delayed_job_active_record devise + devise-jwt devise_invitable dotenv-rails draper @@ -529,6 +549,7 @@ DEPENDENCIES puma (= 6.2.2) pundit rack-attack + rack-cors rails (~> 7.0.5) rails-controller-testing rake diff --git a/app/controllers/concerns/accessible.rb b/app/controllers/concerns/accessible.rb index 269abe0476..be37df1d75 100644 --- a/app/controllers/concerns/accessible.rb +++ b/app/controllers/concerns/accessible.rb @@ -12,7 +12,7 @@ def check_user flash.clear redirect_to(authenticated_all_casa_admin_root_path) and return # override "after_sign_in_path_for" and redirect user to root path if no target URL is stored in session - elsif current_user && session[:user_return_to].nil? + elsif request.format.html? && current_user && session[:user_return_to].nil? flash.clear # The authenticated root path can be defined in your routes.rb in: devise_scope :user do... redirect_to(authenticated_user_root_path) and return diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index a1afeb575d..8d667535ec 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -1,6 +1,36 @@ # frozen_string_literal: true class Users::SessionsController < Devise::SessionsController + # respond_to :json include Accessible skip_before_action :check_user, only: :destroy + + def create + respond_to do |format| + format.html { super } + format.json { + warden.authenticate!(scope: resource_name, recall: "#{controller_path}#faliure") + render json: "Successfully authenticated", status: 200 + } + end + end + + # API authentication error + def faliure + render json: "Something went wrong with API sign in :(", status: 401 + end + + private + + def respond_to_on_destroy + # We actually need to hardcode this as Rails default responder doesn't + # support returning empty response on GET request + respond_to do |format| + format.json { + render json: "Successfully Signed Out", status: 200 + } + format.all { head :no_content } + format.any(*navigational_formats) { redirect_to after_sign_out_path_for(resource_name), status: Devise.responder.redirect_status } + end + end end diff --git a/app/models/jwt_denylist.rb b/app/models/jwt_denylist.rb new file mode 100644 index 0000000000..2eb57f14e2 --- /dev/null +++ b/app/models/jwt_denylist.rb @@ -0,0 +1,18 @@ +class JwtDenylist < ApplicationRecord + include Devise::JWT::RevocationStrategies::Denylist + + self.table_name = "jwt_denylist" +end + +# == Schema Information +# +# Table name: jwt_denylist +# +# id :bigint not null, primary key +# exp :datetime not null +# jti :string not null +# +# Indexes +# +# index_jwt_denylist_on_jti (jti) +# diff --git a/app/models/user.rb b/app/models/user.rb index 1dca574781..9694d4ad98 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -12,7 +12,7 @@ class User < ApplicationRecord validates_with UserValidator - devise :database_authenticatable, :invitable, :recoverable, :validatable, :timeoutable, :trackable, :confirmable + devise :database_authenticatable, :invitable, :recoverable, :validatable, :timeoutable, :trackable, :confirmable, :jwt_authenticatable, jwt_revocation_strategy: JwtDenylist belongs_to :casa_org diff --git a/config/application.rb b/config/application.rb index fb94a1763c..273ccfddd3 100644 --- a/config/application.rb +++ b/config/application.rb @@ -26,5 +26,8 @@ class Application < Rails::Application config.active_storage.variant_processor = :mini_magick config.active_storage.content_types_to_serve_as_binary.delete("image/svg+xml") config.serve_static_assets = true + config.to_prepare do + DeviseController.respond_to :html, :json + end end end diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc index 4c0cc7ab67..6762972412 100644 --- a/config/credentials/development.yml.enc +++ b/config/credentials/development.yml.enc @@ -1 +1 @@ -svCtLWmi6TUWfy4jhsNxZgGKdzBrjq5JjKkGUaDA5tlP2XFn6XY8lJDVhF+T82kGjwT4EgsBheMZqPMbytlJ6iSDBIq/bHfjl1E5Zx3DqCkd4gDYgVK0roJffesKQPuWUSQUzvJV9pZ9VQEKbh+YA/I/N6aWGbkYlKXTOPHMY7F+rfiKXb8vHodUGWxCTycsWLpe/ohBvF7zzSwxkG7sEmbnRnqYd2Tmn0ASf6vNKXOzPamQ21rrgUss427/zjCjzWHCk4iUaHnhQQYwC2zJ+m1/0Uu+sM5CkYJhddsPbeeQkd7vgPjHBylgkT6L86XTz8sBrQDZB51TbmNouygu96NzQwE472c0csFEWwjz7fepy7sZkHN5KqQ=--dx6D/QqFOeacGYGg--+r3ffqcg8wONL9oMId9u5g== \ No newline at end of file +kCD5s9jBDZZfXdKirYJD95LfwF3xeWi2E+OGS9x9/8EsI6WMzl+jNUjuBQt/fUdO5R5pCHXXPCYPzsGnDyTKJJczqszDXDsG2xS5eSImw9O7BGmNDh7W1sQ5kMmjZPUUu9GaCcAjmnvh0ImXM2X+Lo/5X3NeAZ7FllFmUd3CaBD5J3N3mbXlJgVGRylabaKABqrZqbRnZK5QDOcjwxYspsnu3m9M2qx5B7m2RnpKdytDpxbRtGSj34cZmH0C+rGOCQUm9rSR5wrQVmmJ5DdElYAnM7lo6bUTf2W5wbd51TsrEATPCHx1iK89ot/alUIjmHcoMlUstLnSX8VQzQeIluqa7a7o2IN/p++vknWELVlYROn67wiRTrLPXeDy+NkhiYkhQCBl3AIL8qs92tKX9xwvM75q5pGwsxQneGM5IjOSHUnEmw6j0f6B0R1oksOGt/xg9VP73ANP3zxqNTnbzRNoqO9wVVju0jJVt8KGoXVzdQx2qQSU5ZqUulJ7v88Xjro7Ew6mSY5gh4itzzsVIF7kAEJM20eh2WWxNEDMZyzvKDD34XWxOb8nks9MF0yCJzWBIog=--m9KJMwI1N3qSf0ry--d64JLH6L2BJNNIW2DdMR6w== \ No newline at end of file diff --git a/config/credentials/test.yml.enc b/config/credentials/test.yml.enc index 51801db2a1..3fa70806dc 100644 --- a/config/credentials/test.yml.enc +++ b/config/credentials/test.yml.enc @@ -1 +1 @@ -zsvjeQzFV0vM7h3jsx7RUyA3WTQsEYWvEMreEvVbi4L0HN7sDNrP7kay2FZS5VEwrW5mJZdnu63BXa8fK/h1agvaaiOO9FhDXfyK+VT86TAfsLa1gsBK9mHjWSdXCJE9TZj9OjOtR3R/qHOI+2uPUnysh7Om4a0ckiu4Jwex3OcbgCYj2+G2JQtwHkWhlyBthGxLjuDDFfx+qxkJWkN7V9FhN0FkkPaflyj4FjR9BUf3/CB8pvHXqJ1lmxVScYsyhh50mc+CKyVptpqbLi9Jou3SiUePREX03ynV0KPR+7mT3FH9gCj4QyzzS1t3JOUfrgqeVFAzdV1TW01olinOyG2aMrZn1aA7GWfDeIr/GwnaPfUMmZNj4RQ=--KmPWCR7xHr6jUSx5--1L2S0bUzD2Bc+JFAHX7xKg== \ No newline at end of file +Z9z6La5/jrIcjvqDXZLmtw2hMblsJ4e+LfUCe3sOPdhdkk7iqSNlEpMe5/HSPubVjpTPuciGifG3W8SCfrESdK3yEYS8VRY1pImPrxBRzE8VjJ6/tyNZ6U/FWfveK2uisnxDeiM1j2PfbM1nhlggMWW+Ll4+LJba7HFQtJn4oyES4dK9syrgcB1zMZeD/Tdqub9eNIUBCqj/ehOnvUnTop6O5k5BI3Z7jXH/iNqNYK+Ks2rq0XdP5C3rLaDaotJkdcWjIZSGraovO6MrDl4YfNdcsWIBWsq2VNgRzkz2zrcaGCXaY9drd4r8OMQiULWplCO6ZAXHeuwubkisVdcqUZMhQ52Pklky36xmri76Tt5ztN30PGiD1PYYceDcHeuqnYsTVEonbJp1o4I7Z0ZvIVSR0dy5TeIne7A=--/Gk/JLOC9ouxL6CR--N9wv5rGQmObNmSe5FsRyjA== \ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index 7a63c3c4e3..beba8efc71 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -79,4 +79,7 @@ # config.action_cable.disable_request_forgery_protection = true config.assets.digest = false + + # allow requests from ngrok + config.hosts << ".ngrok-free.app" end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb new file mode 100644 index 0000000000..4884b6ca3b --- /dev/null +++ b/config/initializers/cors.rb @@ -0,0 +1,10 @@ +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins "*" # make sure to change to domain name of frontend + + resource "*", + headers: %w[Authorization], + methods: :any, + expose: %w[Authorization] + end +end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index a7e269e0d3..1ae98686ce 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -300,7 +300,7 @@ # should add them to the navigational formats lists. # # The "*/*" below is required to match Internet Explorer requests. - # config.navigational_formats = ['*/*', :html] + config.navigational_formats = ["*/*", :html, :json] # The default HTTP method used to sign out a resource. Default is :delete. config.sign_out_via = :get @@ -345,4 +345,9 @@ # When set to false, does not sign a user in automatically after their password is # changed. Defaults to true, so a user is signed in automatically after changing a password. # config.sign_in_after_change_password = true + + # ==> Configuration for devise-jwt secret key generation + config.jwt do |jwt| + jwt.secret = Rails.application.credentials.devise.jwt_secret_key + end end diff --git a/db/migrate/20230308041745_create_jwt_denylist.rb b/db/migrate/20230308041745_create_jwt_denylist.rb new file mode 100644 index 0000000000..6a96c3dd0a --- /dev/null +++ b/db/migrate/20230308041745_create_jwt_denylist.rb @@ -0,0 +1,9 @@ +class CreateJwtDenylist < ActiveRecord::Migration[7.0] + def change + create_table :jwt_denylist do |t| + t.string :jti, null: false + t.datetime :exp, null: false + end + add_index :jwt_denylist, :jti + end +end diff --git a/db/schema.rb b/db/schema.rb index 2c96e03de2..f38b23441b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -326,6 +326,12 @@ t.index ["casa_org_id"], name: "index_judges_on_casa_org_id" end + create_table "jwt_denylist", force: :cascade do |t| + t.string "jti", null: false + t.datetime "exp", null: false + t.index ["jti"], name: "index_jwt_denylist_on_jti" + end + create_table "languages", force: :cascade do |t| t.string "name" t.bigint "casa_org_id", null: false diff --git a/spec/requests/users/sessions_spec.rb b/spec/requests/users/sessions_spec.rb new file mode 100644 index 0000000000..d504fb58f1 --- /dev/null +++ b/spec/requests/users/sessions_spec.rb @@ -0,0 +1,64 @@ +require "rails_helper" + +RSpec.describe "Users::SessionsController", type: :request do + let(:casa_org) { create(:casa_org) } + let(:volunteer) { create(:volunteer, casa_org: casa_org) } + + describe "POST create" do + before { + @params = { + user: { + email: volunteer.email, + password: volunteer.password + } + } + } + + context "when a user signs in" do + context "when request format is json" do + after { + expect(response).not_to have_http_status(:redirect) + expect(response.content_type).to eq("application/json; charset=utf-8") + } + + it "respond with jwt in response header" do + # header "Content-Type", "application/json; charset=utf-8" + post "/users/sign_in", as: :json, params: @params + # print request.headers + expect(response.headers).to have_key "Authorization" + expect(response.headers["Authorization"]).to be_starts_with("Bearer") + expect(response.body).to eq "Successfully authenticated" + end + + it "respond with status code 401 for bad request" do + post "/users/sign_in", as: :json, params: {user: {email: "suzume@tojimari.jp", password: ""}} + expect(response.headers).not_to have_key "Authorization" + expect(response.body).to eq "Something went wrong with API sign in :(" + end + end + end + + context "when a user signs out" do + context "when request format is json" do + after { expect(response).not_to have_http_status(:redirect) } + + it "adds JWT to denylist" do + # header "Content-Type", "application/json; charset=utf-8" + post "/users/sign_in", as: :json, params: @params + # get JWT from response header + auth_bearer = response.headers["Authorization"] + # print auth_bearer + expect { + get "/users/sign_out", as: :json, headers: {"Authorization" => auth_bearer} + }.to change(JwtDenylist, :count).by(1) + end + + it "responds with status code 200" do + # header "Content-Type", "application/json; charset=utf-8" + post "/users/sign_in", as: :json, params: @params + get "/users/sign_out", as: :json + end + end + end + end +end