| // Copyright 2019 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chrome/browser/chromeos/login/saml/saml_password_expiry_notification.h" |
| |
| #include "ash/public/cpp/notification_utils.h" |
| #include "ash/public/cpp/session/session_activation_observer.h" |
| #include "ash/public/cpp/session/session_controller.h" |
| #include "base/bind.h" |
| #include "base/strings/string16.h" |
| #include "base/strings/string_util.h" |
| #include "base/task/post_task.h" |
| #include "base/task/task_traits.h" |
| #include "chrome/browser/browser_process.h" |
| #include "chrome/browser/chromeos/profiles/profile_helper.h" |
| #include "chrome/browser/notifications/notification_common.h" |
| #include "chrome/browser/notifications/notification_display_service.h" |
| #include "chrome/browser/notifications/notification_display_service_factory.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/profiles/profile_manager.h" |
| #include "chrome/browser/ui/webui/chromeos/insession_password_change_ui.h" |
| #include "chrome/common/pref_names.h" |
| #include "chromeos/login/auth/saml_password_attributes.h" |
| #include "chromeos/strings/grit/chromeos_strings.h" |
| #include "components/prefs/pref_service.h" |
| #include "components/vector_icons/vector_icons.h" |
| #include "content/public/browser/browser_task_traits.h" |
| #include "content/public/browser/browser_thread.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/message_center/public/cpp/notification.h" |
| #include "ui/message_center/public/cpp/notification_delegate.h" |
| |
| using message_center::HandleNotificationClickDelegate; |
| using message_center::Notification; |
| using message_center::NotificationObserver; |
| using message_center::NotificationType; |
| using message_center::NotifierId; |
| using message_center::NotifierType; |
| using message_center::RichNotificationData; |
| using message_center::SystemNotificationWarningLevel; |
| using message_center::ThunkNotificationDelegate; |
| |
| namespace chromeos { |
| |
| namespace { |
| |
| // Unique ID for this notification. |
| const char kNotificationId[] = "saml.password-expiry-notification"; |
| |
| // NotifierId for histogram reporting. |
| const base::NoDestructor<NotifierId> kNotifierId( |
| message_center::NotifierType::SYSTEM_COMPONENT, |
| kNotificationId); |
| |
| // Simplest type of notification - has text but no other UI elements. |
| const NotificationType kNotificationType = |
| message_center::NOTIFICATION_TYPE_SIMPLE; |
| |
| // Generic type for notifications that are not from web pages etc. |
| const NotificationHandler::Type kNotificationHandlerType = |
| NotificationHandler::Type::TRANSIENT; |
| |
| // The icon to use for this notification - looks like an office building. |
| const gfx::VectorIcon& kIcon = vector_icons::kBusinessIcon; |
| |
| // Warning level NORMAL means the notification heading is blue. |
| const SystemNotificationWarningLevel kWarningLevel = |
| SystemNotificationWarningLevel::NORMAL; |
| |
| // Leaving this empty means the notification is attributed to the system - |
| // ie "Chromium OS" or similar. |
| const base::NoDestructor<base::string16> kDisplaySource; |
| |
| // No origin URL is needed since the notification comes from the system. |
| const base::NoDestructor<GURL> kEmptyOriginUrl; |
| |
| // Line separator in the notification body. |
| const base::NoDestructor<base::string16> kLineSeparator( |
| base::string16(1, '\n')); |
| |
| base::string16 GetTitleText(int less_than_n_days) { |
| const bool hasExpired = (less_than_n_days <= 0); |
| return hasExpired ? l10n_util::GetStringUTF16(IDS_PASSWORD_HAS_EXPIRED_TITLE) |
| : l10n_util::GetStringUTF16(IDS_PASSWORD_WILL_EXPIRE_TITLE); |
| } |
| |
| base::string16 GetBodyText(int less_than_n_days) { |
| const std::vector<base::string16> body_lines = { |
| l10n_util::GetPluralStringFUTF16(IDS_PASSWORD_EXPIRY_DAYS_BODY, |
| std::max(less_than_n_days, 0)), |
| l10n_util::GetStringUTF16(IDS_PASSWORD_EXPIRY_CHOOSE_NEW_PASSWORD_LINK)}; |
| |
| return base::JoinString(body_lines, *kLineSeparator); |
| } |
| |
| void ShowNotificationImpl( |
| Profile* profile, |
| int less_than_n_days, |
| scoped_refptr<message_center::NotificationDelegate> delegate) { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| |
| const base::string16 title = GetTitleText(less_than_n_days); |
| const base::string16 body = GetBodyText(less_than_n_days); |
| |
| // TODO(olsen): Add button to notification - see UI mock. |
| RichNotificationData rich_notification_data; |
| std::unique_ptr<Notification> notification = ash::CreateSystemNotification( |
| kNotificationType, kNotificationId, title, body, *kDisplaySource, |
| *kEmptyOriginUrl, *kNotifierId, rich_notification_data, delegate, kIcon, |
| kWarningLevel); |
| |
| NotificationDisplayService* nds = |
| NotificationDisplayServiceFactory::GetForProfile(profile); |
| // Calling close before display ensures that the notification pops up again |
| // even if it is already shown. |
| nds->Close(kNotificationHandlerType, kNotificationId); |
| nds->Display(kNotificationHandlerType, *notification, /*metadata=*/nullptr); |
| } |
| |
| // A time delta of length one hour. |
| const base::TimeDelta kOneHour = base::TimeDelta::FromHours(1); |
| // A time delta of length one day. |
| const base::TimeDelta kOneDay = base::TimeDelta::FromDays(1); |
| |
| // Traits for running RecheckTask. Runs from the UI thread to show notification. |
| const base::TaskTraits kRecheckTaskTraits = { |
| content::BrowserThread::UI, base::TaskPriority::BEST_EFFORT, |
| base::TaskShutdownBehavior::SKIP_ON_SHUTDOWN}; |
| |
| // Returns true if |profile| is still valid. |
| bool IsValidProfile(Profile* profile) { |
| ProfileManager* profile_manager = g_browser_process->profile_manager(); |
| return profile_manager && profile_manager->IsValidProfile(profile); |
| } |
| |
| // Returns true if showing the notification is enabled for this profile. |
| bool IsEnabledForProfile(Profile* profile) { |
| return chromeos::ProfileHelper::IsPrimaryProfile(profile) && |
| profile->GetPrefs()->GetBoolean( |
| prefs::kSamlInSessionPasswordChangeEnabled); |
| } |
| |
| // The Rechecker checks periodically if the notification should be shown or |
| // updated. When it checks depends on when the password will expire. |
| // There is only at most one Rechecker at a time - for the primary user. |
| class Rechecker : public ash::SessionActivationObserver, |
| public NotificationObserver { |
| public: |
| // Shows the notification for the primary profile. |
| void ShowNotification(int less_than_n_days); |
| |
| // Checks again if the notification should be shown, and maybe shows it. |
| void Recheck(); |
| |
| // Calls recheck after the given |delay|. |
| void RecheckAfter(base::TimeDelta delay); |
| |
| // Cancels any pending tasks to call Recheck again. |
| void CancelPendingRecheck(); |
| |
| // ash::SessionActivationObserver: |
| void OnSessionActivated(bool activated) override {} // Not needed. |
| void OnLockStateChanged(bool locked) override; |
| |
| // NotificationObserver: |
| void Click(const base::Optional<int>& button_index, |
| const base::Optional<base::string16>& reply) override; |
| void Close(bool by_user) override; |
| |
| // Ensures the singleton is initialized and started for the given profile. |
| static void StartForProfile(Profile* profile); |
| // Stops and deletes the Rechecker singleton. |
| static void Stop(); |
| |
| private: |
| // The constructor and destructor also maintain the singleton instance. |
| Rechecker(Profile* profile, const AccountId& account_id); |
| ~Rechecker() override; |
| |
| // Singleton, since we only ever need one instance_ for the primary user. |
| static Rechecker* instance_; |
| |
| Profile* profile_; |
| const AccountId account_id_; |
| bool reshow_on_unlock_ = false; |
| |
| // Weak ptr factory for handling interaction with notifications - these |
| // pointers shouldn't be invalidated until this class is deleted. |
| base::WeakPtrFactory<NotificationObserver> observer_weak_ptr_factory_{this}; |
| // Weak ptr factory for posting tasks. Invalidating these pointers will |
| // cancel upcoming tasks. |
| base::WeakPtrFactory<Rechecker> task_weak_ptr_factory_{this}; |
| |
| // Give test helper access to internals. |
| friend SamlPasswordExpiryNotificationTestHelper; |
| |
| DISALLOW_COPY_AND_ASSIGN(Rechecker); |
| }; |
| |
| void Rechecker::ShowNotification(int less_than_n_days) { |
| ShowNotificationImpl(profile_, less_than_n_days, |
| base::MakeRefCounted<ThunkNotificationDelegate>( |
| observer_weak_ptr_factory_.GetWeakPtr())); |
| // When a notification is currently showing, reshow it on every unlock. |
| reshow_on_unlock_ = true; |
| } |
| |
| void Rechecker::Recheck() { |
| // This cancels any pending call to Recheck - we don't want some bug to cause |
| // us to queue up lots of calls to Recheck in the future, we only want one. |
| CancelPendingRecheck(); |
| |
| // In case the profile has been deleted since this task was posted. |
| if (!IsValidProfile(profile_)) { |
| delete this; // No need to keep calling recheck. |
| return; |
| } |
| |
| PrefService* prefs = profile_->GetPrefs(); |
| SamlPasswordAttributes attrs = SamlPasswordAttributes::LoadFromPrefs(prefs); |
| if (!IsEnabledForProfile(profile_) || !attrs.has_expiration_time()) { |
| // Feature is not enabled for this profile, or profile is not primary, or |
| // there is no expiration time. Dismiss if shown, and stop checking. |
| DismissSamlPasswordExpiryNotification(profile_); |
| delete this; |
| return; |
| } |
| |
| // Calculate how many days until the password will expire. |
| const base::TimeDelta time_until_expiry = |
| attrs.expiration_time() - base::Time::Now(); |
| const int less_than_n_days = |
| std::max(0, time_until_expiry.InDaysFloored() + 1); |
| const int advance_warning_days = std::max( |
| 0, prefs->GetInteger(prefs::kSamlPasswordExpirationAdvanceWarningDays)); |
| |
| if (less_than_n_days <= advance_warning_days) { |
| // The password is expired, or expires in less than |advance_warning_days|. |
| // So we show a notification immediately. |
| ShowNotification(less_than_n_days); |
| // We check again whether to reshow / update the notification after one day: |
| RecheckAfter(kOneDay); |
| |
| } else { |
| // We have not yet reached the advance warning threshold. Check again |
| // once we have arrived at expiry_time minus advance_warning_days... |
| base::TimeDelta recheck_delay = |
| time_until_expiry - base::TimeDelta::FromDays(advance_warning_days); |
| // But, wait an extra hour so that when this code is next run, it is clear |
| // we are now inside advance_warning_days (and not right on the boundary). |
| recheck_delay += kOneHour; |
| RecheckAfter(recheck_delay); |
| } |
| } |
| |
| void Rechecker::RecheckAfter(base::TimeDelta delay) { |
| base::PostDelayedTaskWithTraits( |
| FROM_HERE, kRecheckTaskTraits, |
| base::BindOnce(&Rechecker::Recheck, task_weak_ptr_factory_.GetWeakPtr()), |
| std::max(delay, kOneHour)); |
| // This always waits at least one hour before calling Recheck again - we don't |
| // want some bug to cause this code to run every millisecond. |
| } |
| |
| void Rechecker::CancelPendingRecheck() { |
| task_weak_ptr_factory_.InvalidateWeakPtrs(); |
| } |
| |
| void Rechecker::OnLockStateChanged(bool locked) { |
| // If a notification is currently showing, we show a new version of it every |
| // time the user unlocks the screen. This makes the notification pop up once |
| // more - just after typing the password is a good time to remind the user. |
| if (!locked && reshow_on_unlock_) { |
| Recheck(); |
| } |
| } |
| |
| void Rechecker::Click(const base::Optional<int>& button_index, |
| const base::Optional<base::string16>& reply) { |
| // TODO(olsen): Add a button, only handle clicks on the button itself. |
| PasswordChangeDialog::Show(profile_); |
| } |
| |
| void Rechecker::Close(bool by_user) { |
| // When a notification is dismissed, we then don't pop it up again each time |
| // the user unlocks the screen. |
| reshow_on_unlock_ = false; |
| } |
| |
| // static |
| void Rechecker::StartForProfile(Profile* profile) { |
| if (!instance_) { |
| const AccountId account_id = |
| ProfileHelper::Get()->GetUserByProfile(profile)->GetAccountId(); |
| new Rechecker(profile, account_id); |
| } |
| DCHECK(instance_ && instance_->profile_ == profile); |
| instance_->Recheck(); |
| } |
| |
| // static |
| void Rechecker::Stop() { |
| delete instance_; |
| } |
| |
| // static |
| Rechecker* Rechecker::instance_ = nullptr; |
| |
| Rechecker::Rechecker(Profile* profile, const AccountId& account_id) |
| : profile_(profile), account_id_(account_id) { |
| // There must not be an existing singleton instance. |
| DCHECK(!instance_); |
| instance_ = this; |
| |
| // Add |this| as a SessionActivationObserver to see when the screen is locked. |
| auto* session_controller = ash::SessionController::Get(); |
| if (session_controller) { |
| session_controller->AddSessionActivationObserverForAccountId(account_id_, |
| this); |
| } |
| } |
| |
| Rechecker::~Rechecker() { |
| // Remove this as a SessionActivationObserver. |
| auto* session_controller = ash::SessionController::Get(); |
| if (session_controller) { |
| session_controller->RemoveSessionActivationObserverForAccountId(account_id_, |
| this); |
| } |
| |
| // This should still be the singleton instance. |
| DCHECK_EQ(this, instance_); |
| instance_ = nullptr; |
| } |
| |
| } // namespace |
| |
| void MaybeShowSamlPasswordExpiryNotification(Profile* profile) { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| |
| if (IsEnabledForProfile(profile)) { |
| Rechecker::StartForProfile(profile); |
| } |
| } |
| |
| void ShowSamlPasswordExpiryNotification(Profile* profile, |
| int less_than_n_days) { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| ShowNotificationImpl( |
| profile, less_than_n_days, |
| base::MakeRefCounted<HandleNotificationClickDelegate>( |
| base::BindRepeating(&PasswordChangeDialog::Show, profile))); |
| } |
| |
| void DismissSamlPasswordExpiryNotification(Profile* profile) { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| NotificationDisplayServiceFactory::GetForProfile(profile)->Close( |
| kNotificationHandlerType, kNotificationId); |
| } |
| |
| void SamlPasswordExpiryNotificationTestHelper::ResetForTesting() { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| delete Rechecker::instance_; |
| } |
| |
| void SamlPasswordExpiryNotificationTestHelper::SimulateUnlockForTesting() { |
| DCHECK_CURRENTLY_ON(content::BrowserThread::UI); |
| Rechecker::instance_->OnLockStateChanged(/*locked=*/false); |
| } |
| |
| } // namespace chromeos |