- 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.
175 lines
4.3 KiB
Elixir
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
|