diff --git a/.env.development.sample b/.env.development.sample index 7864698..0b2a1ec 100644 --- a/.env.development.sample +++ b/.env.development.sample @@ -3,3 +3,5 @@ DATABASE_URL=mysql2://root:@127.0.0.1:3306/sample?reconnect=true MAIL_URL=smtp://127.0.0.1:1025 SYSTEM_EMAIL=support@sample.com SITE_URL=http://localhost:3000/ +HMAC_SECRET=ZxW8go9hz3ETCSfxFxpwSkYg_602gOPKearsf6DsxgY +REDIS_URL=redis://localhost:6379 diff --git a/.env.test.sample b/.env.test.sample index 67326c8..b1f614e 100644 --- a/.env.test.sample +++ b/.env.test.sample @@ -3,3 +3,5 @@ DATABASE_URL=mysql2://root:@127.0.0.1:3306/sample_test?reconnect=true MAIL_URL=smtp://127.0.0.1:1025 SYSTEM_EMAIL=support@sample.com SITE_URL=http://localhost:3000/ +HMAC_SECRET=ZxW8go9hz3ETCSfxFxpwSkYg_602gOPKearsf6DsxgY +REDIS_URL=redis://localhost:6379 diff --git a/Gemfile b/Gemfile index b654c5a..80ad15c 100644 --- a/Gemfile +++ b/Gemfile @@ -20,10 +20,15 @@ gem 'hanami-validations', '0.6.0' # form validation gem 'dry-validation', '0.10.4' # validation methods for reform gem 'ability_list', '0.0.4' gem 'activesupport', '5.0.0' +gem 'bcrypt', '3.1.11' # encryption +gem 'jwt', '1.5.6' # Json Web Token +gem 'sidekiq', '4.2.9' # handle background jobs +gem 'redis', '3.3.3' group :development, :test do gem 'awesome_print', '1.7.0' gem 'pry', '0.10.4' + gem 'letter_opener', '1.4.1' end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index ad129a5..df1ab75 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,11 +14,13 @@ GEM descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) + bcrypt (3.1.11) builder (3.2.2) coderay (1.1.1) coercible (1.0.0) descendants_tracker (~> 0.0.1) concurrent-ruby (1.0.4) + connection_pool (2.2.1) crack (0.4.3) safe_yaml (~> 1.0.0) daemons (1.2.4) @@ -90,6 +92,11 @@ GEM i18n (0.7.0) ice_nine (0.11.2) inflecto (0.0.2) + jwt (1.5.6) + launchy (2.4.3) + addressable (~> 2.3) + letter_opener (1.4.1) + launchy (~> 2.2) listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) @@ -118,12 +125,15 @@ GEM rack (>= 0.4) rack-indifferent (1.1.0) rack (>= 1.5) + rack-protection (1.5.3) + rack rack-test (0.6.3) rack (>= 1.0) rake (11.2.2) rb-fsevent (0.9.8) rb-inotify (0.9.7) ffi (>= 0.5.0) + redis (3.3.3) rerun (0.11.0) listen (~> 3.0) rspec (3.5.0) @@ -142,6 +152,11 @@ GEM ruby_dep (1.5.0) safe_yaml (1.0.4) sequel (4.40.0) + sidekiq (4.2.9) + concurrent-ruby (~> 1.0) + connection_pool (~> 2.2, >= 2.2.0) + rack-protection (>= 1.5.0) + redis (~> 3.2, >= 3.2.1) slop (3.6.0) thin (1.7.0) daemons (~> 1.0, >= 1.0.9) @@ -170,6 +185,7 @@ DEPENDENCIES ability_list (= 0.0.4) activesupport (= 5.0.0) awesome_print (= 1.7.0) + bcrypt (= 3.1.11) database_cleaner (= 1.5.3) dry-validation (= 0.10.4) factory_girl (= 4.7.0) @@ -180,6 +196,8 @@ DEPENDENCIES grape-swagger (= 0.25.1) grape-swagger-entity (= 0.1.5) hanami-validations (= 0.6.0) + jwt (= 1.5.6) + letter_opener (= 1.4.1) mail (= 2.6.4) mysql2 (= 0.4.5) pry (= 0.10.4) @@ -187,9 +205,11 @@ DEPENDENCIES rack-indifferent (= 1.1) rack-test (= 0.6.3) rake (= 11.2.2) + redis (= 3.3.3) rerun (= 0.11.0) rspec (= 3.5.0) sequel (= 4.40.0) + sidekiq (= 4.2.9) thin (= 1.7.0) uuidtools (= 2.1.5) vcr (= 3.0.3) @@ -199,4 +219,4 @@ RUBY VERSION ruby 2.3.3p222 BUNDLED WITH - 1.13.6 + 1.14.5 diff --git a/Makefile b/Makefile index 3f7042b..7e24daf 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,9 @@ install: run: bundle exec rerun -b --pattern '{Gemfile,Gemfile.lock,.gems,.bundle,.env*,config.ru,**/*.{rb,ru,yml}}' -- thin start --port=3000 --threaded +worker: + bundle exec sidekiq -C ./application/config/yaml/sidekiq.yml + console: bundle exec pry -r ./application/api diff --git a/application/api.rb b/application/api.rb index 7ddc2d9..7bcfa5b 100644 --- a/application/api.rb +++ b/application/api.rb @@ -13,6 +13,7 @@ require 'bundler' Bundler.setup :default, RACK_ENV require 'rack/indifferent' +require 'sidekiq' require 'grape' require 'grape/batch' # Initialize the application so we can add all our components to it @@ -21,20 +22,34 @@ class Api < Grape::API; end # Include all config files require 'config/sequel' require 'config/hanami' +require 'config/mail' require 'config/grape' # require some global libs require 'lib/core_ext' require 'lib/time_formats' require 'lib/io' +require 'lib/pretty_logger' # load active support helpers require 'active_support' require 'active_support/core_ext' +# require authentication libs +require 'jwt' +require 'bcrypt' + # require all models Dir['./application/models/*.rb'].each { |rb| require rb } +# require all validators +Dir['./application/api_validators/**/*.rb'].each { |rb| require rb } + +# require all mailers +require './application/mailers/mailer_base.rb' +Dir['./application/mailers/**/*.rb'].each { |rb| require rb } + + Dir['./application/api_helpers/**/*.rb'].each { |rb| require rb } class Api < Grape::API version 'v1.0', using: :path @@ -50,14 +65,13 @@ class Api < Grape::API error! ret, 400 end + logger PrettyLogger.new + helpers SharedParams helpers ApiResponse + helpers ApiFormat include Auth - before do - authenticate! - end - Dir['./application/api_entities/**/*.rb'].each { |rb| require rb } Dir['./application/api/**/*.rb'].each { |rb| require rb } diff --git a/application/api/users.rb b/application/api/users.rb index 40de636..d207a6b 100644 --- a/application/api/users.rb +++ b/application/api/users.rb @@ -1,13 +1,106 @@ class Api resource :users do + params do includes :basic_search end + get do users = SEQUEL_DB[:users].all - { - data: users - } + present users, with: Api::Entities::User end + + desc "Creates a user" + params do + requires :first_name, type: String, desc: "User's first name" + requires :last_name, type: String, desc: "User's last name" + requires :email, type: String, desc: "User's email name" + requires :password, type: String, desc: "User's password" + optional :date_of_birth, type: String, desc: "User's date of birth / Format: YYYY:MM:DD HH:MM:SS TZ" + end + + post do + result = Api::Validators::CreateUser.new(declared(params)).validate + error!({ errors: result.messages } , 422) unless result.success? + + user = Api::Models::User.new(result.output) + user.save + + Api::WelcomeUserMailer.perform_async(user.id) + + present user, with: Api::Entities::User + end + + desc "Updates a user" + params do + optional :first_name, type: String, desc: "User's first name" + optional :last_name, type: String, desc: "User's last name" + optional :email, type: String, desc: "User's email name" + optional :date_of_birth, type: String, desc: "User's date of birth / Format: YYYY:MM:DD HH:MM:SS TZ" + end + + put '/:id' do + authenticate! + + result = Api::Validators::UpdateUser.new(declared(params)).validate + error!({ errors: result.messages } , 422) unless result.success? + + unless user = Api::Models::User.where(id: params[:id]).first + return api_response({error_type: :not_found}) + end + + unless current_user.can?(:edit, user) + return api_response({error_type: :forbidden}) + end + + user.update(result.output) + + present user, with: Api::Entities::User + end + + desc "Resets a user's password" + params do + requires :new_password, type: String, desc: "New password" + requires :confirm_password, type: String, desc: "Password confirmation" + end + + patch '/:id/reset_password' do + result = Api::Validators::ResetUserPassword.new(declared(params)).validate + error!({ errors: result.messages } , 422) unless result.success? + + unless user = Api::Models::User.where(id: params[:id]).first + return api_response({error_type: :not_found}) + end + + user.password = result.output['new_password'] + user.save + + Api::ResetPasswordMailer.perform_async(user.id) + + api_response({status: :ok}) + end + + desc "Signin a user" + params do + requires :email, type: String, desc: "User's email" + requires :password, type: String, desc: "User's password" + end + + post '/signin' do + result = Api::Validators::UserSignin.new(declared(params)).validate + error!({ errors: result.messages } , 422) unless result.success? + + unless user = Api::Models::User.where(email: result.output['email']).first + return api_response({error_type: :unauthorized}) + end + + unless user.password == result.output['password'] + return api_response({error_type: :unauthorized}) + end + + token = generate_token_for_user(user) + api_response({token: token}) + end + end end diff --git a/application/api_entities/user.rb b/application/api_entities/user.rb new file mode 100644 index 0000000..07a570d --- /dev/null +++ b/application/api_entities/user.rb @@ -0,0 +1,15 @@ +class Api + module Entities + class User < Grape::Entity + root 'users', 'user' + + expose :id + expose :full_name + expose :first_name + expose :last_name + expose :email + expose :date_of_birth, format_with: :datetime_string + + end + end +end diff --git a/application/api_helpers/api_format.rb b/application/api_helpers/api_format.rb new file mode 100644 index 0000000..c0983f8 --- /dev/null +++ b/application/api_helpers/api_format.rb @@ -0,0 +1,10 @@ +class Api + module ApiFormat + extend Grape::API::Helpers + + Grape::Entity.format_with :datetime_string do |date| + date.to_time.to_s if date + end + + end +end diff --git a/application/api_helpers/api_response.rb b/application/api_helpers/api_response.rb index 4613eca..fd87a43 100644 --- a/application/api_helpers/api_response.rb +++ b/application/api_helpers/api_response.rb @@ -9,6 +9,8 @@ def api_response response status 404 when :forbidden status 403 + when :unauthorized + status 401 else status 400 end diff --git a/application/api_helpers/auth.rb b/application/api_helpers/auth.rb index b46a936..dd2553b 100644 --- a/application/api_helpers/auth.rb +++ b/application/api_helpers/auth.rb @@ -8,12 +8,25 @@ module Auth module HelperMethods def authenticate! - # Library to authenticate user can go here + token = request.env['HTTP_AUTHORIZATION'] + payload = JWT.decode(token, HMAC_SECRET)[0] + if @current_user = ::Api::Models::User.where(email: payload['email']).first + true + else + error!({error_type: 'not_authorized'}, 401) + end + rescue JWT::DecodeError, JWT::VerificationError + error!({error_type: 'not_authorized'}, 401) end def current_user @current_user end + + def generate_token_for_user(user) + payload = { email: user.email } + JWT.encode(payload, HMAC_SECRET) + end end end end diff --git a/application/api_validators/create_user.rb b/application/api_validators/create_user.rb new file mode 100644 index 0000000..d51697e --- /dev/null +++ b/application/api_validators/create_user.rb @@ -0,0 +1,16 @@ +class Api + module Validators + class CreateUser + include Hanami::Validations + predicates FormPredicates + + validations do + required('first_name') { filled? & str? } + required('last_name') { filled? & str? } + required('email') { email? } + required('password') { filled? & str? } + optional('date_of_birth') { datetime_str? } + end + end + end +end diff --git a/application/api_validators/reset_user_password.rb b/application/api_validators/reset_user_password.rb new file mode 100644 index 0000000..eb38c48 --- /dev/null +++ b/application/api_validators/reset_user_password.rb @@ -0,0 +1,19 @@ +class Api + module Validators + class ResetUserPassword + + include Hanami::Validations + predicates FormPredicates + + validations do + required('new_password').filled(:str?) + required('confirm_password').filled(:str?) + + rule(confirm_password: ['new_password', 'confirm_password']) do |new_password, confirm_password| + new_password.eql?(confirm_password) + end + end + + end + end +end diff --git a/application/api_validators/update_user.rb b/application/api_validators/update_user.rb new file mode 100644 index 0000000..e988719 --- /dev/null +++ b/application/api_validators/update_user.rb @@ -0,0 +1,15 @@ +class Api + module Validators + class UpdateUser + include Hanami::Validations + predicates FormPredicates + + validations do + optional('first_name') { filled? & str? } + optional('last_name') { filled? & str? } + optional('email') { email? } + optional('date_of_birth') { datetime_str? } + end + end + end +end diff --git a/application/api_validators/user_signin.rb b/application/api_validators/user_signin.rb new file mode 100644 index 0000000..28e5c09 --- /dev/null +++ b/application/api_validators/user_signin.rb @@ -0,0 +1,13 @@ +class Api + module Validators + class UserSignin + include Hanami::Validations + predicates FormPredicates + + validations do + required('email') { email? } + required('password') { filled? & str? } + end + end + end +end diff --git a/application/config/mail.rb b/application/config/mail.rb new file mode 100644 index 0000000..bfc9490 --- /dev/null +++ b/application/config/mail.rb @@ -0,0 +1,11 @@ +require 'mail' + +Mail.defaults do + if %w(development test).include?(RACK_ENV) + require 'letter_opener' + + delivery_method LetterOpener::DeliveryMethod, :location => File.expand_path('../../../tmp/letter_opener', __FILE__) + else + delivery_method :sendmail + end +end diff --git a/application/config/variables.rb b/application/config/variables.rb index 4916a70..cec202e 100644 --- a/application/config/variables.rb +++ b/application/config/variables.rb @@ -23,3 +23,4 @@ MAIL_URL = ENV.fetch('MAIL_URL').freeze SYSTEM_EMAIL = ENV.fetch('SYSTEM_EMAIL').freeze SITE_URL = ENV.fetch('SITE_URL').freeze +HMAC_SECRET = ENV.fetch('HMAC_SECRET').freeze diff --git a/application/config/yaml/errors.yml b/application/config/yaml/errors.yml index 4128a88..92ea01e 100644 --- a/application/config/yaml/errors.yml +++ b/application/config/yaml/errors.yml @@ -3,3 +3,4 @@ en: email?: must be an email datetime_str?: must be in format YYYY-MM-DD HH:MM:SS phone?: must be valid format (ex - 8005556262) + confirm_password: doesn't match diff --git a/application/config/yaml/sidekiq.yml b/application/config/yaml/sidekiq.yml index 071bd80..f233d4b 100644 --- a/application/config/yaml/sidekiq.yml +++ b/application/config/yaml/sidekiq.yml @@ -2,6 +2,7 @@ :queues: - [priority, 7] - [default, 5] + - [mailers, 4] - [seldom, 3] staging: :concurrency: 10 diff --git a/application/mailers/mailer_base.rb b/application/mailers/mailer_base.rb new file mode 100644 index 0000000..e14a9a8 --- /dev/null +++ b/application/mailers/mailer_base.rb @@ -0,0 +1,54 @@ +class Api::MailerBase + include ERB::Util + + include Sidekiq::Worker + sidekiq_options queue: 'mailers' + + def mail(from:, to:, subject:) + mailer = Mail.new do + to to + from from + subject subject + end + + if template?(:text) + mailer.text_part = Mail::Part.new + mailer.text_part.body = render_template(:text) + end + + if template?(:html) + mailer.html_part = Mail::Part.new + mailer.html_part.content_type = 'text/html; charset=UTF-8' + mailer.html_part.body = render_template(:html) + end + + mailer.deliver + end + + def template_name + raise NotImplementedError + end + + protected + + def template?(extension) + template_exists?(template_path(extension)) + end + + def render_template(extension) + render(File.read(template_path(extension))) + end + + def template_path(extension) + File.expand_path("../templates/#{template_name}.#{extension}.erb", __FILE__) + end + + def template_exists?(path) + File.exists?(path) + end + + def render(template) + ERB.new(template).result(binding) + end + +end diff --git a/application/mailers/reset_password_mailer.rb b/application/mailers/reset_password_mailer.rb new file mode 100644 index 0000000..85ebc40 --- /dev/null +++ b/application/mailers/reset_password_mailer.rb @@ -0,0 +1,17 @@ +class Api::ResetPasswordMailer < Api::MailerBase + + def perform(user_id) + @user = Api::Models::User.where(id: user_id).first + + mail( + to: @user.email, + from: 'no-reply@sample.com', + subject: 'Your password was reset!' + ) + end + + def template_name + 'reset_password' + end + +end diff --git a/application/mailers/templates/reset_password.html.erb b/application/mailers/templates/reset_password.html.erb new file mode 100644 index 0000000..6a58f74 --- /dev/null +++ b/application/mailers/templates/reset_password.html.erb @@ -0,0 +1,7 @@ +
+ Hi <%= @user.full_name %>, + + Your password was reset. +
diff --git a/application/mailers/templates/reset_password.text.erb b/application/mailers/templates/reset_password.text.erb new file mode 100644 index 0000000..5520052 --- /dev/null +++ b/application/mailers/templates/reset_password.text.erb @@ -0,0 +1,5 @@ +Password Reset Confirmation + +Hi <%= @user.full_name %>, + +Your password was reset. diff --git a/application/mailers/templates/welcome_user.html.erb b/application/mailers/templates/welcome_user.html.erb new file mode 100644 index 0000000..5288a72 --- /dev/null +++ b/application/mailers/templates/welcome_user.html.erb @@ -0,0 +1 @@ +