From 2a7897c0d3dd6fd71496a73e8767e71b6e98fb8e Mon Sep 17 00:00:00 2001 From: Ed Bardsley Date: Tue, 29 Jun 2021 10:23:25 -0700 Subject: [PATCH] AoG support for the "Auth Code" OAuth flows. It appears that Implicit flows are not available for new projects, so this implements support for Auth Code flows. It: - adds support for grant_type=authentication_code in /auth - adds a /tokens endpoint to implement token exchange - adds support for expiration to tokens and simplifies map handling a bit - fixes argument parsing to use CGI, which correctly handles things like URLs in POST request arguments - runs perltidy This follows the flow outlined at https://developers.google.com/assistant/identity/oauth2 --- lib/AoGSmartHome_Items.pm | 18 +-- lib/http_server_aog.pl | 271 +++++++++++++++++++++++++++++--------- 2 files changed, 222 insertions(+), 67 deletions(-) diff --git a/lib/AoGSmartHome_Items.pm b/lib/AoGSmartHome_Items.pm index c94ba3623..b81cdbce0 100644 --- a/lib/AoGSmartHome_Items.pm +++ b/lib/AoGSmartHome_Items.pm @@ -20,7 +20,9 @@ provider. aog_auth_path = /oauth # OAuth URI aog_fulfillment_url = /aog # Fulfillment URI aog_client_id = # OAuth client ID + aog_client_secret = # OAuth client secret aog_oauth_token_file = xxxxxx # OAuth token file + aog_oauth_codes_file = xxxxxx # OAuth "code" file (code and refresh_tokens) aog_project_id = xxxxxxx # Google project ID aog_uuid_start = x # UUID start aog_agentuserid = xxxxx # Agent User ID (optional but recommended) @@ -62,11 +64,11 @@ good with the defaults, you can add an object like: # In MHT - AOGSMARTHOME_ITEM, AoGSmartHomeItems, light1 + AOGSMARTHOME_ITEM, AoGSmartHomeItems, light1 # or in user code - $AoGSmartHomeItems->add('$light1'); + $AoGSmartHomeItems->add('$light1'); - This defaults to using the without the $. If want to change the name you say to the @@ -93,12 +95,12 @@ objects. The dim % is the actual number you say to Alexa, so if you say "Alexa,Set Light 1 to 75 %" then the dim % value will be 75. -The module supports 300 devices which is the max supported by the Echo +The module supports 300 devices which is the max supported by the Echo =head2 Complete Examples MHT examples: - + AOGSMARTHOME_ITEMS, AoGSmartHomeItems AOGSMARTHOME_ITEM, AoGSmartHomeItems, light1 light1, set, on, off, state # these are the defaults AOGSMARTHOME_ITEM, AoGSmartHomeItems, light1 # same as the line above @@ -119,7 +121,7 @@ To change the name of an object to a more natural name that you would say to the To map a voice command, '!' is replaced by the Echo/GH command (on/off/dim%). My actual voice command in MH is "set night mode on", so I configure it like: - $AoGSmartHomeItems->add('set night mode !','NightMode','run_voice_cmd'); + $AoGSmartHomeItems->add('set night mode !','NightMode','run_voice_cmd'); If I say "Alexa, Turn on Night Mode", run_voice_cmd("set night mode on") is run in MH. @@ -146,7 +148,7 @@ When the sub is run 2 arguments are passed to it: Argument 1 is (state or set) A AOGSMARTHOME_ITEM, AoGSmartHomeItems, thermostat, Cool, cool_setpoint, on, off, get_cool_sp In order to be able to say things like "Alexa, set thermostat up by 2", a sub must be created in user code -When the above is said to the Echo, it first gets the current state, then subtracts or adds the amount that was said. +When the above is said to the Echo, it first gets the current state, then subtracts or adds the amount that was said. sub temperature { my ($type, $state) = @_; @@ -156,7 +158,7 @@ When the above is said to the Echo, it first gets the current state, then subtra # we are changing heat and cool so just return a static number, we just need the diff # because the Echo will add or subtact the amount that was said to it. - # so if we say "set thermostat up by 2", 52 will be returned in $state + # so if we say "set thermostat up by 2", 52 will be returned in $state if ($type eq 'state') { return 50; } return '' unless ($state =~ /\d+/); Make sure we have a number @@ -172,7 +174,7 @@ When the above is said to the Echo, it first gets the current state, then subtra set $alexa_temp_timer '7', sub { $thermostat->heat_setpoint($heatsp) } } -# Map our new temperature sub in the .mht file so the Echo/Google Home can discover it +# Map our new temperature sub in the .mht file so the Echo/Google Home can discover it AOGSMARTHOME_ITEM, AoGSmartHomeItems, thermostat, thermostat, &temperature diff --git a/lib/http_server_aog.pl b/lib/http_server_aog.pl index cfcde2cde..36a1633e1 100644 --- a/lib/http_server_aog.pl +++ b/lib/http_server_aog.pl @@ -30,31 +30,44 @@ =head2 METHODS =cut +use strict; + +use CGI; use Config; use MIME::Base64; -use JSON qw(decode_json); +use JSON qw(encode_json decode_json); use Storable; -use constant RANDBITS => $Config{randbits}; -use constant RAND_MAX => 2**RANDBITS; +use constant RANDBITS => $Config{randbits}; +use constant RAND_MAX => 2**RANDBITS; +use constant ACCESS_TOKEN_EXPIRATION_SECONDS => 24 * 60 * 60; +use constant NEVER_EXPIRES => eval( $Config{nv_overflows_integers_at} ); # Cache of OAuth authentication tokens. Persistent tokens are stored # in $::config_parms{'aog_oauth_tokens_file'} and read on startup. +# +# $oauth_tokens is for implicit tokens and access tokens. +# +# $oauth_codes is for authorization_codes and refresh_tokens that cannot be +# used for fulfillment. +my $FILENAME_PARAMETER = "_FILENAME_PARAMETER"; my $oauth_tokens; +my $oauth_codes; sub http_server_aog_startup { - if ( !$::config_parms{'aog_enable'}) { - &main::print_log("[AoGSmartHome] AoG is disabled."); - return; - } else { - &main::print_log("\n[AoGSmartHome] AoG is enabled; will look for AoG requests via HTTP."); + if ( !$::config_parms{'aog_enable'} ) { + &main::print_log("[AoGSmartHome] AoG is disabled."); + return; + } + else { + &main::print_log("\n[AoGSmartHome] AoG is enabled; will look for AoG requests via HTTP."); } # We don't want defaults for these important parameters so we disable # AoG integration if one or more are missing. if ( !defined $::config_parms{'aog_auth_path'} - || !defined $::config_parms{'aog_fulfillment_url'} - || !defined $::config_parms{'aog_client_id'} - || !defined $::config_parms{'aog_project_id'} ) + || !defined $::config_parms{'aog_fulfillment_url'} + || !defined $::config_parms{'aog_client_id'} + || !defined $::config_parms{'aog_project_id'} ) { print STDERR "[AoGSmartHome] AoG is enabled but one or more .ini file parameters are missing; disabling AoG!\n"; print STDERR "[AoGSmartHome] Required .ini file parameters: aog_auth_path aog_fulfillment_url aog_client_id aog_project_id\n"; @@ -64,19 +77,29 @@ sub http_server_aog_startup { $::config_parms{'aog_oauth_tokens_file'} = "$config_parms{data_dir}/.aog_tokens" if !defined $::config_parms{'aog_oauth_tokens_file'}; + $::config_parms{'aog_oauth_codes_file'} = "$config_parms{data_dir}/.aog_codes" + if !defined $::config_parms{'aog_oauth_codes_file'}; if ( -e $::config_parms{'aog_oauth_tokens_file'} ) { $oauth_tokens = retrieve( $::config_parms{'aog_oauth_tokens_file'} ); } + if ( -e $::config_parms{'aog_oauth_codes_file'} ) { + $oauth_codes = retrieve( $::config_parms{'aog_oauth_codes_file'} ); + } + $oauth_tokens->{$FILENAME_PARAMETER} = 'aog_oauth_tokens_file'; + $oauth_codes->{$FILENAME_PARAMETER} = 'aog_oauth_codes_file'; + remove_expired_tokens($oauth_tokens); + remove_expired_tokens($oauth_codes); if ( $main::Debug{'aog'} ) { - print STDERR < $style @@ -114,6 +137,7 @@ ($) EOF + } my $html_head = "HTTP/1.1 $http_response\r\n"; $html_head .= "Server: MisterHouse\r\n"; @@ -124,6 +148,61 @@ ($) return $html_head . $html_body; } +sub check_token($$) { + my ( $token, $token_map ) = @_; + return undef if ( !exists $token_map->{$token} ); + my ( $value, $expiration ) = @{ $token_map->{$token} }; + if ( time() >= $expiration ) { + print "[AoGSmartHome] Debug: token '$token' expired at $expiration, removing it.\n" + if $main::Debug{'aog'}; + delete $token_map->{$token}; + store $token_map, $::config_parms{ $token_map->{$FILENAME_PARAMETER} }; + return undef; + } + return $value; +} + +sub remove_expired_tokens($) { + my ($token_map) = @_; + my $now = time(); + foreach my $t ( keys %{$token_map} ) { + next if ( $t eq $FILENAME_PARAMETER ); + my ( $value, $expiration ) = @{ $token_map->{$t} }; + if ( !$expiration ) { # Probably a legacy token + $expiration = NEVER_EXPIRES; + print "[AoGSmartHome] Debug: token '$t' has no expiration, setting to $expiration.\n" + if $main::Debug{'aog'}; + $token_map->{$t} = [ $value, $expiration ]; + } + if ( $now >= $expiration ) { + print "[AoGSmartHome] Debug: token '$t' expired at $expiration, removing it.\n" + if $main::Debug{'aog'}; + delete $token_map->{$t}; + } + } + store $token_map, $::config_parms{ $token_map->{$FILENAME_PARAMETER} }; +} + +sub generate_new_token($$$) { + my ( $value, $expiration, $token_map ) = @_; + my $token; + + # We didn't find an existing token for the authenticated user; + # generate a new token (making sure token is unique). + do { + $token = encode_base64( int rand(RAND_MAX), '' ); + } while ( exists $token_map->{$token} ); + + $token_map->{$token} = [ $value, $expiration ]; + + print "[AoGSmartHome] Debug: generated token '$token' for '$value' (expiration $expiration).\n" + if $main::Debug{'aog'}; + + store $token_map, $::config_parms{ $token_map->{$FILENAME_PARAMETER} }; + + return $token; +} + sub process_http_aog { my ( $uri, $request_type, $body, $socket, %Http ) = @_; my $html; @@ -136,59 +215,67 @@ sub process_http_aog { return 0; } + my $argv = \%HTTP_ARGV; + if ( $request_type eq 'POST' ) { + + # The merging in http_server.pl uses a regular expression that excludes lots of valid parts + # of application/x-www-form-urlencoded bodies, such as a full URL in redirect_url, or + # slashes in the client ids or secrets. Using CGI directly is more robust; + $argv = scalar CGI->new($body)->Vars(); + } + if ( $uri eq $::config_parms{'aog_auth_path'} ) { print "[AoGSmartHome] Debug: Processing OAuth request.\n" if $main::Debug{'aog'}; if ( $request_type eq 'POST' ) { print "[AoGSmartHome] Debug: Processing HTTP POST.\n" if $main::Debug{'aog'}; - if ( !exists $HTTP_ARGV{'password'} ) { + if ( !exists $argv->{'password'} ) { &main::print_log("[AoGSmartHome] missing 'password' argument in HTTP POST"); return http_error("400 Bad Request"); } - $Authorized = password_check( $HTTP_ARGV{'password'}, 'http' ); + $Authorized = password_check( $argv->{'password'}, 'http' ); if ( !$Authorized ) { $html = "

Login failed.

\n"; } } - if ( !exists $HTTP_ARGV{'client_id'} ) { + if ( !exists $argv->{'client_id'} ) { &main::print_log("[AoGSmartHome] client_id parameter missing from OAuth request."); return http_error("400 Bad Request"); } - if ( $HTTP_ARGV{'client_id'} ne $::config_parms{'aog_client_id'} ) { - &main::print_log( - "[AoGSmartHome] Received client_id \'$HTTP_ARGV{'client_id'}\' does not match our client_id \'$::config_parms{'aog_client_id'}\'."); + if ( $argv->{'client_id'} ne $::config_parms{'aog_client_id'} ) { + &main::print_log("[AoGSmartHome] Received client_id \'$argv->{'client_id'}\' does not match our client_id \'$::config_parms{'aog_client_id'}\'."); return http_error("400 Bad Request"); } - if ( !exists $HTTP_ARGV{'state'} ) { + if ( !exists $argv->{'state'} ) { &main::print_log("[AoGSmartHome] state parameter missing from OAuth request."); return http_error("400 Bad Request"); } - if ( !exists $HTTP_ARGV{'redirect_uri'} ) { + if ( !exists $argv->{'redirect_uri'} ) { &main::print_log("[AoGSmartHome] redirect_uri parameter missing from OAuth request."); return http_error("400 Bad Request"); } # Verify "redirect_uri" value - if ( $HTTP_ARGV{'redirect_uri'} !~ m%https://oauth-redirect.googleusercontent.com/r/$::config_parms{'project_id'}% ) { + if ( $argv->{'redirect_uri'} !~ m%https://oauth-redirect.googleusercontent.com/r/$::config_parms{'project_id'}% ) { &main::print_log("[AoGSmartHome] invalid redirect_uri (should be \"https://oauth-redirect.googleusercontent.com/r/$::config_parms{'project_id'}\""); return http_error("400 Bad Request"); } - if ( !exists $HTTP_ARGV{'response_type'} ) { + if ( !exists $argv->{'response_type'} ) { &main::print_log("[AoGSmartHome] response_type parameter missing from OAuth request."); return http_error("400 Bad Request"); } - if ( $HTTP_ARGV{'response_type'} ne 'token' ) { + if ( $argv->{'response_type'} ne 'token' && $argv->{'response_type'} ne 'code' ) { &main::print_log( - "[AoGSmartHome] Invalid response_type \'$HTTP_ARGV{'response_type'}\' in OAuth request; must be 'token' for OAuth 2.0 implicit flow."); + "[AoGSmartHome] Invalid response_type \'$argv->{'response_type'}\' in OAuth request; must be 'token' or 'code' for OAuth 2.0 flow."); return http_error("400 Bad Request"); } @@ -202,10 +289,10 @@ sub process_http_aog { Password: - - - - + + + +

This form is used for logging into MisterHouse.

@@ -218,37 +305,102 @@ sub process_http_aog { # User is authenticated. # - my $token; + if ( $argv->{'response_type'} eq 'token' ) { + my $token = generate_new_token( $Authorized, NEVER_EXPIRES, $oauth_tokens ); + return http_redirect("$argv->{'redirect_uri'}#access_token=$token&token_type=bearer&state=$argv->{'state'}"); - foreach my $t ( keys %{$oauth_tokens} ) { - if ( $oauth_tokens->{$t} eq $Authorized ) { - print "[AoGSmartHome] Debug: found token '$t' for user '$Authorized'\n" - if $main::Debug{'aog'}; - $token = $t; - last; - } } + elsif ( $argv->{'response_type'} eq 'code' ) { + my $code = generate_new_token( + $Authorized, time() + 600, # initial code is valid for 10m + $oauth_codes, + ); + return http_redirect("$argv->{'redirect_uri'}?code=$code&token_type=bearer&state=$argv->{'state'}"); - if ( !$token ) { + } + else { + &main::print_log( + "[AoGSmartHome] Invalid response_type \'$argv->{'response_type'}\' in OAuth finalization; must be 'token' or 'code' for OAuth 2.0 flow."); + return http_error("400 Bad Request"); + } + } + elsif ( defined $::config_parms{'aog_tokens_path'} && $uri eq $::config_parms{'aog_tokens_path'} ) { + print "[AoGSmartHome] Debug: Processing token exchange request.\n" if $main::Debug{'aog'}; + my $invalid_grant = encode_json { error => "invalid_grant" }; - # We didn't find an existing token for the authenticated user; - # generate a new token (making sure token is unique). - while (1) { - $token = encode_base64( int rand(RAND_MAX), '' ); + if ( $request_type ne 'POST' ) { + &main::print_log("[AoGSmartHome] request is not a POST request!"); + return http_error("400 Bad Request"); + } - if ( !exists $oauth_tokens->{$token} ) { - $oauth_tokens->{$token} = $Authorized; - last; - } - } + # Verify that the client_id identifies the request origin as an authorized origin, and that + # the client_secret matches the expected value. + if ( !exists $argv->{'client_id'} ) { + &main::print_log("[AoGSmartHome] client_id parameter missing from OAuth request."); + return http_error( "400 Bad Request", $invalid_grant ); + } - print "[AoGSmartHome] Debug: token for user '$Authorized' did not exist; generated token '$token'\n" - if $main::Debug{'aog'}; + if ( $argv->{'client_id'} ne $::config_parms{'aog_client_id'} ) { + &main::print_log("[AoGSmartHome] Received client_id \'$argv->{'client_id'}\' does not match our client_id \'$::config_parms{'aog_client_id'}\'."); + return http_error( "400 Bad Request", $invalid_grant ); + } - store $oauth_tokens, $::config_parms{'aog_oauth_tokens_file'}; + if ( !exists $argv->{'client_secret'} ) { + &main::print_log("[AoGSmartHome] client_secret parameter missing from OAuth request."); + return http_error( "400 Bad Request", $invalid_grant ); } - return http_redirect("$HTTP_ARGV{'redirect_uri'}#access_token=$token&token_type=bearer&state=$HTTP_ARGV{'state'}"); + if ( $argv->{'client_secret'} ne $::config_parms{'aog_client_secret'} ) { + &main::print_log( + "[AoGSmartHome] Received client_secret \'$argv->{'client_secret'}\' does not match our client_id \'$::config_parms{'aog_client_secret'}\'."); + return http_error( "400 Bad Request", $invalid_grant ); + } + + # Verify authorization code is valid and not expired, and the client ID specified in the + # request matches the client ID associated with the authorization code. + if ( !exists $argv->{'code'} && !exists $argv->{'refresh_token'} ) { + &main::print_log("[AoGSmartHome] code and refresh_token parameter missing from OAuth request."); + return http_error( "400 Bad Request", $invalid_grant ); + } + + my $code = $argv->{'code'}; + my $refresh_token; + if ($code) { # grant_type=authorization_code + # Verify the URL specified by the redirect_uri parameter is identical to the value used in + # the initial authorization request. + if ( $argv->{'redirect_uri'} !~ m%https://oauth-redirect.googleusercontent.com/r/$::config_parms{'project_id'}% ) { + &main::print_log( + "[AoGSmartHome] invalid redirect_uri (got \'$argv->{'redirect_uri'}\', should be \"https://oauth-redirect.googleusercontent.com/r/" + . $::config_parms{'project_id'} + . "\"" ); + return http_error( "400 Bad Request", $invalid_grant ); + } + } + else { # grant_type=refresh_token + $code = $argv->{'refresh_token'}; + $refresh_token = $code; # reuse existing refresh_token + print "[AoGSmartHome] Debug: using refresh_token '$code'.\n" if $main::Debug{'aog'}; + } + my $authenticated = check_token( $code, $oauth_codes ); + if ( !$authenticated ) { + &main::print_log("[AoGSmartHome] Received code \'$argv->{'code'}\' does not exist."); + return http_error( "400 Bad Request", $invalid_grant ); + } + + # Otherwise, using the user ID from the authorization code, generate a refresh token and an access token. These tokens can be any string value, but they must uniquely represent the user and the client the token is for, and they must not be guessable. For access tokens, also record the expiration time of the token (typically an hour after you issue the token). Refresh tokens do not expire. + # Return the following JSON object in the body of the HTTPS response: + my $token = generate_new_token( $authenticated, time() + ACCESS_TOKEN_EXPIRATION_SECONDS, $oauth_tokens, ); + + $refresh_token = generate_new_token( $authenticated, NEVER_EXPIRES, $oauth_codes, ) if ( !$refresh_token ); + + return &main::json_page( + encode_json { + token_type => "Bearer", + access_token => $token, + refresh_token => $refresh_token, + expires_in => ACCESS_TOKEN_EXPIRATION_SECONDS, + } + ); } elsif ( $uri eq $::config_parms{'aog_fulfillment_url'} ) { print "[AoGSmartHome] Debug: Processing fulfillment request.\n" if $main::Debug{'aog'}; @@ -259,8 +411,9 @@ sub process_http_aog { my $received_token = $1; - if ( exists $oauth_tokens->{$received_token} ) { - print "[AoGSmartHome] Debug: fulfillment request has correct token '$received_token' for user '$oauth_tokens->{$received_token}'\n" + my $authenticated = check_token( $received_token, $oauth_tokens ); + if ($authenticated) { + print "[AoGSmartHome] Debug: fulfillment request has correct token '$received_token' for user '$authenticated'\n" if $main::Debug{'aog'}; } else {