diff --git a/Gemfile b/Gemfile index 9eebefb9..0a393dce 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,8 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby "3.2.1" +gem "pundit" + gem "simple_form" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" diff --git a/Gemfile.lock b/Gemfile.lock index 80234896..68f9395d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -235,6 +235,8 @@ GEM public_suffix (5.0.4) puma (5.6.7) nio4r (~> 2.0) + pundit (2.3.2) + activesupport (>= 3.0.0) racc (1.6.2) rack (2.2.8) rack-protection (3.0.6) @@ -427,6 +429,7 @@ DEPENDENCIES pg (~> 1.1) pry-rails puma (~> 5.0) + pundit rails (~> 7.0.4, >= 7.0.4.3) rails-erd rails_db diff --git a/README.md b/README.md index 091560cf..4a11f732 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,542 @@ # industrial-auth-1 Target: https://industrial-auth-1.matchthetarget.com/ + +Video: https://share.descript.com/view/qqL5sX534E1 + +Lesson: https://learn.firstdraft.com/lessons/201-photogram-industrial-authorization + +## I. Photogram Industrial Authorization - the long way + +### A. Objectives: +- Hide certain routes, such as: + - /comments + - /photos + - /likes + - /follow_requests + - /ID/edit +- Make private profiles private. +- Using scaffold and leaving all routes will make the above routes available. + +### B. Tasks + +Hide the links and lock the ability to do the following: + - delete and edit photos and comments that don't belong to the user. + - view other user's posts and like section unless the profile is not private or the user is an accepted follower. + - see other user's pending follow requests. + - see a collection of photos of another user's leaders (feed, discover). For instance, /carol/feed and /carol/discover should not be visitable by other users, but Carol herself. + +### C. Approach + +1. Consider each and every route and action a user can take. +2. Solve security holes with: +- Filters: before_action and skip_before_action (https://guides.rubyonrails.org/action_controller_overview.html#filters). +- Redirecting: redirect_to and redirect_back (https://api.rubyonrails.org/v6.1.0/classes/ActionController/Redirecting.html). +- Devise’s current_user method. +- Ruby’s if/else statements. +- Deleting or limiting routes with only: and except: after resources. + +### D. Step-by-step Procedure + +#### D1. Limit routes using `except` and `only`. +1. Populate tables with `rails sample_data`. +2. Run server with `bin/dev` and login as alice. +3. (5-11 min) There are several GET actions available for each route. Here are some of it: + - index + - show + - new + - edit + - create + - destroy + - update + +4. We can limit routes by adding except: or only: parameters. + +For the follow_request route, we only allow a user to: create, update, and destroy. Other actions, such as ubdex, show, new, and edit are not allowed. In this case,let's change the routes.rb as follows. + +``` +# config/routes.rb + +Rails.application.routes.draw do + # ... + resources :comments + resources :follow_requests, except: [:index, :show, :new, :edit] + # ... +``` + +5. For likes, we only allow a user to create and destroy. + +``` +# config/routes.rb + +Rails.application.routes.draw do + # ... + resources :follow_requests, except: [:index, :show, :new, :edit] + resources :likes, only: [:create, :destroy] + # ... +``` + +6. For ohotos, we need most of the routes, but the index: + +``` +# config/routes.rb + +Rails.application.routes.draw do + # ... + resources :follow_requests, except: [:index, :show, :new, :edit] + resources :likes, only: [:create, :destroy] + resources :photos, except: [:index] + # ... +``` +However, you can delete not just your photos, but other people's as well. + +#### D2. Authorization in controller with before_action + +1. Controller is one level below the routing, where we can restrict and fix photo deletion to allow a user to only delete his/her photo and not other users'. +2. One way to restrict a user from deleting another user's photo is by adding an if-else statement within app/controllers/photos_controller.rb, as follows. + +``` + # ... + def destroy + if current_user == @photo.owner + @photo.destroy + + respond_to do |format| + format.html { redirect_back fallback_location: root_url, notice: "Photo was successfully destroyed." } + format.json { head :no_content } + end + else + redirect_back(fallback_location: root_url, notice: "Nice try, but that is not your photo.") + end + end + # ... +``` + +3. Alternatively, check the ownership on some other actions.using before_action as follows. Use this approach in place of the previous one. + +``` +# app/controllers/photos_controller.rb + +class PhotosController < ApplicationController + before_action :set_photo, only: %i[ show edit update destroy ] + before_action :ensure_current_user_is_owner, only: [:destroy, :update, :edit] + # ... + private + # Use callbacks to share common setup or constraints between actions. + def set_photo + @photo = Photo.find(params[:id]) + end + + def ensure_current_user_is_owner + if current_user != @photo.owner + redirect_back fallback_location: root_url, alert: "You're not authorized for that." + end + end + # ... +end +``` + +#### D3. Conditionals in the view templates + +1. On the html page, let's hide the links/buttons/icons that are not available to the user (e.g., the edit or delete font awesome links to photos should not be visible to the users). +2. Use the conditional statement to show these buttons only if the current_user is the photo owner. + +``` + + +
+
+

+ <%= image_tag photo.owner.avatar_image, class: "rounded-circle mr-2", width: 36 %> + + <%= link_to photo.owner.username, user_path(photo.owner.username), class: "text-dark" %> +

+ +
+ <% if current_user == photo.owner %> + <%= link_to edit_photo_path(photo), class: "btn btn-link btn-sm text-muted" do %> + + <% end %> + + <%= link_to photo, method: :delete, class: "btn btn-link btn-sm text-muted" do %> + + <% end %> + <% end %> + +
+
+ +``` + +#### D4. Hiding private users + +1. Use conditionals in the views/users/show.html.erb page to hide private users that are set to "Private" or not being followed. We encapsulate this within the variable truevalue which includes three conditions. + +``` + + +
+
+ <%= render "users/user", user: @user %> +
+
+ +<% truevalue = current_user == @user || !@user.private? || current_user.leaders.include?(@user) %> + +<% if truevalue %> +
+
+ <%= render "users/profile_nav", user: @user %> +
+
+ + <% @user.own_photos.each do |photo| %> +
+
+ <%= render "photos/photo", photo: photo %> +
+
+ <% end %> +<% end %> +``` + + +#### D5. Commenting on a photo + +1. Let's enable users to comment only public photos or photos of their leaders and not private photos. We will not allow comments if the current user is not the photo owner, the photo owner profile is private, and the current user does not follow the photo owner. Add a private method which is called before action: + +``` +#app/controllers/comments_controller.rb + +class CommentsController < ApplicationController + before_action :set_comment, only: %i[ show edit update destroy ] + before_action :is_an_authorized_user, only: [:destroy, :create] + #... + def is_an_authorized_user + @photo = Photo.find(params.fetch(:comment).fetch(:photo_id)) + if current_user != @photo.owner && @photo.owner.private? && !current_user.leaders.include?(@photo.owner) + redirect_back fallback_location: root_url, alert: "Not authorized" + end + end + #... +end +``` + +2. Also, link comment table to owner through photo, so the attributes can be accessed: + +``` +#app/models/comment.rb + +class Comment < ApplicationRecord + belongs_to :author, class_name: "User", counter_cache: true + belongs_to :photo, counter_cache: true + has_one :owner, through: :photo + + validates :body, presence: true +end +``` + +#### E. Create a pull request + +1. First, create a branch from the head with: `git checkout -b rg_photogram_industrial_authorization`. Publish the branch. +2. Switch main to the earliest version of the app with: `git reset --hard 46772ee`. +3. Update main `git push origin main --force`. + +## II. Industrial Authorization Using Pundit + +Create a new branch +1. The above changes are considered "painful". In the next lesson, we will learn to use some shortcuts with the pundit gem. +2. create a new branch: `git checkout -b rg_authorization_with_pundit`. Publish the branch. + +Move main to a certain branch in three steps: +1. git checkout main +2. git reset --hard +3. git push origin main --force + +#### Objectives + +1. create Pundit policies +2. use before_action +3. raise an exception +4. using Pundit helper method to gain access to the methods in all other controllers +5. incorporate Pundit into views +6. implement inheritance, aliasing, etc using Ruby +7. secure-by-default + +*** + +#### A. Create Pundit policies + +1. Install pundit as follows: + - add gem "pundit" to your Gemfile + - type: `bundle install`. + - type in the terminal `rails g pundit:install`. + - Add `include Pundit`: + + ``` + #app/controllers/application_controller.rb + +class ApplicationController + include Pundit +#... + ``` + +2. Like Ajax, implementing pundit is a three-step process: + Step 1: Add before_action to point to pundit authorization in the respective controllers, including the application_controller. + Declare the same instances and objects declared within the corresponding controller in the _policy.rb file. + Step 2: Create the individual policy for each table controller (comments_policy, follow_requests_policy, likes_policy, etc.). + Step 3: Define the respective methods within each policy to return a true or false output. + + +3. Create a folder within app/ called policies/, and within it create a file called photo_policy.rb and a class and initialize and show methods as follows. + + +``` +#app/policies/photo_policy.rb + +class PhotoPolicy + attr_reader :user, :photo + + def initialize(user, photo) + @user = user + @photo = photo + end +end + +#Our policy is that a photo should only be seen by the owner or followers +#of the owner, unless the owner is not private in which case anyone can +#see it + +def show? + user == photo.owner || + !photo.owner.private? || + photo.owner.followers.include?(user) + end +end +``` + +3. Populate tables with `rake sample_data`. + +4. Test if the policy is applied successfully as follows: +- First, get two users: + +``` +[1] pry(main)> alice = User.first +=> # +[2] pry(main)> bob = User.second +=> # +``` + +- Check followers and private status: + +``` +[3] pry(main)> alice.followers.include?(bob) +=> false +[4] pry(main)> alice.private? +=> true +``` + +- Get one of alice's photo: + +``` +photo = alice.own_photos.first +``` + +- First, we instantiate a policy for Alice, policy_a, and based on our .show? method, we check the visibility: + +``` +[6] pry(main)> policy_a = PhotoPolicy.new(alice, photo) +=> # +[7] pry(main)> policy_a.show? +=> true +``` + +- Do the same for Bob: + +``` +[8] pry(main)> policy_b = PhotoPolicy.new(bob, photo) +=> # +[9] pry(main)> policy_b.show? +=> false +``` + +#### B. Use before_action + +1. Apply before_action as follows: + +``` +#app/controllers/photos_controller.rb + +class PhotosController < ApplicationController + before_action :set_photo, only: %i[ show edit update destroy ] + before_action :ensure_current_user_is_owner, only: [:destroy, :update, :edit] + before_action :ensure_user_is_authorized, only: [:show] + + #... + private + # Use callbacks to share common setup or constraints between actions. + def set_photo + @photo = Photo.find(params[:id]) + end + + def ensure_current_user_is_owner + if current_user != @photo.owner + redirect_back fallback_location: root_url, alert: "You're not authorized for that." + end + end + + def ensure_user_is_authorized + if !PhotoPolicy.new(current_user, @photo).show? + redirect_back fallback_location: root_url + end + end + #... +end +``` + +Navigating into a private user's photo should not be allowed. For example, looking at rails/db, we know that alethia is a private user and alexis is not private. Sign in with alexis and try to look at alethia's photo by visiting https://urban-spoon-wgr7j6ggj7fvjxr-3000.app.github.dev/alethia. In this case, no pictures are shown. + + +#### C. Raising An Exception + +1. Let's raise an exception and generate an error message instead of redirecting if the current_user is not authorized as follows. + +``` +#app/controllers/photos_controller.rb + +class PhotosController < ApplicationController + #... + before_action :ensure_user_is_authorized, only: [:show] + #... + def ensure_user_is_authorized + if !PhotoPolicy.new(current_user, @photo).show? + raise Pundit::NotAuthorizedError, "not allowed" + end + end + #... +end +``` + +I tried to visit https://urban-spoon-wgr7j6ggj7fvjxr-3000.app.github.dev/alethia/liked, but no exception was raised. + +- Alternatively, we can redirect with a flash message, as follows: + +``` +#app/controllers/application_controller.rb + +class ApplicationController + #... + rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized + + private + + def user_not_authorized + flash[:alert] = "You are not authorized to perform this action." + + redirect_back fallback_location: root_url + end +end +``` + +- Another shorthandthat we can adapt using Pundit is changing: + +``` +#app/controllers/photos_controller.rb + +class PhotosController < ApplicationController + #... + before_action :ensure_user_is_authorized, only: [:show] + #... + def ensure_user_is_authorized + if !PhotoPolicy.new(current_user, @photo).show? + raise Pundit::NotAuthorizedError, "not allowed" + end + end + #... +end +``` + +with + +``` +#app/controllers/photos_controller.rb + +class PhotosController < ApplicationController + #... + def show + authorize @photo + end + #... +end +``` + +When you pass the authorize method an instance of Photo: + +- It assumes there is a class called PhotoPolicy in app/policies. + +- It assumes there is a method called current_user. + +- It passes current_user as the first argument and whatever you pass to authorize as the second argument to a new instance of PhotoPolicy. + +- It calls a method named after the action with a ? appended on the new policy instance. + +- If it gets back false, it raises Pundit::NotAuthorizedError. + +To dos: +- For ecah table, create the corresponding _policy.rb files. +- update the _controller files. +- update the relevant methods within the _policy files as it applies to the allowed actions. For example, for photos the relevant actions are create and destroy (like and un-like). For follow_requestsm the relevant actions are create, destroy, and update (request/un-request). + +#### D. Views with Pundit + +1. Define a show? method in our user’s policy, like so: + +``` +# app/policies/user_policy.rb + +class UserPolicy + attr_reader :current_user, :user + + def initialize(current_user, user) + @current_user = current_user + @user = user + end + + def show? + user == current_user || + !user.private? || + user.followers.include?(current_user) + end + + def feed? + true + end + +end +``` + +Change the view template conditional from: + +``` + + + +<% if current_user == @user || !@user.private? || current_user.leaders.include?(@user) %> + +<% end %> +``` + +To: + +``` + + + +<% if policy(@user).show? %> + +<% end %> +``` + + +#### Notes: +- One security flaw in this app is that the /rails/db page is accessible. diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index bd664b1d..0de6d1f6 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,12 +1,27 @@ class ApplicationController < ActionController::Base + include Pundit before_action :authenticate_user! before_action :configure_permitted_parameters, if: :devise_controller? + rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized + + + # after_action :verify_authorized, unless: :devise_controller? + # after_action :verify_policy_scoped, only: :index, unless: :devise_controller? + protected def configure_permitted_parameters devise_parameter_sanitizer.permit(:sign_up, keys: [:username, :private, :name, :bio, :website, :avatar_image]) devise_parameter_sanitizer.permit(:account_update, keys: [:username, :private, :name, :bio, :website, :avatar_image]) end + + private + + def user_not_authorized + flash[:alert] = "You are not authorized to perform this action." + + redirect_back fallback_location: root_url + end end diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 046a8e5d..bca75c8f 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -1,6 +1,10 @@ class CommentsController < ApplicationController before_action :set_comment, only: %i[ show edit update destroy ] + # before_action :is_an_authorized_user, only: [:destroy, :create] + before_action { authorize @comment || Comment} + + # GET /comments or /comments.json def index @comments = Comment.all @@ -67,4 +71,11 @@ def set_comment def comment_params params.require(:comment).permit(:author_id, :photo_id, :body) end + + def is_an_authorized_user + @photo = Photo.find(params.fetch(:comment).fetch(:photo_id)) + if current_user != @photo.owner && @photo.owner.private? && !current_user.leaders.include?(@photo.owner) + redirect_back fallback_location: root_url, alert: "Not authorized" + end + end end diff --git a/app/controllers/follow_requests_controller.rb b/app/controllers/follow_requests_controller.rb index 9c30da7c..7df37fe1 100644 --- a/app/controllers/follow_requests_controller.rb +++ b/app/controllers/follow_requests_controller.rb @@ -1,6 +1,8 @@ class FollowRequestsController < ApplicationController before_action :set_follow_request, only: %i[ show edit update destroy ] + before_action { authorize @follow_requests || FollowRequests } + # GET /follow_requests or /follow_requests.json def index @follow_requests = FollowRequest.all diff --git a/app/controllers/photos_controller.rb b/app/controllers/photos_controller.rb index 78e53163..64f787ee 100644 --- a/app/controllers/photos_controller.rb +++ b/app/controllers/photos_controller.rb @@ -1,6 +1,9 @@ class PhotosController < ApplicationController before_action :set_photo, only: %i[ show edit update destroy ] - + before_action :ensure_current_user_is_owner, only: [:destroy, :update, :edit] + + before_action { authorize @photo || Photo } + # GET /photos or /photos.json def index @photos = Photo.all @@ -8,6 +11,7 @@ def index # GET /photos/1 or /photos/1.json def show + authorize @photo end # GET /photos/new @@ -63,6 +67,12 @@ def set_photo @photo = Photo.find(params[:id]) end + def ensure_current_user_is_owner + if current_user != @photo.owner + redirect_back fallback_location: root_url, alert: "You're not authorized for that." + end + end + # Only allow a list of trusted parameters through. def photo_params params.require(:photo).permit(:image, :comments_count, :likes_count, :caption, :owner_id) diff --git a/app/models/comment.rb b/app/models/comment.rb index 14a8eb00..0761b0e8 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -22,6 +22,7 @@ class Comment < ApplicationRecord belongs_to :author, class_name: "User", counter_cache: true belongs_to :photo, counter_cache: true + has_one :owner, through: :photo validates :body, presence: true end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 00000000..be644fe3 --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class ApplicationPolicy + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def index? + false + end + + def show? + false + end + + def create? + false + end + + def new? + create? + end + + def update? + false + end + + def edit? + update? + end + + def destroy? + false + end + + class Scope + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve + raise NoMethodError, "You must define #resolve in #{self.class}" + end + + private + + attr_reader :user, :scope + end +end diff --git a/app/policies/comment_policy.rb b/app/policies/comment_policy.rb new file mode 100644 index 00000000..def231b0 --- /dev/null +++ b/app/policies/comment_policy.rb @@ -0,0 +1,32 @@ +class CommentPolicy + attr_reader :user, :comment + + def initialize(user, comment) + @user = user + @comment = comment + end + + #methods: create, destroy, edit, update + + #A user can make comments if they own the photo, the photo is public, or they are followers. + def new? + user == photo.owner || + !photo.owner.private? || + photo.owner.followers.include?(user) + end + + + #only the author can destroy, edit, or update + def destroy? + user == comment.author + end + + def edit? + user == comment.author + end + + def update? + user == comment.author + end + +end diff --git a/app/policies/follow_request_policy.rb b/app/policies/follow_request_policy.rb new file mode 100644 index 00000000..7c33e23a --- /dev/null +++ b/app/policies/follow_request_policy.rb @@ -0,0 +1,26 @@ +class FollowRequest + attr_reader :current_user, :user + +def initialize(current_user, user) + @current_user = current_user + @user = user +end + +#methods: create, destroy, and update + +#only current_user can create a request + + +def new? + user == current_user +end + +#only current user can destroy the request +def destroy? + user == current_user +end + +#only current user can modify the request +def update? + user == current_user +end diff --git a/app/policies/photo_policy.rb b/app/policies/photo_policy.rb new file mode 100644 index 00000000..701c2c64 --- /dev/null +++ b/app/policies/photo_policy.rb @@ -0,0 +1,34 @@ +# app/policies/photo_policy.rb + +class PhotoPolicy + attr_reader :user, :photo + + def initialize(user, photo) + @user = user + @photo = photo + end + + #methods: create, destroy, show + + # Our policy is that a photo should only be seen by the owner or followers + # of the owner, unless the owner is not private in which case anyone can + # see it + + #only current user can create a new photo + def new? + user == current_user + end + + #only owner can destroy photo + def destroy? + user == photo.owner + end + + #only owner, non-private photo, and follower can view photos + def show? + user == photo.owner || + !photo.owner.private? || + photo.owner.followers.include?(user) + end + +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb new file mode 100644 index 00000000..4f9a31e0 --- /dev/null +++ b/app/policies/user_policy.rb @@ -0,0 +1,24 @@ +# app/policies/user_policy.rb + +class UserPolicy + attr_reader :current_user, :user + + def initialize(current_user, user) + @current_user = current_user + @user = user + end + + + #methods: view and feed + + def show? + user == current_user || + !user.private? || + user.followers.include?(current_user) + end + + def feed? + true + end + +end diff --git a/app/views/photos/_photo.html.erb b/app/views/photos/_photo.html.erb index f0de50b8..d553af45 100644 --- a/app/views/photos/_photo.html.erb +++ b/app/views/photos/_photo.html.erb @@ -7,12 +7,14 @@
- <%= link_to edit_photo_path(photo), class: "btn btn-link btn-sm text-muted" do %> - - <% end %> + <% if current_user == photo.owner %> + <%= link_to edit_photo_path(photo), class: "btn btn-link btn-sm text-muted" do %> + + <% end %> - <%= link_to photo, data: { turbo_method: :delete }, class: "btn btn-link btn-sm text-muted" do %> - + <%= link_to photo, method: :delete, class: "btn btn-link btn-sm text-muted" do %> + + <% end %> <% end %>
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 5656d7d5..ceecc1ed 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -4,16 +4,18 @@ -
-
- <%= render "users/profile_nav", user: @user %> -
-
- -<% @user.own_photos.each do |photo| %> -
+<% if policy(@user).show? %> +
- <%= render "photos/photo", photo: photo %> + <%= render "users/profile_nav", user: @user %>
+ + <% @user.own_photos.each do |photo| %> +
+
+ <%= render "photos/photo", photo: photo %> +
+
+ <% end %> <% end %> diff --git a/config/routes.rb b/config/routes.rb index 47050a54..53545094 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,9 +4,9 @@ devise_for :users resources :comments - resources :follow_requests - resources :likes - resources :photos + resources :follow_requests, except: [:index, :show, :new, :edit] + resources :likes, only: [:create, :destroy] + resources :photos, except: [:index] get ":username" => "users#show", as: :user get ":username/liked" => "users#liked", as: :liked @@ -14,4 +14,4 @@ get ":username/discover" => "users#discover", as: :discover get ":username/followers" => "users#followers", as: :followers get ":username/following" => "users#following", as: :following -end \ No newline at end of file +end