diff --git a/README.md b/README.md index 12c17a6..c89eec5 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ bin/rails db:migrate SCOPE=ctws VERSION=0 ### Hook the application `User` model with the engine -By default the user model is `User` but you can change it by creating a `ctws.rb` initializer file in `config/initializers` and put this content in it: +By default the user model is `User` but you can change it by creating or editing the `ctws.rb` initializer file in `config/initializers` and put this content in it: ```ruby Ctws.user_class = "Account" @@ -63,6 +63,14 @@ The application `User` model **must have `email` and `password` attributes**. For `password` validation [`ActiveModel::SecurePassword::InstanceMethodsOnActivation authenticate`](https://apidock.com/rails/v4.2.7/ActiveModel/SecurePassword/InstanceMethodsOnActivation/authenticate) and [`Devise::Models::DatabaseAuthenticatable#valid_password?`](http://www.rubydoc.info/github/plataformatec/devise/Devise%2FModels%2FDatabaseAuthenticatable:valid_password%3F) User instance methods are supported. +### Set the `JWT` expiry time + +By default the token expiry time is 24h but you can change it by creating or editing the `ctws.rb` initializer file in `config/initializers` and put this content in it: + +```ruby +Ctws.jwt_expiration_time = 24.hours.from_now +``` + + +### min_app_version + +**request:** + +```bash +curl localhost:3000/ws/v1/min_app_version +``` + +**response:** + +```json +HTTP/1.1 200 OK +Cache-Control: max-age=0, private, must-revalidate +Content-Type: application/vnd.api+json; charset=utf-8 +ETag: W/"8dcf1379b7ee203a6d72b3c7773d47f4" +Transfer-Encoding: chunked +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Request-Id: c1924546-0212-45fe-b86a-83ee3a3b2fa4 +X-Runtime: 0.003897 +X-XSS-Protection: 1; mode=block + +{ + "data": [ + { + "id": 3, + "type": "min_app_version", + "attributes": { + "codename": "Second release", + "description": "Second release Description text", + "min_version": "0.0.2", + "platform": "android", + "store_uri": "htttps://fdsafdsafdsaf.cot", + "updated_at": "2017-06-22T17:53:31.252+02:00" + } + }, + { + "type": "min_app_version", + "id": 1, + "attributes": { + "codename": "First Release", + "description": "You need to update your app. You will be redirected to the corresponding store", + "min_version": "0.0.1", + "platform": "ios", + "store_uri": "https://itunes.apple.com/", + "updated_at": "2017-06-21T14:29:59.348+02:00" + } + } + ] +} +``` +### signup + +**request:** + +```bash +curl -X POST -F "email=user@example.com" -F "password=123456789" http://localhost:3000/ws/v1/signup +``` + +**Successful response:** + +```json +HTTP/1.1 201 Created +Cache-Control: max-age=0, private, must-revalidate +Content-Type: application/vnd.api+json; charset=utf-8 +ETag: W/"ab43e77c2d67636c5c0cd707e661c311" +Transfer-Encoding: chunked +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Request-Id: 75f9d9cc-4ce2-4aed-9349-e995bb122f39 +X-Runtime: 1.727068 +X-XSS-Protection: 1; mode=block + +{ + "data": { + "type": "user", + "id": 20, + "attributes": { + "message": "Account created successfully", + "auth_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyMCwiZXhwIjoxNDk5ODU1MjQ5fQ.H9ljjShWOAv8b9xn9ZLKv-zgmH8xkPe6dkdhH4JrJPw", + "created_at": "2017-07-11T12:27:27.916+02:00" + } + } +} +``` + +**Error response:** + +```json +HTTP/1.1 401 Unauthorized +Cache-Control: no-cache +Content-Type: application/vnd.api+json; charset=utf-8 +Transfer-Encoding: chunked +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Request-Id: b0d91125-446d-4ed3-95cb-4a702ee24289 +X-Runtime: 0.091940 +X-XSS-Protection: 1; mode=block + +{ + "errors": { + "message": "Invalid credentials" + } +} +``` +### login + +**request:** + +```bash +curl -X POST -F "email=user@example.com" -F "password=123456789" http://localhost:3000/ws/v1/login +``` + +**Successful response:** + +```json +HTTP/1.1 200 OK +Cache-Control: max-age=0, private, must-revalidate +Content-Type: application/vnd.api+json; charset=utf-8 +ETag: W/"4e7a5faaf9eb480a7a7dadb734d01da1" +Transfer-Encoding: chunked +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Request-Id: 565a8015-9087-480c-b5b6-1dbf634c7d83 +X-Runtime: 0.278055 +X-XSS-Protection: 1; mode=block + +{ + "data": { + "type": "authentication", + "attributes": { + "message": "Authenticated user successfully", + "auth_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxNiwiZXhwIjoxNDk5ODU2MDgyfQ.FOLNcInu0yxnp_dqVnyzfzGNwKyv_ERoflhW4cvTa60" + } + } +} +``` + +**Error response:** + +```json +HTTP/1.1 401 Unauthorized +Cache-Control: no-cache +Content-Type: application/vnd.api+json; charset=utf-8 +Transfer-Encoding: chunked +X-Content-Type-Options: nosniff +X-Frame-Options: SAMEORIGIN +X-Request-Id: e95e4b63-3a83-409f-bd05-4cf2dea6136a +X-Runtime: 0.015463 +X-XSS-Protection: 1; mode=block + +{ + "errors": { + "message": "Invalid credentials" + } +} +``` ## Tests diff --git a/app/auth/ctws/authenticate_user.rb b/app/auth/ctws/authenticate_user.rb index cf8a022..3e42a0a 100644 --- a/app/auth/ctws/authenticate_user.rb +++ b/app/auth/ctws/authenticate_user.rb @@ -1,6 +1,6 @@ module Ctws class AuthenticateUser - def initialize(email, password) + def initialize(email, password="12346") @email = email @password = password end @@ -8,6 +8,10 @@ def initialize(email, password) # Service entry point def call Ctws::JsonWebToken.encode(user_id: user.id) if user + + # attrs_hash = {} + # Ctws.jwt_auth_token_attrs.each {|a| attrs_hash.merge!({"user_#{a}": user.try(a)})} + # Ctws::JsonWebToken.encode(attrs_hash) if user end private @@ -18,12 +22,15 @@ def call def user user = Ctws.user_class.find_by(email: email) - # try method of Active Record's has_secure_password or Devise valid_password? - authenticated = user.try(:authenticate, password) || user.try(:valid_password?, password) - + if Ctws.user_validate_with_password + # try method of Active Record's has_secure_password or Devise valid_password? + authenticated = user.try(:authenticate, password) || user.try(:valid_password?, password) + elsif !Ctws.user_validate_with_password + authenticated = true + end return user if user && authenticated # raise Authentication error if credentials are invalid raise(Ctws::ExceptionHandler::AuthenticationError, Ctws::Message.invalid_credentials) end - end + end end \ No newline at end of file diff --git a/app/controllers/concerns/ctws/exception_handler.rb b/app/controllers/concerns/ctws/exception_handler.rb index db2c069..71c7807 100644 --- a/app/controllers/concerns/ctws/exception_handler.rb +++ b/app/controllers/concerns/ctws/exception_handler.rb @@ -2,6 +2,7 @@ module Ctws # In the case where the record does not exist, # ActiveRecord will throw an exception ActiveRecord::RecordNotFound. # We'll rescue from this exception and return a 404 message. + # List of Rails Status Code Symbols http://billpatrianakos.me/blog/2013/10/13/list-of-rails-status-code-symbols module ExceptionHandler # provides the more graceful `included` method @@ -12,37 +13,45 @@ class AuthenticationError < StandardError; end class MissingToken < StandardError; end class InvalidToken < StandardError; end class ExpiredSignature < StandardError; end - + class UnprocessableEntity < StandardError; end + class RoutingError < StandardError; end + included do # Define custom handlers rescue_from ActiveRecord::RecordInvalid, with: :four_twenty_two rescue_from ExceptionHandler::AuthenticationError, with: :unauthorized_request rescue_from ExceptionHandler::MissingToken, with: :four_twenty_two rescue_from ExceptionHandler::InvalidToken, with: :four_twenty_two + rescue_from ExceptionHandler::UnprocessableEntity, with: :four_twenty_two rescue_from ExceptionHandler::ExpiredSignature, with: :four_ninety_eight + rescue_from ExceptionHandler::RoutingError, with: :not_found + rescue_from ActiveRecord::RecordNotFound, with: :not_found + rescue_from ActionController::RoutingError, with: :not_found - rescue_from ActiveRecord::RecordNotFound do |e| - json_response({ message: e.message }, :not_found) - end - rescue_from ActiveRecord::RecordInvalid do |e| json_response({ message: e.message }, :unprocessable_entity) end end + # JSON response with message; Status code 401 - Unauthorized + def unauthorized_request(e) + json_response({ message: e.message }, :unauthorized) + end + + # JSON response with message; Status code 401 - Unauthorized + def not_found(e) + json_response({ message: e.message }, :not_found) + end + # JSON response with message; Status code 422 - unprocessable entity def four_twenty_two(e) json_response({ message: e.message }, :unprocessable_entity) end - # JSON response with message; Status code 401 - Unauthorized - def unauthorized_request(e) - json_response({ message: e.message }, :unauthorized) - end # JSON response with message; Status code 498 - Invalid Token def four_ninety_eight(e) - json_response({ message: e.message }, :invalid_token) + json_response({ message: e.message }, :invalid_token) end end diff --git a/app/controllers/concerns/ctws/response.rb b/app/controllers/concerns/ctws/response.rb index 3d5f28d..a9cbc5b 100644 --- a/app/controllers/concerns/ctws/response.rb +++ b/app/controllers/concerns/ctws/response.rb @@ -2,20 +2,24 @@ module Ctws module Response # responds with JSON and an HTTP status code (200 by default) # json_response(@todo, :created) - def success? status + def payload? object, status case status when :not_found, :unprocessable_entity, :unauthorized, :invalid_token - false + self.errors_payload(object) else - true + self.data_payload(object) end end - def json_response(object, status = :ok) - responds = { - success: self.success?(status), - data: object - } - render json: responds, status: status + + def json_response(object = {}, status = :ok) + render json: self.payload?(object, status), status: status + end + + def data_payload(object) + {data: object} + end + def errors_payload(object) + {errors: object} end end end \ No newline at end of file diff --git a/app/controllers/ctws/authentication_controller.rb b/app/controllers/ctws/authentication_controller.rb index fde9d3a..8ea7905 100644 --- a/app/controllers/ctws/authentication_controller.rb +++ b/app/controllers/ctws/authentication_controller.rb @@ -5,11 +5,22 @@ class AuthenticationController < CtwsController # return auth token once user is authenticated def authenticate auth_token = Ctws::AuthenticateUser.new(auth_params[:email], auth_params[:password]).call - json_response(auth_token: auth_token) + json_response auth_as_jsonapi(auth_token) + end private + def auth_as_jsonapi auth_token + { + type: controller_name, + attributes: { + message: Ctws::Message.authenticated_user_success, + auth_token: auth_token, + } + } + end + def auth_params params.permit(:email, :password) end diff --git a/app/controllers/ctws/ctws_controller.rb b/app/controllers/ctws/ctws_controller.rb index b817772..5e794d1 100644 --- a/app/controllers/ctws/ctws_controller.rb +++ b/app/controllers/ctws/ctws_controller.rb @@ -8,8 +8,12 @@ class CtwsController < ApplicationController # called before every action on controllers before_action :authorize_request + skip_before_action :authorize_request, only: [:raise_not_found!] attr_reader :current_user - + + def raise_not_found! + raise Ctws::ExceptionHandler::RoutingError, ("#{Ctws::Message.unmatched_route(params[:unmatched_route])}") + end private # Check for valid request token and return user diff --git a/app/controllers/ctws/min_app_versions_controller.rb b/app/controllers/ctws/min_app_versions_controller.rb index 2a91507..fce70bf 100644 --- a/app/controllers/ctws/min_app_versions_controller.rb +++ b/app/controllers/ctws/min_app_versions_controller.rb @@ -1,12 +1,15 @@ module Ctws class MinAppVersionsController < CtwsController - #skip_before_action :authorize_request, only: :min_app_version skip_before_action :authorize_request, only: [:min_app_version] before_action :set_min_app_version, only: [:show, :update, :destroy] # GET /min_app_version - def min_app_version - json_response MinAppVersion.group(:platform) + def min_app_version + min_app_versions = [] + MinAppVersion.group(:platform).each do |platform| + min_app_versions << platform.as_jsonapi + end + json_response min_app_versions end # GET /min_app_versions @@ -16,7 +19,7 @@ def index # GET /min_app_versions/1 def show - json_response @min_app_version + json_response @min_app_version.as_jsonapi end # POST /min_app_versions diff --git a/app/controllers/ctws/users_controller.rb b/app/controllers/ctws/users_controller.rb index 454927b..b78a5c5 100644 --- a/app/controllers/ctws/users_controller.rb +++ b/app/controllers/ctws/users_controller.rb @@ -8,13 +8,30 @@ def create # We use Active Record's create! method so that in the event there's an error, # an exception will be raised and handled in the exception handler. ctws_user = Ctws.user_class.create!(ctws_user_params) - auth_token = Ctws::AuthenticateUser.new(ctws_user.email, ctws_user.password).call - response = { message: Ctws::Message.account_created, auth_token: auth_token } - json_response(response, :created) + if Ctws.user_validate_with_password + auth_token = Ctws::AuthenticateUser.new(ctws_user.email, ctws_user.password).call + elsif !Ctws.user_validate_with_password + auth_token = Ctws::AuthenticateUser.new(ctws_user.email).call + end + # response = { message: Ctws::Message.account_created, auth_token: auth_token } + + json_response(user_as_jsonapi(ctws_user, auth_token), :created) end private + def user_as_jsonapi user, auth_token + { + type: ActiveModel::Naming.param_key(Ctws.user_class), + id: user.id, + attributes: { + message: Ctws::Message.account_created, + auth_token: auth_token, + created_at: user.created_at + } + } + end + def ctws_user_params params.permit(:email, :password, :password_confirmation) end diff --git a/app/lib/ctws/json_web_token.rb b/app/lib/ctws/json_web_token.rb index a92798e..c7284b1 100644 --- a/app/lib/ctws/json_web_token.rb +++ b/app/lib/ctws/json_web_token.rb @@ -5,7 +5,7 @@ class JsonWebToken # secret to encode and decode token HMAC_SECRET = Rails.application.secrets.secret_key_base - def self.encode(payload, exp = 24.hours.from_now) + def self.encode(payload, exp = Ctws.jwt_expiration_time) # set expiry to 24 hours from creation time payload[:exp] = exp.to_i # sign token with application secret diff --git a/app/lib/ctws/message.rb b/app/lib/ctws/message.rb index 8e23df8..b9dc9d1 100644 --- a/app/lib/ctws/message.rb +++ b/app/lib/ctws/message.rb @@ -4,6 +4,10 @@ def self.not_found(record = 'record') "Sorry, #{record} not found." end + def self.unmatched_route(route = 'route') + "No route matches #{route}" + end + def self.invalid_credentials 'Invalid credentials' end @@ -28,6 +32,10 @@ def self.account_not_created 'Account could not be created' end + def self.authenticated_user_success + 'Authenticated user successfully' + end + def self.expired_token 'Sorry, your token has expired. Please login to continue.' end diff --git a/app/models/ctws/min_app_version.rb b/app/models/ctws/min_app_version.rb index b0bd17c..8a796be 100644 --- a/app/models/ctws/min_app_version.rb +++ b/app/models/ctws/min_app_version.rb @@ -1,5 +1,22 @@ module Ctws class MinAppVersion < ApplicationRecord validates_presence_of :codename, :description, :platform, :min_version, :store_uri + + def as_jsonapi(options={}) + { + type: ActiveModel::Naming.param_key(self), + id: self.id, + attributes: { + codename: self.codename, + description: self.description, + min_version: self.min_version, + platform: self.platform, + store_uri: self.store_uri, + updated_at: self.updated_at + } + } + end end + + end diff --git a/cheat.md b/cheat.md index 562c400..b8a7d27 100644 --- a/cheat.md +++ b/cheat.md @@ -4,6 +4,8 @@ list latest min_version: ```bash curl localhost:3000/ws/v1/min_app_version + +http :3000/ws/v1/min_app_version ``` --- @@ -12,6 +14,9 @@ read min version 1: ```bash curl localhost:3000/ws/v1/min_app_versions/1 -H "Content-Type: application/json" -H "Authorization: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE0OTQ5MjkyNTl9.Fei1711sEGDGrymWMaBIvXdx3k0CVckNUbkMD5VV1Gk" + +http :3000/ws/v1/min_app_versions/1 Authorization:'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo5LCJleHAiOjE0OTgyMzIwODd9.lq3Badyrxa114_51JZyIxrf7SM0-Q2jxXz7Oql5JEsY' +http :3000/ws/v1/min_app_versions/1 Authorization:'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyMCwidXNlcl9lbWFpbCI6InJlY0ByZWMuY29tIiwidXNlcl9hcHBfcmVnaXN0ZXJlZF9hdCI6IjIwMTctMDctMTEgMTY6MzU6MjIgKzAyMDAiLCJleHAiOjIyNTcxNjk1Mzd9.xwNEOdb4edsBzfu3Jbq7CAd9HKSAhpbTHPPhTaW7xso' ``` --- @@ -20,6 +25,8 @@ signup: ```bash curl -X POST -F "email=agusti.br@coditramuntana.com" -F "password=123456789" http://localhost:3000/ws/v1/signup + +http POST :3000/ws/v1/signup email="agustibr.10@coditramuntana.com" password="123456789" ``` --- @@ -28,7 +35,7 @@ login: ```bash curl -X POST -F "email=agusti.br@coditramuntana.com" -F "password=123456789" http://localhost:3000/ws/v1/login - +http POST :3000/ws/v1/login email="agusti.br@coditramuntana.com" password="123456789" ``` create version (httpie): @@ -42,7 +49,7 @@ http POST :3000/ws/v1/min_app_versions codename="Test Version" description="Lore edit version (httpie): ```bash -http PUT :3000/ws/v1/min_app_versions/3 codename="Test Version edit" Authorization:'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE0OTQ5MjkyNTl9.Fei1711sEGDGrymWMaBIvXdx3k0CVckNUbkMD5VV1Gk' +http PUT :3000/ws/v1/min_app_versions/3 codename="Test Version edit" Authorization:'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo5LCJleHAiOjE0OTgyMTI4MDR9.AzSDyNVfUuW85mxZq8mVQ3_lF_0l-GD1-S4Q7oabdvo' ``` ---- \ No newline at end of file diff --git a/config/initializers/ctws.rb b/config/initializers/ctws.rb index 574e03b..77e6079 100644 --- a/config/initializers/ctws.rb +++ b/config/initializers/ctws.rb @@ -1 +1,4 @@ -Ctws.user_class = "User" \ No newline at end of file +Ctws.user_class = "User" +Ctws.user_validate_with_password = true +Ctws.jwt_expiration_time = 24.hours.from_now +Ctws.jwt_auth_token_attrs = %i(id email) # TODO: implement \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 54a92c2..2ae992d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,6 +10,8 @@ get 'min_app_version', to: 'min_app_versions#min_app_version' end + get '*unmatched_route', to: 'ctws#raise_not_found!' + # match '/', :to => 'base#raise_not_found!', via: :all # match '*other', :to => 'base#raise_not_found!', via: :all diff --git a/lib/ctws.rb b/lib/ctws.rb index f7543a0..ab979a7 100644 --- a/lib/ctws.rb +++ b/lib/ctws.rb @@ -2,7 +2,16 @@ module Ctws mattr_accessor :user_class + mattr_accessor :user_validate_with_password + mattr_accessor :jwt_expiration_time + mattr_accessor :jwt_auth_token_attrs + def self.user_class @@user_class.constantize end + + def self.jwt_auth_token_attrs + # TODO: implement + @@jwt_auth_token_attrs + end end diff --git a/spec/controllers/ctws/ctws_controller_spec.rb b/spec/controllers/ctws/ctws_controller_spec.rb index de52612..fa35898 100644 --- a/spec/controllers/ctws/ctws_controller_spec.rb +++ b/spec/controllers/ctws/ctws_controller_spec.rb @@ -8,7 +8,7 @@ module Ctws let(:headers) { { 'Authorization' => token_generator(ctws_user.id) } } let(:invalid_headers) { { 'Authorization' => nil } } - describe "#authorize_request" do + describe "authorize_request" do context "when auth token is passed" do before { allow(request).to receive(:headers).and_return(headers) } @@ -29,6 +29,13 @@ module Ctws end end end + describe "routes" do + context "when route is not found" do + xit "returns 404" do + to raise_error(Ctws::ExceptionHandler::RoutingError, /No route matches/) + end + end + end end end diff --git a/spec/requests/ctws/authentication_spec.rb b/spec/requests/ctws/authentication_spec.rb index 05fa74e..95c5c9f 100644 --- a/spec/requests/ctws/authentication_spec.rb +++ b/spec/requests/ctws/authentication_spec.rb @@ -30,7 +30,7 @@ module Ctws before { post '/ctws/v1/login', params: valid_credentials, headers: headers } it 'returns an authentication token' do - expect(json["data"]['auth_token']).not_to be_nil + expect(json["data"]["attributes"]["auth_token"]).not_to be_nil end end @@ -39,7 +39,7 @@ module Ctws before { post '/ctws/v1/login', params: invalid_credentials, headers: headers } it 'returns a failure message' do - expect(json["data"]['message']).to match(/Invalid credentials/) + expect(json["errors"]["message"]).to match(/Invalid credentials/) end end end diff --git a/spec/requests/ctws/users_spec.rb b/spec/requests/ctws/users_spec.rb index 60b7fa3..924ae9a 100644 --- a/spec/requests/ctws/users_spec.rb +++ b/spec/requests/ctws/users_spec.rb @@ -19,11 +19,11 @@ module Ctws end it 'returns success message' do - expect(json["data"]['message']).to match(/Account created successfully/) + expect(json["data"]["attributes"]["message"]).to match(/Account created successfully/) end it 'returns an authentication token' do - expect(json["data"]['auth_token']).not_to be_nil + expect(json["data"]["attributes"]["auth_token"]).not_to be_nil end end @@ -35,8 +35,7 @@ module Ctws end it 'returns failure message' do - expect(json["data"]['message']) - .to match(/Validation failed: Password can't be blank, Email can't be blank, Password digest can't be blank/) + expect(json["errors"]['message']).not_to be_nil end end end