Users & Auth

This commit is contained in:
Mark Moser 2018-11-10 18:46:47 -06:00
parent 904a071fc0
commit 8a7b3d8ae0
26 changed files with 663 additions and 14 deletions

View File

@ -10,6 +10,7 @@ gem 'rails', '~> 5.2.1'
gem 'bcrypt', '~> 3.1.7' gem 'bcrypt', '~> 3.1.7'
gem 'bootsnap', '>= 1.1.0', require: false gem 'bootsnap', '>= 1.1.0', require: false
gem 'imperator', github: 'karmajunkie/imperator'
gem 'jbuilder', '~> 2.5' gem 'jbuilder', '~> 2.5'
gem 'json', '~> 2.1' gem 'json', '~> 2.1'
gem 'jwt', '2.1.0' gem 'jwt', '2.1.0'

View File

@ -21,6 +21,13 @@ GIT
shellany (~> 0.0) shellany (~> 0.0)
thor (>= 0.18.1) thor (>= 0.18.1)
GIT
remote: https://github.com/karmajunkie/imperator.git
revision: 7256d702be8ac70706b2e8101e2326c50f0c6fb2
specs:
imperator (0.2.0)
virtus (~> 1.0.0)
GIT GIT
remote: https://github.com/kern/minitest-reporters.git remote: https://github.com/kern/minitest-reporters.git
revision: d04c7e9b0504542f6084dc5a2060c774dc6cdda4 revision: d04c7e9b0504542f6084dc5a2060c774dc6cdda4
@ -82,6 +89,10 @@ GEM
arel (9.0.0) arel (9.0.0)
ast (2.4.0) ast (2.4.0)
awesome_print (1.8.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) bcrypt (3.1.12)
binding_of_caller (0.8.0) binding_of_caller (0.8.0)
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
@ -92,10 +103,15 @@ GEM
byebug (10.0.2) byebug (10.0.2)
choice (0.2.0) choice (0.2.0)
coderay (1.1.2) coderay (1.1.2)
coercible (1.0.0)
descendants_tracker (~> 0.0.1)
concurrent-ruby (1.1.3) concurrent-ruby (1.1.3)
crass (1.0.4) crass (1.0.4)
debug_inspector (0.0.3) debug_inspector (0.0.3)
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
docile (1.3.1) docile (1.3.1)
equalizer (0.0.11)
erubi (1.7.1) erubi (1.7.1)
ffi (1.9.25) ffi (1.9.25)
figaro (1.1.1) figaro (1.1.1)
@ -122,6 +138,7 @@ GEM
spring spring
i18n (1.1.1) i18n (1.1.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
jaro_winkler (1.5.1) jaro_winkler (1.5.1)
jbuilder (2.8.0) jbuilder (2.8.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
@ -243,6 +260,11 @@ GEM
unicode-display_width (1.4.0) unicode-display_width (1.4.0)
validates_email_format_of (1.6.3) validates_email_format_of (1.6.3)
i18n 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-driver (0.7.0)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.3) websocket-extensions (0.1.3)
@ -265,6 +287,7 @@ DEPENDENCIES
guard-rubocop guard-rubocop
guard-shell guard-shell
guard-spring guard-spring
imperator!
jbuilder (~> 2.5) jbuilder (~> 2.5)
json (~> 2.1) json (~> 2.1)
jwt (= 2.1.0) jwt (= 2.1.0)

View File

@ -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

View File

@ -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

View File

@ -1,4 +1,31 @@
# frozen_string_literal: true # frozen_string_literal: true
class ApplicationController < ActionController::API 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 end

View File

@ -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

View File

@ -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

View File

@ -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

40
app/models/user.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,4 @@
# frozen_string_literal: true
json.auth_token @token
json.user @user, partial: "v1/users/user", as: :user

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
json.extract! user,
:id,
:display_name,
:email,
:role
json.url v1_user_url(user, format: :json)

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.array! @users, partial: 'v1/users/user', as: :user

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! "v1/users/user", user: @user

View File

@ -10,5 +10,6 @@
# stripe_api_key: sk_live_EeHnL644i6zo4Iyq4v1KdV9H # stripe_api_key: sk_live_EeHnL644i6zo4Iyq4v1KdV9H
# stripe_publishable_key: pk_live_9lcthxpSIHbGwmdO941O1XVU # stripe_publishable_key: pk_live_9lcthxpSIHbGwmdO941O1XVU
jwt: 98a6c2b7c9a1eb1da66595970e6f699a2e1fb339c2b759f443380ad86a95ba299bc03f5b38681b57bbc5e0bbedb3ee2d61e6a5e5b4f836ddfd22c27fa09b7fbb
aws_key: this-is-the-key aws_key: this-is-the-key
aws_secret: this-is-the-secret aws_secret: this-is-the-secret

View File

@ -1,5 +1,14 @@
# frozen_string_literal: true # frozen_string_literal: true
Rails.application.routes.draw do 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 # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end end

View File

@ -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

View File

@ -10,6 +10,16 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 end

View File

@ -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

View File

@ -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

42
test/fixtures/users.yml vendored Normal file
View File

@ -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

16
test/models/user_test.rb Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -3,14 +3,8 @@
An API for a micro blogging platform An API for a micro blogging platform
Guests can only view published content and author list Guests can only view published content and author list
Standard can edit their profile & blogs Authors can edit their profile & blogs
Admins can view list of users and edit any blog Admins can view the list of authors and edit any blog
users:
display_name
email
password_digest
roles: guest, standard, admin
blogs: blogs:
title title
@ -18,20 +12,25 @@ blogs:
published_date published_date
author_id author_id
## Tasks ## Tasks
### User Bios ### User Bios
As a user, I want to be able to author a bio that is As an author, I want to be able to write a short bio that
displayed with each of my postings. is displayed with each of my postings.
### Moderation ### Moderation
As an admin, I want to be able to put a blog it in a state 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 Once a blog post has this state, the author must edit and
submit for another review before an admin will republish. 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.