Skip to content

Commit

Permalink
[AF-12] People authentication (#15)
Browse files Browse the repository at this point in the history
* Add authentication resource for users

* Refactoring return monads in operation classes

- Update lib version

* Allow only active users can authenticate

* Fix method documentation
  • Loading branch information
ricardopacheco authored Feb 17, 2024
1 parent bb8adfe commit 346f64c
Show file tree
Hide file tree
Showing 18 changed files with 329 additions and 15 deletions.
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

0 comments on commit 346f64c

Please sign in to comment.