towerops/lib/towerops_web/controllers/api/mobile_auth_controller.ex
Graham McIntire 532d88ffb9
perf: disable Req retry for faster error tests
- Add retry: false to VISP Client HTTP requests
- Add retry: false to ReleaseChecker GitHub API requests
- Remove unused Plug.Conn import from RemoteIpLogger
- Remove unused default parameter from req_get/2

Results:
- VISP sync test: 7007ms → 113ms (62x faster)
- ReleaseChecker test: 7004ms → 17ms (402x faster)
- UserResetPasswordLive test: 1495ms → 284ms (5x faster)

Req's default retry behavior (1s, 2s, 4s exponential backoff) was
causing 7-second delays for HTTP 500/503 error responses in tests.
For these clients, immediate failure is preferred over retries.
2026-03-10 16:19:31 -05:00

177 lines
4.2 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 ->
# Preload user to return email
qr_token = Towerops.Repo.preload(qr_token, :user)
json(conn, %{
valid: true,
user_email: qr_token.user.email
})
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.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