Packages
phoenix_kit
1.7.202
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
Current section
Files
lib/phoenix_kit/users/qr_login.ex
defmodule PhoenixKit.Users.QrLogin do
@moduledoc """
QR device-handoff login ("scan to sign in") for PhoenixKit.
This is the PhoenixKit-side integration of the [`keyfob`](https://hex.pm/packages/keyfob)
library. Keyfob owns the rendezvous — request lifecycle, expiry,
single-use enforcement, the secret split, PubSub signaling. This module
wires it into PhoenixKit: the internal PubSub, the `qr_login_enabled`
setting, device-metadata extraction for the confirm screen, and activity
logging on approval.
## The flow
1. A signed-out browser opens `/users/qr-login`
(`PhoenixKitWeb.Users.QrLogin`) and a QR encoding the phone-confirm URL
is shown. The LiveView waits.
2. The user's already-signed-in phone scans it, opens the confirm URL
(`PhoenixKitWeb.Users.QrLoginConfirm`), reviews the requesting device,
and taps Approve — which calls `approve/2` with the phone user's uuid.
3. Approval mints a one-time login token delivered over PubSub only to
the waiting browser, which navigates to the completion controller
(`PhoenixKitWeb.Users.QrLoginComplete`).
4. The controller calls `consume/1` (`{:ok, user_uuid}`, exactly once) and
logs that user in with PhoenixKit's own session machinery.
Approval always happens on the trusted (phone) device; the browser gets
nothing until the phone approves. See `keyfob`'s docs for the threat
model (QR-jacking, the secret split).
All keyfob calls are routed through PhoenixKit's internal PubSub
(`#{inspect(:phoenix_kit_internal_pubsub)}`) so the desktop LiveView and
the phone confirm LiveView rendezvous on the same bus. The default
`Keyfob.Store.ETS` (started in `PhoenixKit.Supervisor`) holds requests.
"""
require Logger
alias Phoenix.LiveView
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Geolocation
alias PhoenixKit.Utils.IpAddress
alias PhoenixKit.Utils.UserAgent
# PhoenixKit's internal PubSub, started by `PhoenixKit.PubSub.Manager`.
@pubsub :phoenix_kit_internal_pubsub
@doc """
Whether QR device-handoff login is enabled (setting `qr_login_enabled`,
default `false`).
"""
@spec enabled?() :: boolean()
def enabled?, do: Settings.get_boolean_setting("qr_login_enabled", false)
@doc "The PubSub name every keyfob call in this integration uses."
@spec pubsub() :: atom()
def pubsub, do: @pubsub
@doc "Shared keyfob options — routes every call through PhoenixKit's internal PubSub."
@spec keyfob_opts() :: keyword()
def keyfob_opts, do: [pubsub: @pubsub]
## ── Thin keyfob wrappers (so call sites never repeat the opts) ─────────
@doc "See `Keyfob.create_request/1`. Merges the shared PubSub option."
def create_request(opts \\ []), do: Keyfob.create_request(Keyword.merge(keyfob_opts(), opts))
@doc "See `Keyfob.peek/2`."
def peek(token), do: Keyfob.peek(token, keyfob_opts())
@doc "See `Keyfob.approve/3`. `user_ref` is the approving user's uuid."
def approve(token, user_ref), do: Keyfob.approve(token, user_ref, keyfob_opts())
@doc "See `Keyfob.deny/2`."
def deny(token), do: Keyfob.deny(token, keyfob_opts())
@doc "See `Keyfob.consume/2`. Returns `{:ok, user_uuid}` exactly once."
def consume(token), do: Keyfob.consume(token, keyfob_opts())
@doc "See `Keyfob.subscribe/2`."
def subscribe(token), do: Keyfob.subscribe(token, keyfob_opts())
@doc "See `Keyfob.unsubscribe/2`."
def unsubscribe(token), do: Keyfob.unsubscribe(token, keyfob_opts())
## ── Device metadata for the confirm screen ────────────────────────────
@doc """
Extracts a device-description map from the requesting browser's connected
socket, shown verbatim on the phone confirm screen so the human can
recognise (or reject) the sign-in.
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)
# 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
# Geolocation.lookup_location/1 tries two providers sequentially, each with
# its own 5s HTTP timeout — up to ~10s in the worst case. That's fine for
# the registration/login-alert paths (fire-and-forget), but here it runs
# inline in the QR page's connected mount, blocking the code the page
# exists to show. Bound it well below that so a slow/unreachable geo API
# degrades to "no location" instead of stalling the mint.
@lookup_timeout_ms 1_500
@doc """
Formats a best-effort `"City, Country"` (or just `"Country"`) string for
an IP, or `nil` when the lookup fails, is unavailable, or is still running
after #{@lookup_timeout_ms}ms. Never raises — a geo backend hiccup must
not crash (or stall) 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
# async_nolink (not Task.async): the lookup must never be able to crash
# the calling LiveView via a link, only ever resolve to nil on failure.
task =
Task.Supervisor.async_nolink(PhoenixKit.TaskSupervisor, fn ->
Geolocation.lookup_location(ip)
end)
result =
case Task.yield(task, @lookup_timeout_ms) || Task.shutdown(task, :brutal_kill) do
{:ok, lookup_result} -> lookup_result
_ -> :timeout
end
case result 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 """
Records a `user.qr_login_approved` activity for the approving user.
Actor and target are the same user (they approved their own sign-in), so
no notification is generated — this is an audit-trail entry only. Errors
are swallowed: a logging failure must never block the sign-in.
"""
@spec log_approval(map(), map()) :: :ok
def log_approval(user, meta \\ %{}) do
if Code.ensure_loaded?(PhoenixKit.Activity) do
try do
PhoenixKit.Activity.log(%{
action: "user.qr_login_approved",
module: "users",
mode: "manual",
actor_uuid: user.uuid,
target_uuid: user.uuid,
resource_type: "user",
resource_uuid: user.uuid,
metadata: %{"actor_role" => "user", "device" => meta}
})
rescue
error ->
Logger.warning("[PhoenixKit.QrLogin] activity log failed: #{inspect(error)}")
end
end
:ok
end
## ── Internals ──────────────────────────────────────────────────────────
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