Public/Connect-UnifiController.ps1
|
function Connect-UnifiController { [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [string]$Controller, [int]$Port = 443, # If omitted, a credential prompt will appear [PSCredential]$Credential, # TOTP code for accounts with 2FA enabled. # If not supplied and the controller requires 2FA, you will be prompted interactively. [string]$Token, # Disable TLS certificate validation — required for controllers using self-signed certs [switch]$SkipCertificateCheck, # Persist controller URL, username, and TLS setting to disk (password is never saved) [switch]$Save ) # Normalize to a full URL $baseUrl = if ($Controller -match '^https?://') { $Controller.TrimEnd('/') } else { "https://${Controller}:${Port}" } if (-not $Credential) { $Credential = Get-Credential -Message "Unifi credentials for $baseUrl" if (-not $Credential) { Write-Warning "No credentials provided." return } } # ------------------------------------------------------------------------- # Step 1 — credentials only. TOTP never goes here; it belongs in step 2. # ------------------------------------------------------------------------- $loginBody = @{ username = $Credential.UserName password = $Credential.GetNetworkCredential().Password remember = $true } | ConvertTo-Json -Compress Write-Verbose "Step 1: POST $baseUrl/api/login (user: $($Credential.UserName))" $step1Params = @{ Method = 'POST' Uri = "$baseUrl/api/login" Body = $loginBody ContentType = 'application/json' SessionVariable = 'webSession' ErrorAction = 'Stop' } if ($SkipCertificateCheck) { $step1Params.SkipCertificateCheck = $true } $response = $null try { $response = Invoke-WebRequest @step1Params } catch { $errBody = $null $serverMsg = $null if ($_.ErrorDetails.Message) { try { $errBody = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Stop $serverMsg = $errBody.meta.msg } catch {} } Write-Verbose "Step 1 error: $serverMsg" Write-Verbose "Raw response body: $($_.ErrorDetails.Message)" if ($serverMsg -eq 'api.err.Ubic2faTokenRequired') { # ------------------------------------------------------------------ # Step 2 — re-submit credentials + TOTP. The mfa_cookie JWT from Step 1 # is a server-side signal only and is not sent back. # ------------------------------------------------------------------ $defaultMfaId = $errBody.data[0].default_mfa $authenticators = $errBody.data[0].authenticators Write-Verbose "Step 2: 2FA challenge received (default method: $defaultMfaId)" $totpToken = if ($Token) { Write-Verbose "Using token supplied via -Token parameter" $Token } else { Write-Host "2FA required. Registered authenticators:" -ForegroundColor Yellow foreach ($auth in $authenticators) { $marker = if ($auth.id -eq $defaultMfaId) { ' <-- default' } else { '' } Write-Host " [$($auth.type.ToUpper())] $($auth.name)$marker" } Read-Host "Enter your 2FA code" } # Re-send full credentials with the TOTP code appended. # mfa_cookie is NOT sent back — it was a server signal only. # strict:true is required by the Ubiquiti SSO flow. $mfaBody = @{ username = $Credential.UserName password = $Credential.GetNetworkCredential().Password remember = $true strict = $true ubic_2fa_token = $totpToken } | ConvertTo-Json -Compress Write-Verbose "Step 2: POST $baseUrl/api/login (fields: username, password, remember, strict, ubic_2fa_token)" $step2Params = @{ Method = 'POST' Uri = "$baseUrl/api/login" Body = $mfaBody ContentType = 'application/json' SessionVariable = 'webSession' ErrorAction = 'Stop' } if ($SkipCertificateCheck) { $step2Params.SkipCertificateCheck = $true } try { $response = Invoke-WebRequest @step2Params } catch { $mfaMsg = $null if ($_.ErrorDetails.Message) { try { $mfaMsg = ($_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Stop).meta.msg } catch {} } Write-Error "2FA verification failed$(if ($mfaMsg) { " ($mfaMsg)" }). The code may have expired — try again immediately after reading it from your authenticator." Write-Verbose "Raw step 2 exception: $_" return } } else { # Not a 2FA challenge — report the error cleanly if ($serverMsg) { switch -Exact ($serverMsg) { 'api.err.Invalid' { Write-Error "Login failed: invalid credentials for $baseUrl.`nIf using a Ubiquiti cloud account, the username is your SSO email address." break } default { Write-Error "Login failed ($serverMsg) on $baseUrl." } } } else { Write-Error "Connection to $baseUrl failed. Run with -Verbose for details." } return } } # ------------------------------------------------------------------------- # Both step 1 (no 2FA) and step 2 (2FA complete) land here # ------------------------------------------------------------------------- $parsed = $response.Content | ConvertFrom-Json if ($parsed.meta.rc -ne 'ok') { Write-Error "Login rejected: $($parsed.meta.msg)" return } # Dump all response headers and cookies under -Verbose to aid CSRF debugging Write-Verbose "Login response headers:" foreach ($h in $response.Headers.GetEnumerator()) { Write-Verbose " $($h.Key): $($h.Value -join ', ')" } Write-Verbose "Session cookies for $baseUrl :" foreach ($c in $webSession.Cookies.GetCookies([uri]$baseUrl)) { $preview = if ($c.Value.Length -gt 60) { $c.Value.Substring(0,60) + '...' } else { $c.Value } Write-Verbose " $($c.Name) = $preview" } # UniFi requires X-Csrf-Token header on all write operations (PUT/POST/DELETE). # The token is returned at login — check the response header first, then cookies. $csrfToken = $null $csrfHeader = $response.Headers['X-Csrf-Token'] if ($csrfHeader) { $csrfToken = @($csrfHeader)[0] Write-Verbose "CSRF token found in response header." } if (-not $csrfToken) { $csrfCookie = $webSession.Cookies.GetCookies([uri]$baseUrl) | Where-Object Name -eq 'csrf_token' | Select-Object -First 1 if ($csrfCookie) { $csrfToken = $csrfCookie.Value Write-Verbose "CSRF token found in 'csrf_token' cookie." } } if (-not $csrfToken) { Write-Verbose "WARNING: No CSRF token found — write operations may return 401." } $script:UnifiSession = $webSession $script:UnifiConfig = @{ ControllerUrl = $baseUrl Username = $Credential.UserName DefaultSite = 'default' SkipCertificateCheck = $SkipCertificateCheck.IsPresent ConnectedAt = (Get-Date -Format 'o') CsrfToken = $csrfToken } if ($Save) { Write-UnifiConfig -Config $script:UnifiConfig Write-Verbose "Config saved to $(Get-UnifiConfigPath)" } Write-Host "Connected to $baseUrl as $($Credential.UserName)" -ForegroundColor Green } |