Creating Google Users from PowerShell via API

Hi!

This article will describe how PowerShell interacts with the Google API to manipulate G Suite users.

In the organization, we use several internal and cloud services. For the most part, authorization in them comes down to Google or Active Directory, between which we cannot maintain a replica, respectively, when a new employee leaves, you need to create / enable an account in these two systems. To automate the process, we decided to write a script that collects information and sends it to both services.

Authorization

When compiling the requirements, we decided to use real human administrators for authorization, this simplifies the analysis of actions in case of accidental or intentional massive changes.

Google APIs use the OAuth 2.0 protocol for authentication and authorization. Usage scenarios and a more detailed description can be found here: Using OAuth 2.0 to Access Google APIs.

I chose the script that is used for authorization in desktop applications. There is also an option to use a service account that does not require unnecessary movements from the user.

The picture below is a schematic description of the selected scenario from the Google page.

Creating Google Users from PowerShell via API

  1. First, we send the user to the Google account authentication page, specifying the GET parameters:
    • application ID
    • areas that the application needs access to
    • the address to which the user will be redirected after the procedure is completed
    • the way we will update the token
    • Security Code
    • verification code transmission format

  2. After authorization is completed, the user will be redirected to the page specified in the first request, with an error or authorization code passed by GET parameters
  3. The application (script) will need to get these parameters and, if the code is received, execute the following request to get tokens
  4. When correctly requested, the Google API returns:
    • Access token with which we can make requests
    • The expiration date of this token
    • Refresh token required to refresh the Access token.

First you need to go to the Google API console: Credentials - Google API Console, select the desired application, and in the Credentials section, create an OAuth client ID. In the same place (or later, in the properties of the created identifier), you need to specify the addresses to which redirection is allowed. In our case, this will be several localhost entries with different ports (see below).

To make it easier to read the script algorithm, you can output the first steps into a separate function that will return Access and refresh tokens for the application:

$client_secret = 'Our Client Secret'
$client_id = 'Our Client ID'
function Get-GoogleAuthToken {
  if (-not [System.Net.HttpListener]::IsSupported) {
    "HttpListener is not supported."
    exit 1
  }
  $codeverifier = -join ((65..90) + (97..122) + (48..57) + 45 + 46 + 95 + 126 |Get-Random -Count 60| % {[char]$_})
  $hasher = new-object System.Security.Cryptography.SHA256Managed
  $hashByteArray = $hasher.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($codeverifier))
  $base64 = ((([System.Convert]::ToBase64String($hashByteArray)).replace('=','')).replace('+','-')).replace('/','_')
  $ports = @(10600,15084,39700,42847,65387,32079)
  $port = $ports[(get-random -Minimum 0 -maximum 5)]
  Write-Host "Start browser..."
  Start-Process "https://accounts.google.com/o/oauth2/v2/auth?code_challenge_method=S256&code_challenge=$base64&access_type=offline&client_id=$client_id&redirect_uri=http://localhost:$port&response_type=code&scope=https://www.googleapis.com/auth/admin.directory.user https://www.googleapis.com/auth/admin.directory.group"
  $listener = New-Object System.Net.HttpListener
  $listener.Prefixes.Add("http://localhost:"+$port+'/')
  try {$listener.Start()} catch {
    "Unable to start listener."
    exit 1
  }
  while (($code -eq $null)) {
    $context = $listener.GetContext()
    Write-Host "Connection accepted" -f 'mag'
    $url = $context.Request.RawUrl
    $code = $url.split('?')[1].split('=')[1].split('&')[0]
    if ($url.split('?')[1].split('=')[0] -eq 'error') {
      Write-Host "Error!"$code -f 'red'
      $buffer = [System.Text.Encoding]::UTF8.GetBytes("Error!"+$code)
      $context.Response.ContentLength64 = $buffer.Length
      $context.Response.OutputStream.Write($buffer, 0, $buffer.Length)
      $context.Response.OutputStream.Close()
      $listener.Stop()
      exit 1
    }
    $buffer = [System.Text.Encoding]::UTF8.GetBytes("Now you can close this browser tab.")
    $context.Response.ContentLength64 = $buffer.Length
    $context.Response.OutputStream.Write($buffer, 0, $buffer.Length)
    $context.Response.OutputStream.Close()
    $listener.Stop()
  }
  Return Invoke-RestMethod -Method Post -Uri "https://www.googleapis.com/oauth2/v4/token" -Body @{
    code = $code
    client_id = $client_id
    client_secret = $client_secret
    redirect_uri = 'http://localhost:'+$port
    grant_type = 'authorization_code'
    code_verifier   = $codeverifier
  }
  $code = $null

We set the Client ID and Client Secret obtained in the OAuth Client ID properties, and the code verifier is a 43 to 128 character string that should be randomly generated from unreserved characters: [AZ] / [az] / [0-9 ] / "-" / "." / "_" / "~".

This code will then be retransmitted. It eliminates a vulnerability in which an attacker can intercept the response returned by the redirect after the user is authorized.
You can send the code verifier in the current request in cleartext (which makes it meaningless - this is only suitable for systems that do not support SHA256), or by creating a hash using the SHA256 algorithm, which must be encoded in BASE64Url (different from Base64 by two table characters) and remove the character line endings: =.

Next, we need to start listening to http on the local machine in order to receive a response after authorization, which will be returned by a redirect.

Administrative tasks are performed on a dedicated server, we can't rule out the possibility that multiple administrators will run the script at the same time, so it will randomly choose a port for the current user, but I specified predefined ports, because. they must also be added as trusted in the API console.

access_type=offline means that the application can renew an expired token on its own without user interaction with the browser,
response_type=code sets the format of how the code will be returned (a reference to the old authorization method, when the user copied the code from the browser into the script),
scope specifies scopes and type of access. They must be separated by spaces or %20 (according to URL Encoding). The list of access areas with types can be seen here: OAuth 2.0 Scopes for Google APIs.

After receiving the authorization code, the application will return a close message to the browser, stop listening on the port and send a POST request to receive the token. We specify in it the previously set id and secret from the console API, the address to which the user will be redirected and the grant_type in accordance with the protocol specification.

In response, we will receive an Access token, its validity period in seconds, and a Refresh token, with which we can update the Access token.

The application must store the tokens in a secure place with a long shelf life, so until we revoke the access received, the refresh token will not be returned to the application. At the end, I added a request to revoke the token, if the application was not completed successfully and the refresh token did not return, it will start the procedure again (we considered it unsafe to store tokens locally on the terminal, and we don’t want to complicate it with cryptography or open the browser often).

do {
  $token_result = Get-GoogleAuthToken
  $token = $token_result.access_token
  if ($token_result.refresh_token -eq $null) {
    Write-Host ("Session is not destroyed. Revoking token...")
    Invoke-WebRequest -Uri ("https://accounts.google.com/o/oauth2/revoke?token="+$token)
  }
} while ($token_result.refresh_token -eq $null)
$refresh_token = $token_result.refresh_token
$minute = ([int]("{0:mm}" -f ([timespan]::fromseconds($token_result.expires_in))))+((Get-date).Minute)-2
if ($minute -lt 0) {$minute += 60}
elseif ($minute -gt 59) {$minute -=60}
$token_expire = @{
  hour = ([int]("{0:hh}" -f ([timespan]::fromseconds($token_result.expires_in))))+((Get-date).Hour)
  minute = $minute
}

As you have already noticed, when revoking a token, Invoke-WebRequest is used. Unlike Invoke-RestMethod, it does not return the received data in a usable format and show the status of the request.

Next, the script asks to enter the user's first and last name, generating a login + email.

Inquiries

The next requests will be - first of all, you need to check if there is already a user with such a login in order to get a decision on the formation of a new one or the inclusion of the current one.

I decided to implement all requests in the format of a single function with a selection using switch:

function GoogleQuery {
  param (
    $type,
    $query
  )
  switch ($type) {
    "SearchAccount" {
      Return Invoke-RestMethod -Method Get -Uri "https://www.googleapis.com/admin/directory/v1/users" -Headers @{Authorization = "Bearer "+(Get-GoogleToken)} -Body @{
        domain = 'rocketguys.com'
        query  = "email:$query"
      }
    }
    "UpdateAccount" {
      $body = @{
        name  = @{
          givenName = $query['givenName']
          familyName = $query['familyName']
        }
        suspended = 'false'
        password = $query['password']
        changePasswordAtNextLogin = 'true'
        phones = @(@{
          primary = 'true'
          value = $query['phone']
          type = "mobile"
        })
        orgUnitPath = $query['orgunit']
      }
      Return Invoke-RestMethod -Method Put -Uri ("https://www.googleapis.com/admin/directory/v1/users/"+$query['email']) -Headers @{Authorization = "Bearer "+(Get-GoogleToken)} -Body (ConvertTo-Json $body) -ContentType 'application/json; charset=utf-8'
    }
    
    "CreateAccount" {
      $body = @{
        primaryEmail = $query['email']
        name  = @{
          givenName = $query['givenName']
          familyName = $query['familyName']
        }
        suspended = 'false'
        password = $query['password']
        changePasswordAtNextLogin = 'true'
        phones = @(@{
          primary = 'true'
          value = $query['phone']
          type = "mobile"
        })
        orgUnitPath = $query['orgunit']
      }
      Return Invoke-RestMethod -Method Post -Uri "https://www.googleapis.com/admin/directory/v1/users" -Headers @{Authorization = "Bearer "+(Get-GoogleToken)} -Body (ConvertTo-Json $body) -ContentType 'application/json; charset=utf-8'
    }
    "AddMember" {
      $body = @{
        userKey = $query['email']
      }
      $ifrequest = Invoke-RestMethod -Method Get -Uri "https://www.googleapis.com/admin/directory/v1/groups" -Headers @{Authorization = "Bearer "+(Get-GoogleToken)} -Body $body
      $array = @()
      foreach ($group in $ifrequest.groups) {$array += $group.email}
      if ($array -notcontains $query['groupkey']) {
        $body = @{
          email = $query['email']
          role = "MEMBER"
        }
        Return Invoke-RestMethod -Method Post -Uri ("https://www.googleapis.com/admin/directory/v1/groups/"+$query['groupkey']+"/members") -Headers @{Authorization = "Bearer "+(Get-GoogleToken)} -Body (ConvertTo-Json $body) -ContentType 'application/json; charset=utf-8'
      } else {
        Return ($query['email']+" now is a member of "+$query['groupkey'])
      }
    }
  }
}

In each request, you need to send an Authorization header containing the token type and the Access token itself. Currently, the token type is always Bearer. Because we need to check that the token is not expired and update it after an hour from the moment of issue, I specified a request for another function that returns an Access token. The same piece of code is at the beginning of the script when receiving the first Access token:

function Get-GoogleToken {
  if (((Get-date).Hour -gt $token_expire.hour) -or (((Get-date).Hour -ge $token_expire.hour) -and ((Get-date).Minute -gt $token_expire.minute))) {
  Write-Host "Token Expired. Refreshing..."
    $request = (Invoke-RestMethod -Method Post -Uri "https://www.googleapis.com/oauth2/v4/token" -ContentType 'application/x-www-form-urlencoded' -Body @{
      client_id = $client_id
      client_secret = $client_secret
      refresh_token = $refresh_token
      grant_type = 'refresh_token'
    })
    $token = $request.access_token
    $minute = ([int]("{0:mm}" -f ([timespan]::fromseconds($request.expires_in))))+((Get-date).Minute)-2
    if ($minute -lt 0) {$minute += 60}
    elseif ($minute -gt 59) {$minute -=60}
    $script:token_expire = @{
      hour = ([int]("{0:hh}" -f ([timespan]::fromseconds($request.expires_in))))+((Get-date).Hour)
      minute = $minute
    }
  }
  return $token
}

Checking the login for existence:

function Check_Google {
  $query = (GoogleQuery 'SearchAccount' $username)
  if ($query.users -ne $null) {
    $user = $query.users[0]
    Write-Host $user.name.fullName' - '$user.PrimaryEmail' - suspended: '$user.Suspended
    $GAresult = $user
  }
  if ($GAresult) {
      $return = $GAresult
  } else {$return = 'gg'}
  return $return
}

The email:$query request will ask the API to look for a user with exactly that email, including aliases. You can also use wildcard: =, :, :{PREFIX}*.

To obtain data, the GET request method is used, to insert data (creating an account or adding a member to a group) - POST, to update existing data - PUT, to delete a record (for example, a member from a group) - DELETE.

The script will also ask for a phone number (non-validated string) and for inclusion in a regional distribution group. It decides which organizational unit the user should have based on the selected Active Directory OU and will come up with a password:

do {
  $phone = Read-Host "Π’Π΅Π»Π΅Ρ„ΠΎΠ½ Π² Ρ„ΠΎΡ€ΠΌΠ°Ρ‚Π΅ +7Ρ…Ρ…Ρ…Ρ…Ρ…Ρ…Ρ…Ρ…"
} while (-not $phone)
do {
    $moscow = Read-Host "Π’ Московский офис? (y/n) "
} while (-not (($moscow -eq 'y') -or ($moscow -eq 'n')))
$orgunit = '/'
if ($OU -like "*OU=Delivery,OU=Users,OU=ROOT,DC=rocket,DC=local") {
    Write-host "Π‘ΡƒΠ΄Π΅Ρ‚ создана Π² /Team delivery"
    $orgunit = "/Team delivery"
}
$Password =  -join ( 48..57 + 65..90 + 97..122 | Get-Random -Count 12 | % {[char]$_})+"*Ba"

And then begins to manipulate the account:

$query = @{
  email = $email
  givenName = $firstname
  familyName = $lastname
  password = $password
  phone = $phone
  orgunit = $orgunit
}
if ($GMailExist) {
  Write-Host "ЗапускаСм ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠ΅ Π°ΠΊΠΊΠ°ΡƒΠ½Ρ‚Π°" -f mag
  (GoogleQuery 'UpdateAccount' $query) | fl
  write-host "НС Π·Π°Π±ΡƒΠ΄ΡŒ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΈΡ‚ΡŒ Π³Ρ€ΡƒΠΏΠΏΡ‹ Ρƒ Π²ΠΊΠ»ΡŽΡ‡Π΅Π½Π½ΠΎΠ³ΠΎ $Username Π² Google."
} else {
  Write-Host "ЗапускаСм созданиС Π°ΠΊΠΊΠ°ΡƒΠ½Ρ‚Π°" -f mag
  (GoogleQuery 'CreateAccount' $query) | fl
}
if ($moscow -eq "y"){
  write-host "ДобавляСм Π² Π³Ρ€ΡƒΠΏΠΏΡƒ moscowoffice"
  $query = @{
    groupkey = '[email protected]'
    email = $email
  }
  (GoogleQuery 'AddMember' $query) | fl
}

The update and account creation functions have a similar syntax, not all additional fields are required, in the section with phone numbers you need to specify an array that can contain at least one record with a number and its type.

In order not to get an error when adding a user to a group, we can first check if he is already in this group by getting a list of group members or the composition from the user himself.

Querying the group membership of a particular user will not be recursive and will only show immediate membership. Including a user in a parent group that already has a child group of which the user is a member will succeed.

Conclusion

It remains to send the user a password for a new account. We do this via SMS, and send general information with instructions and login to personal mail, which, along with a phone number, was provided by the recruitment department. As an alternative, you can save money and send the password to the secret telegram chat, which can also be considered the second factor (macbooks will be an exception).

Thank you for reading to the end. I will be glad to see suggestions for improving the style of writing articles and I wish you to catch fewer mistakes when writing scripts =)

List of links that may be thematically useful or just answer questions:

Source: habr.com

Add a comment