From ad20bbedf95253fee69537cfcbf9af4dab87adb5 Mon Sep 17 00:00:00 2001 From: Brent Date: Wed, 24 Jan 2018 22:13:42 +0700 Subject: [PATCH] Add authentication with JWT tokens in http header. - Need to provide a Authentication header with a JWT token to access events_controller. - Add API endpoint to create users. - Add API endpoint to authenticate. - provide email and password to get token back. --- Gemfile | 2 + Gemfile.lock | 4 + app/controllers/application_controller.rb | 4 +- app/controllers/authentications_controller.rb | 28 ++++ app/controllers/concerns/authentication.rb | 34 +++++ app/controllers/events_controller.rb | 11 +- app/controllers/users_controller.rb | 13 ++ app/models/event.rb | 2 + app/models/user.rb | 8 + app/serializers/event_serializer.rb | 1 + config/routes.rb | 3 + ..._add_email_and_password_digest_to_users.rb | 10 ++ .../20180124075452_add_organiser_to_events.rb | 7 + db/schema.rb | 5 +- spec/factories/event_factory.rb | 1 + spec/factories/user_factory.rb | 16 +- spec/rails_helper.rb | 3 +- spec/requests/authentications_request_spec.rb | 66 ++++++++ spec/requests/events_request_spec.rb | 144 +++++++++++++----- spec/requests/users_request_spec.rb | 59 +++++++ spec/support/an_authorized_action.rb | 31 ++++ 21 files changed, 405 insertions(+), 47 deletions(-) create mode 100644 app/controllers/authentications_controller.rb create mode 100644 app/controllers/concerns/authentication.rb create mode 100644 app/controllers/users_controller.rb create mode 100644 db/migrate/20180122130957_add_email_and_password_digest_to_users.rb create mode 100644 db/migrate/20180124075452_add_organiser_to_events.rb create mode 100644 spec/requests/authentications_request_spec.rb create mode 100644 spec/requests/users_request_spec.rb create mode 100644 spec/support/an_authorized_action.rb diff --git a/Gemfile b/Gemfile index fc50b4e..e6e15e0 100644 --- a/Gemfile +++ b/Gemfile @@ -12,6 +12,8 @@ gem 'puma', '~> 3.7' gem 'paranoia' gem 'acts_as_human' gem 'active_model_serializers' +gem 'bcrypt' +gem 'jwt' # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder # gem 'jbuilder', '~> 2.5' diff --git a/Gemfile.lock b/Gemfile.lock index 7e61eb5..787cc47 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -46,6 +46,7 @@ GEM acts_as_human (3.0.2) arel (8.0.0) awesome_print (1.8.0) + bcrypt (3.1.11) builder (3.2.3) byebug (9.1.0) case_transform (0.2) @@ -81,6 +82,7 @@ GEM i18n (0.9.0) concurrent-ruby (~> 1.0) jsonapi-renderer (0.1.3) + jwt (2.1.0) listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) @@ -193,9 +195,11 @@ DEPENDENCIES active_model_serializers acts_as_human awesome_print + bcrypt byebug factory_girl_rails guard-rspec + jwt listen (>= 3.0.5, < 3.2) mysql2 (>= 0.3.18, < 0.5) paranoia diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c9683f1..8081dea 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,7 @@ -class ApplicationController < ActionController::API +AuthenticationError = Class.new(StandardError) +class ApplicationController < ActionController::API include RecordInvalidHandler include RecordNotFoundHandler + include Authentication end diff --git a/app/controllers/authentications_controller.rb b/app/controllers/authentications_controller.rb new file mode 100644 index 0000000..5ef3ec9 --- /dev/null +++ b/app/controllers/authentications_controller.rb @@ -0,0 +1,28 @@ +class AuthenticationsController < ApplicationController + + def create + @user = User.find_by(email: params[:email]) + invalid_credentials if @user.nil? + + if @user.authenticate(params[:password]) + render json: {'auth_token' => auth_token}.to_json + else + invalid_credentials + end + end + + private + + def auth_token + JWT.encode({ + user_id: @user.id, + exp: 24.hours.from_now.to_i + }, + Rails.application.secrets.secret_key_base + ) + end + + def invalid_credentials + raise AuthenticationError, 'Invalid credentials' + end +end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 0000000..a2109ee --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,34 @@ +module Authentication + extend ActiveSupport::Concern + + included do + + rescue_from AuthenticationError do |e| + message = { message: e.message } + render json: message, status: :unauthorized + end + + attr_reader :current_user + + def load_current_user + raise AuthenticationError, 'Token required' if auth_header.blank? + @current_user = User.find( get_user_id ) + rescue JWT::DecodeError => e + raise AuthenticationError, 'Invalid token' + end + + private + + def get_user_id + JWT.decode(auth_header, secret_key)[0]['user_id'] + end + + def auth_header + request.headers['Authorization'] + end + + def secret_key + Rails.application.secrets.secret_key_base + end + end +end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 349868e..a598cd2 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,17 +1,18 @@ class EventsController < ApplicationController + before_action :load_current_user before_action :load_event, only: [:update, :destroy] def index - render json: Event.all + render json: current_user.events end def create - @event = Event.create!(group_params) + @event = current_user.events.create!(event_params) render json: @event, status: :created end def update - @event.update!( group_params ) + @event.update!( event_params ) end def destroy @@ -20,7 +21,7 @@ def destroy private - def group_params + def event_params params.require(:event).permit( :name, :description, :location, :published, :starting, :ending, :duration, group_attributes: [ @@ -31,6 +32,6 @@ def group_params end def load_event - @event = Event.find params[:id] + @event = current_user.events.find params[:id] end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..70cd6b9 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,13 @@ +class UsersController < ApplicationController + + def create + User.create!(user_params) + render json: {message: 'Account created'}, status: :created + end + + private + + def user_params + params.require(:user).permit(:full_name, :email, :password) + end +end diff --git a/app/models/event.rb b/app/models/event.rb index b8b783a..13f1e53 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -2,6 +2,8 @@ class Event < ApplicationRecord acts_as_paranoid + belongs_to :organiser, class_name: 'User' + belongs_to :group accepts_nested_attributes_for :group diff --git a/app/models/user.rb b/app/models/user.rb index 379658a..845756d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,2 +1,10 @@ class User < ApplicationRecord + + has_secure_password + + validates :email, presence: true + + acts_as_human + + has_many :events, foreign_key: 'organiser_id' end diff --git a/app/serializers/event_serializer.rb b/app/serializers/event_serializer.rb index 65c9053..5443cc9 100644 --- a/app/serializers/event_serializer.rb +++ b/app/serializers/event_serializer.rb @@ -4,4 +4,5 @@ class EventSerializer < ActiveModel::Serializer :description belongs_to :group + belongs_to :organiser end diff --git a/config/routes.rb b/config/routes.rb index 872a324..09b4ba2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,5 +2,8 @@ root to: 'events#index' + post 'login', to: 'authentications#create' + + resources :users resources :events end diff --git a/db/migrate/20180122130957_add_email_and_password_digest_to_users.rb b/db/migrate/20180122130957_add_email_and_password_digest_to_users.rb new file mode 100644 index 0000000..cdf3d3d --- /dev/null +++ b/db/migrate/20180122130957_add_email_and_password_digest_to_users.rb @@ -0,0 +1,10 @@ +class AddEmailAndPasswordDigestToUsers < ActiveRecord::Migration[5.1] + + def change + add_column :users, :email, :string + change_column_null :users, :email, false + + add_column :users, :password_digest, :string + change_column_null :users, :password_digest, false + end +end diff --git a/db/migrate/20180124075452_add_organiser_to_events.rb b/db/migrate/20180124075452_add_organiser_to_events.rb new file mode 100644 index 0000000..00fd7da --- /dev/null +++ b/db/migrate/20180124075452_add_organiser_to_events.rb @@ -0,0 +1,7 @@ +class AddOrganiserToEvents < ActiveRecord::Migration[5.1] + + def change + add_column :events, :organiser_id, :integer, foreign_key: true + change_column_null :events, :organiser_id, false + end +end diff --git a/db/schema.rb b/db/schema.rb index 068fd76..0427020 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.define(version: 20171025072237) do +ActiveRecord::Schema.define(version: 20180124075452) do create_table "events", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| t.string "name" @@ -24,6 +24,7 @@ t.datetime "updated_at", null: false t.datetime "deleted_at" t.bigint "group_id" + t.integer "organiser_id", null: false t.index ["deleted_at"], name: "index_events_on_deleted_at" t.index ["group_id"], name: "index_events_on_group_id" end @@ -49,6 +50,8 @@ t.string "first_name" t.string "middle_names" t.string "last_name" + t.string "email", null: false + t.string "password_digest", null: false end add_foreign_key "events", "groups" diff --git a/spec/factories/event_factory.rb b/spec/factories/event_factory.rb index e870109..316bbc4 100644 --- a/spec/factories/event_factory.rb +++ b/spec/factories/event_factory.rb @@ -1,6 +1,7 @@ FactoryGirl.define do factory :event do + organiser name 'My Birthday Party' location 'Amirandes Grecotel Exclusive Resort' starting '2017-10-17' diff --git a/spec/factories/user_factory.rb b/spec/factories/user_factory.rb index cda77c7..76b0580 100644 --- a/spec/factories/user_factory.rb +++ b/spec/factories/user_factory.rb @@ -1,6 +1,20 @@ FactoryGirl.define do factory :user do - full_name 'Nelson Mandela' + full_name 'kurt russell' + email 'kurt@example.com' + password 'password' + + factory :organiser do + full_name 'Carl Cox' + email 'carl@example.com' + password 'techno' + end + + factory :someone_else do + full_name 'Someone Else' + email 'someone@example.com' + password 'strange' + end end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index d30e0df..ce02ff0 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -23,6 +23,7 @@ # Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } require './spec/support/request_spec_helper' +require './spec/support/an_authorized_action' # Checks for pending migration and applies them before tests are run. # If you are not using ActiveRecord, you can remove this line. @@ -40,7 +41,7 @@ config.include RequestSpecHelper, type: :request config.include FactoryGirl::Syntax::Methods - + # RSpec Rails can automatically mix in different behaviours to your tests # based on their file location, for example enabling you to call `get` and # `post` in specs under `spec/controllers`. diff --git a/spec/requests/authentications_request_spec.rb b/spec/requests/authentications_request_spec.rb new file mode 100644 index 0000000..17432f6 --- /dev/null +++ b/spec/requests/authentications_request_spec.rb @@ -0,0 +1,66 @@ +require 'rails_helper' + +RSpec.describe "Authentications", type: :request do + + describe "POST /authentications" do + let(:user) { create(:user) } + + let(:valid) do + { email: user.email, password: 'password' } + end + + let(:app_json) do + {"Content-Type" => "application/json"} + end + + context 'logging in' do + before do + expect(JWT).to receive(:encode).with( + { + user_id: user.id, + exp: 24.hours.from_now.to_i + }, + Rails.application.secrets.secret_key_base + ).and_return 'TOKEN' + end + before { post '/login', params: valid.to_json, headers: app_json } + + it 'returns an auth_token' do + expect( json['auth_token'] ).to eq 'TOKEN' + end + end + + context 'An email that does NOT exist' do + let(:invalid_email) { valid.merge(email: 'does.not.exist@example.com') } + + before { post '/login', params: invalid_email.to_json, headers: app_json } + + it 'should error' do + expect( response ).to have_http_status(401) + expect( response.body ).to match /Invalid credentials/ + end + end + + context 'The wrong password' do + let(:wrong_pass) { valid.merge(password: 'wrong') } + + before { post '/login', params: wrong_pass.to_json, headers: app_json } + + it 'should error' do + expect( response ).to have_http_status(401) + expect( response.body ).to match /Invalid credentials/ + end + end + + context 'A blank password' do + let(:blank_pass) { valid.merge(password: '') } + + before { post '/login', params: blank_pass.to_json, headers: app_json } + + it 'should error' do + expect( response ).to have_http_status(401) + expect( response.body ).to match /Invalid credentials/ + end + end + end +end diff --git a/spec/requests/events_request_spec.rb b/spec/requests/events_request_spec.rb index 521f2b7..2eb7b9e 100644 --- a/spec/requests/events_request_spec.rb +++ b/spec/requests/events_request_spec.rb @@ -1,65 +1,98 @@ RSpec.describe "Events" do + let(:secret_key) { Rails.application.secrets.secret_key_base } + + def generate_token(user) + JWT.encode({ + user_id: user.id, + exp: 24.hours.from_now.to_i + }, + secret_key + ) + end + + def auth(user) + { 'Authorization' => generate_token(user) } + end + + let(:organiser) { create(:organiser) } # INDEX - context 'with an event' do - let!(:event) { create(:event) } + context 'an event with this organiser' do + let!(:event) { create(:event, organiser: organiser) } + + context 'and an event, organised by someone else' do + let!(:another_event) { create(:event) } - context 'Requesting all events :index' do - before { get '/events' } + context 'Requesting all events #index' do + before { get '/events', headers: auth(organiser) } + + it 'returns just the one event' do + expect( json.count ).to eq 1 + expect( json.first['organiser']['id'] ).to eq organiser.id + end + end + end + + context 'Requesting all events #index' do + before { get '/events', headers: auth(organiser) } it 'returns 200' do expect( response ).to have_http_status(200) end - it 'should return the events' do - expect( json ).to be_kind_of(Array) + it 'returns the event info' do expect( json.first['name'] ).to eq 'My Birthday Party' expect( json.first['id'] ).to eq event.id end - it 'should return the group info' do + it 'returns the group info' do expect( json.first['group'] ).to be_kind_of Hash expect( json.first['group']['name'] ).to eq 'FB Sucks' end end + + it_behaves_like 'an action that requires authorization' do + let(:action) { 'get#events' } + let(:params) { {} } + end end # CREATE it "saves the Event" do expect { - post '/events', params: {event: event} + post '/events', params: {event: event}, headers: auth(organiser) }.to change(Event, :count).by(1) end it "saves a group" do expect { - post '/events', params: {event: event} + post '/events', params: {event: event}, headers: auth(organiser) }.to change(Group, :count).by(1) end - it 'should add users as members of the group' do + it 'adds users as members of the group' do expect { - post '/events', params: {event: event} + post '/events', params: {event: event}, headers: auth(organiser) }.to change(GroupUser, :count).by(2) end - let(:user1) { create(:user) } - let(:user2) { create(:user) } + let(:guest1) { create(:user) } + let(:guest2) { create(:user) } def event attributes_for(:event).merge({ group_attributes: { name: 'Only Cool Dudes', group_users_attributes: [ - {user_id: user1.to_param}, - {user_id: user2.to_param}, + {user_id: guest1.to_param}, + {user_id: guest2.to_param}, ] } }) end - context 'Creating an Event' do - before { post '/events', params: {event: event} } + context '#Creating an Event' do + before { post '/events', params: {event: event}, headers: auth(organiser) } it 'creates an event' do expect( json ).to match ({ @@ -76,8 +109,11 @@ def event 'description' => event[:description], 'group' => { 'name' => 'Only Cool Dudes' - } + }, + 'organiser' => be_kind_of(Hash) }) + + expect( json['organiser']['id'] ).to eq organiser.id end def date_like @@ -93,8 +129,16 @@ def date_like end end + describe '#Creating' do + + it_behaves_like 'an action that requires authorization' do + let(:action) { 'post#events' } + let(:params) { {event: event} } + end + end + context 'An invalid Event' do - before { post '/events', params: {event: invalid} } + before { post '/events', params: {event: invalid}, headers: auth(organiser) } def invalid attributes_for(:event).merge( @@ -117,10 +161,10 @@ def invalid end # UPDATE - context 'Updating an Event' do - let(:event) { create(:event) } + context '#Updating an Event' do + let(:event) { create(:event, organiser: organiser) } - def params + def ev { name: 'New Name', starting: '2017-10-18', @@ -129,7 +173,7 @@ def params end context 'that exists' do - before { patch "/events/#{event.to_param}", params: {event: params} } + before { patch "/events/#{event.to_param}", params: {event: ev}, headers: auth(organiser) } it 'returns 204' do expect( response ).to have_http_status(204) @@ -144,14 +188,16 @@ def params end end - context 'that does not exist' do - before { patch "/events/0", params: params } + it_behaves_like 'an action that requires authorization' do + let(:action) { "patch#events/#{event.to_param}" } + let(:params) { {event: ev} } + end - it 'returns 404' do - expect(response).to have_http_status(404) - end + context 'that does not exist' do + before { patch "/events/0", params: {event: ev}, headers: auth(organiser) } it 'returns not found' do + expect(response).to have_http_status(404) expect( response.body ).to match /Couldn't find Event with 'id'=0/ @@ -159,7 +205,7 @@ def params end context 'thats invalid' do - before { patch "/events/#{event.to_param}", params: {event: invalid} } + before { patch "/events/#{event.to_param}", params: {event: invalid}, headers: auth(organiser) } def invalid attributes_for(:event).merge( @@ -168,20 +214,25 @@ def invalid ) end - it 'returns 422' do + it 'errors' do expect( response ).to have_http_status(422) - end - - it 'returns validation failed' do expect( response.body ) .to match(/Validation failed: Description can't be blank/) end end + context 'by a different user' do + before { patch "/events/#{event.to_param}", params: {event: ev}, headers: auth(create(:someone_else)) } + + it 'is not found' do + expect( response ).to have_http_status(404) + end + end + context 'with a new group name' do - before { patch "/events/#{event.to_param}", params: {event: g_name} } + before { patch "/events/#{event.to_param}", params: {event: new_group_name}, headers: auth(organiser) } - def g_name + def new_group_name attributes_for(:event).merge( group_attributes: { id: event.group.id, @@ -197,10 +248,10 @@ def g_name end # DELETE - describe 'Deleting an Event' do - let(:event) { create(:event) } + describe '#Destroying an Event' do + let(:event) { create(:event, organiser: organiser) } - before { delete "/events/#{event.to_param}" } + before { delete "/events/#{event.to_param}", headers: auth(organiser) } it 'returns status code 204' do expect( response ).to have_http_status(204) @@ -214,4 +265,21 @@ def g_name expect( event.reload.deleted_at ).to be end end + + describe '#Destroying' do + let(:event) { create(:event, organiser: organiser) } + + context 'by a different user' do + before { delete "/events/#{event.to_param}", headers: auth(create(:someone_else)) } + + it 'is not found' do + expect( response ).to have_http_status(404) + end + end + + it_behaves_like 'an action that requires authorization' do + let(:action) { "delete#events/#{event.to_param}" } + let(:params) { {} } + end + end end diff --git a/spec/requests/users_request_spec.rb b/spec/requests/users_request_spec.rb new file mode 100644 index 0000000..5a84207 --- /dev/null +++ b/spec/requests/users_request_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +RSpec.describe "Users", type: :request do + + describe "POST /users" do + + it 'creates a user' do + expect { + post '/users', params: {user: attributes_for(:user)} + }.to change(User, :count).by(1) + end + + context '#Creating a user' do + before { post '/users', params: {user: attributes_for(:user)} } + + it 'returns 201' do + expect( response ).to have_http_status(201) + expect( response.body ).to match /Account created/ + end + + let(:saved) { User.first } + + it 'saves the attributes' do + expect( saved.full_name ).not_to be_blank + + expect( saved.password ).to be_blank + expect( saved.password_digest ).not_to be_blank + + expect( saved.email ).to match %r{[a-z]+@example\.com} + end + end + + context 'without a password' do + let(:without_password) do + attributes_for(:user).merge(password: '') + end + before { post '/users', params: {user: without_password} } + + it 'returns an error' do + expect( response ).to have_http_status(422) + expect( response.body ) + .to match(/Validation failed: Password can't be blank/) + end + end + + context 'without a email' do + let(:without_email) do + attributes_for(:user).merge(email: '') + end + before { post '/users', params: {user: without_email} } + + it 'returns an error' do + expect( response ).to have_http_status(422) + expect( response.body ) + .to match(/Validation failed: Email can't be blank/) + end + end + end +end diff --git a/spec/support/an_authorized_action.rb b/spec/support/an_authorized_action.rb new file mode 100644 index 0000000..600db14 --- /dev/null +++ b/spec/support/an_authorized_action.rb @@ -0,0 +1,31 @@ +shared_examples 'an action that requires authorization' do + + def req + action.split('#').first + end + + def url + "/#{action.split('#').last}" + end + + context 'and a missing auth token' do + before { send(req, url, params: params, headers: {'Authorization' => nil}) } + + it 'errors' do + expect( response ).to have_http_status(401) + expect( response.body ).to match /Token required/ + end + end + + context 'wrong auth token' do + before do + expect(JWT).to receive(:decode).with('Wrong', secret_key).and_call_original + end + before { send(req, url, params: params, headers: {'Authorization' => 'Wrong'}) } + + it 'errors' do + expect( response ).to have_http_status(401) + expect( response.body ).to match /Invalid token/ + end + end +end