Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[AF-12] People authentication #15

Merged
merged 4 commits into from
Feb 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
## [Unreleased]

## [0.3.1] - 2024-02-27

### Added

- Authentication for people (users).
- Only active users can authenticate.

### Changed

- [Standardization] Refactoring return monads in operation classes.

## [0.2.0] - 2024-02-27

### Added
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
auction_fun_core (0.2.0)
auction_fun_core (0.3.1)

GEM
remote: https://rubygems.org/
Expand Down
3 changes: 3 additions & 0 deletions config/locales/contracts/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,11 @@ en-US:
taken: "has already been taken"
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"
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"
3 changes: 3 additions & 0 deletions config/locales/contracts/pt-BR.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,11 @@ 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"
inactive_account: "Sua conta está suspensa ou inativa"
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"
6 changes: 6 additions & 0 deletions lib/auction_fun_core/contracts/application_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 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.
# 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))

context[:user] ||= user_repository.by_login(values[:login])

next if context[:user].present? && context[:user].active? && (BCrypt::Password.new(context[:user].password_digest) == values[:password])

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

if context[:user].present? && context[:user].inactive?
key(:base).failure(I18n.t("inactive_account", scope: I18N_SCOPE))
end

key(:base).failure(I18n.t("login_not_found", scope: I18N_SCOPE))
end
end
end
end
end
8 changes: 8 additions & 0 deletions lib/auction_fun_core/entities/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/auction_fun_core/events/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion lib/auction_fun_core/events/listener.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand All @@ -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.
Expand All @@ -64,9 +68,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
Expand Down
9 changes: 8 additions & 1 deletion lib/auction_fun_core/repos/user_context/user_repository.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion lib/auction_fun_core/version.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 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: "[email protected]", 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

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
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
Loading
Loading