Packages
phoenix_kit
2.9.0
2.13.5
2.13.4
2.13.3
2.13.2
2.13.1
2.13.0
2.12.1
2.12.0
2.11.0
2.10.0
2.9.0
2.8.1
2.8.0
2.7.0
2.6.0
2.5.0
2.4.0
2.3.0
2.2.0
2.1.0
2.0.1
2.0.0
1.7.236
1.7.235
1.7.234
1.7.233
1.7.232
1.7.231
1.7.230
1.7.229
1.7.228
1.7.227
1.7.226
1.7.225
1.7.224
1.7.223
1.7.222
1.7.221
1.7.220
1.7.219
1.7.218
1.7.217
1.7.216
1.7.215
1.7.214
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
Current section
Files
lib/phoenix_kit_web/users/qr_login_confirm.ex
defmodule PhoenixKitWeb.Users.QrLoginConfirm do
@moduledoc """
Phone-side approval screen for QR device-handoff login.
Opened on the user's already-signed-in phone (via the QR the desktop
shows). It displays the requesting device and, on the user's **explicit**
Approve, calls `PhoenixKit.Users.QrLogin.approve/2` — minting the login
token that signs the waiting browser in. There is no auto-approve: this
screen is the defense against QR-jacking.
Authenticated route — the phone must already be signed in. If it isn't,
the authenticated pipeline redirects to the login page first.
"""
use PhoenixKitWeb, :live_view
require Logger
alias PhoenixKit.Users.QrLogin, as: QrLoginContext
alias PhoenixKit.Utils.Routes
def mount(%{"token" => token}, _session, socket) do
socket =
socket
|> assign(:token, token)
|> assign(:project_title, PhoenixKit.Settings.get_project_title())
|> assign(:page_title, gettext("Approve sign-in"))
# Disabling the setting must act as an immediate kill switch — even for a
# request minted while it was on.
if QrLoginContext.enabled?() do
# Looked up on the dead render as well as the connected one.
#
# This used to be gated behind `connected?` so a future non-ETS (DB /
# Redis) keyfob store wouldn't be queried twice per mount. That saved
# one read of a read-only, idempotent lookup, and cost the thing this
# screen exists for: the disconnected render assigned `meta: %{}`, and
# every row in the device panel is behind a presence guard, so the
# panel came up EMPTY while the heading, the warning and both buttons
# rendered fully and looked ready.
#
# So the one screen whose whole job is "here is the device asking to
# sign in as you — is this you?" presented a complete, confident
# approval prompt with the identifying information silently missing.
# Usually that window is too short to notice; behind a proxy that does
# not forward the WebSocket upgrade cleanly it lasts as long as the
# transport takes to fall back, and it is paid again on every remount.
# Training people to approve before the details arrive defeats the
# defense this page is.
#
# One extra read is the cheaper side of that trade by a wide margin.
{state, meta} = look_up(token)
{:ok, socket |> assign(:kf_state, state) |> assign(:meta, meta)}
else
{:ok,
socket
|> put_flash(:error, gettext("QR code sign-in is not available."))
|> redirect(
to: Routes.safe_destination(socket, scope: socket.assigns[:phoenix_kit_current_scope])
)}
end
end
# The `case` matches return values, so a store that RAISES instead of
# answering would go straight past it — and this runs on the disconnected
# render, where that is a 500 page rather than a LiveView that quietly
# remounts.
#
# keyfob's own store no longer does that (0.1.1 made its reads total, which
# is why the dependency is pinned there). But `Keyfob.Store` is a behaviour
# a host may implement over anything — Redis, a database, a cluster-wide
# cache — and the one thing every one of those has in common is that it can
# be unreachable in ways ETS cannot. Core cannot assume a stranger's
# implementation is total.
#
# A store that cannot answer means the request is not actionable, which is
# what "expired" already tells the user — so say that, and log the reason
# rather than showing them a stack trace.
@doc false
# Public only so the raise path can be tested directly.
def look_up(token) do
case QrLoginContext.peek(token) do
{:ok, %{state: :pending, meta: meta}} -> {:pending, meta}
{:ok, %{state: :approved, meta: meta}} -> {:approved, meta}
# not_found / expired both present as "expired" to the user — the code
# is no longer actionable either way.
_ -> {:expired, %{}}
end
rescue
error ->
Logger.warning("[PhoenixKit.QrLoginConfirm] could not read the request: #{inspect(error)}")
{:expired, %{}}
catch
# `rescue` alone does not cover this, and this is the shape the failure
# actually takes. The comment above is right that a host `Keyfob.Store`
# over Redis or a database "can be unreachable in ways ETS cannot" — and
# the way those stores are reached is a `GenServer.call`, which EXITS
# (`:noproc` when the process is gone, `:timeout` when it is wedged)
# rather than raising. This module's own dependency note says so in as
# many words: before keyfob 0.1.1 an unreachable store made its reads
# raise "and its GenServer-backed calls EXIT".
#
# So the guard covered one of the two failure modes it was added for, and
# the uncovered one is the more likely of the pair for exactly the
# third-party stores the guard exists to survive — on the dead render,
# where the comment above notes the cost is a 500 page.
:exit, reason ->
Logger.warning(
"[PhoenixKit.QrLoginConfirm] store did not answer: #{inspect(reason)} — " <>
"presenting the request as expired"
)
{:expired, %{}}
end
def handle_event("keyfob_approve", _params, socket) do
if QrLoginContext.enabled?() do
user = socket.assigns.phoenix_kit_current_user
case QrLoginContext.approve(socket.assigns.token, user.uuid) do
:ok ->
QrLoginContext.log_approval(user, socket.assigns.meta)
{:noreply, assign(socket, :kf_state, :approved)}
{:error, reason} ->
{:noreply,
socket
|> assign(:kf_state, error_state(reason))
|> put_flash(:error, error_message(reason))}
end
else
# The setting was disabled after this page loaded — refuse the
# approval instead of letting an in-flight flow complete.
{:noreply,
socket
|> assign(:kf_state, :expired)
|> put_flash(:error, gettext("QR code sign-in is not available."))}
end
end
def handle_event("keyfob_deny", _params, socket) do
QrLoginContext.deny(socket.assigns.token)
{:noreply, assign(socket, :kf_state, :denied)}
end
# Defensive, and the reason is specific to this page.
#
# It is an authenticated mount, so the auth `on_mount` subscribes it to that
# user's scope topic, and every one of those hooks passes a message it does
# not recognise through to the LiveView (`{:cont, socket}`). This module had
# no `handle_info` at all, so any such message was a FunctionClauseError —
# and a LiveView that crashes here does not fail visibly. The client rejoins
# and reloads the page, which on the approval screen looks like the scan
# "not working" rather than like an error.
#
# Nothing is known to send one today. That is exactly why it costs nothing
# to survive, and the log line names the message if something ever does.
def handle_info(msg, socket) do
Logger.debug("[PhoenixKit.QrLoginConfirm] ignoring unexpected message: #{inspect(msg)}")
{:noreply, socket}
end
@doc false
# Does this request carry anything that actually identifies the device?
#
# `requested_at` is stamped for every request, so its presence says nothing
# about whether we know WHO is asking — only these four do, and every one of
# them is absent when the underlying connect-info or geo lookup is
# unavailable.
#
# Public only so it can be tested directly: it decides whether this screen
# shows a bare empty panel or says the device could not be determined, and
# reaching it through a full render drags in settings and a database.
def identifying_details?(meta) do
Enum.any?([:browser, :os, :ip, :location], &present?(Map.get(meta, &1)))
end
defp present?(value), do: is_binary(value) and String.trim(value) != ""
# A no-longer-pending request (expired / consumed / already approved on
# another device) renders as "expired"; a genuine deny is separate.
defp error_state(:not_pending), do: :expired
defp error_state(:expired), do: :expired
defp error_state(_), do: :expired
defp error_message(:not_pending),
do: gettext("This sign-in was already handled.")
defp error_message(_),
do: gettext("This sign-in request expired or is no longer valid.")
defp confirm_labels do
%{
title: gettext("Approve this sign-in?"),
subtitle: gettext("A browser is asking to sign in as you."),
browser: gettext("Browser"),
os: gettext("Device"),
ip: gettext("IP address"),
location: gettext("Location"),
requested_at: gettext("Requested"),
warning:
gettext("Only approve if this is you. Approving signs that browser in to your account."),
approve: gettext("Approve"),
deny: gettext("Deny"),
approved: gettext("Signed in"),
approved_hint: gettext("You can close this page."),
denied: gettext("Request denied."),
expired: gettext("This request expired or was already used.")
}
end
end