towerops/lib/towerops_web/controllers/api/mobile_auth_controller.ex
Graham McIntire abfd44fd75 fix: 3 more high-severity bugs (H19, H20, H25)
- H19: /api/v1/mobile/auth/qr/verify no longer returns user_email. Knowing
  a QR token now only tells the caller the token is valid; the email is
  only revealed by /complete which consumes the token.
- H20: CoverageWorker max_attempts dropped from 3 to 1. The fail/2 path
  already returns :ok and writes the failure onto the coverage record,
  so the 3-attempt retry policy was never reachable and was misleading.
- H25: ChecksController.create rejects device_ids that don't belong to
  the caller's organization (via ScopedResource.fetch). Without this an
  API token could attach service checks to devices in another tenant.

Also strip bug ID references from in-code comments (per feedback that
H/M/L numbers don't survive past their bugs.md removal); commit history
keeps the audit trail.
2026-05-12 11:32:20 -05:00

175 lines
4.3 KiB
Elixir

defmodule ToweropsWeb.Api.MobileAuthController do
@moduledoc """
API controller for mobile app authentication.
Handles QR code-based authentication flow:
1. Web app creates QR token (handled by LiveView)
2. Mobile app scans QR code and calls verify_qr_token
3. Mobile app calls complete_qr_login to get session token
"""
use ToweropsWeb, :controller
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
alias Towerops.MobileSessions
@doc """
POST /api/v1/mobile/auth/qr/verify
Verifies a QR login token is valid (not expired, not used).
Request body:
{
"token": "base64-encoded-token"
}
Response:
{
"valid": true,
"user_email": "user@example.com"
}
"""
def verify_qr_token(conn, %{"token" => token}) do
case MobileSessions.get_qr_login_token(token) do
nil ->
conn
|> put_status(:unauthorized)
|> json(%{valid: false, error: "Invalid or expired token"})
_qr_token ->
# Don't leak the associated user's email here. Anyone who guesses or
# captures a QR token would otherwise be able to enumerate emails by
# calling /verify. The email is only meaningful at /complete (which
# consumes the token and creates a session).
json(conn, %{valid: true})
end
end
def verify_qr_token(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Missing token parameter"})
end
@doc """
POST /api/v1/mobile/auth/qr/complete
Completes QR login by creating a mobile session.
Request body:
{
"token": "base64-encoded-qr-token",
"device_name": "iPhone 15 Pro",
"device_os": "iOS 17.2",
"app_version": "1.0.0"
}
Response:
{
"session_token": "long-lived-session-token",
"expires_at": "2026-04-15T19:44:25Z",
"user": {
"id": "uuid",
"email": "user@example.com"
}
}
"""
def complete_qr_login(conn, params) do
token = params["token"]
device_name = params["device_name"]
device_os = params["device_os"]
app_version = params["app_version"]
push_token = params["push_token"]
push_platform = params["push_platform"]
if token do
device_attrs = %{
device_name: device_name,
device_os: device_os,
app_version: app_version,
push_token: push_token,
push_platform: push_platform
}
case MobileSessions.complete_qr_login(token, device_attrs) do
{:ok, session} ->
session = Towerops.Repo.preload(session, :user)
json(conn, %{
session_token: session.raw_token,
expires_at: session.expires_at,
user: %{
id: session.user.id,
email: session.user.email
}
})
{:error, :invalid_token} ->
conn
|> put_status(:unauthorized)
|> json(%{error: "Invalid or expired token"})
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "Failed to create session", details: translate_errors(changeset)})
end
else
conn
|> put_status(:bad_request)
|> json(%{error: "Missing token parameter"})
end
end
@doc """
GET /api/v1/mobile/auth/session
Returns current session info (requires mobile auth).
Response:
{
"id": "session-uuid",
"device_name": "iPhone 15 Pro",
"device_os": "iOS 17.2",
"app_version": "1.0.0",
"last_used_at": "2026-01-15T19:44:25Z",
"expires_at": "2026-04-15T19:44:25Z"
}
"""
def get_session(conn, _params) do
session = conn.assigns.current_mobile_session
json(conn, %{
id: session.id,
device_name: session.device_name,
device_os: session.device_os,
app_version: session.app_version,
last_used_at: session.last_used_at,
expires_at: session.expires_at
})
end
@doc """
DELETE /api/v1/mobile/auth/session
Revokes the current mobile session (logout).
Response:
{
"success": true
}
"""
def revoke_session(conn, _params) do
session = conn.assigns.current_mobile_session
case MobileSessions.revoke_session(session.id) do
{:ok, _} ->
json(conn, %{success: true})
{:error, _} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Failed to revoke session"})
end
end
end