Skip to content
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
10 changes: 8 additions & 2 deletions lib/phoenix_kit/migrations/postgres.ex
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,13 @@ defmodule PhoenixKit.Migrations.Postgres do
- Replaces unique index with partial index (slug-mode only, WHERE slug IS NOT NULL)
- Adds unique index on `(group_uuid, post_date, post_time)` for timestamp-mode posts

### V146 - Catalogue item primary supplier ⚡ LATEST
### V147 - Known-device geo-location ⚡ LATEST
- Adds nullable `location` (`City, Country`) to
`phoenix_kit_user_known_devices`. Resolved once at new-device time by
`PhoenixKit.Users.LoginAlerts` and stored so the user's Active Sessions
list can show sign-in location without a per-render geo lookup.

### V146 - Catalogue item primary supplier
- Adds nullable `primary_supplier_uuid` FK (`ON DELETE SET NULL`) +
partial index to `phoenix_kit_cat_items` — an item's default
supplier, independent of manufacturer (generic/unbranded materials;
Expand Down Expand Up @@ -1282,7 +1288,7 @@ defmodule PhoenixKit.Migrations.Postgres do
alias PhoenixKit.Migrations.Postgres.Helpers

@initial_version 1
@current_version 146
@current_version 147
@default_prefix "public"

# First version whose SQL references uuid_generate_v7(). Chains that
Expand Down
38 changes: 38 additions & 0 deletions lib/phoenix_kit/migrations/postgres/v147.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
defmodule PhoenixKit.Migrations.Postgres.V147 do
@moduledoc """
V147: Persist geo-location on known devices.

Adds a nullable `location` column to `phoenix_kit_user_known_devices`. The
"City, Country" string is already resolved at new-device time by
`PhoenixKit.Users.LoginAlerts` (it was only used in the alert email);
storing it lets the user's Active Sessions list show where each session
signed in from without an extra geo lookup per page render.
"""

use Ecto.Migration

def up(opts) do
prefix = Map.get(opts, :prefix, "public")
p = prefix_str(prefix)

alter table(:phoenix_kit_user_known_devices, prefix: prefix) do
add_if_not_exists(:location, :string, size: 255)
end

execute("COMMENT ON TABLE #{p}phoenix_kit IS '147'")
end

def down(opts) do
prefix = Map.get(opts, :prefix, "public")
p = prefix_str(prefix)

alter table(:phoenix_kit_user_known_devices, prefix: prefix) do
remove_if_exists(:location, :string)
end

execute("COMMENT ON TABLE #{p}phoenix_kit IS '146'")
end

defp prefix_str("public"), do: "public."
defp prefix_str(prefix), do: "#{prefix}."
end
7 changes: 7 additions & 0 deletions lib/phoenix_kit/notifications/types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ defmodule PhoenixKit.Notifications.Types do

defp core_types do
[
%{
key: "security",
label: "Security",
description: "New sign-ins to your account from an unrecognized device",
actions: ["user.new_login_detected"],
default: true
},
%{
key: "account",
label: "Account",
Expand Down
2 changes: 2 additions & 0 deletions lib/phoenix_kit/users/auth/known_device.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ defmodule PhoenixKit.Users.Auth.KnownDevice do
field :user_agent_hash, :string
field :browser, :string
field :os, :string
field :location, :string
field :first_seen_at, :utc_datetime
field :last_seen_at, :utc_datetime

Expand All @@ -39,6 +40,7 @@ defmodule PhoenixKit.Users.Auth.KnownDevice do
:user_agent_hash,
:browser,
:os,
:location,
:first_seen_at,
:last_seen_at
])
Expand Down
44 changes: 42 additions & 2 deletions lib/phoenix_kit/users/login_alerts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,15 @@ defmodule PhoenixKit.Users.LoginAlerts do

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

Expand Down Expand Up @@ -83,6 +87,9 @@ defmodule PhoenixKit.Users.LoginAlerts do
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
}
Expand All @@ -95,12 +102,45 @@ defmodule PhoenixKit.Users.LoginAlerts do
)

log_new_login(user, attrs)
notify_in_app(user, attrs)

email_attrs = Map.put(attrs, :location, location_for(fingerprint.ip_address))
UserNotifier.deliver_new_login_alert(user, email_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(%{
Expand Down
54 changes: 50 additions & 4 deletions lib/phoenix_kit/users/qr_login.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ defmodule PhoenixKit.Users.QrLogin do

alias Phoenix.LiveView
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Geolocation
alias PhoenixKit.Utils.IpAddress
alias PhoenixKit.Utils.UserAgent

Expand Down Expand Up @@ -88,20 +89,56 @@ defmodule PhoenixKit.Users.QrLogin do
socket, shown verbatim on the phone confirm screen so the human can
recognise (or reject) the sign-in.

Keys: `:browser`, `:os`, `:ip` — any of which may be absent when the
underlying connect-info is unavailable.
Keys: `:browser`, `:os`, `:ip`, `:location` — any of which may be absent
when the underlying connect-info (or geo lookup) is unavailable — plus
`:requested_at`, an absolute UTC timestamp of when the code was minted so
the approver can sanity-check "did I just do this?".

The geo lookup is a best-effort, timeout-bounded call on the requesting
browser's IP (same machinery registration/login-alerts already use); it
runs at QR-mint time so the location is baked into the request the phone
later reads.
"""
@spec device_meta(LiveView.Socket.t()) :: map()
def device_meta(socket) do
ua = LiveView.get_connect_info(socket, :user_agent)
ip = IpAddress.extract_from_socket(socket)
# extract_from_socket/1 returns the literal "unknown" when peer_data is
# unavailable (proxies, some transports) — treat that (and blanks) as
# absent so the confirm screen omits the IP row instead of showing a
# bare "unknown", and so we don't feed a placeholder into the geo lookup.
ip = present_ip(IpAddress.extract_from_socket(socket))

%{}
%{requested_at: requested_at()}
|> put_present(:browser, UserAgent.browser(ua))
|> put_present(:os, UserAgent.os(ua))
|> put_present(:ip, ip)
|> put_present(:location, ip && location_for(ip))
end

@doc """
Formats a best-effort `"City, Country"` (or just `"Country"`) string for
an IP, or `nil` when the lookup fails or is unavailable. Never raises — a
geo backend hiccup must not crash the QR mint that shows the code.
"""
@spec location_for(String.t() | nil) :: String.t() | nil
def location_for(ip) when is_binary(ip) do
case Geolocation.lookup_location(ip) do
{:ok, %{"city" => city, "country" => country}}
when is_binary(city) and city != "" and is_binary(country) ->
"#{city}, #{country}"

{:ok, %{"country" => country}} when is_binary(country) and country != "" ->
country

_ ->
nil
end
rescue
_ -> nil
end

def location_for(_), do: nil

## ── Activity logging ───────────────────────────────────────────────────

@doc """
Expand Down Expand Up @@ -139,4 +176,13 @@ defmodule PhoenixKit.Users.QrLogin do
defp put_present(map, _key, nil), do: map
defp put_present(map, _key, ""), do: map
defp put_present(map, key, value), do: Map.put(map, key, value)

# Placeholder IPs from `IpAddress.extract_from_socket/1` (unreadable peer
# data) count as "no IP" so they neither render nor drive a geo lookup.
defp present_ip(ip) when ip in [nil, "", "unknown"], do: nil
defp present_ip(ip), do: ip

defp requested_at do
Calendar.strftime(DateTime.utc_now(), "%Y-%m-%d %H:%M UTC")
end
end
125 changes: 124 additions & 1 deletion lib/phoenix_kit/users/sessions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ defmodule PhoenixKit.Users.Sessions do
"""

import Ecto.Query, warn: false
require Logger
alias PhoenixKit.Admin.Events
alias PhoenixKit.RepoHelper, as: Repo
alias PhoenixKit.Users.Auth.{User, UserToken}
alias PhoenixKit.Users.Auth.{KnownDevice, User, UserToken}
alias PhoenixKit.Utils.Date, as: UtilsDate

@session_validity_in_days 60
Expand Down Expand Up @@ -105,6 +106,95 @@ defmodule PhoenixKit.Users.Sessions do
|> Enum.map(&format_session_info/1)
end

@doc """
Lists a user's active sessions enriched with device info, for the
self-service "Active Sessions" UI.

Each session's `(ip_address, user_agent_hash)` is matched against the
user's `KnownDevice` history to recover browser/OS/location/last-active
(session tokens store only the hashed UA, never the raw string). Sessions
predating fingerprinting — or from a device never recorded as "known" —
degrade gracefully to an "Unknown device" with nil fields.

`current_token` is the raw session token of the browser making the
request (from the session's `"user_token"`); the matching row is flagged
`is_current: true` so the UI can mark it and omit its "Sign out" button.
"""
def list_user_device_sessions(%User{uuid: user_uuid}, current_token) do
known = known_devices_by_fingerprint(user_uuid)
current_uuid = current_session_uuid(user_uuid, current_token)

from(token in UserToken,
where: token.context == "session",
where: token.user_uuid == ^user_uuid,
where: token.inserted_at > ago(@session_validity_in_days, "day"),
select: %{
token_uuid: token.uuid,
ip_address: token.ip_address,
user_agent_hash: token.user_agent_hash,
created_at: token.inserted_at
},
order_by: [desc: token.inserted_at]
)
|> Repo.all()
|> Enum.map(fn s ->
device = Map.get(known, {s.ip_address, s.user_agent_hash})

%{
token_uuid: s.token_uuid,
ip_address: s.ip_address,
browser: device && device.browser,
os: device && device.os,
location: device && device.location,
last_active: (device && device.last_seen_at) || s.created_at,
created_at: s.created_at,
is_current: s.token_uuid == current_uuid
}
end)
end

@doc """
Revokes one of a user's *own* sessions by token uuid.

Scoped to `user` so a user can never revoke another user's session by
guessing a token uuid. Returns `:ok` or `{:error, :not_found}`.
"""
def revoke_user_session(%User{uuid: user_uuid}, token_uuid) when is_binary(token_uuid) do
case Repo.delete_all(
from(t in UserToken,
where: t.uuid == ^token_uuid and t.user_uuid == ^user_uuid and t.context == "session"
)
) do
{1, _} ->
Events.broadcast_session_revoked(token_uuid)
:ok

{0, _} ->
{:error, :not_found}
end
end

@doc """
Revokes all of a user's sessions except the one identified by
`current_token` (kept so the acting browser stays signed in). Returns the
number revoked. With a nil token, revokes every session for the user.
"""
def revoke_other_user_sessions(%User{} = user, nil), do: revoke_user_sessions(user)

def revoke_other_user_sessions(%User{uuid: user_uuid}, current_token)
when is_binary(current_token) do
{count, _} =
Repo.delete_all(
from(t in UserToken,
where:
t.user_uuid == ^user_uuid and t.context == "session" and t.token != ^current_token
)
)

if count > 0, do: Events.broadcast_user_sessions_revoked(user_uuid, count)
count
end

@doc """
Gets detailed information about a specific session by token ID.

Expand Down Expand Up @@ -271,6 +361,39 @@ defmodule PhoenixKit.Users.Sessions do
}
end

# Loads the user's known devices keyed by {ip_address, user_agent_hash}
# for O(1) enrichment of each session row.
#
# Degrades to no enrichment (empty map) if the known-devices table isn't
# present yet — a parent app can deploy code carrying this feature before
# running the V143/V147 migrations, and the sessions list (built from the
# tokens table) must still render rather than crash the settings page.
defp known_devices_by_fingerprint(user_uuid) do
from(d in KnownDevice, where: d.user_uuid == ^user_uuid)
|> Repo.all()
|> Map.new(fn d -> {{d.ip_address, d.user_agent_hash}, d} end)
rescue
error in [Postgrex.Error, DBConnection.ConnectionError] ->
Logger.warning(
"[PhoenixKit.Sessions] known-device enrichment skipped " <>
"(run PhoenixKit migrations to V147?): #{inspect(error)}"
)

%{}
end

# Resolves the token uuid of the acting session (session tokens are stored
# raw, so a direct byte match is correct). Nil token / no match → nil.
defp current_session_uuid(_user_uuid, nil), do: nil

defp current_session_uuid(user_uuid, token) when is_binary(token) do
from(t in UserToken,
where: t.context == "session" and t.user_uuid == ^user_uuid and t.token == ^token,
select: t.uuid
)
|> Repo.one()
end

# Private helper to format session information
defp format_session_info(session_data) do
%{
Expand Down
Loading