214 lines
8.2 KiB
PowerShell
214 lines
8.2 KiB
PowerShell
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
|
|
}
|