Packages

phoenix_kit

1.7.213
1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit users login_alerts.ex
Raw

lib/phoenix_kit/users/login_alerts.ex

defmodule PhoenixKit.Users.LoginAlerts do
@moduledoc """
New-login security alerts ("we noticed a new login to your account").
On every login (`PhoenixKitWeb.Users.Auth.log_in_user/3`), the request's
`(ip_address, user_agent_hash)` pair is checked against
`PhoenixKit.Users.Auth.KnownDevice` rows for that user. An unrecognized
pair is a new device: it's persisted, a `user.new_login_detected`
activity entry is logged, and — when `new_login_alert_enabled` is on —
an email goes out via
`PhoenixKit.Users.Auth.UserNotifier.deliver_new_login_alert/2`.
A recognized pair just bumps `last_seen_at` — no alert, no email.
Sends synchronously (matching every other PhoenixKit auth email —
confirmation, password reset, magic link — none of which are queued
through Oban): a "new device" login is inherently rare per user (every
subsequent login from the same device is silent), so the odd extra
round-trip on a first-time login doesn't justify background-job
infrastructure this feature would otherwise be the only user of. A
send failure is logged and swallowed — it must never block sign-in.
"""
require Logger
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.Notifications
alias PhoenixKit.RepoHelper
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth.KnownDevice
alias PhoenixKit.Users.Auth.UserNotifier
alias PhoenixKit.Utils.Geolocation
alias PhoenixKit.Utils.Routes
alias PhoenixKit.Utils.SessionFingerprint
alias PhoenixKit.Utils.UserAgent
@doc """
Whether new-login alerts are enabled (setting `new_login_alert_enabled`,
default `false`).
"""
@spec enabled?() :: boolean()
def enabled?, do: Settings.get_boolean_setting("new_login_alert_enabled", false)
@doc """
Records a login from `conn` for `user`, alerting on a new device.
No-ops entirely (no DB write, no email) when the feature is disabled.
Never raises — a failure here must never block sign-in.
"""
@spec check(map(), Plug.Conn.t()) :: :ok
def check(user, conn) do
if enabled?(), do: do_check(user, conn)
:ok
rescue
error ->
Logger.warning("[PhoenixKit.LoginAlerts] check failed: #{inspect(error)}")
:ok
end
defp do_check(user, conn) do
fingerprint = SessionFingerprint.create_fingerprint(conn)
now = DateTime.utc_now() |> DateTime.truncate(:second)
repo = RepoHelper.repo()
case repo.get_by(KnownDevice,
user_uuid: user.uuid,
ip_address: fingerprint.ip_address,
user_agent_hash: fingerprint.user_agent_hash
) do
nil ->
record_new_device(user, conn, fingerprint, now)
%KnownDevice{} = device ->
device |> KnownDevice.changeset(%{last_seen_at: now}) |> repo.update()
:ok
end
end
defp record_new_device(user, conn, fingerprint, now) do
repo = RepoHelper.repo()
ua = user_agent_header(conn)
attrs = %{
user_uuid: user.uuid,
ip_address: fingerprint.ip_address,
user_agent_hash: fingerprint.user_agent_hash,
browser: UserAgent.browser(ua),
os: UserAgent.os(ua),
# Resolved once here and persisted (V147) so the Active Sessions list
# can show it later without re-hitting the geo API per page render.
location: location_for(fingerprint.ip_address),
first_seen_at: now,
last_seen_at: now
}
%KnownDevice{}
|> KnownDevice.changeset(attrs)
|> repo.insert(
on_conflict: [set: [last_seen_at: now]],
conflict_target: [:user_uuid, :ip_address, :user_agent_hash]
)
log_new_login(user, attrs)
notify_in_app(user, attrs)
UserNotifier.deliver_new_login_alert(user, attrs)
:ok
end
# In-app notification for the new sign-in. The `user.new_login_detected`
# activity is self-actor (actor == target), so the activity→notification
# hook correctly skips it — this is the sanctioned standalone path for an
# app-driven self-notice, filtered through the recipient's "security"
# type preference (fail-open). Links to the Active Sessions section.
defp notify_in_app(user, attrs) do
if Code.ensure_loaded?(Notifications) do
Notifications.create(%{
recipient_uuid: user.uuid,
type: "security",
icon: "hero-shield-exclamation",
link: Routes.path("/dashboard/settings"),
text: new_login_text(attrs)
})
end
rescue
error ->
Logger.warning("[PhoenixKit.LoginAlerts] in-app notify failed: #{inspect(error)}")
:ok
end
defp new_login_text(attrs) do
details =
[attrs.browser, attrs.os, attrs.location]
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|> Enum.join(", ")
case details do
"" -> gettext("New sign-in to your account.")
_ -> gettext("New sign-in to your account from %{details}.", details: details)
end
end
defp log_new_login(user, attrs) do
if Code.ensure_loaded?(PhoenixKit.Activity) do
PhoenixKit.Activity.log(%{
action: "user.new_login_detected",
module: "users",
mode: "auto",
actor_uuid: user.uuid,
target_uuid: user.uuid,
resource_type: "user",
resource_uuid: user.uuid,
metadata: %{
"actor_role" => "user",
"ip_address" => attrs.ip_address,
"browser" => attrs.browser,
"os" => attrs.os
}
})
end
rescue
error ->
Logger.warning("[PhoenixKit.LoginAlerts] activity log failed: #{inspect(error)}")
end
defp user_agent_header(conn) do
case Plug.Conn.get_req_header(conn, "user-agent") do
[ua | _] -> ua
[] -> nil
end
end
@doc """
Best-effort "City, Country" string for `ip_address`, or `nil`.
Never raises; a lookup failure (disabled, rate-limited, invalid IP)
just means the alert email omits the location line.
"""
@spec location_for(String.t()) :: String.t() | nil
def location_for(ip_address) do
case Geolocation.lookup_location(ip_address) do
{:ok, %{"city" => city, "country" => country}}
when is_binary(city) and city != "" and is_binary(country) and country != "" ->
"#{city}, #{country}"
{:ok, %{"country" => country}} when is_binary(country) and country != "" ->
country
_ ->
nil
end
rescue
_ -> nil
end
end