diff --git a/.codeclimate.yml b/.codeclimate.yml index 6aa79068e..8809057b5 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -5,7 +5,7 @@ plugins: rubocop: enabled: true reek: - enabled: true + enabled: false checks: FeatureEnvy: enabled: false diff --git a/Gemfile b/Gemfile index 92cf82bff..8718b8646 100644 --- a/Gemfile +++ b/Gemfile @@ -33,11 +33,14 @@ gem 'countries', require: 'countries/global' gem 'fontello_rails_converter' gem 'bootstrap-datepicker-rails' gem 'public_suffix' +gem 'devise-security' +gem 'rails_email_validator' gem 'doorkeeper-jwt', git: 'https://github.com/rubykube/doorkeeper-jwt.git' gem 'memoist', '~> 0.16' gem 'jwt', '~> 2.1' gem 'jwt-multisig', '~> 1.0' +gem 'sentry-raven', '~> 2.7', require: false group :development, :test do gem 'listen', '~> 3.1' diff --git a/Gemfile.lock b/Gemfile.lock index 681834847..b8880710f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -109,6 +109,9 @@ GEM railties (>= 4.1.0, < 5.2) responders warden (~> 1.2.3) + devise-security (0.12.0) + devise (>= 4.2.0, < 5.0) + rails (>= 4.1.0, < 6.0) diff-lcs (1.3) docile (1.3.0) domain_name (0.5.20170404) @@ -276,6 +279,8 @@ GEM nokogiri (>= 1.6) rails-html-sanitizer (1.0.4) loofah (~> 2.2, >= 2.2.2) + rails_email_validator (0.1.4) + activemodel (>= 3.0.0) railties (5.1.4) actionpack (= 5.1.4) activesupport (= 5.1.4) @@ -331,6 +336,8 @@ GEM selenium-webdriver (3.8.0) childprocess (~> 0.5) rubyzip (~> 1.0) + sentry-raven (2.7.3) + faraday (>= 0.7.6, < 1.0) serverengine (1.5.11) sigdump (~> 0.2.2) shoulda-matchers (3.1.2) @@ -397,6 +404,7 @@ DEPENDENCIES chromedriver-helper (~> 1.1) countries devise (~> 4.4) + devise-security doorkeeper (~> 4.2.6) doorkeeper-jwt! factory_bot_rails (~> 4.8) @@ -425,9 +433,11 @@ DEPENDENCIES rack-cors (~> 1.0.2) rails (~> 5.1.4) rails-controller-testing + rails_email_validator rspec-rails (~> 3.7.1) sassc-rails (~> 1.3) selenium-webdriver (~> 3.8) + sentry-raven (~> 2.7) shoulda-matchers (~> 3.1.2) simplecov sneakers (~> 2.6) diff --git a/app/api/entities/api_key.rb b/app/api/entities/api_key.rb new file mode 100644 index 000000000..73a3b75e0 --- /dev/null +++ b/app/api/entities/api_key.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Entities + class APIKey < Grape::Entity + format_with(:iso_timestamp) { |d| d.utc.iso8601 } + + expose :uid, documentation: { type: 'String' } + expose :public_key, documentation: { type: 'String' } + expose :scopes, documentation: { type: 'String', desc: 'comma separated scopes' } + expose :expires_in, documentation: { type: 'String', desc: 'expires_in duration in seconds. Min 30 seconds, Max 86400 seconds' } + expose :state, documentation: { type: 'String' } + + with_options(format_with: :iso_timestamp) do + expose :created_at + expose :updated_at + end + end +end diff --git a/app/api/management_api/v1/jwt_authentication_middleware.rb b/app/api/management_api/v1/jwt_authentication_middleware.rb index 4ad0cb6f0..574835d1a 100644 --- a/app/api/management_api/v1/jwt_authentication_middleware.rb +++ b/app/api/management_api/v1/jwt_authentication_middleware.rb @@ -36,27 +36,27 @@ def jwt memoize :jwt def check_request_method! - unless request.post? || request.put? - raise Exceptions::Authentication, \ - message: 'Only POST and PUT verbs are allowed.', - status: 405 - end + return if request.post? || request.put? + + raise Exceptions::Authentication, \ + message: 'Only POST and PUT verbs are allowed.', + status: 405 end def check_query_parameters! - unless request.GET.empty? - raise Exceptions::Authentication, \ - message: 'Query parameters are not allowed.', - status: 400 - end + return if request.GET.empty? + + raise Exceptions::Authentication, \ + message: 'Query parameters are not allowed.', + status: 400 end def check_content_type! - unless request.content_type == 'application/json' - raise Exceptions::Authentication, \ - message: 'Only JSON body is accepted.', - status: 400 - end + return if request.content_type == 'application/json' + + raise Exceptions::Authentication, \ + message: 'Only JSON body is accepted.', + status: 400 end def check_jwt!(jwt) diff --git a/app/api/user_api/v1/api_keys.rb b/app/api/user_api/v1/api_keys.rb new file mode 100644 index 000000000..82cd22a8c --- /dev/null +++ b/app/api/user_api/v1/api_keys.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module UserApi + module V1 + # Responsible for CRUD for api keys + class APIKeys < Grape::API + resource :api_keys do + before do + unless current_account.otp_enabled + error!('Only accounts with enabled 2FA alowed', 400) + end + + unless Vault::TOTP.validate?(current_account.uid, params[:totp_code]) + error!('Your code is invalid', 422) + end + end + + desc 'List all api keys for current account.' + params do + requires :totp_code, type: String, desc: 'Code from Google Authenticator', allow_blank: false + end + get do + present current_account.api_keys, with: Entities::APIKey + end + + desc 'Return an api key by uid' + params do + requires :uid, type: String, allow_blank: false + requires :totp_code, type: String, desc: 'Code from Google Authenticator', allow_blank: false + end + get ':uid' do + api_key = current_account.api_keys.find_by!(uid: params[:uid]) + present api_key, with: Entities::APIKey + end + + desc 'Create an api key' + params do + requires :public_key, type: String, + allow_blank: false + optional :scopes, type: String, + allow_blank: false, + desc: 'comma separated scopes' + optional :expires_in, type: String, + allow_blank: false, + desc: 'expires_in duration in seconds' + requires :totp_code, type: String, desc: 'Code from Google Authenticator', allow_blank: false + end + post do + declared_params = declared(params, include_missing: false).except(:totp_code) + api_key = current_account.api_keys.create(declared_params) + if api_key.errors.any? + error!(api_key.errors.full_messages.to_sentence, 422) + end + + present api_key, with: Entities::APIKey + end + + desc 'Updates an api key' + params do + requires :uid, type: String, allow_blank: false + optional :public_key, type: String, + allow_blank: false + optional :scopes, type: String, + allow_blank: false, + desc: 'comma separated scopes' + optional :expires_in, type: String, + allow_blank: false, + desc: 'expires_in duration in seconds' + optional :state, type: String, desc: 'State of API Key. "active" state means key is active and can be used for auth', + allow_blank: false + requires :totp_code, type: String, desc: 'Code from Google Authenticator', allow_blank: false + end + patch ':uid' do + declared_params = declared(params, include_missing: false).except(:totp_code) + api_key = current_account.api_keys.find_by!(uid: params[:uid]) + unless api_key.update(declared_params) + error!(api_key.errors.full_messages.to_sentence, 422) + end + + present api_key, with: Entities::APIKey + end + + desc 'Delete an api key' + params do + requires :uid, type: String, allow_blank: false + requires :totp_code, type: String, desc: 'Code from Google Authenticator', allow_blank: false + end + delete ':uid' do + api_key = current_account.api_keys.find_by!(uid: params[:uid]) + api_key.destroy + status 204 + end + end + end + end +end diff --git a/app/api/user_api/v1/base.rb b/app/api/user_api/v1/base.rb index 8cb8361b2..7726af82c 100644 --- a/app/api/user_api/v1/base.rb +++ b/app/api/user_api/v1/base.rb @@ -15,7 +15,7 @@ class Base < Grape::API do_not_route_options! - rescue_from(ActiveRecord::RecordNotFound) { error!('Record is not found', 404) } + rescue_from(ActiveRecord::RecordNotFound) { |_e| error!('Record is not found', 404) } rescue_from(Vault::VaultError) do |error| error_message = error.message Rails.logger.error "#{error.class}: #{error_message}" @@ -43,6 +43,7 @@ class Base < Grape::API mount UserApi::V1::Phones mount UserApi::V1::Sessions mount UserApi::V1::Labels + mount UserApi::V1::APIKeys add_swagger_documentation base_path: '/api', info: { diff --git a/app/api/user_api/v1/security.rb b/app/api/user_api/v1/security.rb index b80c62b0b..57d3b2ec3 100644 --- a/app/api/user_api/v1/security.rb +++ b/app/api/user_api/v1/security.rb @@ -84,6 +84,22 @@ class Security < Grape::API error!(account.errors.full_messages.to_sentence, 422) end end + + desc 'Verify API key' + params do + requires :uid, type: String, desc: 'API Key uid', allow_blank: false + optional :account_uid, type: String, desc: 'Account uid', allow_blank: false + end + post '/verify_api_key' do + status 200 + api_key = APIKey.find_by!(uid: params[:uid]) + + if params[:account_uid].present? && api_key.account.uid != params[:account_uid] + error!('Account has no api key with provided uid', 422) + end + + { state: api_key.state } + end end end end diff --git a/app/api/user_api/v1/session_jwt_generator.rb b/app/api/user_api/v1/session_jwt_generator.rb new file mode 100644 index 000000000..41ce1c581 --- /dev/null +++ b/app/api/user_api/v1/session_jwt_generator.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module UserApi + module V1 + class SessionJWTGenerator + ALGORITHM = 'RS256' + + def initialize(jwt_token:, kid:) + @kid = kid + @jwt_token = jwt_token + @api_key = APIKey.active.find_by!(uid: kid) + end + + def verify_payload + payload, = decode_payload + payload.present? + end + + def generate_session_jwt + account = @api_key.account + payload = { + iat: Time.current.to_i, + exp: @api_key.expires_in.seconds.from_now.to_i, + sub: 'session', + iss: 'barong', + aud: @api_key.scopes, + jti: SecureRandom.hex(12).upcase, + uid: account.uid, + email: account.email, + role: account.role, + level: account.level, + state: account.state, + api_kid: @api_key.uid + } + + JWT.encode(payload, secret_key, ALGORITHM) + end + + private + + def secret_key + key_path = ENV['JWT_PRIVATE_KEY_PATH'] + private_key = if key_path.present? + File.read(key_path) + else + Base64.urlsafe_decode64(Rails.application.secrets.jwt_shared_secret_key) + end + + OpenSSL::PKey.read private_key + end + + def decode_payload + public_key = OpenSSL::PKey.read(Base64.urlsafe_decode64(@api_key.public_key)) + return {} if public_key.private? + + JWT.decode(@jwt_token, + public_key, + true, + APIKey::JWT_OPTIONS) + end + end + end +end diff --git a/app/api/user_api/v1/sessions.rb b/app/api/user_api/v1/sessions.rb index c8aeb9920..e817afe04 100644 --- a/app/api/user_api/v1/sessions.rb +++ b/app/api/user_api/v1/sessions.rb @@ -28,6 +28,22 @@ class Sessions < Grape::API error!('Invalid Email or password.', 401) end end + + desc 'Validates client jwt and generates peatio session jwt' + params do + requires :kid, type: String, allow_blank: false, desc: 'API Key uid' + requires :jwt_token, type: String, allow_blank: false + end + post 'generate_jwt' do + status 200 + declared_params = declared(params).symbolize_keys + generator = SessionJWTGenerator.new declared_params + error!('Payload is invalid', 401) unless generator.verify_payload + + { token: generator.generate_session_jwt } + rescue JWT::DecodeError => e + error! "Failed to decode and verify JWT: #{e.inspect}", 401 + end end end end diff --git a/app/assets/javascripts/web.js b/app/assets/javascripts/web.js index a83a9c668..ed4af0de9 100644 --- a/app/assets/javascripts/web.js +++ b/app/assets/javascripts/web.js @@ -16,7 +16,7 @@ window.onload = function () { headers: { 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') }, method: 'POST', data: { number: number }, - url: 'verification', + url: '/phones/verification', success: function(result){ if (result.success){ $('.loader').css("display", "none"); diff --git a/app/models/account.rb b/app/models/account.rb index a1770d501..ef334ca1f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -7,7 +7,7 @@ class Account < ApplicationRecord # Include default devise modules. Others available are: # :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :trackable, :validatable, + :recoverable, :rememberable, :trackable, :secure_validatable, :confirmable, :lockable acts_as_eventable prefix: 'account', on: %i[create update] @@ -16,9 +16,11 @@ class Account < ApplicationRecord has_many :phones, dependent: :destroy has_many :documents, dependent: :destroy has_many :labels + has_many :api_keys, class_name: 'APIKey' before_validation :assign_uid + validates :email, email: true validates :email, uniqueness: true validates :uid, presence: true, uniqueness: true diff --git a/app/models/api_key.rb b/app/models/api_key.rb new file mode 100644 index 000000000..ce39ebe95 --- /dev/null +++ b/app/models/api_key.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# -*- SkipSchemaAnnotations +# model for saving user api key pairs +class APIKey < ApplicationRecord + JWT_OPTIONS = { + verify_expiration: true, + verify_iat: true, + verify_jti: true, + sub: 'api_key_jwt', + verify_sub: true, + iss: 'external', + verify_iss: true, + algorithm: 'RS256' + }.freeze + + before_create :set_uid + before_validation :set_expires_in + validates :account_id, :public_key, presence: true + validates :expires_in, numericality: { + only_integer: true, + greater_than_or_equal_to: 30, + less_than_or_equal_to: 1.days.to_i + } + + belongs_to :account + scope :active, -> { where(state: 'active') } + +private + + def set_uid + self.uid = SecureRandom.uuid + end + + def set_expires_in + self.expires_in ||= 1.days.to_i + end +end + +# == Schema Information +# Schema version: 20180503073934 +# +# Table name: api_keys +# +# uid :string(36) not null, primary key +# public_key :text(65535) not null +# scopes :string(255) +# expires_in :integer not null +# state :string(255) default("active"), not null +# account_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_api_keys_on_account_id (account_id) +# +# Foreign Keys +# +# fk_rails_... (account_id => accounts.id) +# diff --git a/app/models/level.rb b/app/models/level.rb index d7cdcf109..d634cd199 100644 --- a/app/models/level.rb +++ b/app/models/level.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Label level mapping model class Level < ApplicationRecord validates :key, :value, :description, presence: true validates :value, uniqueness: { scope: :key } diff --git a/app/models/phone.rb b/app/models/phone.rb index d2ffe2b7c..660b4bff2 100644 --- a/app/models/phone.rb +++ b/app/models/phone.rb @@ -40,7 +40,7 @@ def sanitize_number end # == Schema Information -# Schema version: 20180423125629 +# Schema version: 20180503073934 # # Table name: phones # diff --git a/ci/bump.rb b/ci/bump.rb index 9e46a7a49..082d8808f 100644 --- a/ci/bump.rb +++ b/ci/bump.rb @@ -2,6 +2,7 @@ require "net/http" require "json" require "uri" +require "cgi" # # Returns bot's username in GitHub. @@ -129,7 +130,7 @@ module Barong # # @return [Array] def versions - @versions ||= github_api_authenticated_get("/repos/#{repository_slug}/tags").map do |x| + @versions ||= github_api_load_collection("/repos/#{repository_slug}/tags").map do |x| Gem::Version.new(x.fetch("name")) end.sort end @@ -140,7 +141,7 @@ def versions # @return [Hash] # Key is commit's SHA-1 hash, value is instance of Gem::Version. def tagged_commits_mapping - @commits ||= github_api_authenticated_get("/repos/#{repository_slug}/tags").each_with_object({}) do |x, memo| + @commits ||= github_api_load_collection("/repos/#{repository_slug}/tags").each_with_object({}) do |x, memo| memo[x.fetch("commit").fetch("sha")] = Gem::Version.new(x.fetch("name")) end end @@ -151,7 +152,7 @@ def tagged_commits_mapping # @return [Array] # Array of hashes each containing "name" & "version" keys. def version_specific_branches - @branches ||= github_api_authenticated_get("/repos/#{repository_slug}/branches").map do |x| + @branches ||= github_api_load_collection("/repos/#{repository_slug}/branches").map do |x| if x.fetch("name") =~ /\A(\d)-(\d)-\w+\z/ { name: x["name"], version: Gem::Version.new($1 + "." + $2) } end @@ -163,11 +164,14 @@ def version_specific_branches # # @param path [String] # Request path. -# @return [Hash] -def github_api_authenticated_get(path) +# @param query [Hash] +# Query parameters. +# @return [Hash, Array] +def github_api_authenticated_get(path, query = {}) http = Net::HTTP.new("api.github.com", 443) http.use_ssl = true - response = http.get path, "Authorization" => %[token #{ENV.fetch("GITHUB_API_KEY")}] + query_string = query.map { |(k, v)| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&") + response = http.get "#{path}?#{query_string}", "Authorization" => %[token #{ENV.fetch("GITHUB_API_KEY")}] if response.code.to_i == 200 JSON.load(response.body) else @@ -175,6 +179,22 @@ def github_api_authenticated_get(path) end end +# Fetches full collection using GitHub API (performs pagination under the hood). +# +# @param path [String] +# The collection request path. +# @return [Array] +def github_api_load_collection(path) + objects = [] + page = 0 + loop do + loaded = github_api_authenticated_get(path, page: page += 1, per_page: 100) + objects += loaded + break if loaded.empty? + end + objects +end + # # Returns true if version has exactly 3 version segments (major, minor, patch), and all are integers. # diff --git a/config/initializers/devise-security.rb b/config/initializers/devise-security.rb new file mode 100644 index 000000000..5acac0b88 --- /dev/null +++ b/config/initializers/devise-security.rb @@ -0,0 +1,38 @@ +Devise.setup do |config| + # ==> Security Extension + # Configure security extension for devise + + # Should the password expire (e.g 3.months) + # config.expire_password_after = false + + # Need 1 char of A-Z, a-z and 0-9 + config.password_regex = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z])/ + + # How many passwords to keep in archive + # config.password_archiving_count = 5 + + # Deny old password (true, false, count) + # config.deny_old_passwords = true + + # enable email validation for :secure_validatable. (true, false, validation_options) + # dependency: need an email validator like rails_email_validator + config.email_validation = true + + # captcha integration for recover form + # config.captcha_for_recover = true + + # captcha integration for sign up form + # config.captcha_for_sign_up = true + + # captcha integration for sign in form + # config.captcha_for_sign_in = true + + # captcha integration for unlock form + # config.captcha_for_unlock = true + + # captcha integration for confirmation form + # config.captcha_for_confirmation = true + + # Time period for account expiry from last_activity_at + # config.expire_after = 90.days +end diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb new file mode 100644 index 000000000..046d35174 --- /dev/null +++ b/config/initializers/sentry.rb @@ -0,0 +1,4 @@ +if ENV['SENTRY_DSN_BACKEND'].present? && ENV['SENTRY_ENV'].to_s.split(',').include?(Rails.env) + require 'sentry-raven' + Raven.configure { |config| config.dsn = ENV['SENTRY_DSN_BACKEND'] } +end diff --git a/config/locales/devise.security_extension.de.yml b/config/locales/devise.security_extension.de.yml new file mode 100644 index 000000000..3f051f6fb --- /dev/null +++ b/config/locales/devise.security_extension.de.yml @@ -0,0 +1,16 @@ +de: + errors: + messages: + taken_in_past: 'wurde bereits in der Vergangenheit verwendet!' + equal_to_current_password: 'darf nicht dem aktuellen Passwort entsprechen!' + password_format: 'müssen große, kleine Buchstaben und Ziffern enthalten' + devise: + invalid_captcha: 'Die Captchaeingabe ist nicht gültig!' + paranoid_verify: + code_required: 'Bitte geben Sie den Code unser Support-Team zur Verfügung gestellt' + password_expired: + updated: 'Das neue Passwort wurde übernommen.' + change_required: 'Ihr Passwort ist abgelaufen. Bitte vergeben sie ein neues Passwort!' + failure: + session_limited: 'Ihre Anmeldedaten wurden in einem anderen Browser genutzt. Bitte melden Sie sich erneut an, um in diesem Browser fortzufahren.' + expired: 'Ihr Account ist aufgrund zu langer Inaktiviät abgelaufen. Bitte kontaktieren Sie den Administrator.' diff --git a/config/locales/devise.security_extension.en.yml b/config/locales/devise.security_extension.en.yml new file mode 100644 index 000000000..5622091b1 --- /dev/null +++ b/config/locales/devise.security_extension.en.yml @@ -0,0 +1,17 @@ +en: + errors: + messages: + taken_in_past: 'was used previously.' + equal_to_current_password: 'must be different than the current password.' + password_format: 'must contain big, small letters and digits' + devise: + invalid_captcha: 'The captcha input was invalid.' + invalid_security_question: 'The security question answer was invalid.' + paranoid_verify: + code_required: 'Please enter the code our support team provided' + password_expired: + updated: 'Your new password is saved.' + change_required: 'Your password is expired. Please renew your password.' + failure: + session_limited: 'Your login credentials were used in another browser. Please sign in again to continue in this browser.' + expired: 'Your account has expired due to inactivity. Please contact the site administrator.' diff --git a/config/locales/devise.security_extension.it.yml b/config/locales/devise.security_extension.it.yml new file mode 100644 index 000000000..646ae4ea0 --- /dev/null +++ b/config/locales/devise.security_extension.it.yml @@ -0,0 +1,10 @@ +it: + errors: + messages: + taken_in_past: "e' stata gia' utilizzata in passato!" + equal_to_current_password: " deve essere differente dalla password corrente!" + devise: + invalid_captcha: "Il captcha inserito non e' valido!" + password_expired: + updated: "La tua nuova password e' stata salvata." + change_required: "La tua password e' scaduta. Si prega di rinnovarla!" \ No newline at end of file diff --git a/db/migrate/20180503073934_create_api_keys.rb b/db/migrate/20180503073934_create_api_keys.rb new file mode 100644 index 000000000..f97b8fe3f --- /dev/null +++ b/db/migrate/20180503073934_create_api_keys.rb @@ -0,0 +1,14 @@ +class CreateApiKeys < ActiveRecord::Migration[5.1] + def change + create_table :api_keys, id: false do |t| + t.string :uid, limit: 36, primary_key: true, null: false + t.text :public_key, null: false + t.string :scopes + t.integer :expires_in, null: false + t.string :state, null: false, default: 'active' + t.references :account, foreign_key: true, index: true, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 184f8c08a..190a9354b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -44,6 +44,17 @@ t.index ["unlock_token"], name: "index_accounts_on_unlock_token", unique: true end + create_table "api_keys", primary_key: "uid", id: :string, limit: 36, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| + t.text "public_key", null: false + t.string "scopes" + t.integer "expires_in", null: false + t.string "state", default: "active", null: false + t.bigint "account_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_api_keys_on_account_id" + end + create_table "documents", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| t.bigint "account_id" t.string "upload" @@ -155,6 +166,7 @@ t.index ["domain"], name: "index_websites_on_domain", unique: true end + add_foreign_key "api_keys", "accounts" add_foreign_key "documents", "accounts" add_foreign_key "labels", "accounts", on_delete: :cascade add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" diff --git a/docs/index.md b/docs/index.md index 903361fd0..da61f1531 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,5 @@ --- -title: Barong v1.7.0 +title: Barong v1.8.0.alpha language_tabs: - http: HTTP - shell: Curl @@ -14,13 +14,13 @@ headingLevel: 2 --- -

Barong v1.7.0

+

Barong v1.8.0.alpha

> Scroll down for code samples, example requests and responses. Select a language for code samples from the tabs above or the mobile navigation menu. -API for barong OAuth server +API for barong OAuth server Base URLs: @@ -35,6 +35,97 @@ Base URLs: Operations about accounts +## postV1AccountsConfirm + + + + + +> Code samples + + +```http +POST //localhost:3000/api/v1/accounts/confirm HTTP/1.1 +Host: null +Content-Type: application/x-www-form-urlencoded + + +``` + + +```shell +# You can also use wget +curl -X POST //localhost:3000/api/v1/accounts/confirm \ + -H 'Content-Type: application/x-www-form-urlencoded' + + +``` + + +```javascript +var headers = { + 'Content-Type':'application/x-www-form-urlencoded' + + +}; + + +$.ajax({ + url: '//localhost:3000/api/v1/accounts/confirm', + method: 'post', + + + headers: headers, + success: function(data) { + console.log(JSON.stringify(data)); + } +}) + + +``` + + +`POST /v1/accounts/confirm` + + +*Confirms new account* + + +Confirms new account + + +> Body parameter + + +```yaml +confirmation_token: string + + +``` + + +

Parameters

+ + +|Parameter|In|Type|Required|Description| +|---|---|---|---|---| +|body|body|object|false|No description| +|» confirmation_token|body|string|true|Token from email| + + +

Responses

+ + +|Status|Meaning|Description|Schema| +|---|---|---|---| +|201|[Created](https://tools.ietf.org/html/rfc7231#section-6.3.2)|Confirms new account|None| + + + + + ## postV1Accounts @@ -356,7 +447,7 @@ Create a profile for current_account ```yaml first_name: string last_name: string -dob: '2018-04-20' +dob: '2018-05-15' address: string postcode: string city: string @@ -464,6 +555,99 @@ This operation does not require authentication Operations about securities +## postV1SecurityVerifyApiKey + + + + + +> Code samples + + +```http +POST //localhost:3000/api/v1/security/verify_api_key HTTP/1.1 +Host: null +Content-Type: application/x-www-form-urlencoded + + +``` + + +```shell +# You can also use wget +curl -X POST //localhost:3000/api/v1/security/verify_api_key \ + -H 'Content-Type: application/x-www-form-urlencoded' + + +``` + + +```javascript +var headers = { + 'Content-Type':'application/x-www-form-urlencoded' + + +}; + + +$.ajax({ + url: '//localhost:3000/api/v1/security/verify_api_key', + method: 'post', + + + headers: headers, + success: function(data) { + console.log(JSON.stringify(data)); + } +}) + + +``` + + +`POST /v1/security/verify_api_key` + + +*Verify API key* + + +Verify API key + + +> Body parameter + + +```yaml +uid: string +account_uid: string + + +``` + + +

Parameters

+ + +|Parameter|In|Type|Required|Description| +|---|---|---|---|---| +|body|body|object|false|No description| +|» uid|body|string|true|API Key uid| +|» account_uid|body|string|false|Account uid| + + +

Responses

+ + +|Status|Meaning|Description|Schema| +|---|---|---|---| +|201|[Created](https://tools.ietf.org/html/rfc7231#section-6.3.2)|Verify API key|None| + + + + + ## putV1SecurityResetPassword @@ -1224,17 +1408,17 @@ This operation does not require authentication -## postV1Phones +## postV1PhonesSendCode - + > Code samples ```http -POST //localhost:3000/api/v1/phones HTTP/1.1 +POST //localhost:3000/api/v1/phones/send_code HTTP/1.1 Host: null Content-Type: application/x-www-form-urlencoded @@ -1244,7 +1428,7 @@ Content-Type: application/x-www-form-urlencoded ```shell # You can also use wget -curl -X POST //localhost:3000/api/v1/phones \ +curl -X POST //localhost:3000/api/v1/phones/send_code \ -H 'Content-Type: application/x-www-form-urlencoded' @@ -1260,7 +1444,7 @@ var headers = { $.ajax({ - url: '//localhost:3000/api/v1/phones', + url: '//localhost:3000/api/v1/phones/send_code', method: 'post', @@ -1274,13 +1458,13 @@ $.ajax({ ``` -`POST /v1/phones` +`POST /v1/phones/send_code` -*Add new phone* +*Resend activation code* -Add new phone +Resend activation code > Body parameter @@ -1293,21 +1477,21 @@ phone_number: string ``` -

Parameters

+

Parameters

|Parameter|In|Type|Required|Description| |---|---|---|---|---| -|body|body|object|false|No description| +|body|body|[postV1PhonesSendCode](#schemapostv1phonessendcode)|false|No description| |» phone_number|body|string|true|Phone number with country code| -

Responses

+

Responses

|Status|Meaning|Description|Schema| |---|---|---|---| -|201|[Created](https://tools.ietf.org/html/rfc7231#section-6.3.2)|Add new phone|None| +|201|[Created](https://tools.ietf.org/html/rfc7231#section-6.3.2)|Resend activation code|None| -

sessions

- - -Operations about sessions - - -## postV1Sessions +## postV1Phones - + > Code samples ```http -POST //localhost:3000/api/v1/sessions HTTP/1.1 +POST //localhost:3000/api/v1/phones HTTP/1.1 Host: null Content-Type: application/x-www-form-urlencoded @@ -1341,7 +1519,7 @@ Content-Type: application/x-www-form-urlencoded ```shell # You can also use wget -curl -X POST //localhost:3000/api/v1/sessions \ +curl -X POST //localhost:3000/api/v1/phones \ -H 'Content-Type: application/x-www-form-urlencoded' @@ -1357,7 +1535,7 @@ var headers = { $.ajax({ - url: '//localhost:3000/api/v1/sessions', + url: '//localhost:3000/api/v1/phones', method: 'post', @@ -1371,46 +1549,40 @@ $.ajax({ ``` -`POST /v1/sessions` +`POST /v1/phones` -*Start a new session* +*Add new phone* -Start a new session +Add new phone > Body parameter ```yaml -email: string -password: string -application_id: string -expires_in: string +phone_number: string ``` -

Parameters

+

Parameters

|Parameter|In|Type|Required|Description| |---|---|---|---|---| -|body|body|object|false|No description| -|» email|body|string|true|No description| -|» password|body|string|true|No description| -|» application_id|body|string|true|No description| -|» expires_in|body|string|false|No description| +|body|body|[postV1PhonesSendCode](#schemapostv1phonessendcode)|false|No description| +|» phone_number|body|string|true|Phone number with country code| -

Responses

+

Responses

|Status|Meaning|Description|Schema| |---|---|---|---| -|201|[Created](https://tools.ietf.org/html/rfc7231#section-6.3.2)|Start a new session|None| +|201|[Created](https://tools.ietf.org/html/rfc7231#section-6.3.2)|Add new phone|None| -

labels

+

sessions

-Operations about labels +Operations about sessions -## deleteV1LabelsKey +## postV1SessionsGenerateJwt - + > Code samples ```http -DELETE //localhost:3000/api/v1/labels/{key} HTTP/1.1 +POST //localhost:3000/api/v1/sessions/generate_jwt HTTP/1.1 Host: null +Content-Type: application/x-www-form-urlencoded ``` @@ -1443,20 +1616,27 @@ Host: null ```shell # You can also use wget -curl -X DELETE //localhost:3000/api/v1/labels/{key} +curl -X POST //localhost:3000/api/v1/sessions/generate_jwt \ + -H 'Content-Type: application/x-www-form-urlencoded' ``` ```javascript +var headers = { + 'Content-Type':'application/x-www-form-urlencoded' + + +}; $.ajax({ - url: '//localhost:3000/api/v1/labels/{key}', - method: 'delete', + url: '//localhost:3000/api/v1/sessions/generate_jwt', + method: 'post', + headers: headers, success: function(data) { console.log(JSON.stringify(data)); } @@ -1466,29 +1646,42 @@ $.ajax({ ``` -`DELETE /v1/labels/{key}` +`POST /v1/sessions/generate_jwt` -*Delete a label with 'public' scope.* +*Validates client jwt and generates peatio session jwt* -Delete a label with 'public' scope. +Validates client jwt and generates peatio session jwt -

Parameters

+> Body parameter + + +```yaml +kid: string +jwt_token: string + + +``` + + +

Parameters

|Parameter|In|Type|Required|Description| |---|---|---|---|---| -|key|path|string|true|Label key.| +|body|body|object|false|No description| +|» kid|body|string|true|API Key uid| +|» jwt_token|body|string|true|No description| -

Responses

+

Responses

|Status|Meaning|Description|Schema| |---|---|---|---| -|204|[No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5)|Delete a label with 'public' scope.|None| +|201|[Created](https://tools.ietf.org/html/rfc7231#section-6.3.2)|Validates client jwt and generates peatio session jwt|None| -## patchV1LabelsKey +## postV1Sessions - + > Code samples ```http -PATCH //localhost:3000/api/v1/labels/{key} HTTP/1.1 +POST //localhost:3000/api/v1/sessions HTTP/1.1 Host: null Content-Type: application/x-www-form-urlencoded @@ -1516,7 +1709,7 @@ Content-Type: application/x-www-form-urlencoded ```shell # You can also use wget -curl -X PATCH //localhost:3000/api/v1/labels/{key} \ +curl -X POST //localhost:3000/api/v1/sessions \ -H 'Content-Type: application/x-www-form-urlencoded' @@ -1532,8 +1725,8 @@ var headers = { $.ajax({ - url: '//localhost:3000/api/v1/labels/{key}', - method: 'patch', + url: '//localhost:3000/api/v1/sessions', + method: 'post', headers: headers, @@ -1546,7 +1739,182 @@ $.ajax({ ``` -`PATCH /v1/labels/{key}` +`POST /v1/sessions` + + +*Start a new session* + + +Start a new session + + +> Body parameter + + +```yaml +email: string +password: string +application_id: string +expires_in: string + + +``` + + +

Parameters

+ + +|Parameter|In|Type|Required|Description| +|---|---|---|---|---| +|body|body|object|false|No description| +|» email|body|string|true|No description| +|» password|body|string|true|No description| +|» application_id|body|string|true|No description| +|» expires_in|body|string|false|No description| + + +

Responses

+ + +|Status|Meaning|Description|Schema| +|---|---|---|---| +|201|[Created](https://tools.ietf.org/html/rfc7231#section-6.3.2)|Start a new session|None| + + + + + +

labels

+ + +Operations about labels + + +## deleteV1LabelsKey + + + + + +> Code samples + + +```http +DELETE //localhost:3000/api/v1/labels/{key} HTTP/1.1 +Host: null + + +``` + + +```shell +# You can also use wget +curl -X DELETE //localhost:3000/api/v1/labels/{key} + + +``` + + +```javascript + + +$.ajax({ + url: '//localhost:3000/api/v1/labels/{key}', + method: 'delete', + + + success: function(data) { + console.log(JSON.stringify(data)); + } +}) + + +``` + + +`DELETE /v1/labels/{key}` + + +*Delete a label with 'public' scope.* + + +Delete a label with 'public' scope. + + +

Parameters

+ + +|Parameter|In|Type|Required|Description| +|---|---|---|---|---| +|key|path|string|true|Label key.| + + +

Responses

+ + +|Status|Meaning|Description|Schema| +|---|---|---|---| +|204|[No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5)|Delete a label with 'public' scope.|None| + + + + + +## patchV1LabelsKey + + + + + +> Code samples + + +```http +PATCH //localhost:3000/api/v1/labels/{key} HTTP/1.1 +Host: null +Content-Type: application/x-www-form-urlencoded + + +``` + + +```shell +# You can also use wget +curl -X PATCH //localhost:3000/api/v1/labels/{key} \ + -H 'Content-Type: application/x-www-form-urlencoded' + + +``` + + +```javascript +var headers = { + 'Content-Type':'application/x-www-form-urlencoded' + + +}; + + +$.ajax({ + url: '//localhost:3000/api/v1/labels/{key}', + method: 'patch', + + + headers: headers, + success: function(data) { + console.log(JSON.stringify(data)); + } +}) + + +``` + + +`PATCH /v1/labels/{key}` *Update a label with 'public' scope.* @@ -1817,13 +2185,439 @@ This operation does not require authentication - - "name": "Barong" -} - diff --git a/docs/specs/api_keys.md b/docs/specs/api_keys.md new file mode 100644 index 000000000..5169ba362 --- /dev/null +++ b/docs/specs/api_keys.md @@ -0,0 +1,46 @@ +# API Keys + +## How does it work? + +To be available to send requests to the [peatio api](https://github.com/rubykube/peatio/blob/master/docs/api/member_api_v2.md) you need to send proper jwt (JSON Web Token) signed by Barong. +Only Barong can sign valid token. + +The user must have 2FA enabled before using API Keys. +You need to provide valid TOTP code on api key access. + +### API Key flow: +1. Create an api key with a `public key`, `scopes` and `expired_in`. `Expired_in` is an optional. Default value is 24 hours. +2. Save private key and api key uid to your script +3. Sign request with your private key +4. Send request to Barong with `jwt_token` and `kid` +4. Barong read `kid`, select public key and verify your request +5. If payload is valid, Barong generates peatio jwt +6. Use peatio jwt to access to peatio api +7. Peatio jwt will be expired after `expired_in` time +8. Go to step 3 and repeat next steps again + +To create an api key, please use [POST /api/v1/api_keys](https://github.com/rubykube/barong/blob/master/docs/index.md#postv1apikeys) +To send signed request and generate jwt, please use [POST /api/v1/sessions/generate_jwt](https://github.com/rubykube/barong/blob/master/docs/index.md#postv1sessionsgeneratejwt) + +## Configuration + +```yml +# JWT configuration. +# You can generate keypair using: +# +# ruby -e "require 'openssl'; require 'base64'; OpenSSL::PKey::RSA.generate(2048).tap { |p| puts '', 'PRIVATE RSA KEY (URL-safe Base64 encoded, PEM):', '', Base64.urlsafe_encode64(p.to_pem), '', 'PUBLIC RSA KEY (URL-safe Base64 encoded, PEM):', '', Base64.urlsafe_encode64(p.public_key.to_pem) }" +# +``` + +You can encode payload with folowing ruby code: +``` +secret_key = OpenSSL::PKey.read(Base64.urlsafe_decode64('private_key)) +payload = { + iat: Time.current.to_i, + exp: 20.minutes.from_now.to_i, + sub: 'api_key_jwt', + iss: 'external', + jti: SecureRandom.hex(12).upcase +} +jwt_token = JWT.encode(payload, secret_key, 'RS256') +``` diff --git a/lib/phone_utils.rb b/lib/phone_utils.rb index f004b8f37..ef885cf1e 100644 --- a/lib/phone_utils.rb +++ b/lib/phone_utils.rb @@ -13,7 +13,7 @@ def sanitize(unsafe_phone) def valid?(unsafe_phone) number = sanitize(unsafe_phone) phone = Phonelib.parse(number) - phone.valid? && phone.international(false) == number + phone.valid? end def send_confirmation_sms(phone) diff --git a/lib/tasks/db_load_fake.rake b/lib/tasks/db_load_fake.rake index 67150dce6..0daa5fd12 100644 --- a/lib/tasks/db_load_fake.rake +++ b/lib/tasks/db_load_fake.rake @@ -4,10 +4,10 @@ namespace :db do namespace :load do desc 'Creating the fake data' task fake: :environment do - account = Account.create!(email: 'admin@gmail.com', password: '123123', role: 'admin', confirmed_at: Faker::Time.between(2.days.ago, Date.today)) + account = Account.create!(email: 'admin@gmail.com', password: 'Pass123123', role: 'admin', confirmed_at: Faker::Time.between(2.days.ago, Date.today)) Profile.create!(account: account, first_name: Faker::Name.first_name, last_name: Faker::Name.last_name, country: Faker::Address.country, dob: Faker::Date.between(25.years.ago, 10.years.ago), address: Faker::Address.street_address, city: Faker::Address.city) - compliance = Account.create!(email: 'compliance@gmail.com', password: '123123', role: 'compliance', confirmed_at: Faker::Time.between(2.days.ago, Date.today)) + compliance = Account.create!(email: 'compliance@gmail.com', password: 'Pass123123', role: 'compliance', confirmed_at: Faker::Time.between(2.days.ago, Date.today)) Profile.create!(account: compliance, first_name: Faker::Name.first_name, last_name: Faker::Name.last_name, country: Faker::Address.country, dob: Faker::Date.between(25.years.ago, 10.years.ago), address: Faker::Address.street_address, city: Faker::Address.city) [*1..100].each do diff --git a/spec/api/user_api/v1/accounts_spec.rb b/spec/api/user_api/v1/accounts_spec.rb index 002691379..a1903fe77 100644 --- a/spec/api/user_api/v1/accounts_spec.rb +++ b/spec/api/user_api/v1/accounts_spec.rb @@ -32,7 +32,7 @@ before { do_request } context 'when email is invalid' do - let(:params) { { email: 'bad_format', password: 'password' } } + let(:params) { { email: 'bad_format', password: 'Password1' } } it 'renders an error' do expect_status_to_eq 422 @@ -40,6 +40,15 @@ end end + context 'when password is invalid' do + let(:params) { { email: 'vadid.email@gmail.com', password: 'password' } } + + it 'renders an error' do + expect_status_to_eq 422 + expect_body.to eq(error: ['Password must contain big, small letters and digits']) + end + end + context 'when email and password are absent' do let(:params) {} @@ -50,7 +59,7 @@ end context 'when email is blank' do - let(:params) { { email: '', password: 'password' } } + let(:params) { { email: '', password: 'Password1' } } it 'renders an error' do expect_status_to_eq 400 @@ -59,7 +68,7 @@ end context 'when email is valid' do - let(:params) { { email: Faker::Internet.email, password: 'password' } } + let(:params) { { email: 'vadid.email@gmail.com', password: 'Password1' } } it 'creates an account' do expect_status_to_eq 201 @@ -69,26 +78,26 @@ describe 'PUT /api/v1/accounts/password' do let(:url) { '/api/v1/accounts/password' } - let!(:password0) { 'testpassword111' } - let!(:password1) { 'testpassword123' } + let!(:password0) { 'Testpassword111' } + let!(:password1) { 'Testpassword123' } let(:params0) do { - old_password: password0, - new_password: password1 + old_password: 'Password0', + new_password: 'Password1' } end let(:params1) do { - old_password: password1, - new_password: password0 + old_password: 'Password1', + new_password: 'Password0' } end subject!(:acc) do create :account, - password: password0, - password_confirmation: password0 + password: 'Password0', + password_confirmation: 'Password0' end let!(:access_token) do diff --git a/spec/api/user_api/v1/api_keys_spec.rb b/spec/api/user_api/v1/api_keys_spec.rb new file mode 100644 index 000000000..2a53f1ff6 --- /dev/null +++ b/spec/api/user_api/v1/api_keys_spec.rb @@ -0,0 +1,272 @@ +# frozen_string_literal: true + +describe 'Api::V1::APIKeys' do + include_context 'doorkeeper authentication' + let!(:current_account) do + create(:account, otp_enabled: otp_enabled) + end + let(:otp_enabled) { true } + let!(:api_key) { create :api_key, account: current_account } + let(:valid_otp_code) { '1357' } + let(:invalid_otp_code) { '1234' } + + before do + allow(Vault::TOTP).to receive(:validate?) + .with(current_account.uid, valid_otp_code) { true } + allow(Vault::TOTP).to receive(:validate?) + .with(current_account.uid, invalid_otp_code) { false } + allow(Vault::TOTP).to receive(:validate?) + .with(current_account.uid, nil) { false } + end + + describe 'GET /api/v1/api_keys' do + let(:do_request) { get "/api/v1/api_keys?totp_code=#{totp_code}", headers: auth_header } + let(:totp_code) { valid_otp_code } + + it 'Return api keys for current account' do + do_request + expect(response.status).to eq(200) + expect(json_body.size).to eq(1) + expect(json_body.first[:uid]).to eq(api_key.uid) + end + + context 'when otp is not enabled' do + let(:otp_enabled) { false } + + it 'renders an error' do + do_request + expect(response.status).to eq(400) + expect_body.to eq(error: 'Only accounts with enabled 2FA alowed') + end + end + + context 'when code is invalid' do + let(:totp_code) { invalid_otp_code } + + it 'renders an error' do + do_request + expect(response.status).to eq(422) + expect_body.to eq(error: 'Your code is invalid') + end + end + end + + describe 'GET /api/v1/api_keys/:uid' do + let(:do_request) do + get "/api/v1/api_keys/#{api_key.uid}?totp_code=#{totp_code}", + headers: auth_header + end + let(:expected_fields) do + { + uid: api_key.uid, + public_key: api_key.public_key, + state: api_key.state, + scopes: 'trade' + } + end + let(:totp_code) { valid_otp_code } + + it 'Return api key for current account' do + do_request + expect(response.status).to eq(200) + expect(json_body).to include(expected_fields) + end + + context 'when otp is not enabled' do + let(:otp_enabled) { false } + + it 'renders an error' do + do_request + expect(response.status).to eq(400) + expect_body.to eq(error: 'Only accounts with enabled 2FA alowed') + end + end + + context 'when code is invalid' do + let(:totp_code) { invalid_otp_code } + + it 'renders an error' do + do_request + expect(response.status).to eq(422) + expect_body.to eq(error: 'Your code is invalid') + end + end + end + + describe 'POST /api/v1/api_keys' do + let(:do_request) do + post '/api/v1/api_keys', + params: params.merge(totp_code: totp_code), + headers: auth_header + end + let(:totp_code) { valid_otp_code } + + context 'when fields are valid' do + let(:params) do + { + scopes: 'trade', + public_key: Faker::Crypto.sha256 + } + end + let(:expected_fields) do + { + uid: instance_of(String), + public_key: params[:public_key], + state: 'active', + scopes: params[:scopes], + expires_in: 1.day.to_i + } + end + + it 'Create an api key' do + expect { do_request }.to change { APIKey.count }.by(1) + expect(response.status).to eq(201) + expect_body.to include(expected_fields) + end + + it 'sets expires_in' do + params[:expires_in] = 2.hours.to_i + expected_fields[:expires_in] = 2.hours.to_i + expect { do_request }.to change { APIKey.count }.by(1) + expect(response.status).to eq(201) + expect_body.to include(expected_fields) + end + + context 'when otp is not enabled' do + let(:otp_enabled) { false } + + it 'renders an error' do + do_request + expect(response.status).to eq(400) + expect_body.to eq(error: 'Only accounts with enabled 2FA alowed') + end + end + + context 'when code is invalid' do + let(:totp_code) { invalid_otp_code } + + it 'renders an error' do + do_request + expect(response.status).to eq(422) + expect_body.to eq(error: 'Your code is invalid') + end + end + end + + context 'when expires in is greater than allowed' do + let(:params) do + { + public_key: Faker::Crypto.sha256, + expires_in: 1.day.to_i + 1 + } + end + + it 'renders an error' do + expect { do_request }.to_not change { APIKey.count } + expect(response.status).to eq(422) + expect_body.to eq(error: 'Expires in must be less than or equal to 86400') + end + end + end + + describe 'PATCH /api/v1/api_keys/:uid' do + let(:do_request) do + patch "/api/v1/api_keys/#{api_key.uid}", params: params.merge(totp_code: totp_code), + headers: auth_header + end + let(:totp_code) { valid_otp_code } + context 'when valid fields' do + let(:params) do + { + public_key: Faker::Crypto.sha256, + expires_in: 1.hour.to_i, + state: 'inactive' + } + end + + it 'Updates an api key' do + expect { do_request }.to change { api_key.reload.public_key }.to(params[:public_key]) + expect(response.status).to eq(200) + end + + it 'Updates a state' do + expect { do_request }.to change { api_key.reload.state } + .from('active').to('inactive') + expect(response.status).to eq(200) + end + + it 'Updates an api key expires in' do + expect { do_request }.to change { api_key.reload.expires_in } + .from(1.day.to_i).to(1.hour.to_i) + expect(response.status).to eq(200) + end + + context 'when otp is not enabled' do + let(:otp_enabled) { false } + + it 'renders an error' do + do_request + expect(response.status).to eq(400) + expect_body.to eq(error: 'Only accounts with enabled 2FA alowed') + end + end + + context 'when code is invalid' do + let(:totp_code) { invalid_otp_code } + + it 'renders an error' do + do_request + expect(response.status).to eq(422) + expect_body.to eq(error: 'Your code is invalid') + end + end + end + + context 'when expires in is greater than allowed' do + let(:params) do + { + expires_in: 1.day.to_i + 1 + } + end + + it 'renders an error' do + expect { do_request }.to_not change { APIKey.count } + expect(response.status).to eq(422) + expect_body.to eq(error: 'Expires in must be less than or equal to 86400') + end + end + end + + describe 'DELETE /api/v1/api_keys/:uid' do + let(:do_request) do + delete "/api/v1/api_keys/#{api_key.uid}?totp_code=#{totp_code}", + headers: auth_header + end + let(:totp_code) { valid_otp_code } + + it 'Removes an api key' do + do_request + expect(response.status).to eq(204) + end + + context 'when otp is not enabled' do + let(:otp_enabled) { false } + + it 'renders an error' do + do_request + expect(response.status).to eq(400) + expect_body.to eq(error: 'Only accounts with enabled 2FA alowed') + end + end + + context 'when code is invalid' do + let(:totp_code) { invalid_otp_code } + + it 'renders an error' do + do_request + expect(response.status).to eq(422) + expect_body.to eq(error: 'Your code is invalid') + end + end + end +end diff --git a/spec/api/user_api/v1/security_spec.rb b/spec/api/user_api/v1/security_spec.rb index dbe4c22f7..0b5656ff6 100644 --- a/spec/api/user_api/v1/security_spec.rb +++ b/spec/api/user_api/v1/security_spec.rb @@ -185,7 +185,7 @@ let(:params) { { email: email } } context 'when email is unknown' do - let(:email) { 'unknown@example.com' } + let(:email) { 'unknown@gmail.com' } it 'renders not found error' do expect(Devise::Mailer).to_not receive(:reset_password_instructions) @@ -196,7 +196,7 @@ context 'when account is found by email' do let!(:account) { create(:account, email: email) } - let(:email) { 'email@example.com' } + let(:email) { 'email@gmail.com' } let(:fake_mailer) { double(deliver: '') } it 'sends reset password instructions' do @@ -230,7 +230,7 @@ context 'when reset_password_token is invalid' do let(:reset_password_token) { 'invalid' } - let(:password) { 'password' } + let(:password) { 'Password1' } it 'renders 404 error' do do_request @@ -242,7 +242,7 @@ context 'when reset_password_token is valid' do let!(:account) { create(:account) } let(:reset_password_token) { account.send_reset_password_instructions } - let(:password) { 'password' } + let(:password) { 'Password1' } it 'resets a password' do expect { do_request }.to change { account.reload.encrypted_password } @@ -250,4 +250,38 @@ end end end + + describe 'POST /api/v1/security/verify_api_key' do + let!(:account) { create(:account) } + let!(:api_key) { create(:api_key, account: account) } + let(:do_request) do + post '/api/v1/security/verify_api_key', params: params, headers: auth_header + end + + let(:params) { { uid: api_key.uid } } + + context 'when key is found' do + it 'responses with api_key state' do + do_request + expect(response.status).to eq(200) + expect_body.to eq(state: api_key.state) + end + + context 'when account_id provided' do + it 'responses with api_key state' do + params[:account_uid] = account.uid + do_request + expect(response.status).to eq(200) + expect_body.to eq(state: api_key.state) + end + + it 'renders an error' do + params[:account_uid] = 'invalid' + do_request + expect(response.status).to eq(422) + expect_body.to eq(error: 'Account has no api key with provided uid') + end + end + end + end end diff --git a/spec/api/user_api/v1/sessions_spec.rb b/spec/api/user_api/v1/sessions_spec.rb index a345e3a52..3912c8c02 100644 --- a/spec/api/user_api/v1/sessions_spec.rb +++ b/spec/api/user_api/v1/sessions_spec.rb @@ -2,8 +2,8 @@ describe 'Session create test' do describe 'POST /api/v1/sessions' do - let!(:email) { 'user@barong.io' } - let!(:password) { 'testpassword111' } + let!(:email) { 'user@gmail.com' } + let!(:password) { 'testPassword111' } let(:uri) { '/api/v1/sessions' } let(:check_uri) { '/api/v1/security/renew' } let!(:application) { create :doorkeeper_application } @@ -83,4 +83,86 @@ end end end + + describe 'POST /api/v1/sessions/generate_jwt' do + let(:do_request) do + post '/api/v1/sessions/generate_jwt', params: params + end + let(:params) { {} } + let!(:account) { create(:account) } + let!(:api_key) do + create(:api_key, account: account, + public_key: jwt_keypair_encoded[:public]) + end + + context 'when required params are missing' do + it 'renders an error' do + do_request + expect_status.to eq 400 + expect_body.to eq(error: 'kid is missing, kid is empty, jwt_token is missing, jwt_token is empty') + end + end + + context 'when key is not found' do + let(:params) do + { + kid: 'invalid', + jwt_token: 'invalid_token' + } + end + it 'renders an error' do + do_request + expect_status.to eq 404 + expect_body.to eq(error: 'Record is not found') + end + end + + context 'when payload is invalid' do + let(:params) do + { + kid: api_key.uid, + jwt_token: 'invalid_token' + } + end + it 'renders an error' do + do_request + expect_status.to eq 401 + expect(json_body[:error]).to include('Failed to decode and verify JWT') + end + end + + context 'when payload is valid' do + let(:params) do + { + kid: api_key.uid, + jwt_token: encode_api_key_payload({}) + } + end + let(:expected_payload) do + { + sub: 'session', + iss: 'barong', + aud: api_key.scopes, + email: account.email, + level: account.level, + role: account.role, + state: account.state + } + end + + before do + expect(Rails.application.secrets).to \ + receive(:jwt_shared_secret_key) { jwt_keypair_encoded[:private] } + do_request + end + + it { expect_status.to eq 200 } + it 'generates valid session jwt' do + token = json_body[:token] + payload, = jwt_decode(token) + + expect(payload.symbolize_keys).to include(expected_payload) + end + end + end end diff --git a/spec/factories/account.rb b/spec/factories/account.rb index c46fe3fa7..ade02c485 100644 --- a/spec/factories/account.rb +++ b/spec/factories/account.rb @@ -2,7 +2,7 @@ FactoryBot.define do factory :account do - email { Faker::Internet.email } + sequence(:email) { |n| "user#{n}@gmail.com" } password 'B@rong2018' password_confirmation 'B@rong2018' confirmed_at { Time.current } diff --git a/spec/factories/api_keys.rb b/spec/factories/api_keys.rb new file mode 100644 index 000000000..e269c30f3 --- /dev/null +++ b/spec/factories/api_keys.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :api_key, class: 'APIKey' do + account + public_key { Faker::Crypto.sha256 } + scopes 'trade' + end +end diff --git a/spec/features/sign_up_spec.rb b/spec/features/sign_up_spec.rb index 049dfcadd..03873b7f3 100644 --- a/spec/features/sign_up_spec.rb +++ b/spec/features/sign_up_spec.rb @@ -3,7 +3,7 @@ describe 'Sign up' do it 'allows to sign up with email, password and password confirmation' do visit new_account_registration_path - fill_in 'account_email', with: 'account@accounts.peatio.tech' + fill_in 'account_email', with: 'account@gmail.com' fill_in 'account_password', with: 'B@rong2018' fill_in 'account_password_confirmation', with: 'B@rong2018' click_on 'Submit' diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 2c727ada9..1303caa66 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -15,7 +15,7 @@ context 'Account with 2 or more documents' do it do - account = Account.create!(email: 'test@mail.com', password: '123123') + account = Account.create!(email: 'test@gmail.com', password: 'Pass123123') expect(Account.count).to eq 1 document1 = account.documents.create!(upload: uploaded_file, doc_type: 'Passport', diff --git a/spec/models/api_key_spec.rb b/spec/models/api_key_spec.rb new file mode 100644 index 000000000..7e47a6945 --- /dev/null +++ b/spec/models/api_key_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.describe APIKey, type: :model do + it { should validate_presence_of(:account_id) } + it { should validate_presence_of(:public_key) } + + it 'validates expires_in' do + should validate_numericality_of(:expires_in) + .only_integer + .is_greater_than_or_equal_to(30) + .is_less_than_or_equal_to(24.hours.to_i) + end +end diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb index 0e00f2f6f..6541ac4ec 100644 --- a/spec/support/helpers.rb +++ b/spec/support/helpers.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop:disable Metrics/ModuleLength module APITestHelpers extend Memoist @@ -20,38 +21,60 @@ def expect_body end def post_json(destination, body, headers = {}) - params = String === body ? body : body.to_json post destination, - params: params, + params: build_body(body), headers: headers.reverse_merge('Content-Type' => 'application/json') end def put_json(destination, body, headers = {}) - params = String === body ? body : body.to_json - put destination, - params: params, + params: build_body(body), headers: headers.reverse_merge('Content-Type' => 'application/json') end - def delete_json(destination, body, headers = {}) - params = String === body ? body : body.to_json - - delete destination, - params: params, - headers: headers.reverse_merge('Content-Type' => 'application/json') + def build_body(body) + body.is_a?(String) ? body : body.to_json end def jwt_keypair_encoded require 'openssl' require 'base64' - OpenSSL::PKey::RSA.generate(2048).yield_self do |p| - { public: Base64.urlsafe_encode64(p.public_key.to_pem), - private: Base64.urlsafe_encode64(p.to_pem) } - end.tap { |p| ENV['JWT_PUBLIC_KEY'] = p[:public] } + result = OpenSSL::PKey::RSA.generate(2048).yield_self do |p| + { + public: Base64.urlsafe_encode64(p.public_key.to_pem), + private: Base64.urlsafe_encode64(p.to_pem) + } + end + + ENV['JWT_PUBLIC_KEY'] = result[:public] + result end memoize :jwt_keypair_encoded + def build_ssl_pkey(key) + OpenSSL::PKey.read(Base64.urlsafe_decode64(key)) + end + + def jwt_encode(payload) + build_ssl_pkey(jwt_keypair_encoded[:private]).yield_self do |key| + JWT.encode(payload, key, 'RS256') + end + end + + def jwt_decode(token) + build_ssl_pkey(jwt_keypair_encoded[:public]).yield_self do |key| + JWT.decode(token, key, true, algorithm: 'RS256') + end + end + + def encode_api_key_payload(data) + jwt_encode data.reverse_merge(iat: Time.current.to_i, + exp: 30.seconds.from_now.to_i, + sub: 'api_key_jwt', + iss: 'external', + jti: SecureRandom.hex(12).upcase) + end + def multisig_jwt(payload, keychain, signers, algorithms) JWT::Multisig.generate_jwt(payload, keychain.slice(*signers), algorithms) end @@ -104,5 +127,6 @@ def set_level(account, level) end end end +# rubocop:enable Metrics/ModuleLength RSpec.configure { |config| config.include APITestHelpers } diff --git a/spec/views/documents/new.html.erb_spec.rb b/spec/views/documents/new.html.erb_spec.rb index 085eb6930..53232c3d5 100644 --- a/spec/views/documents/new.html.erb_spec.rb +++ b/spec/views/documents/new.html.erb_spec.rb @@ -4,7 +4,7 @@ RSpec.describe 'documents/new', type: :view do before(:each) do - account = assign(:account, Account.create!(email: 'myemail@mail.com', password: 'MyString')) + account = assign(:account, Account.create!(email: 'myemail@gmail.com', password: 'MyString1')) assign(:document, Document.new( account: account, upload: File.open('app/assets/images/background.jpg'),