christaylorcodes / ConnectWiseControlAPI

PowerShell wrapper for ConnectWise Control
MIT License
69 stars 36 forks source link

Hosted instance requires MFA #12

Open sdubin2 opened 2 years ago

sdubin2 commented 2 years ago

"To protect your users, you are required to turn on two-factor authentication for all of your internal users."

Your module says: "Requires an account without MFA. Use a complex username and password."

Will you be able to implement MFA into your module?

tcsi-github commented 2 years ago

I am curious about this as well. Is there a plan or workaround for this?

mrmattipants commented 2 years ago

While Researching & Investigating potential MFA/2FA related processes, I believe that I may have stumbled upon what are essentially API Endpoints, in the “script.ashx” File, that is associated with the current Control Login Page.

In fact, these EndPoints are extremely similar to those found in the following ConnectWise Control Documentation.

ConnectWise Control - External API Calls: https://docs.connectwise.com/ConnectWise_Control_Documentation/Developers/External_API_calls_to_ConnectWise_Control

With that being said, I thought I’d upload the “Script.ashx” File to my own Public Repo, for your Review, as I thought that it might just help your Team, as far as the development of an MFA/2FA Supported Module, which is something that is desperately needed by many.

ConnectWise Control - Script.ashx: https://raw.githubusercontent.com/mrmattipants/ConnectWiseControlAPI/main/Script.ashx

In particular, I’d like to direct your gaze to the following “TryLogin” Function, which contains a “oneTimePassword” Parameter, among a few others, which appear to be MFA/2FA Related.

TryLogin":function (userName, password, oneTimePassword, shouldTrust, securityNonce, onSuccess, onFailure, userContext, userNameOverride, passwordOverride) { return SC.http.invokeService('Services/AuthenticationService.ashx', 'TryLogin', [userName, password, oneTimePassword, shouldTrust, securityNonce], onSuccess, onFailure, userContext, userNameOverride, passwordOverride);

Regardless of whether you can utilize this Information or Not, I figured that it was, at the very least, something worth sharing.

Luke-Williams9 commented 2 years ago

I've been working on this today, and I think I got it. You can still use the same basic auth that the module uses, but add 'X-One-Time-Password' to the header, with the 6 digit OTP as its value.

The module needs an extra function like 'Get-GoogleAuthenticatorPin' from this module: https://github.com/HumanEquivalentUnit/PowerShell-Misc/blob/master/GoogleAuthenticator.psm1

I'm still testing, but the header of each request sent to CWC needs to have the current OTP in it.

All of this are modifications I've been adding to this module locally. I'd be happy to submit the changes here if you want - I'm just new to working with git repositories in general, so I'm not sure how to go about it.

Luke-Williams9 commented 1 year ago

Please update this module to support MFA - its basically useless now without. Here is what I manually did to your module, to make it work:

Modified /Public/Authentication/Connect-CWC.ps1

function Connect-CWC {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]$Server,
        [Parameter(Mandatory = $True)]
        [pscredential]$Credentials,
        [string]$secret,
        [switch]$Force
    )

    if ($script:CWCServerConnection -and !$Force) {
        Write-Verbose "Using cached Authentication information."
        return
    }

    $Server = $Server -replace("http.*:\/\/",'')
    $EncodedCredentials = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes("$($Credentials.UserName):$($Credentials.GetNetworkCredential().Password)"))
    $Headers = @{
        'authorization' = "Basic $EncodedCredentials"
        'content-type' = "application/json; charset=utf-8"
        'X-One-Time-Password' = (Get-OTP $secret).code
        'origin' = "https://$Server"
    }

    $FrontPage = Invoke-WebRequest -Uri $Headers.origin -Headers $Headers -UseBasicParsing
    $Regex = [Regex]'(?<=antiForgeryToken":")(.*)(?=","isUserAdministrator)'
    $Match = $Regex.Match($FrontPage.content)
    if($Match.Success){ $Headers.'x-anti-forgery-token' = $Match.Value.ToString() }
    else{ Write-Verbose 'Unable to find anti forgery token. Some commands may not work.' }
    $script:CWCServerConnection = @{
        Server = $Server
        Headers = $Headers
        Secret = $secret
    }
    Write-Verbose ($script:CWCServerConnection | Out-String)

    try{
        $null = Get-CWCSessionGroup -ErrorAction Stop
        Write-Verbose '$CWCServerConnection, variable initialized.'
    }
    catch{
        Remove-Variable CWCServerConnection -Scope script
        Write-Verbose 'Authentication failed.'
        Write-Error $_
    }
}

Added new private function /Private/Get-OTP.ps1 Thanks to https://github.com/HumanEquivalentUnit/PowerShell-Misc/blob/master/GoogleAuthenticator.psm1

function Get-OTP {
    [CmdletBinding()]
    Param (
        # BASE32 encoded Secret e.g. 5WYYADYB5DK2BIOV
        [Parameter(Mandatory=$true,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        [string]
        $Secret,

        # OTP time window in seconds
        $TimeWindow = 30
    )

    $Base32Charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
    # Convert the secret from BASE32 to a byte array
    # via a BigInteger so we can use its bit-shifting support,
    # instead of having to handle byte boundaries in code.
    $bigInteger = [Numerics.BigInteger]::Zero
    foreach ($char in ($secret.ToUpper() -replace '[^A-Z2-7]').GetEnumerator()) {
        $bigInteger = ($bigInteger -shl 5) -bor ($Base32Charset.IndexOf($char))
    }

    [byte[]]$secretAsBytes = $bigInteger.ToByteArray()

    # BigInteger sometimes adds a 0 byte to the end,
    # if the positive number could be mistaken as a two's complement negative number.
    # If it happens, we need to remove it.
    if ($secretAsBytes[-1] -eq 0) {
        $secretAsBytes = $secretAsBytes[0..($secretAsBytes.Count - 2)]
    }

    # BigInteger stores bytes in Little-Endian order, 
    # but we need them in Big-Endian order.
    [array]::Reverse($secretAsBytes)

    # Unix epoch time in UTC and divide by the window time,
    # so the PIN won't change for that many seconds
    $epochTime = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()

    # Convert the time to a big-endian byte array
    $timeBytes = [BitConverter]::GetBytes([int64][math]::Floor($epochTime / $TimeWindow))
    if ([BitConverter]::IsLittleEndian) { 
        [array]::Reverse($timeBytes) 
    }

    # Do the HMAC calculation with the default SHA1
    # Google Authenticator app does support other hash algorithms, this code doesn't
    $hmacGen = [Security.Cryptography.HMACSHA1]::new($secretAsBytes)
    $hash = $hmacGen.ComputeHash($timeBytes)

    # The hash value is SHA1 size but we want a 6 digit PIN
    # the TOTP protocol has a calculation to do that
    #
    # Google Authenticator app may support other PIN lengths, this code doesn't

    # take half the last byte
    $offset = $hash[$hash.Length-1] -band 0xF

    # use it as an index into the hash bytes and take 4 bytes from there, #
    # big-endian needed
    $fourBytes = $hash[$offset..($offset+3)]
    if ([BitConverter]::IsLittleEndian) {
        [array]::Reverse($fourBytes)
    }

    # Remove the most significant bit
    $num = [BitConverter]::ToInt32($fourBytes, 0) -band 0x7FFFFFFF

    # remainder of dividing by 1M
    # pad to 6 digits with leading zero(s)
    # and put a space for nice readability
    $PIN = ($num % 1000000).ToString().PadLeft(6, '0')

    [PSCustomObject]@{
        'code' = $PIN
        'timeout' = ($TimeWindow - ($epochTime % $TimeWindow))
    }
}
jonwbstr commented 1 year ago

The Headers hashtable stores the value generated at connection time, so the connection is only good for 60s. Not sure how to update the OTP each time a command is run.

jonwbstr commented 1 year ago

In \Private\Invoke-CWCWebRequest.ps1, I added this to always get a new OTP code.

$script:cwcserverconnection.Headers.'X-One-Time-Password' = $(Get-OTP -Secret $script:cwcserverconnection.Secret).Code
        return Write-Error ($ErrorMessage | Out-String)
    }

+   $script:cwcserverconnection.Headers.'X-One-Time-Password' = $(Get-OTP -Secret $script:cwcserverconnection.Secret).Code
    $BaseURI = "https://$($script:CWCServerConnection.Server)"
    $Arguments.URI = Join-Url $BaseURI $Arguments.Endpoint
    $Arguments.remove('Endpoint')
zanderson-aim commented 1 year ago

These updates still working? Running Version 23.2.9.8466 and I get a 401 access denied error. Confirmed the OTP code matches the authenticator app, and I can login with user/password/code just fine through the website.

Found my problem it was permissions, solution here

Luke-Williams9 commented 1 year ago

@zanderson-aim I've created a fork of this project. https://github.com/Luke-Williams9/ConnectWiseControlAPI

I just put it up today, so it may not be bug-free yet. Its working for me though. Let me know how it goes.

mrmattipants commented 6 months ago

I didn't realize that there had actually been some progress, in regard to this Issue. I will test-out all of the suggestions and see if there is anything I can do, to contribute, as well.

A huge "Thank You!" to Luke-Williams9 and jonwbstr. I greatly appreciate the work you did, on this Issue.

Of course, this isn't my Repo, but this API will definitely be useful, as I need to cleanup and implement a large number of updates, in my employer's CWC Environment. I'm hoping that this API is going to help streamline those tasks.

Szeraax commented 2 months ago

I am loving this module, Thanks Chris so much for all your work. I feel the exact same way that Luke does: This module MUST support MFA. As such, I guess I'll uninstall it and download the one from Luke instead.

xxxmtixxx commented 2 months ago

I am maintaining a fork with changes from Chris and Luke, if you care to check it out.

https://github.com/xxxmtixxx/ConnectWiseControlAPI

I've also included a script to create a user and assign a machine. Please let me know if you test and how it works for you.

Szeraax commented 2 months ago

Good news: I figured out how to get this to work using a real WebSession! Works with Email or Authy or Google OTP or Yubikey too :D And its really pretty darn simple.

Szeraax commented 2 months ago

I went the same route that @mrmattipants was pointing after inspecting the login process and implemented TryLogin. As such, the module with this patch now uses a WebSession to stay authenticated rather than a collection of headers. This also means that when you do MFA one time to login, you don't need to do MFA again until the session expires.

Future work could include things such as: using the WebSession and doing the password again without having to do the MFA (like the "trust this computer for 7 days" option that appears in web) or allow for the export/saving of the WebSession to disk so that you can password auth and not need MFA auth for several days or across powershell sessions. But I wanted to keep this patch small so that it was as easy to digest as possible.

Here's the commit: https://github.com/Szeraax/ConnectWiseControlAPI/commit/ee2a59086b77ab79fb1c93dc91eb2191ce766d31

If there is interest, I will release this as a separate module since we don't see Chris being too active with this module. Again, mad props to all the work that Chris has done to make this module. It is working awesome!

Szeraax commented 2 months ago

Alright, I've been busy today. I present, ConnectWiser: A wiser way to use ConnectWise Control. Github link.

Supports non-interactive use like so:

Connect-CWC -Server contoso.screenconnect.com -Credentials $cred -OtpCode abcdeabcdeabceabceabceabceabcebacbdea

Should also support 6-digit codes to sign in and then NOT PROMPT you about them every minute :D. If you use email for 2nd auth factor, it'll prompt you for the code that you get from the instance after you attempt to login. I'm really happy with how easy it has been to extend what Chris has written thus far. PRs/issues welcome.