From 427c4520040bdb0fcffc6d77ca82b16e69b41136 Mon Sep 17 00:00:00 2001 From: Ricardo Pacheco Date: Sat, 17 Feb 2024 16:48:26 -0300 Subject: [PATCH 1/4] Add authentication resource for users --- CHANGELOG.md | 6 ++ config/locales/contracts/en-US.yml | 2 + config/locales/contracts/pt-BR.yml | 2 + .../contracts/application_contract.rb | 6 ++ .../user_context/authentication_contract.rb | 45 +++++++++++ lib/auction_fun_core/events/app.rb | 1 + lib/auction_fun_core/events/listener.rb | 10 ++- .../user_context/authentication_operation.rb | 53 +++++++++++++ .../user_context/registration_operation.rb | 4 +- .../repos/user_context/user_repository.rb | 9 ++- .../authentication_contract_spec.rb | 77 +++++++++++++++++++ .../authentication_operation_spec.rb | 76 ++++++++++++++++++ 12 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 lib/auction_fun_core/contracts/user_context/authentication_contract.rb create mode 100644 lib/auction_fun_core/operations/user_context/authentication_operation.rb create mode 100644 spec/auction_fun_core/contracts/user_context/authentication_contract_spec.rb create mode 100644 spec/auction_fun_core/operations/user_context/authentication_operation_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f60413..917751e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## [Unreleased] +## [0.3.0] - 2024-02-27 + +### Added + +- Authentication for people (users). + ## [0.2.0] - 2024-02-27 ### Added diff --git a/config/locales/contracts/en-US.yml b/config/locales/contracts/en-US.yml index c466bbb..7f1b5bc 100644 --- a/config/locales/contracts/en-US.yml +++ b/config/locales/contracts/en-US.yml @@ -59,8 +59,10 @@ en-US: taken: "has already been taken" not_found: "not found" password_confirmation: "doesn't match password" + login_not_found: "Invalid credentials" macro: email_format: "need to be a valid email" + login_format: "invalid login" name_format: "must be between %{min} and %{max} characters" password_format: "must be between %{min} and %{max} characters" phone_format: "need to be a valid mobile number" diff --git a/config/locales/contracts/pt-BR.yml b/config/locales/contracts/pt-BR.yml index 1ba8742..c1c9a64 100644 --- a/config/locales/contracts/pt-BR.yml +++ b/config/locales/contracts/pt-BR.yml @@ -58,8 +58,10 @@ pt-BR: taken: "não está disponível" not_found: "não encontrado" password_confirmation: "não corresponde à senha" + login_not_found: "Credenciais inválidas" macro: email_format: "não é um email válido" + login_format: "login inválido" name_format: "deve ter entre %{min} e %{max} caracteres" password_format: "deve ter entre %{min} e %{max} caracteres" phone_format: "não é um número de celular válido" diff --git a/lib/auction_fun_core/contracts/application_contract.rb b/lib/auction_fun_core/contracts/application_contract.rb index 5c8f550..b25c61d 100644 --- a/lib/auction_fun_core/contracts/application_contract.rb +++ b/lib/auction_fun_core/contracts/application_contract.rb @@ -25,6 +25,12 @@ class ApplicationContract < Dry::Validation::Contract key.failure(I18n.t(:email_format, scope: I18N_MACRO_SCOPE)) end + register_macro(:login_format) do + next if EMAIL_REGEX.match?(value) || Phonelib.parse(value).valid? + + key.failure(I18n.t(:login_format, scope: I18N_MACRO_SCOPE)) + end + register_macro(:name_format) do next if value.length.between?(MIN_NAME_LENGTH, MAX_NAME_LENGTH) diff --git a/lib/auction_fun_core/contracts/user_context/authentication_contract.rb b/lib/auction_fun_core/contracts/user_context/authentication_contract.rb new file mode 100644 index 0000000..0c8d0a2 --- /dev/null +++ b/lib/auction_fun_core/contracts/user_context/authentication_contract.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Contracts + module UserContext + # Contract class to authenticate users. + class AuthenticationContract < ApplicationContract + I18N_SCOPE = "contracts.errors.custom.default" + + option :user_repository, default: proc { Repos::UserContext::UserRepository.new } + + params do + required(:login) + required(:password) + + before(:value_coercer) do |result| + result.to_h.compact + end + end + + rule(:login).validate(:login_format) + rule(:password).validate(:password_format) + + # Validation for login. + # Must to be present and format should be a email or phone. + rule do |context:| + next if (rule_error?(:login) || schema_error?(:login)) || (rule_error?(:password) || schema_error?(:password)) + + context[:user] ||= user_repository.by_login(values[:login]) + next if context[:user].present? && valid_password?(values[:password], context[:user].password_digest) + + key(:base).failure(I18n.t("login_not_found", scope: I18N_SCOPE)) + end + + private + + def valid_password?(password, password_digest) + return false if password_digest.blank? + + BCrypt::Password.new(password_digest) == password + end + end + end + end +end diff --git a/lib/auction_fun_core/events/app.rb b/lib/auction_fun_core/events/app.rb index 903c405..48494f0 100644 --- a/lib/auction_fun_core/events/app.rb +++ b/lib/auction_fun_core/events/app.rb @@ -8,6 +8,7 @@ class App # @!parser include Dry::Events::Publisher[:app] include Dry::Events::Publisher[:app] + register_event("users.authentication") register_event("users.registration") end end diff --git a/lib/auction_fun_core/events/listener.rb b/lib/auction_fun_core/events/listener.rb index 4b3ecfb..d232265 100644 --- a/lib/auction_fun_core/events/listener.rb +++ b/lib/auction_fun_core/events/listener.rb @@ -6,11 +6,19 @@ module Events # @see https://dry-rb.org/gems/dry-events/main/#event-listeners class Listener # Listener for to *users.registration* event. - # @param event [ROM::Struct::User] the user object + # @param user [ROM::Struct::User] the user object def on_users_registration(user) logger("New registered user: #{user.to_h}") end + # Listener for to *users.authentication* event. + # @param attributes [Hash] Authentication attributes + # @option user_id [Integer] User ID + # @option time [DateTime] Authentication time + def on_users_authentication(attributes) + logger("User #{attributes[:user_id]} authenticated on: #{attributes[:time].iso8601}") + end + private # Append message to system log. diff --git a/lib/auction_fun_core/operations/user_context/authentication_operation.rb b/lib/auction_fun_core/operations/user_context/authentication_operation.rb new file mode 100644 index 0000000..fe13c78 --- /dev/null +++ b/lib/auction_fun_core/operations/user_context/authentication_operation.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Operations + module UserContext + ## + # Operation class for authenticate users. + # + class AuthenticationOperation < AuctionFunCore::Operations::Base + include Import["contracts.user_context.authentication_contract"] + # include Import["repos.user_context.user_repository"] + + def self.call(attributes, &block) + operation = new.call(attributes) + + return operation unless block + + Dry::Matcher::ResultMatcher.call(operation, &block) + end + + # @todo Add custom doc + def call(attributes) + user = yield validate_contract(attributes) + + yield publish_user_authentication(user.id) + + Success(user) + end + + # Calls the authentication contract class to perform the validation + # and authentication of the informed attributes. + # @param attrs [Hash] user attributes + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def validate_contract(attrs) + contract = authentication_contract.call(attrs) + + return Failure(contract.errors.to_h) if contract.failure? + + Success(contract.context[:user]) + end + + # Triggers the publication of event *users.registration*. + # @param user_id [Integer] User ID + # @return [Dry::Monads::Result::Success] + def publish_user_authentication(user_id, time = Time.current) + Success( + Application[:event].publish("users.authentication", {user_id: user_id, time: time}) + ) + end + end + end + end +end diff --git a/lib/auction_fun_core/operations/user_context/registration_operation.rb b/lib/auction_fun_core/operations/user_context/registration_operation.rb index cc83697..a765b74 100644 --- a/lib/auction_fun_core/operations/user_context/registration_operation.rb +++ b/lib/auction_fun_core/operations/user_context/registration_operation.rb @@ -64,9 +64,7 @@ def persist(result) def publish_user_registration(user_id) user = user_repository.by_id!(user_id) - Success( - Application[:event].publish("users.registration", user.info) - ) + Success(Application[:event].publish("users.registration", user.info)) end end end diff --git a/lib/auction_fun_core/repos/user_context/user_repository.rb b/lib/auction_fun_core/repos/user_context/user_repository.rb index 98aca69..8ece399 100644 --- a/lib/auction_fun_core/repos/user_context/user_repository.rb +++ b/lib/auction_fun_core/repos/user_context/user_repository.rb @@ -24,7 +24,7 @@ def count # Mount SQL conditions in query for search in database. # @param conditions [Hash] DSL Dataset - # @return [AuctionCore::Relations::Users] + # @return [AuctionFunCore::Relations::Users] def query(conditions) users.where(conditions) end @@ -44,6 +44,13 @@ def by_id!(id) users.by_pk(id).one! end + # Search user in database by email of phone keys. + # @param login [String] User email or phone + # @return [ROM::Struct::User, nil] + def by_login(login) + users.where(Sequel[email: login] | Sequel[phone: login]).one + end + # Checks if it returns any user given one or more conditions. # @param conditions [Hash] DSL Dataset # @return [true] when some user is returned from the given condition. diff --git a/spec/auction_fun_core/contracts/user_context/authentication_contract_spec.rb b/spec/auction_fun_core/contracts/user_context/authentication_contract_spec.rb new file mode 100644 index 0000000..14f87f3 --- /dev/null +++ b/spec/auction_fun_core/contracts/user_context/authentication_contract_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Contracts::UserContext::AuthenticationContract, type: :contract do + describe "#call" do + subject(:contract) { described_class.new.call(attributes) } + + context "when params are blank" do + let(:attributes) { {} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:login]).to include(I18n.t("contracts.errors.key?")) + expect(contract.errors[:password]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when login params are invalid" do + context "when email is invalid" do + let(:attributes) { {login: "invalid_email"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:login]).to include( + I18n.t("contracts.errors.custom.macro.login_format") + ) + end + end + + context "when phone is invalid" do + let(:attributes) { {login: "12345"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:login]).to include( + I18n.t("contracts.errors.custom.macro.login_format") + ) + end + end + end + + context "with database" do + context "when login is not found on database" do + let(:attributes) { {login: "notfound@user.com", password: "example"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:base]).to include( + I18n.t("contracts.errors.custom.default.login_not_found") + ) + end + end + + context "when password doesn't match with storage password on database" do + let(:user) { Factory[:user] } + let(:attributes) { {login: user.email, password: "invalid"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:base]).to include( + I18n.t("contracts.errors.custom.default.login_not_found") + ) + end + end + end + + context "when credentials are valid" do + let(:user) { Factory[:user] } + let(:attributes) { {login: user.email, password: "password"} } + + it "expect return success" do + expect(contract).to be_success + end + end + end +end diff --git a/spec/auction_fun_core/operations/user_context/authentication_operation_spec.rb b/spec/auction_fun_core/operations/user_context/authentication_operation_spec.rb new file mode 100644 index 0000000..b453d53 --- /dev/null +++ b/spec/auction_fun_core/operations/user_context/authentication_operation_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Operations::UserContext::AuthenticationOperation, type: :operation do + let(:user_repo) { AuctionCore::Repos::UserRepo.new } + + describe ".call(attributes, &block)" do + let(:operation) { described_class } + + context "when block is given" do + context "when operation happens with success" do + let(:user) { Factory[:user] } + let(:attributes) { {login: user.email, password: "password"} } + + it "expect result success matching block" do + matched_success = nil + matched_failure = nil + + operation.call(attributes) do |o| + o.success { |v| matched_success = v } + o.failure { |f| matched_failure = f } + end + + expect(matched_success).to be_a(AuctionFunCore::Entities::User) + expect(matched_failure).to be_nil + end + end + + context "when operation happens with failure" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect result matching block" do + matched_success = nil + matched_failure = nil + + operation.call(attributes) do |o| + o.success { |v| matched_success = v } + o.failure { |f| matched_failure = f } + end + + expect(matched_success).to be_nil + expect(matched_failure[:login]).to include(I18n.t("contracts.errors.key?")) + end + end + end + end + + describe "#call(attributes)" do + subject(:operation) { described_class.new.call(attributes) } + + context "when contract are invalid" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect return failure with error messages" do + expect(operation).to be_failure + expect(operation.failure[:login]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when contract are valid" do + let(:user) { Factory[:user] } + let(:attributes) { {login: user.email, password: "password"} } + + before do + allow(AuctionFunCore::Application[:event]).to receive(:publish) + end + + it "expect return success" do + expect(operation).to be_success + + expect(AuctionFunCore::Application[:event]).to have_received(:publish).once + end + end + end +end From 65b4e099278588f09bed268f5f2ad57c0736071d Mon Sep 17 00:00:00 2001 From: Ricardo Pacheco Date: Sat, 17 Feb 2024 16:55:04 -0300 Subject: [PATCH 2/4] Refactoring return monads in operation classes - Update lib version --- CHANGELOG.md | 6 +++++- Gemfile.lock | 2 +- .../user_context/registration_operation.rb | 12 ++++++++---- lib/auction_fun_core/version.rb | 2 +- .../user_context/registration_operation_spec.rb | 4 ++-- spec/auction_fun_core_spec.rb | 2 +- 6 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 917751e..6dc612c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,15 @@ ## [Unreleased] -## [0.3.0] - 2024-02-27 +## [0.3.1] - 2024-02-27 ### Added - Authentication for people (users). +### Changed + +- [Standardization] Refactoring return monads in operation classes. + ## [0.2.0] - 2024-02-27 ### Added diff --git a/Gemfile.lock b/Gemfile.lock index 7bc5b73..73d3005 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - auction_fun_core (0.2.0) + auction_fun_core (0.3.1) GEM remote: https://rubygems.org/ diff --git a/lib/auction_fun_core/operations/user_context/registration_operation.rb b/lib/auction_fun_core/operations/user_context/registration_operation.rb index a765b74..b7ee3b7 100644 --- a/lib/auction_fun_core/operations/user_context/registration_operation.rb +++ b/lib/auction_fun_core/operations/user_context/registration_operation.rb @@ -20,7 +20,7 @@ def self.call(attributes, &block) # @todo Add custom doc def call(attributes) - values = yield validate(attributes) + values = yield validate_contract(attributes) values_with_encrypt_password = yield encrypt_password(values) user_repository.transaction do |_t| @@ -32,12 +32,16 @@ def call(attributes) Success(@user) end - # Calls the user creation contract class to perform the validation + # Calls registration contract class to perform the validation # of the informed attributes. # @param attrs [Hash] user attributes # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] - def validate(attrs) - registration_contract.call(attrs).to_monad + def validate_contract(attrs) + contract = registration_contract.call(attrs) + + return Failure(contract.errors.to_h) if contract.failure? + + Success(contract.to_h) end # Transforms the password attribute, encrypting it to be saved in the database. diff --git a/lib/auction_fun_core/version.rb b/lib/auction_fun_core/version.rb index 472ccc3..b51ed28 100644 --- a/lib/auction_fun_core/version.rb +++ b/lib/auction_fun_core/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module AuctionFunCore - VERSION = "0.2.0" + VERSION = "0.3.1" # Required class module is a gem dependency class Version; end diff --git a/spec/auction_fun_core/operations/user_context/registration_operation_spec.rb b/spec/auction_fun_core/operations/user_context/registration_operation_spec.rb index e5b6e88..ac50112 100644 --- a/spec/auction_fun_core/operations/user_context/registration_operation_spec.rb +++ b/spec/auction_fun_core/operations/user_context/registration_operation_spec.rb @@ -44,7 +44,7 @@ end expect(matched_success).to be_nil - expect(matched_failure.errors.to_h[:name]).to include(I18n.t("contracts.errors.key?")) + expect(matched_failure[:name]).to include(I18n.t("contracts.errors.key?")) end end end @@ -64,7 +64,7 @@ it "expect return failure with error messages" do expect(operation).to be_failure - expect(operation.failure.errors).to be_present + expect(operation.failure[:name]).to include(I18n.t("contracts.errors.key?")) end end diff --git a/spec/auction_fun_core_spec.rb b/spec/auction_fun_core_spec.rb index 25d76a9..3c79fd5 100644 --- a/spec/auction_fun_core_spec.rb +++ b/spec/auction_fun_core_spec.rb @@ -2,6 +2,6 @@ RSpec.describe AuctionFunCore do it "has a version number" do - expect(AuctionFunCore::VERSION).to eq("0.2.0") + expect(AuctionFunCore::VERSION).to eq("0.3.1") end end From bc5794fcff804c293f9766e9b9b0899b90b1a2a8 Mon Sep 17 00:00:00 2001 From: Ricardo Pacheco Date: Sat, 17 Feb 2024 17:26:25 -0300 Subject: [PATCH 3/4] Allow only active users can authenticate --- CHANGELOG.md | 1 + config/locales/contracts/en-US.yml | 1 + config/locales/contracts/pt-BR.yml | 1 + .../user_context/authentication_contract.rb | 15 ++++++++------- lib/auction_fun_core/entities/user.rb | 8 ++++++++ .../user_context/authentication_contract_spec.rb | 12 ++++++++++++ spec/support/factories/users.rb | 2 +- 7 files changed, 32 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc612c..e95a136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Authentication for people (users). +- Only active users can authenticate. ### Changed diff --git a/config/locales/contracts/en-US.yml b/config/locales/contracts/en-US.yml index 7f1b5bc..d7eebf5 100644 --- a/config/locales/contracts/en-US.yml +++ b/config/locales/contracts/en-US.yml @@ -60,6 +60,7 @@ en-US: not_found: "not found" password_confirmation: "doesn't match password" login_not_found: "Invalid credentials" + inactive_account: "Your account is suspended or inactive" macro: email_format: "need to be a valid email" login_format: "invalid login" diff --git a/config/locales/contracts/pt-BR.yml b/config/locales/contracts/pt-BR.yml index c1c9a64..bfa133f 100644 --- a/config/locales/contracts/pt-BR.yml +++ b/config/locales/contracts/pt-BR.yml @@ -59,6 +59,7 @@ pt-BR: not_found: "não encontrado" password_confirmation: "não corresponde à senha" login_not_found: "Credenciais inválidas" + inactive_account: "Sua conta está suspensa ou inativa" macro: email_format: "não é um email válido" login_format: "login inválido" diff --git a/lib/auction_fun_core/contracts/user_context/authentication_contract.rb b/lib/auction_fun_core/contracts/user_context/authentication_contract.rb index 0c8d0a2..c719cee 100644 --- a/lib/auction_fun_core/contracts/user_context/authentication_contract.rb +++ b/lib/auction_fun_core/contracts/user_context/authentication_contract.rb @@ -27,17 +27,18 @@ class AuthenticationContract < ApplicationContract next if (rule_error?(:login) || schema_error?(:login)) || (rule_error?(:password) || schema_error?(:password)) context[:user] ||= user_repository.by_login(values[:login]) - next if context[:user].present? && valid_password?(values[:password], context[:user].password_digest) - key(:base).failure(I18n.t("login_not_found", scope: I18N_SCOPE)) - end + next if context[:user].present? && context[:user].active? && (BCrypt::Password.new(context[:user].password_digest) == values[:password]) - private + if context[:user].blank? || (BCrypt::Password.new(context[:user].password_digest) != values[:password]) + key(:base).failure(I18n.t("login_not_found", scope: I18N_SCOPE)) + end - def valid_password?(password, password_digest) - return false if password_digest.blank? + if context[:user].present? && context[:user].inactive? + key(:base).failure(I18n.t("inactive_account", scope: I18N_SCOPE)) + end - BCrypt::Password.new(password_digest) == password + key(:base).failure(I18n.t("login_not_found", scope: I18N_SCOPE)) end end end diff --git a/lib/auction_fun_core/entities/user.rb b/lib/auction_fun_core/entities/user.rb index 3757d47..de7a52a 100644 --- a/lib/auction_fun_core/entities/user.rb +++ b/lib/auction_fun_core/entities/user.rb @@ -5,6 +5,14 @@ module Entities # User Relations class. This return simple objects with attribute readers # to represent data in your user. class User < ROM::Struct + def active? + active + end + + def inactive? + !active + end + def info attributes.except(:password_digest) end diff --git a/spec/auction_fun_core/contracts/user_context/authentication_contract_spec.rb b/spec/auction_fun_core/contracts/user_context/authentication_contract_spec.rb index 14f87f3..87d903d 100644 --- a/spec/auction_fun_core/contracts/user_context/authentication_contract_spec.rb +++ b/spec/auction_fun_core/contracts/user_context/authentication_contract_spec.rb @@ -63,6 +63,18 @@ ) end end + + context "when credentials are valid but user is inactive" do + let(:user) { Factory[:user, :inactive] } + let(:attributes) { {login: user.email, password: "password"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:base]).to include( + I18n.t("contracts.errors.custom.default.inactive_account") + ) + end + end end context "when credentials are valid" do diff --git a/spec/support/factories/users.rb b/spec/support/factories/users.rb index 2eda793..53c8f2b 100644 --- a/spec/support/factories/users.rb +++ b/spec/support/factories/users.rb @@ -6,7 +6,7 @@ f.phone { fake(:phone_number, :cell_phone_in_e164).tr_s("^0-9", "") } f.password_digest { BCrypt::Password.create("password") } - f.trait :disabled do |t| + f.trait :inactive do |t| t.active { false } end end From ecbd83ffcb767859cbd548e3e395e64c79eecbac Mon Sep 17 00:00:00 2001 From: Ricardo Pacheco Date: Sat, 17 Feb 2024 17:34:35 -0300 Subject: [PATCH 4/4] Fix method documentation --- .../contracts/user_context/authentication_contract.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/auction_fun_core/contracts/user_context/authentication_contract.rb b/lib/auction_fun_core/contracts/user_context/authentication_contract.rb index c719cee..91126e9 100644 --- a/lib/auction_fun_core/contracts/user_context/authentication_contract.rb +++ b/lib/auction_fun_core/contracts/user_context/authentication_contract.rb @@ -22,7 +22,8 @@ class AuthenticationContract < ApplicationContract rule(:password).validate(:password_format) # Validation for login. - # Must to be present and format should be a email or phone. + # Searches for the user in the database from the login, and, if found, + # compares the entered password. rule do |context:| next if (rule_error?(:login) || schema_error?(:login)) || (rule_error?(:password) || schema_error?(:password))