IDandCollabTools.psm1

<#
    .SYNOPSIS
    A module containing helper functions for common tasks involving Azure AD and M365
#>


function Get-MSALAuthHeader {
    
    <#
        .SYNOPSIS
        Helper function to generate and return on MS Graph auth header using MSAL.PS
 
        .DESCRIPTION
        This scrip uses the MSAL.PS module to get a Bearer token for the MS Graph API.
        It uses the combination of tenant ID, client (app) id and certificate thumbprint to do this.
        As such an existign App Registration is required in Azure AD, and a certificate linked ot that
        App Registration whose private key is installed in the user's Personal Certififcate Store.
 
        The resulting token will have the API permissions assigned to the service principal
        (i.e. the App Registration), so ensure that App Reg has the require permissions for the task.
        Requires the module MSAL.PS
         
        .PARAMETER tenantID
        The tenant ID or DNS name of the tenant to target
 
        .PARAMETER clientID
        The ID of the application to use
 
        .PARAMETER thumbprint
        The thumbprint of the certificate associated with the application
        This certificate must be installed in the user's Personal >> Certificates store on the
        computer running the script
 
        .OUTPUTS
        An authenticaiton header in hashtable format, containing the Bearer token
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [string]
        $tenantID,

        [Parameter(Mandatory=$true)]
        [string]
        $clientID,

        [Parameter(Mandatory=$false)]
        [string]
        $thumbprint,

        [Parameter (Mandatory=$False)]
        [string]
        $secret
    )
    
    # set our connection details depending on which auhtentication method is used

    if (-not [string]::IsNullOrEmpty($secret)) {
        # Set up token request using the secret
        $securesecret = ConvertTo-SecureString -String $secret -AsPlainText -Force
        $connectionDetails = @{
            'TenantId'          = $tenantID
            'ClientId'          = $clientID
            'ClientSecret'      = $securesecret
        }
    } elseif (-not [string]::IsNullOrEmpty($thumbprint)) {
        # Set path to certificate
        $path = "Cert:\CurrentUser\My\" + $thumbprint
        # Set up token request
        $connectionDetails = @{
            'TenantId'          = $tenantID
            'ClientId'          = $clientID
            'ClientCertificate' = Get-Item -Path $path
        }
    }
    else {
        Write-Error "Get-MSALAuthHeader: Neither a secret nor a thumbprint were provided - cannot continue"
        return
    }

    $token = Get-MsalToken @connectionDetails

    # prepare auth header for main query
    $MSALAuthHeader = @{
        'Authorization' = $token.CreateAuthorizationHeader()
    }

    return $MSALAuthHeader
}

function Connect-MgGraphAsMsi {

    <#
        .SYNOPSIS
        Get a Bearer token for MS Graph for a Managed Identity and connect to MS Graph.
 
        .DESCRIPTION
        Intended for use in Azure Automation Runbooks.
        Use the Get-AzAccessToken cmdlet to acquire a Bearer token for the MSI running the
        script/Runbook, then runs Connect-MgGraph using that token to connect the Managed
        Identity to MS Graph via the PowerShell SDK.
 
        Optinally, returns the acquired token.
 
        .NOTES
        When returning the token for use with Connect-MgGraph, you will need to convert the
        token property to a SecureString object
 
        .EXAMPLE
        'Connect-MgGraphAsMsi' -tenantID $tenantID
        Connects the Managed Service Identity running the scripot/Runbook to Azure and MgGraph
 
        .EXAMPLE
        '$MgBearerToken = Connect-MgGraphAsMsi -tenantID $tenantID -ReturnAccessToken -Verbose
        $authHeader = @{
            'Authorization' = "Bearer $($MgBearerToken.token)"
        }'
        1. Connects to Azure using Connect-Az.Account and the MS Graph using Connect-MgGraph,
        returning the token object. Produces messages for each executed step.
        2. Generates an auth header with that token, for e.g. use in direct calls to the MS
        Graph API via Invoke-Webrequest or Invoke-restMethod
 
        .PARAMETER ReturnAccessToken
        Switch - if present, function will return the BearerToken
 
        .PARAMETER tenantID
        the tenant on which to perform the action, used only when debugging
 
        .PARAMETER subscriptionID
        the subscription in which to perform the action, used only when debugging
 
        .OUTPUTS
        Microsoft.Azure.Commands.Profile.Models.PSAccessToken
    #>


    [CmdletBinding()]
    param (

        [Parameter (Mandatory = $False)]
        [Switch] $ReturnAccessToken,

        [Parameter (Mandatory=$False)]
        [string] $tenantID,

        [Parameter (Mandatory=$False)]
        [string] $subscriptionID

    )

    # Connect to Azure as the MSI
    $AzContext = Get-AzContext
    if (-not $AzContext) {
        Write-Verbose "Connect-MsgraphAsMsi: No existing connection, creating fresh connection"
        Connect-AzAccount -Identity
    }
    else {
        Write-Verbose "Connect-MsgraphAsMsi: Existing AzContext found, creating fresh connection"
        Disconnect-AzAccount | Out-Null
        Connect-AzAccount -Identity
        Write-Verbose "Connect-MsgraphAsMsi: Connected to Azure as Managed Identity"
    }

    # Get a Bearer token
    $BearerToken = Get-AzAccessToken -ResourceUrl 'https://graph.microsoft.com/'  -TenantId $tenantID
    # Check that it worked
    $TokenExpires = $BearerToken | Select-Object -ExpandProperty ExpiresOn | Select-Object -ExpandProperty DateTime
    Write-Verbose "Bearer Token acquired: expires at $TokenExpires"

    # Convert the token to a SecureString
    $SecureToken = $BearerToken.Token | ConvertTo-SecureString -AsPlainText -Force

    # check for and close any existing MgGraph connections then create fresh connection
    $MgContext = Get-MgContext
    if (-not $MgContext) {
        Write-Verbose "Connect-MsgraphAsMsi: No existing MgContext found, connecting"
        Connect-MgGraph -AccessToken $SecureToken
    } else {
        Write-Verbose "Connect-MsgraphAsMsi: MgContext exists for account $($MgContext.Account) - creating fresh connection"
        Disconnect-MgGraph | Out-Null
        # Use the SecureString type for connection to MS Graph
        Connect-MgGraph -AccessToken $SecureToken
        Write-Verbose "Connect-MsgraphAsMsi: Connected to MgGraph using token generated by Azure"
    }

    # Check that it worked
    $currentPermissions = Get-MgContext | Select-Object -ExpandProperty Scopes
    Write-Verbose "Access scopes acquired for MgGraph are $currentPermissions"

    if ($ReturnAccessToken.IsPresent) {
        return $BearerToken
    }

}

function Get-AadUsersLastSignin {

    <#
        .SYNOPSIS
        Gets the Last Signin Date and Time for all users in an AzureAd
 
        .DESCRIPTION
        Using native calls to the MS Graph REST API using Invoke-Webrequest,
        this funciton gets all Azure AD users, some of their properties, and
        the Last Signin Date and time for the account.
 
        The LastSigninDateTime property is returned as a DateTime type if present,
        or a string "NONE" if it is empty (i.e. account has never signed in).
 
        Requires a properly-formatted header for auth - see parameters for more
        details.
 
        These properties are returned:
        UserPrincipalName
        DisplayName
        EmployeeID
        Email
        Office
        City
        Country
        AccountEnabled
        AccountType
        UsageLocation
        LastSignInDateTime
 
        .EXAMPLE
        PS> $authHeader = Get-MSALAuthHeader -tenantid contoso.com -clientid <id from your app reg> -thumbprint <certificate thumbprint>
        PS> $LatestSigninData = Get-AadUsersLastSignin -MgGraphHeader $authHeader
        Generate an auth header via the function Get-MSALAuthHeader found in the 'IDandCollabTools' module.
        Connects to MS Graph using Invoke-WebRequest, collects data, and stores it.
        in the object $LatestSigninData for further processing.
        See the note on parameter MgGraphHeader for alternative methods of generating a header if required
 
        .EXAMPLE
        PS> $authHeader = Get-MSALAuthHeader -tenantid contoso.com -clientid <id from your app reg> -thumbprint <certificate thumbprint>
        PS> $LatestSigninData = Get-AadUsersLastSignin -MgGraphHeader $authHeader
        PS> $LatestSigninData | Export-Csv -Path "$((Get-Date).ToString('yyyy-MM-dd'))_LastLoginDate.csv" -NoTypeInformation
        Generate an auth header via the function Get-MSALAuthHeader found in the 'IDandCollabTools' module.
        Connects to MS Graph using Invoke-WebRequest, collects data, and stores it
        in the object $LatestSigninData.
        Exports the collected data to a CSV named "current date + LastLoginDate.csv"
        See the note on parameter MgGraphHeader for alternative methods of generating a header if required
 
        .PARAMETER MgGraphHeader
        An auth header containing a bearer token as Authorization header.
        Can be generated using the object returned by Get-MsalToken in the
        module MSAL.PS, using an App Reg and certificate thumbprint, or the
        functions Connect-MgGraphAsMsi or Get-MSALAuthHeader in the IDandCollabTools module
 
        .OUTPUTS
        A collection containing the collected user data.
    #>


    [CmdletBinding()]
    param (
        [Parameter (Mandatory=$True)]
        $MgGraphHeader
    )

    # Get the first page
    $currentpage = Invoke-WebRequest -Headers $MgGraphHeader -Method GET -Uri "https://graph.microsoft.com/beta/users?`$select=displayName,userPrincipalName,mail,accountEnabled,employeeId,otherMails,usageLocation,officeLocation,city,country,userType,signInActivity" -UseBasicParsing 
    # Exctract the nextlink if it is there
    $nextpage = ($currentpage.Content | ConvertFrom-Json) | Select-Object -ExpandProperty '@odata.nextlink' -ErrorAction SilentlyContinue
    # Store the first page of results
    $results = ($currentpage.Content | ConvertFrom-Json).Value
    # Get subsequent pages and nextlinks if they exist
    while ($nextpage) {
        $currentpage = Invoke-WebRequest -Headers $MgGraphHeader -Uri $nextpage -UseBasicParsing
        $nextpage = ($currentpage.Content | ConvertFrom-Json) | Select-Object -ExpandProperty '@odata.nextlink' -ErrorAction SilentlyContinue
        $results += ($currentpage.Content | ConvertFrom-Json).Value
    }

    $LastLoginData = @()
    # Process the query results so they can be output to file
    $results | ForEach-Object {

        $record = $_
        $signInActivity = $record.SignInActivity
        # convert any string-form date-times into DateTime objects
        Clear-Variable LastLogon -ErrorAction SilentlyContinue
        if ($signInActivity.LastSignInDateTime) { 
            $LastLogon = [Datetime]$signInActivity.LastSignInDateTime 
        } else { 
            $LastLogon = "NONE" 
        }
        # custom object to store combined output from different objects
        $userData = [pscustomobject] @{
            "UserPrincipalName" = $record.userPrincipalName
            "DisplayName" = $record.DisplayName
            "EmployeeID" = $record.employeeId
            "Email" = $record.mail
            "Office" = $record.officeLocation
            "City" = $record.city
            "Country" = $record.country
            "AccountEnabled" = $record.accountEnabled
            "AccountType" = $record.userType
            "UsageLocation" = $record.usageLocation
            "LastSignInDateTime" =  $LastLogon

        }
        $LastLoginData+=$userData   
    }

    return $LastLoginData
}

function Convert-AzureAdSidToObjectId {
    <#
    .SYNOPSIS
    Convert a Azure AD SID to Object ID
      
    .DESCRIPTION
    Converts an Azure AD SID to Object ID.
    Author: Oliver Kieselbach (oliverkieselbach.com)
    The script is provided "AS IS" with no warranties.
      
    .PARAMETER Sid
    The SID to convert
    #>

    
    [CmdletBinding()]
    param([String] $Sid)

    $text = $sid.Replace('S-1-12-1-', '')
    $array = [UInt32[]]$text.Split('-')

    $bytes = New-Object 'Byte[]' 16
    [Buffer]::BlockCopy($array, 0, $bytes, 0, 16)
    [Guid]$guid = $bytes

    return $guid
}

function Convert-AzureAdObjectIdToSid {
    <#
    .SYNOPSIS
    Convert an Azure AD Object ID to SID
      
    .DESCRIPTION
    Converts an Azure AD Object ID to a SID.
    Author: Oliver Kieselbach (oliverkieselbach.com)
    The script is provided "AS IS" with no warranties.
      
    .PARAMETER ObjectID
    The Object ID to convert
    #>


    [CmdletBinding()]
    param([String] $ObjectId)

    $bytes = [Guid]::Parse($ObjectId).ToByteArray()
    $array = New-Object 'UInt32[]' 4

    [Buffer]::BlockCopy($bytes, 0, $array, 0, 16)
    $sid = "S-1-12-1-$array".Replace(' ', '-')

    return $sid
}

function Get-AadDeviceLocalGroupMembers {

    <#
        .SYNOPSIS
        This code compensates for MSFT's unwillingness to fix Get-LocalGroupMember for AzureAD object members.
        If run without parameters, it will return the members of the local Administrators group using the well-known SID.
 
        .DESCRIPTION
        Uses ADSI to fetch all members of a local group.
 
        This code compensates for MSFT's unwillingness to fix Get-LocalGroupMember for AzureAD object members
        Issue here: https://github.com/PowerShell/PowerShell/issues/2996
        Credits:
        @ganlbarone on GitHub for the base code
        @ConfigMgrRSC on Github for the localisation supplement
         
        It will output the SID of AzureAD objects such as roles, groups and users, and any others which cannot be resolved.
        It will also calculate the ObjectID of AzureAD objects and output those.
 
        If run without parameters, it will return the members of the local Administrators group using the well-known SID.
 
        .EXAMPLE
        $results = Get-AadDeviceLocalGroupMembers
        $results
 
        The above will get the members of the local Administrators group using the well-known SID.
        It stores the output of the function in the $results variable, and outputs the results to console
 
        .EXAMPLE
        $results = Get-AadDeviceLocalGroupMembers -targetGroupSID 'S-1-5-32-555'
        $results
 
        The above will get the members of the local Remote Desktop Users group using the well-known SID.
        It stores the output of the function in the $results variable, and outputs the results to console
 
        .EXAMPLE
        $results = Get-AadDeviceLocalGroupMembers -targetGroupName 'Remote Desktop Users'
        $results
 
        The above will get the members of the local Remote Desktop Users group using the group name.
        It stores the output of the function in the $results variable, and outputs the results to console
 
        .OUTPUTS
        System.Management.Automation.PSCustomObject
        Name MemberType Definition
        ---- ---------- ----------
        Equals Method bool Equals(System.Object obj)
        GetHashCode Method int GetHashCode()
        GetType Method type GetType()
        ToString Method string ToString()
        Computer NoteProperty string Computer=Workstation1
        Domain NoteProperty System.String Domain=Contoso
        User NoteProperty System.String User=Administrator
        ObjectID NoteProperty System.String ObjectID=00000000-0000-0000-0000-000000000000
    #>


    [CmdletBinding()]

    param (
        [Parameter (Mandatory=$false)]
        $targetGroupName,

        [Parameter (Mandatory=$false)]
        $targetGroupSID

    )

    # Set our desired $groupName variable dependent on the parameters passed
    if ($null -eq $targetGroupSID -and $null -eq $targetGroupName) {
        # if no parameters are passed, get the name of the local admin group using the well-known SID
        $targetGroupSID = 'S-1-5-32-544'
        [string]$Groupname = (Get-LocalGroup -SID $targetGroupSID)[0].Name
    }
    elseif ($null -ne $targetGroupSID -and $null -eq $targetGroupName) {
        # if the SID is passed but not the name, get the name of the corresponding group
        [string]$Groupname = (Get-LocalGroup -SID $targetGroupSID)[0].Name
    }
    elseif ($null -ne $targetGroupName -and $null -eq $targetGroupSID) {
        # if the name is passed but not the SID, use that
        [string]$Groupname = $targetGroupName
    }
    
    $group = [ADSI]"WinNT://$env:COMPUTERNAME/$Groupname"
    $groupMembers = $group.Invoke('Members') | ForEach-Object {
        $path = ([adsi]$_).path
        $memberSID = $(Split-Path $path -Leaf)
        $AadObjectID = Convert-AzureAdSidToObjectId -Sid $memberSID -ErrorAction SilentlyContinue
        [pscustomobject]@{
            Computer = $env:COMPUTERNAME
            Domain = $(Split-Path (Split-Path $path) -Leaf)
            User = $(Split-Path $path -Leaf)
            ObjectID = $AadObjectID
        }
    }

    return $groupMembers
    
}

function Set-MSDfEDeviceTag {

    <#
         
        .SYNOPSIS
        Sets a registry value (ItemProperty) to a hard-coded string passed in the parameter '-Tag'
 
        .DESCRIPTION
        Sets a registry value (ItemProperty) to a hard-coded string passed in the parameter '-Tag'
        The registry value is used by Microsoft Defender for Endpoint (MSDfE) in the Defender console for filtering
 
        It will determine if the rewquired key exists, and if not create it then set the desired ItemProperty and value
        In case the key exists but the ItemProperty does not, it will be created and set to '$value'
        If the ItemProperty exists but contains an incorrect value, the value will be set to '$value'
 
        .PARAMETER tag
        The tag to be set on the device.
        Should be the same as the Device group targeted by the script for consistency purposes
 
    #>


    [CmdletBinding()]
    param (

        [Parameter(Mandatory=$true)]
        [string]
        $Tag

    )

    # Define the registry path, property name and desired value
    $TagPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows Advanced Threat Protection\DeviceTagging\"
    $Name = "Group"
    # Ensure $value is correct before deployment!!!
    $value = $Tag

    # If the key does not exist, create it, then add the property and value
    If (!(Test-Path $TagPath)) {
        New-Item -Path $TagPath -Force | Out-Null
        New-ItemProperty -Path $TagPath -Name $name -Value $value -PropertyType String -Force | Out-Null
    }

    # If the registry value does not exist but the key does, create the value and add the correct data
    elseif ((Test-Path $TagPath) -and (!(Get-ItemProperty -Path $TagPath -Name $Name -ErrorAction SilentlyContinue))) {
        New-ItemProperty -Path $TagPath -Name $name -Value $value -PropertyType String -Force | Out-Null
    }

    # If the registry key AND value exist, but the value does not contain the required data, edit it
    elseif ((Test-Path $TagPath) -and (Get-ItemProperty -Path $TagPath -Name $Name -ErrorAction SilentlyContinue)) {
        $currentPropertyValue = (Get-ItemProperty -Path $TagPath -Name $Name).$Name
        if ($currentPropertyValue -ne $value) {
            Set-ItemProperty -Path $TagPath -Name $Name -Value $value -Force | Out-Null
        }
    }
}

function Get-RecursiveMgDirectReports {

    <#
        .SYNOPSIS
        Gets an employee reporting hierarchy using the Microsoft.Graph PowerShell cmdlets
 
        .DESCRIPTION
        Uses the Get-MgUsersDirectReport cmdlet to recursively get the details of all people who report
        to a given Manager.
 
        Manager is specified using UPN or objectID using the named parameter.
 
        Use Connect-MgGraph first to establish a connection with the required MS graph API permissions.
        Specific properties are returned in a colleciton of PSObjects for export to CSV or onward processing.
 
        Properties returned are:
        AccountEnabled
        User DisplayName
        EmployeeID
        User UPN
        User Email
        Manager
 
        .EXAMPLE
         
 
        .PARAMETER Manager
        REQUIIRED: the UPN of the initial manager
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]
        $Manager
    )

    $directReports = Get-MgUserDirectReport -UserId $Manager | ForEach-Object {
        Get-MgUser -UserId $_.Id -Property AccountEnabled, DisplayName, EmployeeId, Mail, Manager, UserPrincipalName | Where-Object {$null -ne $_.EmployeeId}
    }

    $Output = foreach ($User in $directReports){
        
        [pscustomobject]@{
            Enabled = $User.AccountEnabled
            UserDisplayName = $User.DisplayName
            EmpID = $user.EmployeeId
            UserUPN = $User.UserPrincipalName
            UserMail = $User.Mail
            Manager = $Manager
        }

        # recurse through each looking for sub-reports
        Get-RecursiveMgDirectReports -Manager $User.UserPrincipalName
    }

    return $Output
}

function Get-GraphAadRoles {
    
    <#
        .SYNOPSIS
        Uses Invoke-RestMethod to fetch a list of Azure AD roles fro the MS Graph API, returning the role name and role id.
         
        .DESCRIPTION
        Uses Invoke-RestMethod to fetch a list of Azure AD roles fro the MS Graph API, returning the role name and role id.
        Whilst MSFT already provide PowerShell cmdlets for this, this method can be used in scenarios where the MSFT cmdlets
        are not available, or where the MSFT cmdlets are not suitable (e.g. when using a certificate for authentication).
 
        Refer to this link for more information on required permissions:
        https://docs.microsoft.com/en-us/graph/api/directoryrole-list?view=graph-rest-1.0&tabs=http
         
        Makes use of the Get-MSALAuthHeader function to generate the authentication header, which in turn makes use of the
        Get-MSALAccessToken function from the module MSAL.PS.
 
        .PARAMETER ClientId
        The ClientId of the Azure AD application that has been granted the required permissions.
 
        .PARAMETER TenantId
        The TenantId of the Azure AD tenant that contains the roles to be fetched.
 
        .PARAMETER CertThumbprint
        The thumbprint of the certificate that has been uploaded to the Azure AD application.
        The corresponding certificate keypair must be installed locally in the CurrentUser\My certificate store.
 
        .EXAMPLE
        PS> $AadRoles = Get-GraphAadRoles -ClientId "00000000-0000-0000-0000-000000000000" -TenantId "00000000-0000-0000-0000-000000000000" -CertThumbprint ABCDEF1234567890ABCDEF1234567890ABCDEF12
 
        .OUTPUTS
        Selected.System.Management.Automation.PSCustomObject
 
    #>


    [CmdletBinding()]

    param(

        [Parameter(Mandatory=$true)]
        [string]
        $ClientId,

        [Parameter(Mandatory=$true)]
        [string]
        $TenantId,

        [Parameter(Mandatory=$False)]
        [string]
        $CertThumbprint,

        [Parameter (Mandatory=$False)]
        [string]
        $secret

    )

    # Generate the authentication header
    if (-not [string]::IsNullOrEmpty($secret)) {
        $header = Get-MSALAuthHeader -ClientId $clientId -TenantId $tenantId -secret $secret
    } elseif (-not [string]::IsNullOrEmpty($CertThumbprint)) {
        $header = Get-MSALAuthHeader -ClientId $clientId -TenantId $tenantId -thumbprint $certThumbprint
    } else {
        Write-Error "Get-GraphAadRoles: Neither a secret nor a thumbprint were provided - cannot continue"
        return
    }

    # Fetch the roles
    $uri = "https://graph.microsoft.com/v1.0/directoryRoles"
    try {
        Write-Verbose "Fetching roles from MS Graph API"
        $roles = Invoke-RestMethod -Uri $uri -Headers $header -Method Get
    }
    catch {
        Write-Error "Failed to fetch roles from MS Graph API. Error: $($_.Exception.Message)"
        return
    }

    if ($roles.value.count -eq 0) {
        Write-Warning "No roles were returned for tenant $tenantId"
        return
    } else {
        Write-Verbose "Fetched $($roles.value.count) roles from MS Graph API"
        $roleData = $roles.value | Select-Object displayName, id
    }
    
    # Return the role data
    return $roleData

}

Export-ModuleMember -Function 'Get-MSALAuthHeader', 'Connect-MgGraphAsMsi', 'Get-AadUsersLastSignin', 'Convert-AzureAdSidToObjectId', 'Convert-AzureAdObjectIdToSid', 'Get-AadDeviceLocalGroupMembers', 'Set-MSDfEDeviceTag', 'Get-RecursiveMgDirectReports','Get-GraphAadRoles'