Current section

Files

Jump to
ash_authentication lib ash_authentication strategies totp audit_log_helpers.ex
Raw

lib/ash_authentication/strategies/totp/audit_log_helpers.ex

# SPDX-FileCopyrightText: 2025 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.Strategy.Totp.AuditLogHelpers do
@moduledoc """
Shared helpers for audit log-based brute force protection.
This module provides common functionality used by both `AuditLogChange` and
`AuditLogPreparation` to count failed TOTP attempts from the audit log.
"""
require Ash.Query
import Ash.Expr
alias AshAuthentication.AuditLogResource
@doc """
Counts failed TOTP attempts for a subject within a time window.
Queries the audit log resource for entries matching:
- The given subject (user identifier)
- Strategy = :totp
- Status = :failure
- Logged at or after the cutoff time
Uses a `FOR UPDATE` lock to prevent race conditions where multiple concurrent
requests could slip past the brute force limit. While this creates some
contention, it ensures accurate rate limiting enforcement.
Returns `{:ok, count}` or `{:error, reason}`.
"""
@spec count_failures(struct(), String.t(), DateTime.t()) ::
{:ok, non_neg_integer()} | {:error, any()}
def count_failures(audit_log, subject, cutoff) do
audit_log_resource = audit_log.audit_log_resource
subject_attr = AuditLogResource.Info.audit_log_attributes_subject!(audit_log_resource)
strategy_attr = AuditLogResource.Info.audit_log_attributes_strategy!(audit_log_resource)
status_attr = AuditLogResource.Info.audit_log_attributes_status!(audit_log_resource)
logged_at_attr = AuditLogResource.Info.audit_log_attributes_logged_at!(audit_log_resource)
query =
audit_log_resource
|> Ash.Query.new()
|> Ash.Query.set_context(%{private: %{ash_authentication?: true}})
|> Ash.Query.filter(^ref(subject_attr) == ^subject)
|> Ash.Query.filter(^ref(strategy_attr) == :totp)
|> Ash.Query.filter(^ref(status_attr) == :failure)
|> Ash.Query.filter(^ref(logged_at_attr) >= ^cutoff)
# Lock prevents race conditions where concurrent requests slip past the brute force limit
|> Ash.Query.lock("FOR UPDATE")
Ash.count(query)
end
end