Log successful and failed login attempts in a separate log file

We log the login parameter and the request IP address.

Quoting the ENS:

> [op.acc.5.r5.1] Se registrarán los accesos con éxito y los fallidos.
This commit is contained in:
Senén Rodero Rodríguez
2023-04-20 13:24:15 +02:00
parent 2aff3b73f9
commit b7073691f1
6 changed files with 143 additions and 0 deletions

View File

@@ -138,6 +138,9 @@ module Consul
config.paths["app/views"].unshift(Rails.root.join("app", "views", "custom")) config.paths["app/views"].unshift(Rails.root.join("app", "views", "custom"))
# Set to true to enable user authentication log
config.authentication_logs = Rails.application.secrets.dig(:security, :authentication_logs) || false
# Set to true to enable devise user lockable feature # Set to true to enable devise user lockable feature
config.devise_lockable = Rails.application.secrets.devise_lockable config.devise_lockable = Rails.application.secrets.devise_lockable

View File

@@ -0,0 +1,24 @@
Warden::Manager.after_authentication do |user, auth, opts|
if Rails.application.config.authentication_logs
request = auth.request
login = request.params.dig(opts[:scope].to_s, "login")
message = "The user #{login} with IP address: #{request.ip} successfully signed in."
AuthenticationLogger.log(message)
end
end
Warden::Manager.before_failure do |env, opts|
if Rails.application.config.authentication_logs
request = Rack::Request.new(env)
login = request.params.dig(opts[:scope].to_s, "login")
message = "The user #{login} with IP address: #{request.ip} failed to sign in."
AuthenticationLogger.log(message)
user = User.find_by(username: login) || User.find_by(email: login)
if user&.failed_attempts == User.maximum_attempts.to_i
message = "The user #{login} with IP address: #{request.ip} reached maximum attempts " \
"and it's temporarily locked."
AuthenticationLogger.log(message)
end
end
end

View File

@@ -21,6 +21,7 @@ development:
devise_lockable: false devise_lockable: false
multitenancy: false multitenancy: false
security: security:
authentication_logs: false
last_sign_in: false last_sign_in: false
password_complexity: false password_complexity: false
# lockable: # lockable:
@@ -59,6 +60,7 @@ staging:
managers_application_key: "" managers_application_key: ""
multitenancy: false multitenancy: false
security: security:
authentication_logs: false
last_sign_in: false last_sign_in: false
password_complexity: false password_complexity: false
# lockable: # lockable:
@@ -102,6 +104,7 @@ preproduction:
managers_application_key: "" managers_application_key: ""
multitenancy: false multitenancy: false
security: security:
authentication_logs: false
last_sign_in: false last_sign_in: false
password_complexity: false password_complexity: false
# lockable: # lockable:
@@ -150,6 +153,7 @@ production:
managers_application_key: "" managers_application_key: ""
multitenancy: false multitenancy: false
security: security:
authentication_logs: false
last_sign_in: false last_sign_in: false
password_complexity: false password_complexity: false
# lockable: # lockable:

View File

@@ -0,0 +1,24 @@
class AuthenticationLogger
@loggers = {}
class << self
def log(message)
logger.info(message)
end
def path
Rails.root.join("log", Tenant.subfolder_path, "authentication.log")
end
private
def logger
@loggers[Apartment::Tenant.current] ||= build_logger
end
def build_logger
FileUtils.mkdir_p(File.dirname(path))
ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(path, level: :info))
end
end
end

View File

@@ -22,4 +22,51 @@ describe Users::SessionsController do
end end
end end
end end
describe "access logs" do
context "when feature is enabled" do
before { allow(Rails.application.config).to receive(:authentication_logs).and_return(true) }
it "when a sign in process succeeds it calls the authentication logger" do
message = "The user citizen@consul.org with IP address: 0.0.0.0 successfully signed in."
expect(AuthenticationLogger).to receive(:log).with(message)
post :create, params: { user: { login: "citizen@consul.org", password: "12345678" }}
end
it "when a sign in process fails it calls the authentication logger" do
message = "The user citizen@consul.org with IP address: 0.0.0.0 failed to sign in."
expect(AuthenticationLogger).to receive(:log).with(message)
post :create, params: { user: { login: "citizen@consul.org", password: "wrong" }}
end
it "when maximum attempts is reached it tracks the user account lock" do
allow(User).to receive(:maximum_attempts).and_return(1)
message_1 = "The user citizen@consul.org with IP address: 0.0.0.0 failed to sign in."
message_2 = "The user citizen@consul.org with IP address: 0.0.0.0 reached maximum attempts " \
"and it's temporarily locked."
expect(AuthenticationLogger).to receive(:log).once.with(message_1)
expect(AuthenticationLogger).to receive(:log).once.with(message_2)
post :create, params: { user: { login: "citizen@consul.org", password: "wrong" }}
end
end
context "when feature is disabled" do
before { allow(Rails.application.config).to receive(:authentication_logs).and_return(false) }
it "when a sign in process succeeds it does not call the authentication logger" do
expect(AuthenticationLogger).not_to receive(:log)
post :create, params: { user: { login: "citizen@consul.org", password: "12345678" }}
end
it "when a sign in process fails it does not call the authentication logger" do
expect(AuthenticationLogger).not_to receive(:log)
post :create, params: { user: { login: "citizen@consul.org", password: "wrong" }}
end
end
end
end end

View File

@@ -0,0 +1,41 @@
require "rails_helper"
describe AuthenticationLogger do
describe ".path" do
context "when multitenancy is disabled" do
before { allow(Rails.application.config).to receive(:multitenancy).and_return(false) }
it "uses default file" do
expect(AuthenticationLogger.path).to eq(Rails.root.join("log", "authentication.log"))
end
end
context "when multitenancy is enabled" do
before { allow(Rails.application.config).to receive(:multitenancy).and_return(true) }
it "uses the default file for the public schema" do
Tenant.switch("public") do
path = Rails.root.join("log", "authentication.log")
expect(AuthenticationLogger.path).to eq(path)
end
end
it "uses a separate file for any other tenant" do
create(:tenant, schema: "tenant1")
Tenant.switch("tenant1") do
path = Rails.root.join("log", "tenants", "tenant1", "authentication.log")
expect(AuthenticationLogger.path).to eq(path)
end
create(:tenant, schema: "tenant2")
Tenant.switch("tenant2") do
path = Rails.root.join("log", "tenants", "tenant2", "authentication.log")
expect(AuthenticationLogger.path).to eq(path)
end
end
end
end
end