diff --git a/.env b/.env deleted file mode 100644 index f17858f..0000000 --- a/.env +++ /dev/null @@ -1,6 +0,0 @@ -POSTGRES_USER=postgres -# If you declared a password when creating the database: -POSTGRES_PASSWORD=gres@r00t -POSTGRES_HOST=localhost -POSTGRES_DB=Blog_App_development -POSTGRES_TEST_DB=Blog_App_test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 370921d..726d2b7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,7 @@ node_modules/ # Ignore blog.http, used for testing api endpoints with REST Client -blog.http \ No newline at end of file +blog.http + +# Ignore .env file for storing environment variables +.env \ No newline at end of file diff --git a/Gemfile b/Gemfile index 229b222..c7a2048 100644 --- a/Gemfile +++ b/Gemfile @@ -15,10 +15,20 @@ gem 'pg', '~> 1.1' # Use Rubocop for linters gem 'rubocop', '>= 1.0', '< 2.0' +# Use rack-cors for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible +# https:// github.com/cyu/rack-cors +gem 'rack-cors' + # Use for Devise Authentication +# https://github.com/heartcombo/devise gem 'devise' +# JWT for Devise Authentication for API +# https://github.com/waiting-for-dev/devise-jwt +gem 'devise-jwt' + # Use CAnCanCan for Authorization +# https://github.com/CanCanCommunity/cancancan gem 'cancancan' # Use for hiding credentials @@ -77,6 +87,8 @@ group :development do gem 'web-console' # Use bullet to fix N + 1 problems gem 'bullet' + # Use letter_opener to open emails in the browser + gem 'letter_opener' # Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler] # gem "rack-mini-profiler" diff --git a/Gemfile.lock b/Gemfile.lock index f33a6d4..b12ab27 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,11 +105,24 @@ GEM railties (>= 4.1.0) responders warden (~> 1.2.3) + devise-jwt (0.10.0) + devise (~> 4.0) + warden-jwt_auth (~> 0.6) diff-lcs (1.5.0) dotenv (2.8.1) dotenv-rails (2.8.1) dotenv (= 2.8.1) railties (>= 3.2) + dry-auto_inject (0.9.0) + dry-container (>= 0.3.4) + dry-configurable (0.16.1) + dry-core (~> 0.6) + zeitwerk (~> 2.6) + dry-container (0.11.0) + concurrent-ruby (~> 1.0) + dry-core (0.9.1) + concurrent-ruby (~> 1.0) + zeitwerk (~> 2.6) erubi (1.11.0) ffi (1.15.5-x64-mingw-ucrt) globalid (1.0.0) @@ -126,6 +139,11 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) json (2.6.2) + jwt (2.6.0) + launchy (2.5.2) + addressable (~> 2.8) + letter_opener (1.8.1) + launchy (>= 2.2, < 3) loofah (2.19.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) @@ -160,6 +178,8 @@ GEM nio4r (~> 2.0) racc (1.6.0) rack (2.2.4) + rack-cors (1.1.1) + rack (>= 2.0.0) rack-test (2.0.2) rack (>= 1.3) rails (7.0.4) @@ -269,6 +289,11 @@ GEM uniform_notifier (1.16.0) warden (1.2.9) rack (>= 2.0.9) + warden-jwt_auth (0.7.0) + dry-auto_inject (~> 0.8) + dry-configurable (~> 0.13) + jwt (~> 2.1) + warden (~> 1.2) web-console (4.2.0) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -297,13 +322,16 @@ DEPENDENCIES database_cleaner debug devise + devise-jwt dotenv-rails ffi importmap-rails jbuilder + letter_opener pagy (~> 5.10) pg (~> 1.1) puma (~> 5.0) + rack-cors rails (~> 7.0.4) rails-controller-testing rspec-rails diff --git a/app/controllers/api/v1/application_controller.rb b/app/controllers/api/v1/application_controller.rb index 3a1093d..9ed7d59 100644 --- a/app/controllers/api/v1/application_controller.rb +++ b/app/controllers/api/v1/application_controller.rb @@ -2,13 +2,24 @@ class Api::V1::ApplicationController < ActionController::API include Response include ExceptionHandler - before_action :restrict_access + # before_action :restrict_access + + before_action :authenticate_user! + before_action :configure_permitted_parameters, if: :devise_controller? + respond_to :json - private + protected - def restrict_access - api_key = ApiKey.find_by_access_token(params[:access_token]) - head :unauthorized unless api_key + def configure_permitted_parameters + devise_parameter_sanitizer.permit(:sign_up) { |u| u.permit(:name, :email, :password, :password_confirmation) } + devise_parameter_sanitizer.permit(:sign_in) { |u| u.permit(:email, :password) } end + + # private + + # def restrict_access + # api_key = ApiKey.find_by_access_token(params[:access_token]) + # head :unauthorized unless api_key + # end end diff --git a/app/controllers/api/v1/comments_controller.rb b/app/controllers/api/v1/comments_controller.rb index dcbdb69..03c59de 100644 --- a/app/controllers/api/v1/comments_controller.rb +++ b/app/controllers/api/v1/comments_controller.rb @@ -30,7 +30,7 @@ def set_author end def set_post - @post = set_author.posts.find(params[:post_id]) + @post = Post.find(params[:post_id]) end def set_comment diff --git a/app/controllers/api/v1/users/registrations_controller.rb b/app/controllers/api/v1/users/registrations_controller.rb new file mode 100644 index 0000000..3623947 --- /dev/null +++ b/app/controllers/api/v1/users/registrations_controller.rb @@ -0,0 +1,85 @@ +class Api::V1::Users::RegistrationsController < Devise::RegistrationsController + # CSRF token verification is not required for API requests as they are stateless. + # The token is generated on the client side and is not accessible to the server. + # So, we skip this verification. + skip_before_action :verify_authenticity_token + + respond_to :json + + private + + def respond_with(resource, _opts = {}) + p resource + resource.persisted? ? register_success : register_failed + end + + def register_success + render json: { + status: 200, + message: 'Signed up sucessfully.' + }, status: :ok + end + + def register_failed + render json: { + status: 422, + message: "Signed up failure. #{resource.errors.full_messages.to_sentence}" + }, status: :unprocessable_entity + end + + # GET /resource/sign_up + # def new + # super + # end + + # POST /resoure# + # def create + # super + # end + + # GET /resource/edit + # def edit + # super + # end + + # PUT /resource + # def update + # super + # end + + # DELETE /resource + # def destroy + # super + # end + + # GET /resource/cancel + # Forces the session data which is usually expired after sign + # in to be expired now. This is useful if the user wants to + # cancel oauth signing in/up in the middle of the process, + # removing all OAuth session data. + # def cancel + # super + # end + + # protected + + # If you have extra params to permit, append them to the sanitizer. + # def configure_sign_up_params + # devise_parameter_sanitizer.permit(:sign_up, keys: [:attribute]) + # end + + # If you have extra params to permit, append them to the sanitizer. + # def configure_account_update_params + # devise_parameter_sanitizer.permit(:account_update, keys: [:attribute]) + # end + + # The path used after sign up. + # def after_sign_up_path_for(resource) + # super(resource) + # end + + # The path used after sign up for inactive accounts. + # def after_inactive_sign_up_path_for(resource) + # super(resource) + # end +end diff --git a/app/controllers/api/v1/users/sessions_controller.rb b/app/controllers/api/v1/users/sessions_controller.rb new file mode 100644 index 0000000..8f7803b --- /dev/null +++ b/app/controllers/api/v1/users/sessions_controller.rb @@ -0,0 +1,85 @@ +class Api::V1::Users::SessionsController < Devise::SessionsController + # CSRF token verification is not required for API requests as they are stateless. + # The token is generated on the client side and is not accessible to the server. + # So, we skip this verification. + skip_before_action :verify_authenticity_token + + respond_to :json + + private + + def respond_with(_resource, _opts = {}) + current_user ? log_in_success : log_in_failure + end + + def respond_to_on_destroy + if request.headers['Authorization'].present? + jwt_payload = JWT.decode(request.headers['Authorization'].split.last, + ENV.fetch('DEVISE_JWT_SECRET_KEY')).first + + current_user = User.find(jwt_payload['sub']) + + current_user ? log_out_success : log_out_failure + else + log_out_failure + end + end + + def log_in_success + render json: { + status: { + code: 200, + message: 'Logged in sucessfully.', + data: current_user + } + }, status: :ok + end + + def log_in_failure + render json: { + status: { + code: 401, + message: "Logged in failure. #{resource.errors.full_messages.to_sentence}", + data: current_user + } + }, status: :unauthorized + end + + def log_out_success + render json: { + status: 200, + message: 'Logged out sucessfully.' + }, status: :ok + end + + def log_out_failure + render json: { + status: 401, + message: 'Logged out failure.' + }, status: :unauthorized + end + + # before_action :configure_sign_in_params, only: [:create] + + # GET /resource/sign_in + # def new + # super + # end + + # POST /resource/sign_in + # def create + # super + # end + + # DELETE /resource/sign_out + # def destroy + # super + # end + + # protected + + # If you have extra params to permit, append them to the sanitizer. + # def configure_sign_in_params + # devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute]) + # end +end diff --git a/app/controllers/concerns/exception_handler.rb b/app/controllers/concerns/exception_handler.rb index d9429bb..ff7c886 100644 --- a/app/controllers/concerns/exception_handler.rb +++ b/app/controllers/concerns/exception_handler.rb @@ -2,11 +2,11 @@ module ExceptionHandler extend ActiveSupport::Concern included do rescue_from ActiveRecord::RecordNotFound do |e| - json_response({ message: e.message }, :not_found) + json_response({ code: 404, message: e.message }, :not_found) end rescue_from ActiveRecord::RecordInvalid do |e| - json_response({ message: e.message }, :unprocessable_entity) + json_response({ code: 422, message: e.message }, :unprocessable_entity) end end end diff --git a/app/models/user.rb b/app/models/user.rb index d48f0f5..09ca7e7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,8 +1,11 @@ class User < ApplicationRecord + include Devise::JWT::RevocationStrategies::JTIMatcher # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :validatable, :confirmable + :recoverable, :rememberable, :validatable, :confirmable, + :jwt_authenticatable, jwt_revocation_strategy: self + validates :name, presence: true validates :posts_counter, comparison: { greater_than_or_equal_to: 0 }, numericality: { only_integer: true } @@ -10,6 +13,10 @@ class User < ApplicationRecord has_many :comments, foreign_key: :author_id, dependent: :destroy has_many :likes, foreign_key: :author_id, dependent: :destroy + # def jwt_payload + # super + # end + # User::Roles # The available roles ROLES = %i[admin default].freeze diff --git a/config/application.rb b/config/application.rb index 8bf1de4..4a10da7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -18,5 +18,8 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + config.session_store :cookie_store, key: '_interslice_session' + config.middleware.use ActionDispatch::Cookies + config.middleware.use config.session_store, config.session_options end end diff --git a/config/environments/development.rb b/config/environments/development.rb index 1ccc3ec..ab9eba1 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -52,6 +52,9 @@ # Default URL options for the Devise mailer config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } + + # Letter Opener config for development environment + config.action_mailer.delivery_method = :letter_opener # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb new file mode 100644 index 0000000..fe02237 --- /dev/null +++ b/config/initializers/cors.rb @@ -0,0 +1,17 @@ +# Be sure to restart your server when you modify this file. + +# Avoid CORS issues when API is called from the frontend app. +# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. + +# Read more: https://github.com/cyu/rack-cors + +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins "example.com" + + resource "*", + headers: :any, + methods: [:get, :post, :put, :patch, :delete, :options, :head], + expose: ["Authorization"] + end +end \ No newline at end of file diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 3d548ab..a3209c0 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -308,4 +308,14 @@ # 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 + config.jwt do |jwt| + jwt.secret = ENV['DEVISE_JWT_SECRET_KEY'] + jwt.dispatch_requests = [ + ['POST', %r{^/api/v1/login$}] + ] + jwt.revocation_requests = [ + ['DELETE', %r{^/api/v1/logout$} ] + ] + jwt.expiration_time = 120.minutes.to_i + end end diff --git a/config/routes.rb b/config/routes.rb index a4a1f87..9e217af 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,8 @@ Rails.application.routes.draw do - devise_for :users + # => This is commented out because we are using devise_for :users in the scope :api do block below + # => and we cant use the default devise_for :users for the web app and the api at the same time + # => because they both use the same routes + #devise_for :users root to: "users#index" @@ -9,14 +12,29 @@ resources :likes, only: [:create] end end + + scope :api, defaults: { format: :json } do + scope :v1 do + devise_for :users, # => this is the devise_for :users that is used for the api + controllers: { + registrations: 'api/v1/users/registrations', + sessions: 'api/v1/users/sessions' + }, + path: '', + path_names: { + sign_in: 'login', + sign_out: 'logout', + registration: 'register' + } + end + end # API routes namespace :api do namespace :v1 do - resources :users do - resources :posts do - resources :comments - resources :likes + resources :users, only: [:index, :show] do + resources :posts, only: [:index, :show] do + resources :comments, only: [:index, :show, :create] end end end diff --git a/db/migrate/20221230204000_add_jti_to_users.rb b/db/migrate/20221230204000_add_jti_to_users.rb new file mode 100644 index 0000000..2acf7f0 --- /dev/null +++ b/db/migrate/20221230204000_add_jti_to_users.rb @@ -0,0 +1,11 @@ +class AddJtiToUsers < ActiveRecord::Migration[7.0] + def change + # add_column :users, :jti, :string, null: false + # add_index :users, :jti, unique: true + # If you already have user records, you will need to initialize its `jti` column before setting it to not nullable. Your migration will look this way: + add_column :users, :jti, :string + User.all.each { |user| user.update_column(:jti, SecureRandom.uuid) } + change_column_null :users, :jti, false + add_index :users, :jti, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index bdbefaf..ff6196c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2022_12_09_043756) do +ActiveRecord::Schema[7.0].define(version: 2022_12_30_204000) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -67,8 +67,10 @@ t.datetime "confirmation_sent_at" t.string "unconfirmed_email" t.string "role" + t.string "jti", null: false t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true + t.index ["jti"], name: "index_users_on_jti", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end