export host selection per service, RT#17914
[freeside.git] / FS / FS / part_export / acct_google.pm
1 package FS::part_export::acct_google;
2
3 use strict;
4 use vars qw(%info %SIG $CACHE);
5 use Tie::IxHash;
6
7 use base 'FS::part_export';
8
9 tie my %options, 'Tie::IxHash',
10   'domain'    => { label => 'Domain name' },
11   'username'  => { label => 'Admin username' },
12   'password'  => { label => 'Admin password' },
13 ;
14 # To handle multiple domains, use separate instances of 
15 # the export.  We assume that they all have different 
16 # admin logins.
17
18 %info = (
19   'svc'        => 'svc_acct',
20   'desc'       => 'Google hosted mail',
21   'options'    => \%options,
22   'nodomain'   => 'Y',
23   'no_machine' => 1,
24   'default_svc_class' => 'Email',
25   'notes'    => <<'END'
26 Export accounts to the Google Provisioning API.  Requires 
27 REST::Google::Apps::Provisioning from CPAN.
28 END
29 );
30
31 sub rebless { shift; }
32
33 sub _export_insert {
34   my($self, $svc_acct) = (shift, shift);
35   $svc_acct->finger =~ /^(.*)\s+(\S+)$/;
36   my ($first, $last) = ($1, $2);
37   $self->google_request('createUser',
38     'username'      => $svc_acct->username,
39     'password'      => $svc_acct->_password,
40     'givenName'     => $first,
41     'familyName'    => $last,
42   );
43 }
44
45 sub _export_replace {
46   my( $self, $new, $old ) = (shift, shift, shift);
47   # We have to do this in two steps, so do the renameUser last so that 
48   # if it fails partway through the username is still coherent.
49   if ( $new->_password ne $old->_password
50     or $new->finger    ne $old->finger ) {
51     $new->finger =~ /^(.*)\s+(\S+)$/;
52     my ($first, $last) = ($1, $2);
53     my $error = $self->google_request('updateUser',
54       'username'    => $old->username,
55       'password'    => $new->_password,
56       'givenName'   => $first,
57       'familyName'  => $last,
58     );
59     return $error if $error;
60   }
61   if ( $new->username ne $old->username ) {
62     my $error = $self->google_request('renameUser',
63       'username'  => $old->username,
64       'newname'   => $new->username
65     );
66     return $error if $error;
67   }
68   return;
69 }
70
71 sub _export_delete {
72   my( $self, $svc_acct ) = (shift, shift);
73   $self->google_request('deleteUser',
74     'username'  => $svc_acct->username
75   );
76 }
77
78 sub _export_suspend {
79   my( $self, $svc_acct ) = (shift, shift);
80   $self->google_request('updateUser',
81     'username'  => $svc_acct->username,
82     'suspended' => 'true',
83   );
84 }
85
86 sub _export_unsuspend {
87   my( $self, $svc_acct ) = (shift, shift);
88   $self->google_request('updateUser',
89     'username'  => $svc_acct->username,
90     'suspended' => 'false',
91   );
92 }
93
94 sub auth_error {
95   my $self = shift;
96   my $google = $self->google_handle;
97   if ( $google->{'error'} ) {
98     my $url = $google->{'captcha_url'} || '';
99     $url = "http://www.google.com/accounts/$url" if $url;
100     return { 'captcha_url' => $url,
101              'message'     => 
102                'Unable to connect to the Google API: '.$google->{'error'}.'.',
103            };
104   }
105   return; #nothing on success
106 }
107
108 sub captcha_auth {
109   my $self = shift;
110   my $response = shift;
111   my $google = $self->google_handle('captcha_response' => $response);
112   return (defined($google->{'token'}));
113 }
114
115 my %google_error = (
116   1000 => 'unknown error',
117   1001 => 'server busy',
118   1100 => 'username belongs to a recently deleted account',
119   1101 => 'user suspended',
120   1200 => 'domain user limit exceeded',
121   1201 => 'domain alias limit exceeded',
122   1202 => 'domain suspended',
123   1203 => 'feature not available on this domain',
124   1300 => 'username in use',
125   1301 => 'user not found',
126   1302 => 'reserved username',
127   1400 => 'illegal character in first name',
128   1401 => 'illegal character in last name',
129   1402 => 'invalid password',
130   1403 => 'illegal character in username',
131   # should be everything we need
132 );
133
134 # Runs the request and returns nothing if it succeeds, or an 
135 # error message.
136
137 sub google_request {
138   my ($self, $method, %opt) = @_;
139   my $google = $self->google_handle(
140     'captcha_response' => delete $opt{'captcha_response'}
141   );
142   return $google->{'error'} if $google->{'error'};
143
144   # Throw away the result from this; we don't use it yet.
145   eval { $google->$method(%opt) };
146   if ( $@ ) {
147     return $google_error{ $@->{'error'}->{'errorCode'} } || $@->{'error'};
148   }
149   return;
150 }
151
152 # Returns a REST::Google::Apps::Provisioning object which is hooked 
153 # to die {error => stuff} on API errors.  The cached auth token 
154 # will be used if possible.  If not, try to authenticate.  On 
155 # authentication error, the R:G:A:P object will still be returned 
156 # but with $google->{'error'} set to the error message.
157
158 sub google_handle {
159   my $self = shift;
160   my %opt = @_;
161   my @class = ( 
162     'REST::Google::Apps::Provisioning',
163     'Cache::FileCache',
164     'LWP::UserAgent 5.815',
165   );
166   foreach (@class) {
167     eval "use $_";
168     die "failed to load $_\n" if $@;
169   }
170   $CACHE ||= new Cache::FileCache( {
171       'namespace'   => __PACKAGE__,
172       'cache_root'  => "$FS::UID::cache_dir/cache.$FS::UID::datasrc",
173   } );
174   my $google = REST::Google::Apps::Provisioning->new(
175     'domain'  => $self->option('domain') 
176   );
177
178   # REST::Google::Apps::Provisioning lacks error reporting.  We deal 
179   # with that by hooking HTTP::Response to throw a useful fatal error 
180   # on failure.
181   $google->{'lwp'}->add_handler( 'response_done' =>
182     sub {
183       my $response = shift;
184       return if $response->is_success;
185
186       my $error = '';
187       if ( $response->content =~ /^</ ) {
188         #presume xml
189         $error = $google->{'xml'}->parse_string($response->content);
190       }
191       elsif ( $response->content =~ /=/ ) {
192         $error = +{ map { if ( /^(\w+)=(.*)$/ ) { lc($1) => $2 } }
193           split("\n", $response->content)
194         };
195       }
196       else { # have something to say if there is no response...
197         $error = {'error' => $response->status_line};
198       }
199       die $error;
200     }
201   );
202
203   my $cache_token = $self->exportnum . '_token';
204   my $cache_captcha = $self->exportnum . '_captcha_token';
205   $google->{'token'} = $CACHE->get($cache_token);
206   if ( !$google->{'token'} ) {
207     my %login = (
208       'username' => $self->option('username'),
209       'password' => $self->option('password'),
210     );
211     if ( $opt{'captcha_response'} ) {
212       $login{'logincaptcha'} = $opt{'captcha_response'};
213       $login{'logintoken'} = $CACHE->get($cache_captcha);
214     }
215     eval { $google->captcha_auth(%login); };
216     if ( $@ ) {
217       $google->{'error'} = $@->{'error'};
218       $google->{'captcha_url'} = $@->{'captchaurl'};
219       $CACHE->set($cache_captcha, $@->{'captchatoken'}, '1 minute');
220       return $google;
221     }
222     $CACHE->remove($cache_captcha);
223     $CACHE->set($cache_token, $google->{'token'}, '1 hour');
224   }
225   return $google;
226 }
227
228 # REST::Google::Apps::Provisioning also lacks a way to do this
229 sub REST::Google::Apps::Provisioning::captcha_auth {
230   my $self = shift;
231
232   return( 1 ) if $self->{'token'};
233
234   my ( $arg );
235   %{$arg} = @_;
236
237   map { $arg->{lc($_)} = $arg->{$_} } keys %{$arg};
238
239   foreach my $param ( qw/ username password / ) {
240     $arg->{$param} || croak( "Missing required '$param' argument" );
241   }
242
243   my @postargs = (
244     'accountType' => 'HOSTED',
245     'service'     => 'apps',
246     'Email'       => $arg->{'username'} . '@' . $self->{'domain'},
247     'Passwd'      => $arg->{'password'},
248   );
249   if ( $arg->{'logincaptcha'} ) {
250     push @postargs, 
251       'logintoken'  => $arg->{'logintoken'},
252       'logincaptcha'=> $arg->{'logincaptcha'}
253       ;
254   }
255   my $response = $self->{'lwp'}->post(
256     'https://www.google.com/accounts/ClientLogin',
257     \@postargs
258   );
259
260   $response->is_success() || return( 0 );
261
262   foreach ( split( /\n/, $response->content() ) ) {
263     $self->{'token'} = $1 if /^Auth=(.+)$/;
264     last if $self->{'token'};
265   }
266
267   return( 1 ) if $self->{'token'} || return( 0 );
268 }
269
270 1;