1// Copyright 2015 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5#include "components/signin/ios/browser/account_consistency_service.h"
6
7#import <WebKit/WebKit.h>
8
9#include "base/bind.h"
10#include "base/logging.h"
11#import "base/mac/foundation_util.h"
12#include "base/macros.h"
13#include "base/strings/sys_string_conversions.h"
14#include "components/content_settings/core/browser/cookie_settings.h"
15#include "components/google/core/common/google_util.h"
16#include "components/prefs/pref_registry_simple.h"
17#include "components/prefs/pref_service.h"
18#include "components/prefs/scoped_user_pref_update.h"
19#include "components/signin/core/browser/account_reconcilor.h"
20#include "components/signin/core/browser/signin_header_helper.h"
21#include "components/signin/public/base/account_consistency_method.h"
22#include "components/signin/public/identity_manager/accounts_cookie_mutator.h"
23#include "ios/web/common/web_view_creation_util.h"
24#include "ios/web/public/browser_state.h"
25#import "ios/web/public/navigation/web_state_policy_decider.h"
26#include "net/base/mac/url_conversions.h"
27#include "net/base/registry_controlled_domains/registry_controlled_domain.h"
28#include "url/gurl.h"
29
30#if !defined(__has_feature) || !__has_feature(objc_arc)
31#error "This file requires ARC support."
32#endif
33
34namespace {
35
36// Threshold (in hours) used to control whether the CHROME_CONNECTED cookie
37// should be added again on a domain it was previously set.
38const int kHoursThresholdToReAddCookie = 24;
39
40// JavaScript template used to set (or delete) the CHROME_CONNECTED cookie.
41// It takes 3 arguments: the domain of the cookie, its value and its expiration
42// date. It also clears the legacy X-CHROME-CONNECTED cookie.
43NSString* const kChromeConnectedCookieTemplate =
44    @"<html><script>domain=\"%@\";"
45     "document.cookie=\"X-CHROME-CONNECTED=; path=/; domain=\" + domain + \";"
46     " expires=Thu, 01-Jan-1970 00:00:01 GMT\";"
47     "document.cookie=\"CHROME_CONNECTED=%@; path=/; domain=\" + domain + \";"
48     " expires=\" + new Date(%f).toGMTString() + \"; secure;"
49     " samesite=lax;\"</script></html>";
50
51// WebStatePolicyDecider that monitors the HTTP headers on Gaia responses,
52// reacting on the X-Chrome-Manage-Accounts header and notifying its delegate.
53// It also notifies the AccountConsistencyService of domains it should add the
54// CHROME_CONNECTED cookie to.
55class AccountConsistencyHandler : public web::WebStatePolicyDecider {
56 public:
57  AccountConsistencyHandler(web::WebState* web_state,
58                            AccountConsistencyService* service,
59                            AccountReconcilor* account_reconcilor,
60                            id<ManageAccountsDelegate> delegate);
61
62 private:
63  // web::WebStatePolicyDecider override
64  bool ShouldAllowResponse(NSURLResponse* response,
65                           bool for_main_frame) override;
66  void WebStateDestroyed() override;
67
68  AccountConsistencyService* account_consistency_service_;  // Weak.
69  AccountReconcilor* account_reconcilor_;                   // Weak.
70  __weak id<ManageAccountsDelegate> delegate_;
71};
72}
73
74AccountConsistencyHandler::AccountConsistencyHandler(
75    web::WebState* web_state,
76    AccountConsistencyService* service,
77    AccountReconcilor* account_reconcilor,
78    id<ManageAccountsDelegate> delegate)
79    : web::WebStatePolicyDecider(web_state),
80      account_consistency_service_(service),
81      account_reconcilor_(account_reconcilor),
82      delegate_(delegate) {}
83
84bool AccountConsistencyHandler::ShouldAllowResponse(NSURLResponse* response,
85                                                    bool for_main_frame) {
86  NSHTTPURLResponse* http_response =
87      base::mac::ObjCCast<NSHTTPURLResponse>(response);
88  if (!http_response)
89    return true;
90
91  GURL url = net::GURLWithNSURL(http_response.URL);
92  if (google_util::IsGoogleDomainUrl(
93          url, google_util::ALLOW_SUBDOMAIN,
94          google_util::DISALLOW_NON_STANDARD_PORTS)) {
95    // User is showing intent to navigate to a Google domain. Add the
96    // CHROME_CONNECTED cookie to the domain if necessary.
97    std::string domain = net::registry_controlled_domains::GetDomainAndRegistry(
98        url, net::registry_controlled_domains::EXCLUDE_PRIVATE_REGISTRIES);
99    account_consistency_service_->AddChromeConnectedCookieToDomain(
100        domain, true /* force_update_if_too_old */);
101    account_consistency_service_->AddChromeConnectedCookieToDomain(
102        "google.com", true /* force_update_if_too_old */);
103  }
104
105  if (!gaia::IsGaiaSignonRealm(url.GetOrigin()))
106    return true;
107  NSString* manage_accounts_header = [[http_response allHeaderFields]
108      objectForKey:@"X-Chrome-Manage-Accounts"];
109  if (!manage_accounts_header)
110    return true;
111
112  signin::ManageAccountsParams params = signin::BuildManageAccountsParams(
113      base::SysNSStringToUTF8(manage_accounts_header));
114
115  account_reconcilor_->OnReceivedManageAccountsResponse(params.service_type);
116  switch (params.service_type) {
117    case signin::GAIA_SERVICE_TYPE_INCOGNITO: {
118      GURL continue_url = GURL(params.continue_url);
119      DLOG_IF(ERROR, !params.continue_url.empty() && !continue_url.is_valid())
120          << "Invalid continuation URL: \"" << continue_url << "\"";
121      [delegate_ onGoIncognito:continue_url];
122      break;
123    }
124    case signin::GAIA_SERVICE_TYPE_SIGNUP:
125    case signin::GAIA_SERVICE_TYPE_ADDSESSION:
126      [delegate_ onAddAccount];
127      break;
128    case signin::GAIA_SERVICE_TYPE_SIGNOUT:
129    case signin::GAIA_SERVICE_TYPE_DEFAULT:
130      [delegate_ onManageAccounts];
131      break;
132    case signin::GAIA_SERVICE_TYPE_NONE:
133      NOTREACHED();
134      break;
135  }
136
137  // WKWebView loads a blank page even if the response code is 204
138  // ("No Content"). http://crbug.com/368717
139  //
140  // Manage accounts responses are handled via native UI. Abort this request
141  // for the following reasons:
142  // * Avoid loading a blank page in WKWebView.
143  // * Avoid adding this request to history.
144  return false;
145}
146
147void AccountConsistencyHandler::WebStateDestroyed() {
148  account_consistency_service_->RemoveWebStateHandler(web_state());
149}
150
151// WKWebView navigation delegate that calls its callback every time a navigation
152// has finished.
153@interface AccountConsistencyNavigationDelegate : NSObject<WKNavigationDelegate>
154
155// Designated initializer. |callback| will be called every time a navigation has
156// finished. |callback| must not be empty.
157- (instancetype)initWithCallback:(const base::RepeatingClosure&)callback
158    NS_DESIGNATED_INITIALIZER;
159
160- (instancetype)init NS_UNAVAILABLE;
161@end
162
163@implementation AccountConsistencyNavigationDelegate {
164  // Callback that will be called every time a navigation has finished.
165  base::RepeatingClosure _callback;
166}
167
168- (instancetype)initWithCallback:(const base::RepeatingClosure&)callback {
169  self = [super init];
170  if (self) {
171    DCHECK(!callback.is_null());
172    _callback = callback;
173  }
174  return self;
175}
176
177- (instancetype)init {
178  NOTREACHED();
179  return nil;
180}
181
182#pragma mark - WKNavigationDelegate
183
184- (void)webView:(WKWebView*)webView
185    didFinishNavigation:(WKNavigation*)navigation {
186  _callback.Run();
187}
188
189@end
190
191const char AccountConsistencyService::kDomainsWithCookiePref[] =
192    "signin.domains_with_cookie";
193
194AccountConsistencyService::CookieRequest
195AccountConsistencyService::CookieRequest::CreateAddCookieRequest(
196    const std::string& domain) {
197  AccountConsistencyService::CookieRequest cookie_request;
198  cookie_request.request_type = ADD_CHROME_CONNECTED_COOKIE;
199  cookie_request.domain = domain;
200  return cookie_request;
201}
202
203AccountConsistencyService::CookieRequest
204AccountConsistencyService::CookieRequest::CreateRemoveCookieRequest(
205    const std::string& domain,
206    base::OnceClosure callback) {
207  AccountConsistencyService::CookieRequest cookie_request;
208  cookie_request.request_type = REMOVE_CHROME_CONNECTED_COOKIE;
209  cookie_request.domain = domain;
210  cookie_request.callback = std::move(callback);
211  return cookie_request;
212}
213
214AccountConsistencyService::CookieRequest::CookieRequest() = default;
215
216AccountConsistencyService::CookieRequest::~CookieRequest() = default;
217
218AccountConsistencyService::CookieRequest::CookieRequest(
219    AccountConsistencyService::CookieRequest&&) = default;
220
221AccountConsistencyService::AccountConsistencyService(
222    web::BrowserState* browser_state,
223    PrefService* prefs,
224    AccountReconcilor* account_reconcilor,
225    scoped_refptr<content_settings::CookieSettings> cookie_settings,
226    signin::IdentityManager* identity_manager)
227    : browser_state_(browser_state),
228      prefs_(prefs),
229      account_reconcilor_(account_reconcilor),
230      cookie_settings_(cookie_settings),
231      identity_manager_(identity_manager),
232      applying_cookie_requests_(false) {
233  identity_manager_->AddObserver(this);
234  ActiveStateManager::FromBrowserState(browser_state_)->AddObserver(this);
235  LoadFromPrefs();
236  if (identity_manager_->HasPrimaryAccount()) {
237    AddChromeConnectedCookies();
238  } else {
239    RemoveChromeConnectedCookies(base::OnceClosure());
240  }
241}
242
243AccountConsistencyService::~AccountConsistencyService() {
244  DCHECK(!web_view_);
245  DCHECK(!navigation_delegate_);
246}
247
248// static
249void AccountConsistencyService::RegisterPrefs(PrefRegistrySimple* registry) {
250  registry->RegisterDictionaryPref(
251      AccountConsistencyService::kDomainsWithCookiePref);
252}
253
254void AccountConsistencyService::SetWebStateHandler(
255    web::WebState* web_state,
256    id<ManageAccountsDelegate> delegate) {
257  DCHECK_EQ(0u, web_state_handlers_.count(web_state));
258  web_state_handlers_[web_state].reset(new AccountConsistencyHandler(
259      web_state, this, account_reconcilor_, delegate));
260}
261
262void AccountConsistencyService::RemoveWebStateHandler(
263    web::WebState* web_state) {
264  DCHECK_LT(0u, web_state_handlers_.count(web_state));
265  web_state_handlers_.erase(web_state);
266}
267
268bool AccountConsistencyService::ShouldAddChromeConnectedCookieToDomain(
269    const std::string& domain,
270    bool force_update_if_too_old) {
271  auto it = last_cookie_update_map_.find(domain);
272  if (it == last_cookie_update_map_.end()) {
273    // |domain| isn't in the map, always add the cookie.
274    return true;
275  }
276  if (!force_update_if_too_old) {
277    // |domain| is in the map and the cookie is considered valid. Don't add it
278    // again.
279    return false;
280  }
281  return (base::Time::Now() - it->second) >
282         base::TimeDelta::FromHours(kHoursThresholdToReAddCookie);
283}
284
285void AccountConsistencyService::RemoveChromeConnectedCookies(
286    base::OnceClosure callback) {
287  DCHECK(!browser_state_->IsOffTheRecord());
288  if (last_cookie_update_map_.empty()) {
289    if (!callback.is_null())
290      std::move(callback).Run();
291    return;
292  }
293  std::map<std::string, base::Time> last_cookie_update_map =
294      last_cookie_update_map_;
295  auto iter_last_item = std::prev(last_cookie_update_map.end());
296  for (auto iter = last_cookie_update_map.begin(); iter != iter_last_item;
297       iter++) {
298    RemoveChromeConnectedCookieFromDomain(iter->first, base::OnceClosure());
299  }
300  RemoveChromeConnectedCookieFromDomain(iter_last_item->first,
301                                        std::move(callback));
302}
303
304void AccountConsistencyService::AddChromeConnectedCookieToDomain(
305    const std::string& domain,
306    bool force_update_if_too_old) {
307  if (!ShouldAddChromeConnectedCookieToDomain(domain,
308                                              force_update_if_too_old)) {
309    return;
310  }
311  last_cookie_update_map_[domain] = base::Time::Now();
312  cookie_requests_.push_back(CookieRequest::CreateAddCookieRequest(domain));
313  ApplyCookieRequests();
314}
315
316void AccountConsistencyService::RemoveChromeConnectedCookieFromDomain(
317    const std::string& domain,
318    base::OnceClosure callback) {
319  DCHECK_NE(0ul, last_cookie_update_map_.count(domain));
320  last_cookie_update_map_.erase(domain);
321  cookie_requests_.push_back(
322      CookieRequest::CreateRemoveCookieRequest(domain, std::move(callback)));
323  ApplyCookieRequests();
324}
325
326void AccountConsistencyService::LoadFromPrefs() {
327  const base::DictionaryValue* dict =
328      prefs_->GetDictionary(kDomainsWithCookiePref);
329  for (base::DictionaryValue::Iterator it(*dict); !it.IsAtEnd(); it.Advance()) {
330    last_cookie_update_map_[it.key()] = base::Time();
331  }
332}
333
334void AccountConsistencyService::Shutdown() {
335  identity_manager_->RemoveObserver(this);
336  ActiveStateManager::FromBrowserState(browser_state_)->RemoveObserver(this);
337  ResetWKWebView();
338  web_state_handlers_.clear();
339}
340
341void AccountConsistencyService::ApplyCookieRequests() {
342  if (applying_cookie_requests_) {
343    // A cookie request is already being applied, the following ones will be
344    // handled as soon as the current one is done.
345    return;
346  }
347  if (cookie_requests_.empty()) {
348    return;
349  }
350  if (!ActiveStateManager::FromBrowserState(browser_state_)->IsActive()) {
351    // Web view usage isn't active for now, ignore cookie requests for now and
352    // wait to be notified that it became active again.
353    return;
354  }
355  applying_cookie_requests_ = true;
356
357  const GURL url("https://" + cookie_requests_.front().domain);
358  std::string cookie_value = "";
359  // Expiration date of the cookie in the JavaScript convention of time, a
360  // number of milliseconds since the epoch.
361  double expiration_date = 0;
362  switch (cookie_requests_.front().request_type) {
363    case ADD_CHROME_CONNECTED_COOKIE:
364      cookie_value = signin::BuildMirrorRequestCookieIfPossible(
365          url, identity_manager_->GetPrimaryAccountInfo().gaia,
366          signin::AccountConsistencyMethod::kMirror, cookie_settings_.get(),
367          signin::PROFILE_MODE_DEFAULT);
368      if (cookie_value.empty()) {
369        // Don't add the cookie. Tentatively correct |last_cookie_update_map_|.
370        last_cookie_update_map_.erase(cookie_requests_.front().domain);
371        FinishedApplyingCookieRequest(false);
372        return;
373      }
374      // Create expiration date of Now+2y to roughly follow the SAPISID cookie.
375      expiration_date =
376          (base::Time::Now() + base::TimeDelta::FromDays(730)).ToJsTime();
377      break;
378    case REMOVE_CHROME_CONNECTED_COOKIE:
379      // Nothing to do. Default values correspond to removing the cookie (no
380      // value, expiration date in the past).
381      break;
382  }
383  NSString* html = [NSString
384      stringWithFormat:kChromeConnectedCookieTemplate,
385                       base::SysUTF8ToNSString(url.host()),
386                       base::SysUTF8ToNSString(cookie_value), expiration_date];
387  // Load an HTML string with embedded JavaScript that will set or remove the
388  // cookie. By setting the base URL to |url|, this effectively allows to modify
389  // cookies on the correct domain without having to do a network request.
390  [GetWKWebView() loadHTMLString:html baseURL:net::NSURLWithGURL(url)];
391}
392
393void AccountConsistencyService::FinishedApplyingCookieRequest(bool success) {
394  DCHECK(!cookie_requests_.empty());
395  CookieRequest& request = cookie_requests_.front();
396  if (success) {
397    DictionaryPrefUpdate update(
398        prefs_, AccountConsistencyService::kDomainsWithCookiePref);
399    switch (request.request_type) {
400      case ADD_CHROME_CONNECTED_COOKIE:
401        // Add request.domain to prefs, use |true| as a dummy value (that is
402        // never used), as the dictionary is used as a set.
403        update->SetKey(request.domain, base::Value(true));
404        break;
405      case REMOVE_CHROME_CONNECTED_COOKIE:
406        // Remove request.domain from prefs.
407        update->RemoveWithoutPathExpansion(request.domain, nullptr);
408        break;
409    }
410  }
411  base::OnceClosure callback(std::move(request.callback));
412  cookie_requests_.pop_front();
413  applying_cookie_requests_ = false;
414  ApplyCookieRequests();
415  if (!callback.is_null()) {
416    std::move(callback).Run();
417  }
418}
419
420WKWebView* AccountConsistencyService::GetWKWebView() {
421  if (!ActiveStateManager::FromBrowserState(browser_state_)->IsActive()) {
422    // |browser_state_| is not active, WKWebView linked to this browser state
423    // should not exist or be created.
424    return nil;
425  }
426  if (!web_view_) {
427    web_view_ = BuildWKWebView();
428    navigation_delegate_ = [[AccountConsistencyNavigationDelegate alloc]
429        initWithCallback:base::BindRepeating(&AccountConsistencyService::
430                                                 FinishedApplyingCookieRequest,
431                                             base::Unretained(this), true)];
432    [web_view_ setNavigationDelegate:navigation_delegate_];
433  }
434  return web_view_;
435}
436
437WKWebView* AccountConsistencyService::BuildWKWebView() {
438  return web::BuildWKWebView(CGRectZero, browser_state_);
439}
440
441void AccountConsistencyService::ResetWKWebView() {
442  [web_view_ setNavigationDelegate:nil];
443  [web_view_ stopLoading];
444  web_view_ = nil;
445  navigation_delegate_ = nil;
446  applying_cookie_requests_ = false;
447}
448
449void AccountConsistencyService::AddChromeConnectedCookies() {
450  DCHECK(!browser_state_->IsOffTheRecord());
451  // These cookie request are preventive and not a strong signal (unlike
452  // navigation to a domain). Don't force update the old cookies in this case.
453  AddChromeConnectedCookieToDomain("google.com",
454                                   false /* force_update_if_too_old */);
455  AddChromeConnectedCookieToDomain("youtube.com",
456                                   false /* force_update_if_too_old */);
457}
458
459void AccountConsistencyService::OnBrowsingDataRemoved() {
460  // CHROME_CONNECTED cookies have been removed, update internal state
461  // accordingly.
462  ResetWKWebView();
463  for (auto& cookie_request : cookie_requests_) {
464    base::OnceClosure callback(std::move(cookie_request.callback));
465    if (!callback.is_null()) {
466      std::move(callback).Run();
467    }
468  }
469  cookie_requests_.clear();
470  last_cookie_update_map_.clear();
471  base::DictionaryValue dict;
472  prefs_->Set(kDomainsWithCookiePref, dict);
473
474  // SAPISID cookie has been removed, notify the GCMS.
475  // TODO(https://crbug.com/930582) : Remove the need to expose this method
476  // or move it to the network::CookieManager.
477  identity_manager_->GetAccountsCookieMutator()->ForceTriggerOnCookieChange();
478}
479
480void AccountConsistencyService::OnPrimaryAccountSet(
481    const CoreAccountInfo& account_info) {
482  AddChromeConnectedCookies();
483}
484
485void AccountConsistencyService::OnPrimaryAccountCleared(
486    const CoreAccountInfo& previous_account_info) {
487  // There is not need to remove CHROME_CONNECTED cookies on |GoogleSignedOut|
488  // events as these cookies will be removed by the GaiaCookieManagerServer
489  // right before fetching the Gaia logout request.
490}
491
492void AccountConsistencyService::OnAccountsInCookieUpdated(
493    const signin::AccountsInCookieJarInfo& accounts_in_cookie_jar_info,
494    const GoogleServiceAuthError& error) {
495  AddChromeConnectedCookies();
496}
497
498void AccountConsistencyService::OnActive() {
499  // |browser_state_| is now active. There might be some pending cookie requests
500  // to apply.
501  ApplyCookieRequests();
502}
503
504void AccountConsistencyService::OnInactive() {
505  // |browser_state_| is now inactive. Stop using |web_view_| and don't create
506  // a new one until it is active.
507  ResetWKWebView();
508}
509