Critical fixes: - Add [:safe] option to binary_to_term to prevent RCE attacks - Implement whitelist validation for String.to_atom conversions - Add input validation before String.to_existing_atom usage Changes: - MIB compiler and cache: Use safe binary deserialization - SNMP contexts: Whitelist protocol, device type, and source atoms - API controllers: Validate error message keys before atom conversion - Reduce function nesting to comply with Credo standards All 6,145 tests passing with zero Credo issues. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
194 lines
4.7 KiB
Elixir
194 lines
4.7 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
|
|
|
|
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
|
|
|
|
# Helper to translate changeset errors
|
|
defp translate_errors(changeset) do
|
|
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
|
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
|
|
safe_translate_key(key, opts)
|
|
end)
|
|
end)
|
|
end
|
|
|
|
# Safely translate error message keys to prevent atom exhaustion
|
|
defp safe_translate_key(key, opts) do
|
|
# Validate key length and format before converting to atom to prevent abuse
|
|
if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do
|
|
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
|
else
|
|
key
|
|
end
|
|
end
|
|
end
|