diff --git a/Gemfile b/Gemfile index f4f9e53..401e894 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,7 @@ gem 'rails', '~> 5.2.1' gem 'bcrypt', '~> 3.1.7' gem 'bootsnap', '>= 1.1.0', require: false +gem 'imperator', github: 'karmajunkie/imperator' gem 'jbuilder', '~> 2.5' gem 'json', '~> 2.1' gem 'jwt', '2.1.0' diff --git a/Gemfile.lock b/Gemfile.lock index e75daa1..7a65f07 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -21,6 +21,13 @@ GIT shellany (~> 0.0) thor (>= 0.18.1) +GIT + remote: https://github.com/karmajunkie/imperator.git + revision: 7256d702be8ac70706b2e8101e2326c50f0c6fb2 + specs: + imperator (0.2.0) + virtus (~> 1.0.0) + GIT remote: https://github.com/kern/minitest-reporters.git revision: d04c7e9b0504542f6084dc5a2060c774dc6cdda4 @@ -82,6 +89,10 @@ GEM arel (9.0.0) ast (2.4.0) awesome_print (1.8.0) + axiom-types (0.1.1) + descendants_tracker (~> 0.0.4) + ice_nine (~> 0.11.0) + thread_safe (~> 0.3, >= 0.3.1) bcrypt (3.1.12) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) @@ -92,10 +103,15 @@ GEM byebug (10.0.2) choice (0.2.0) coderay (1.1.2) + coercible (1.0.0) + descendants_tracker (~> 0.0.1) concurrent-ruby (1.1.3) crass (1.0.4) debug_inspector (0.0.3) + descendants_tracker (0.0.4) + thread_safe (~> 0.3, >= 0.3.1) docile (1.3.1) + equalizer (0.0.11) erubi (1.7.1) ffi (1.9.25) figaro (1.1.1) @@ -122,6 +138,7 @@ GEM spring i18n (1.1.1) concurrent-ruby (~> 1.0) + ice_nine (0.11.2) jaro_winkler (1.5.1) jbuilder (2.8.0) activesupport (>= 4.2.0) @@ -243,6 +260,11 @@ GEM unicode-display_width (1.4.0) validates_email_format_of (1.6.3) i18n + virtus (1.0.5) + axiom-types (~> 0.1) + coercible (~> 1.0) + descendants_tracker (~> 0.0, >= 0.0.3) + equalizer (~> 0.0, >= 0.0.9) websocket-driver (0.7.0) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) @@ -265,6 +287,7 @@ DEPENDENCIES guard-rubocop guard-shell guard-spring + imperator! jbuilder (~> 2.5) json (~> 2.1) jwt (= 2.1.0) diff --git a/app/commands/authenticate_user.rb b/app/commands/authenticate_user.rb new file mode 100644 index 0000000..ba94df7 --- /dev/null +++ b/app/commands/authenticate_user.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AuthenticateUser < Imperator::Command + include ActiveModel::Validations + + string :email + string :password + + validates :email, presence: true + validates :password, presence: true + + def action + JsonWebToken.encode(user_id: user.id) if user + end + + def user + user = @user ||= User.find_by(email: @email) + return user if user&.authenticate(@password) + + errors.add :user_authentication, 'invalid credentials' + nil + end +end diff --git a/app/commands/authorize_request.rb b/app/commands/authorize_request.rb new file mode 100644 index 0000000..bf058ed --- /dev/null +++ b/app/commands/authorize_request.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class AuthorizeRequest < Imperator::Command + include ActiveModel::Validations + + attr_reader :headers + + def initialize(headers) + @headers = headers + end + + def action + user + end + + def valid? + headers["Authorization"].present? + end + + private + + def user + @user ||= User.find(decoded_auth_token[:user_id]) if decoded_auth_token + @user || errors.add(:token, 'Invalid token') && nil + end + + def decoded_auth_token + @decoded_auth_token ||= JsonWebToken.decode(http_auth_header) + end + + def http_auth_header + return headers["Authorization"].split(' ').last if valid? + + errors.add(:token, "Missing token") + nil + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 13c271f..3ed67ff 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,31 @@ # frozen_string_literal: true class ApplicationController < ActionController::API + include Pundit + + before_action :authenticate_request + after_action :verify_authorized, except: :index + after_action :verify_policy_scoped, only: :index + + rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized + + def index; end + + private + + def current_user + @current_user ||= authenticate_request + end + + def authenticate_request + return nil if request.authorization.blank? + + @authenticate_request ||= AuthorizeRequest.new(request.headers).perform + end + + def user_not_authorized + render \ + json: { authorization: ["You are not authorized to perform this action."] }, + status: :unauthorized + end end diff --git a/app/controllers/v1/authentication_controller.rb b/app/controllers/v1/authentication_controller.rb new file mode 100644 index 0000000..4b5a64c --- /dev/null +++ b/app/controllers/v1/authentication_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module V1 + class AuthenticationController < ApplicationController + skip_after_action :verify_authorized + skip_after_action :verify_policy_scoped + + def authenticate + command = AuthenticateUser.new(auth_params) + @token = command.perform + @user = command.user + render "v1/authentication/authenticate" and return unless @token.nil? + + render json: command.errors, status: :unauthorized + end + + private + + def auth_params + params.permit(:email, :password) + end + end +end diff --git a/app/controllers/v1/users_controller.rb b/app/controllers/v1/users_controller.rb new file mode 100644 index 0000000..d3cb534 --- /dev/null +++ b/app/controllers/v1/users_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module V1 + class UsersController < ApplicationController + before_action :set_user, only: %i[show update destroy] + + def index + @users = policy_scope User.all + end + + def show; end + + def create + @user = User.new(user_params) + authorize @user + + if @user.save + render :show, status: :created, location: v1_users_url(@user) + else + render json: @user.errors, status: :unprocessable_entity + end + end + + def update + if @user.update(user_params) + render :show, status: :ok, location: v1_users_url(@user) + else + render json: @user.errors, status: :unprocessable_entity + end + end + + def destroy + @user.destroy + end + + private + + def set_user + @user = User.find(params[:id]) + authorize @user + end + + def user_params + params.require(:user).permit(policy(User).permitted_attributes) + end + end +end diff --git a/app/libs/json_web_token.rb b/app/libs/json_web_token.rb new file mode 100644 index 0000000..4e0850c --- /dev/null +++ b/app/libs/json_web_token.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class JsonWebToken + class << self + def encode(payload, exp = 24.hours.from_now) + payload[:exp] = exp.to_i + JWT.encode(payload, ENV['jwt']) + end + + def decode(token) + body = JWT.decode(token, ENV['jwt'])[0] + HashWithIndifferentAccess.new body + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..05c71a0 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: users +# +# id :integer not null, primary key +# display_name :string not null +# email :string not null +# password_digest :string not null +# role :integer default("author"), not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_users_on_email (email) +# + +class User < ApplicationRecord + has_secure_password + + validates :display_name, presence: true + validates :email, presence: true, email_format: true, uniqueness: true + validates :password_confirmation, presence: true, if: ->(m) { m.password.present? } + validates :role, presence: true + + enum role: { + author: 0, + admin: 2 + } + + def acts_as_admin? + admin? + end + + def acts_as_author? + admin? || author? + end +end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 0000000..50f2d7c --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,51 @@ +# 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 + attr_reader :user, :scope + + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve + scope.all + end + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb new file mode 100644 index 0000000..b2e87ca --- /dev/null +++ b/app/policies/user_policy.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class UserPolicy < ApplicationPolicy + def show? + raise Pundit::NotAuthorizedError if user.nil? + return true if user&.acts_as_admin? + + user == record + end + + def update? + raise Pundit::NotAuthorizedError if user.nil? + + show? + end + + def destroy? + raise Pundit::NotAuthorizedError if user.nil? + + user&.acts_as_admin? + end + + def create? + raise Pundit::NotAuthorizedError if user.nil? + + user&.acts_as_admin? + end + + def permitted_attributes + return base_attributes + %i[role] if user&.acts_as_admin? + + base_attributes + end + + def base_attributes + %i[ + display_name + email + password + password_confirmation + ] + end + + class Scope < Scope + def resolve + raise Pundit::NotAuthorizedError if user.nil? + return scope if user.acts_as_admin? + + scope.where(id: user.id) + end + end +end diff --git a/app/views/v1/authentication/authenticate.json.jbuilder b/app/views/v1/authentication/authenticate.json.jbuilder new file mode 100644 index 0000000..c363ddd --- /dev/null +++ b/app/views/v1/authentication/authenticate.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +json.auth_token @token +json.user @user, partial: "v1/users/user", as: :user diff --git a/app/views/v1/users/_user.json.jbuilder b/app/views/v1/users/_user.json.jbuilder new file mode 100644 index 0000000..75dee6f --- /dev/null +++ b/app/views/v1/users/_user.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +json.extract! user, + :id, + :display_name, + :email, + :role + +json.url v1_user_url(user, format: :json) diff --git a/app/views/v1/users/index.json.jbuilder b/app/views/v1/users/index.json.jbuilder new file mode 100644 index 0000000..8afe999 --- /dev/null +++ b/app/views/v1/users/index.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.array! @users, partial: 'v1/users/user', as: :user diff --git a/app/views/v1/users/show.json.jbuilder b/app/views/v1/users/show.json.jbuilder new file mode 100644 index 0000000..8d3157d --- /dev/null +++ b/app/views/v1/users/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "v1/users/user", user: @user diff --git a/config/application.yml.sample b/config/application.yml.sample index f99503a..8a76bdd 100644 --- a/config/application.yml.sample +++ b/config/application.yml.sample @@ -10,5 +10,6 @@ # stripe_api_key: sk_live_EeHnL644i6zo4Iyq4v1KdV9H # stripe_publishable_key: pk_live_9lcthxpSIHbGwmdO941O1XVU +jwt: 98a6c2b7c9a1eb1da66595970e6f699a2e1fb339c2b759f443380ad86a95ba299bc03f5b38681b57bbc5e0bbedb3ee2d61e6a5e5b4f836ddfd22c27fa09b7fbb aws_key: this-is-the-key aws_secret: this-is-the-secret diff --git a/config/routes.rb b/config/routes.rb index 47cc16e..8e8f35c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,14 @@ # frozen_string_literal: true Rails.application.routes.draw do + concern :api_base do + post 'authenticate', to: 'authentication#authenticate', as: :authenticate + resources :users + end + + namespace :v1, defaults: { format: :json } do + concerns :api_base + end + # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html end diff --git a/db/migrate/20181110183741_create_users.rb b/db/migrate/20181110183741_create_users.rb new file mode 100644 index 0000000..e270a42 --- /dev/null +++ b/db/migrate/20181110183741_create_users.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateUsers < ActiveRecord::Migration[5.2] + def change + create_table :users do |t| + t.string :display_name, null: false + t.string :email, null: false + t.string :password_digest, null: false + t.integer :role, null: false, default: 0 + + t.timestamps + end + add_index :users, :email + end +end diff --git a/db/schema.rb b/db/schema.rb index 52ab025..895b44a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,6 +10,16 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 0) do +ActiveRecord::Schema.define(version: 2018_11_10_183741) do + + create_table "users", force: :cascade do |t| + t.string "display_name", null: false + t.string "email", null: false + t.string "password_digest", null: false + t.integer "role", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_users_on_email" + end end diff --git a/test/controllers/v1/authentication_controller_test.rb b/test/controllers/v1/authentication_controller_test.rb new file mode 100644 index 0000000..70d14a3 --- /dev/null +++ b/test/controllers/v1/authentication_controller_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'test_helper' + +class AuthenticationControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:admin) + end + + test "should return token" do + post v1_authenticate_url, params: { email: @user.email, password: 'password' } + assert_response :success + end + + test "should fail auth" do + post v1_authenticate_url, params: { email: @user.email, password: 'BAD PASSWORD' } + assert_response :unauthorized + end +end diff --git a/test/controllers/v1/users_controller_test.rb b/test/controllers/v1/users_controller_test.rb new file mode 100644 index 0000000..3fef90a --- /dev/null +++ b/test/controllers/v1/users_controller_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'test_helper' + +class UsersControllerTest < ActionDispatch::IntegrationTest + test "admin can list users" do + get v1_users_url, headers: auth_headers(users(:admin)) + + assert_response :success + end + + test "admin can create user" do + assert_difference('User.count') do + post v1_users_url, params: { user: { + display_name: 'some user', + email: 'new.user@mailinator.com', + password: 'password', + password_confirmation: 'password' + } }, headers: auth_headers(users(:admin)) + end + + assert_response :created + end + + test "admin can view users" do + get v1_user_url(users(:admin)), headers: auth_headers(users(:admin)) + + assert_response :success + end + + test "admin can update user" do + patch v1_user_url(users(:admin)), params: { user: { + display_name: 'I am admin' + } }, headers: auth_headers(users(:admin)) + + assert_response :ok + end + + test "admin can destroy user" do + assert_difference('User.count', -1) do + delete v1_user_url(users(:admin)), headers: auth_headers(users(:admin)) + end + + assert_response :no_content + end + + test "author can view herself" do + get v1_user_url(users(:author)), headers: auth_headers(users(:author)) + + assert_response :success + end + + test "author can update herself" do + patch v1_user_url(users(:author)), params: { user: { + display_name: 'I am author!' + } }, headers: auth_headers(users(:author)) + + assert_response :ok + end + + test "sally CANNOT update phil" do + patch v1_user_url(users(:author)), params: { user: { + display_name: 'I am author!' + } }, headers: auth_headers(users(:sally)) + + assert_response :unauthorized + end +end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..3c262ab --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,42 @@ +# == Schema Information +# +# Table name: users +# +# id :integer not null, primary key +# display_name :string not null +# email :string not null +# password_digest :string not null +# role :integer default("author"), not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_users_on_email (email) +# + +# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +admin: + display_name: Awesome Admin + email: awesome.admin@mailinator.com + password_digest: <%= BCrypt::Password.create("password", cost: 4) %> + role: admin + +author: + display_name: lePhil + email: lephil@mailinator.com + password_digest: <%= BCrypt::Password.create("password", cost: 4) %> + role: author + +sally: + display_name: Sally String + email: sally.string@mailinator.com + password_digest: <%= BCrypt::Password.create("password", cost: 4) %> + role: author + +michelle: + display_name: Mighty Michelle + email: mighty.michelle@mailinator.com + password_digest: <%= BCrypt::Password.create("password", cost: 4) %> + role: author diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..7a85210 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'test_helper' + +class UserTest < ActiveSupport::TestCase + test "higer roles can act as lower roles" do + assert users(:admin).acts_as_admin? + assert users(:admin).acts_as_author? + + assert users(:author).acts_as_author? + end + + test "lower roles can NOT act as higher roles" do + assert_not users(:author).acts_as_admin? + end +end diff --git a/test/policies/application_policy_test.rb b/test/policies/application_policy_test.rb new file mode 100644 index 0000000..beca4dd --- /dev/null +++ b/test/policies/application_policy_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ApplicationPolicyTest < PolicyAssertions::Test + # Verify default policies are most restrictive + + test 'should not permit by default' do + admin = users(:admin) + assert_not ApplicationPolicy.new(admin, nil).show? + assert_not ApplicationPolicy.new(admin, nil).index? + assert_not ApplicationPolicy.new(admin, nil).create? + assert_not ApplicationPolicy.new(admin, nil).new? + assert_not ApplicationPolicy.new(admin, nil).update? + assert_not ApplicationPolicy.new(admin, nil).edit? + assert_not ApplicationPolicy.new(admin, nil).destroy? + end +end diff --git a/test/policies/user_policy_test.rb b/test/policies/user_policy_test.rb new file mode 100644 index 0000000..2fc0b82 --- /dev/null +++ b/test/policies/user_policy_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'test_helper' + +class UserPolicyTest < PolicyAssertions::Test + test 'must authenticate for actions' do + assert_raise Pundit::NotAuthorizedError do + %w[create show update destroy].each do |action| + UserPolicy.new(nil, User.new).send("#{action}?") + end + end + end + + test 'should allow admin to scope' do + scope = UserPolicy::Scope.new(users(:admin), User).resolve + assert_equal User.count, scope.count + end + + test 'non admins can only scope themselves' do + %i[author].each do |user| + scope = UserPolicy::Scope.new(users(user), User).resolve + assert_equal 1, scope.count, "Scope did not have 1 result for #{user}" + assert_equal users(user), scope.first, "Scope did not contain self for #{user}" + end + end + + test 'admins have role in permitted params' do + policy = UserPolicy.new users(:admin), nil + assert policy.permitted_attributes.include?(:role) + end + + test 'non-admins can not edit roles' do + %i[author].each do |user| + policy = UserPolicy.new users(user), nil + assert_not policy.permitted_attributes.include?(:role) + end + end + + # create + test 'only admins can create' do + assert_permit users(:admin), User, :create? + + %i[author].each do |user| + assert_not_permitted users(user), User, :create? + end + end + + # delete + test 'only admins can destroy' do + assert_permit users(:admin), User, :destroy? + + %i[author].each do |user| + assert_not_permitted users(user), User, :destroy? + end + end + + # show + test 'admin can view any role' do + %i[admin author].each do |user| + assert_permit users(:admin), users(user), :show? + end + end + + test 'non-admins can view themselves' do + %i[author].each do |user| + assert_permit users(user), users(user), :show? + end + end + + test 'author roles can only view themselves' do + %i[admin sally michelle].each do |user| + assert_not_permitted users(:author), users(user), :show? + end + end + + # updates + test 'admin can update any role' do + %i[admin author].each do |user| + assert_permit users(:admin), users(user), :update? + end + end + + test 'non-admins can update themselves' do + %i[author].each do |user| + assert_permit users(user), users(user), :update? + end + end + + test 'authors can not update other roles' do + %i[admin sally michelle].each do |user| + assert_not_permitted users(:author), users(user), :update? + end + end +end diff --git a/user_stories.md b/user_stories.md index c0640aa..2b63952 100644 --- a/user_stories.md +++ b/user_stories.md @@ -3,14 +3,8 @@ An API for a micro blogging platform Guests can only view published content and author list -Standard can edit their profile & blogs -Admins can view list of users and edit any blog - -users: - display_name - email - password_digest - roles: guest, standard, admin +Authors can edit their profile & blogs +Admins can view the list of authors and edit any blog blogs: title @@ -18,20 +12,25 @@ blogs: published_date author_id - - ## Tasks ### User Bios -As a user, I want to be able to author a bio that is -displayed with each of my postings. +As an author, I want to be able to write a short bio that +is displayed with each of my postings. ### Moderation As an admin, I want to be able to put a blog it in a state -of moderation. +of moderation that also unpublishes it. Once a blog post has this state, the author must edit and submit for another review before an admin will republish. +### Categories + +As an author, I would like to be able to assign as category +to my posting from a known list of options. + +As an admin, I can manage the list of categories available +to the authors.