Hello!
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 is released, 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.
Login
Composing the requirements, we decided to use real people as administrators for authorization, this simplifies the analysis of actions in case of accidental or intentional massive changes.
The Google APIs use the OAuth 2.0 protocol for authentication and authorization. Use cases 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 image below is a schematic description of the selected scenario from the Google page.
- First, we send the user to the authentication page in the Google account, indicating GET parameters:
- application id
- areas that the application needs access to
- address to which the user will be redirected after completion of the procedure
- the way we will update the token
- Security Code
- verification code transmission format
- After authorization is completed, the user will be redirected to the page specified in the first request, with an error or authorization code transmitted by GET parameters
- The application (script) will need to get these parameters and, if the code is received, execute the following request for tokens
- If the request is correct, the Google API returns:
- Access token with which we can make requests
- Validity of this token
- Refresh token needed to update Access token.
First you need to go to the Google API console:
Credentials - Google API Console , select the application you need and in the Credentials section create the client OAuth identifier. 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, it 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 in 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 identifier properties, and the code verifier is a string from 43 to 128 characters in length, which should be randomly generated from non-reserved characters: [AZ] / [az] / [0-9 ] / "-" / "." / "_" / "~".
Further this code will be retransmitted. It eliminates the vulnerability in which an attacker could intercept a response that returned a redirect after user authorization.
You can send the code verifier in the current request in the clear (which makes it pointless - this is suitable only for systems that do not support SHA256), or by creating a hash using the SHA256 algorithm, which must be encoded in BASE64Url (differs from Base64 in two characters of the table) and delete the character line endings: =.
Next, we need to start listening to http on the local machine to get a response after authorization, which will return as a redirect.
Administrative tasks are performed on a special server, we cannot exclude the possibility that several administrators will run the script at the same time, so he will randomly select 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 update the expired token independently without user interaction with the browser,
response_type = code sets the format for how the code will return (referring to the old authorization method when the user copied the code from the browser to the script),
scope indicates the
scope and type of access. They must be separated by spaces or% 20 (according to URL Encoding). A 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 closing message to the browser, stop listening to the port and send a POST request to receive the token. We indicate in it the previously set id and secret from the console API, the address to which the user will be redirected, and grant_type in accordance with the protocol specification.
In response, we will get an Access token, its duration in seconds and a Refresh token, with which we can update the Access token.
The application should store the tokens in a safe 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, but we donβt want to complicate cryptography or often open the browser).
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 may have noticed, when invoking a token, Invoke-WebRequest is used. Unlike Invoke-RestMethod, it does not return the received data in a convenient format for use and shows the status of the request.
Next, the script will ask you to enter the first and last name of the user, generating a username + email.
Inquiries
The following will be the requests - first of all, it is necessary to check whether the user already exists with such a login to get a decision on forming a new one or turning on the current one.
I decided to implement all the 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 type of token and the Access token itself. At the moment, the type of token is always Bearer. Because we need to check that the token is not expired and update it after an hour from the moment it was issued, I indicated 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 }
Email request: $ query will ask the API to search for a user with this email, including aliases will be found. You can also use wildcard:
=,:,: {PREFIX} * .
To obtain data, the GET request method is used, to insert data (create an account or add a member to a group) - POST, to update existing data - PUT, to delete an entry (for example, a participant from a group) - DELETE.
The script will also ask for a phone number (an invalid string) and to include it 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 = 'moscowoffice@rocketguys.com' email = $email } (GoogleQuery 'AddMember' $query) | fl }
The functions of updating and creating an account have the same syntax, not all additional fields are required, in the section with phone numbers you need to specify an array that can contain from 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 whether he is already in this group by receiving a list of group members or composition from the user himself.
A request for the composition of groups of a specific user will not be recursive and will show only direct membership. The inclusion of the user in the parent group, in which the child group of which the user is a member, already exists, will be successful.
Conclusion
It remains to send the user the password for the new account. We do this through SMS, and send general information with instructions and login to personal mail, which, along with the phone number, was provided by the personnel selection department. As an alternative, you can save money and send a password to a 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 writing style of articles and I wish you to catch fewer mistakes when writing scripts =)
List of links that can be thematically useful or just answer your questions: