466 lines
20 KiB
Swift
466 lines
20 KiB
Swift
//
|
|
// Copyright (c) 2020 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
|
|
@objc
|
|
public class Onboarding2FAViewController: OnboardingBaseViewController {
|
|
|
|
// When the users attempts remaining falls below this number,
|
|
// we will show an alert with more detail about the risks.
|
|
private let attemptsAlertThreshold = 4
|
|
|
|
private let pinTextField = UITextField()
|
|
private let pinTypeToggle = UIButton()
|
|
private lazy var nextButton = self.primaryButton(title: CommonStrings.nextButton,
|
|
selector: #selector(nextPressed))
|
|
|
|
private lazy var pinStrokeNormal = pinTextField.addBottomStroke()
|
|
private lazy var pinStrokeError = pinTextField.addBottomStroke(color: .ows_accentRed, strokeWidth: 2)
|
|
private let validationWarningLabel = UILabel()
|
|
|
|
enum PinAttemptState {
|
|
case unattempted
|
|
case invalid(remainingAttempts: UInt32?)
|
|
case exhausted
|
|
case valid
|
|
|
|
var isInvalid: Bool {
|
|
switch self {
|
|
case .unattempted, .valid:
|
|
return false
|
|
case .invalid, .exhausted:
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
private var attemptState: PinAttemptState = .unattempted {
|
|
didSet {
|
|
updateValidationWarnings()
|
|
}
|
|
}
|
|
|
|
private let isUsingKBS: Bool
|
|
private var pinType: KeyBackupService.PinType = .numeric {
|
|
didSet {
|
|
updatePinType()
|
|
}
|
|
}
|
|
|
|
private var databaseStorage: SDSDatabaseStorage { .shared }
|
|
private var hasPendingRestoration: Bool {
|
|
databaseStorage.read { KeyBackupService.hasPendingRestoration(transaction: $0) }
|
|
}
|
|
|
|
public init(onboardingController: OnboardingController, isUsingKBS: Bool) {
|
|
self.isUsingKBS = isUsingKBS
|
|
super.init(onboardingController: onboardingController)
|
|
}
|
|
|
|
var needHelpLink: OWSFlatButton!
|
|
|
|
override public func loadView() {
|
|
view = UIView()
|
|
|
|
view.addSubview(primaryView)
|
|
primaryView.autoPinEdgesToSuperviewEdges()
|
|
|
|
view.backgroundColor = Theme.backgroundColor
|
|
|
|
let titleText = NSLocalizedString("ONBOARDING_PIN_TITLE", comment: "Title of the 'onboarding PIN' view.")
|
|
let explanationText = NSLocalizedString("ONBOARDING_PIN_EXPLANATION", comment: "Title of the 'onboarding PIN' view.")
|
|
|
|
let titleLabel = self.titleLabel(text: titleText)
|
|
let explanationLabel = self.explanationLabel(explanationText: explanationText)
|
|
explanationLabel.font = UIFont.ows_dynamicTypeSubheadlineClamped
|
|
explanationLabel.accessibilityIdentifier = "onboarding.2fa." + "explanationLabel"
|
|
|
|
pinTextField.delegate = self
|
|
pinTextField.isSecureTextEntry = true
|
|
pinTextField.textColor = Theme.primaryTextColor
|
|
pinTextField.textAlignment = .center
|
|
pinTextField.font = .ows_dynamicTypeBodyClamped
|
|
pinTextField.isSecureTextEntry = true
|
|
pinTextField.defaultTextAttributes.updateValue(5, forKey: .kern)
|
|
pinTextField.keyboardAppearance = Theme.keyboardAppearance
|
|
pinTextField.setContentHuggingHorizontalLow()
|
|
pinTextField.setCompressionResistanceHorizontalLow()
|
|
pinTextField.autoSetDimension(.height, toSize: 40)
|
|
pinTextField.accessibilityIdentifier = "onboarding.2fa.pinTextField"
|
|
|
|
validationWarningLabel.textColor = .ows_accentRed
|
|
validationWarningLabel.textAlignment = .center
|
|
validationWarningLabel.font = UIFont.ows_dynamicTypeCaption1Clamped
|
|
validationWarningLabel.accessibilityIdentifier = "onboarding.2fa.validationWarningLabel"
|
|
validationWarningLabel.numberOfLines = 0
|
|
validationWarningLabel.setCompressionResistanceHigh()
|
|
|
|
self.needHelpLink = self.linkButton(title: NSLocalizedString("ONBOARDING_2FA_FORGOT_PIN_LINK",
|
|
comment: "Label for the 'forgot 2FA PIN' link in the 'onboarding 2FA' view."),
|
|
selector: #selector(needHelpLinkWasTapped))
|
|
needHelpLink.accessibilityIdentifier = "onboarding.2fa." + "forgotPinLink"
|
|
|
|
let pinStack = UIStackView(arrangedSubviews: [
|
|
pinTextField,
|
|
UIView.spacer(withHeight: 10),
|
|
validationWarningLabel,
|
|
UIView.spacer(withHeight: 10),
|
|
needHelpLink
|
|
])
|
|
pinStack.axis = .vertical
|
|
pinStack.alignment = .fill
|
|
|
|
let pinStackRow = UIView()
|
|
pinStackRow.addSubview(pinStack)
|
|
pinStack.autoHCenterInSuperview()
|
|
pinStack.autoPinHeightToSuperview()
|
|
pinStack.autoSetDimension(.width, toSize: 227)
|
|
pinStackRow.setContentHuggingVerticalHigh()
|
|
|
|
pinTypeToggle.setTitleColor(.ows_accentBlue, for: .normal)
|
|
pinTypeToggle.titleLabel?.font = .ows_dynamicTypeSubheadlineClamped
|
|
pinTypeToggle.addTarget(self, action: #selector(togglePinType), for: .touchUpInside)
|
|
pinTypeToggle.accessibilityIdentifier = "pinCreation.pinTypeToggle"
|
|
|
|
nextButton.accessibilityIdentifier = "onboarding.2fa." + "nextButton"
|
|
let primaryButtonView = OnboardingBaseViewController.horizontallyWrap(primaryButton: nextButton)
|
|
|
|
let topSpacer = UIView.vStretchingSpacer()
|
|
let bottomSpacer = UIView.vStretchingSpacer()
|
|
let compressableBottomMargin = UIView.vStretchingSpacer(minHeight: 16, maxHeight: primaryLayoutMargins.bottom)
|
|
|
|
let stackView = UIStackView(arrangedSubviews: [
|
|
titleLabel,
|
|
UIView.spacer(withHeight: 10),
|
|
explanationLabel,
|
|
topSpacer,
|
|
pinStackRow,
|
|
bottomSpacer,
|
|
pinTypeToggle,
|
|
UIView.spacer(withHeight: 10),
|
|
primaryButtonView,
|
|
compressableBottomMargin
|
|
])
|
|
stackView.axis = .vertical
|
|
stackView.alignment = .fill
|
|
primaryView.addSubview(stackView)
|
|
|
|
// Because of the keyboard, vertical spacing can get pretty cramped,
|
|
// so we have custom spacer logic.
|
|
stackView.autoPinEdges(toSuperviewMarginsExcludingEdge: .bottom)
|
|
autoPinView(toBottomOfViewControllerOrKeyboard: stackView, avoidNotch: true)
|
|
|
|
// Ensure whitespace is balanced, so inputs are vertically centered.
|
|
topSpacer.autoMatch(.height, to: .height, of: bottomSpacer)
|
|
|
|
updateValidationWarnings()
|
|
updatePinType()
|
|
}
|
|
|
|
public override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
|
|
_ = pinTextField.becomeFirstResponder()
|
|
}
|
|
|
|
// MARK: - Events
|
|
|
|
@objc func needHelpLinkWasTapped() {
|
|
Logger.info("")
|
|
let title = NSLocalizedString("REGISTER_2FA_FORGOT_PIN_ALERT_TITLE",
|
|
comment: "Alert title explaining what happens if you forget your 'two-factor auth pin'.")
|
|
|
|
let message: String
|
|
let emailSubject: String
|
|
var additionalActions = [ActionSheetAction]()
|
|
if isUsingKBS {
|
|
if hasPendingRestoration {
|
|
message = NSLocalizedString("REGISTER_2FA_FORGOT_SVR_PIN_WITHOUT_REGLOCK_ALERT_MESSAGE",
|
|
comment: "Alert body for a forgotten SVR (V2) PIN when the user doesn't have reglock")
|
|
emailSubject = "Signal PIN - iOS (V2 PIN without RegLock)"
|
|
|
|
let createNewPinAction = ActionSheetAction(
|
|
title: NSLocalizedString("ONBOARDING_2FA_CREATE_NEW_PIN",
|
|
comment: "Label for the 'create new pin' button when reglock is disabled during onboarding.")
|
|
) { [weak self] _ in
|
|
let actionSheet = ActionSheetController(
|
|
title: NSLocalizedString("ONBOARDING_2FA_SKIP_PIN_ENTRY_TITLE",
|
|
comment: "Title for the skip pin entry action sheet during onboarding."),
|
|
message: NSLocalizedString("ONBOARDING_2FA_SKIP_PIN_ENTRY_MESSAGE",
|
|
comment: "Explanation for the skip pin entry action sheet during onboarding.")
|
|
)
|
|
let skipAndCreateNew = ActionSheetAction(
|
|
title: NSLocalizedString("ONBOARDING_2FA_SKIP_AND_CREATE_NEW_PIN",
|
|
comment: "Label for the 'skip and create new pin' button when reglock is disabled during onboarding."),
|
|
style: .destructive
|
|
) { [weak self] _ in
|
|
self?.showNextMilestone(wasSuccessful: false)
|
|
}
|
|
actionSheet.addAction(skipAndCreateNew)
|
|
actionSheet.addAction(OWSActionSheets.cancelAction)
|
|
self?.presentActionSheet(actionSheet)
|
|
}
|
|
additionalActions.append(createNewPinAction)
|
|
} else {
|
|
message = NSLocalizedString("REGISTER_2FA_FORGOT_SVR_PIN_ALERT_MESSAGE",
|
|
comment: "Alert body for a forgotten SVR (V2) PIN")
|
|
emailSubject = "Signal PIN - iOS (V2 PIN)"
|
|
}
|
|
} else {
|
|
message = NSLocalizedString("REGISTER_2FA_FORGOT_V1_PIN_ALERT_MESSAGE",
|
|
comment: "Alert body for a forgotten V1 PIN")
|
|
emailSubject = "Signal PIN - iOS (V1 PIN)"
|
|
}
|
|
|
|
ContactSupportAlert.presentAlert(title: title, message: message, emailSubject: emailSubject, fromViewController: self, additionalActions: additionalActions)
|
|
}
|
|
|
|
@objc func nextPressed() {
|
|
Logger.info("")
|
|
|
|
tryToVerify()
|
|
}
|
|
|
|
private func tryToVerify(testTruncatedPin: Bool = false) {
|
|
Logger.info("")
|
|
|
|
var pinToUse = pinTextField.text
|
|
|
|
// If true, we're doing a fallback verification to test if this is a
|
|
// legacy pin that was created with >16 characters and then truncated.
|
|
if testTruncatedPin {
|
|
assert((pinToUse?.count ?? 0) > kLegacyTruncated2FAv1PinLength)
|
|
pinToUse = pinToUse?.substring(to: Int(kLegacyTruncated2FAv1PinLength))
|
|
}
|
|
|
|
guard let pin = pinToUse?.ows_stripped(), pin.count >= kMin2FAPinLength else {
|
|
// Check if we're already in an invalid state, if so we can do nothing
|
|
guard !attemptState.isInvalid else { return }
|
|
attemptState = .invalid(remainingAttempts: nil)
|
|
return
|
|
}
|
|
|
|
let progressView = PinProgressView(
|
|
loadingText: NSLocalizedString("REGISTER_2FA_PIN_PROGRESS",
|
|
comment: "Indicates the work we are doing while verifying the user's pin")
|
|
)
|
|
view.addSubview(progressView)
|
|
progressView.autoPinWidthToSuperview()
|
|
progressView.autoVCenterInSuperview()
|
|
|
|
progressView.startLoading {
|
|
self.view.isUserInteractionEnabled = false
|
|
self.nextButton.alpha = 0.5
|
|
self.pinTypeToggle.alpha = 0.5
|
|
}
|
|
|
|
func animateProgressFail() {
|
|
progressView.loadingComplete(success: false, animateAlongside: {
|
|
self.nextButton.alpha = 1
|
|
self.pinTypeToggle.alpha = 1
|
|
}) {
|
|
self.view.isUserInteractionEnabled = true
|
|
progressView.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
// v1 pins also have a max length, but we'll rely on the server to verify that
|
|
// since we do not know if this is a v1 or a v2 pin at registration time.
|
|
|
|
onboardingController.update(twoFAPin: pin)
|
|
|
|
onboardingController.submitVerification(fromViewController: self, completion: { (outcome) in
|
|
switch outcome {
|
|
case .invalid2FAPin:
|
|
// In the past, we used to truncate pins. To support legacy users,
|
|
// also attempt the truncated version of the pin if the original
|
|
// did not match. This error only occurs for v1 registration locks,
|
|
// the variant that includes remaining attempts is used for v2 locks
|
|
// which should not have this problem.
|
|
guard pin.count <= kLegacyTruncated2FAv1PinLength || testTruncatedPin else {
|
|
return self.tryToVerify(testTruncatedPin: true)
|
|
}
|
|
|
|
self.attemptState = .invalid(remainingAttempts: nil)
|
|
animateProgressFail()
|
|
case .invalidV2RegistrationLockPin(let remainingAttempts):
|
|
self.attemptState = .invalid(remainingAttempts: remainingAttempts)
|
|
animateProgressFail()
|
|
case .exhaustedV2RegistrationLockAttempts:
|
|
self.attemptState = .exhausted
|
|
|
|
progressView.loadingComplete(success: false, animated: false) { [weak self] in
|
|
guard let self = self else { return }
|
|
self.showAttemptsExhausted()
|
|
}
|
|
case .success:
|
|
self.attemptState = .valid
|
|
|
|
// The completion handler always dismisses this view, so we don't want to animate anything.
|
|
progressView.loadingComplete(success: true, animated: false) { [weak self] in
|
|
guard let self = self else { return }
|
|
// If we have success while pending restoration, show the next onboarding milestone.
|
|
if self.hasPendingRestoration { self.showNextMilestone(wasSuccessful: true) }
|
|
}
|
|
|
|
case .invalidVerificationCode:
|
|
owsFailDebug("Invalid verification code in 2FA view.")
|
|
animateProgressFail()
|
|
}
|
|
})
|
|
}
|
|
|
|
private func showAttemptsExhausted() {
|
|
guard let navigationController = navigationController else {
|
|
owsFailDebug("Missing navigationController")
|
|
return
|
|
}
|
|
|
|
let vc = OnboardingPinAttemptsExhaustedViewController(onboardingController: onboardingController)
|
|
navigationController.pushViewController(vc, animated: true)
|
|
}
|
|
|
|
private func showNextMilestone(wasSuccessful: Bool) {
|
|
guard let navigationController = navigationController else {
|
|
owsFailDebug("Missing navigationController")
|
|
return
|
|
}
|
|
|
|
databaseStorage.write { transaction in
|
|
// Clear any pending restoration before moving on. At this point we've either
|
|
// successfully restored the user's PIN or the user chose to re-create their PIN.
|
|
KeyBackupService.clearPendingRestoration(transaction: transaction)
|
|
|
|
// If we were successful, also mark the user as having a PIN
|
|
if wasSuccessful {
|
|
OWS2FAManager.shared().markEnabled(transaction: transaction)
|
|
}
|
|
}
|
|
|
|
onboardingController.showNextMilestone(navigationController: navigationController)
|
|
}
|
|
|
|
var hasEverGuessedWrongPIN = false
|
|
private func updateValidationWarnings() {
|
|
AssertIsOnMainThread()
|
|
|
|
if attemptState.isInvalid {
|
|
hasEverGuessedWrongPIN = true
|
|
}
|
|
pinStrokeNormal.isHidden = attemptState.isInvalid
|
|
pinStrokeError.isHidden = !attemptState.isInvalid
|
|
validationWarningLabel.isHidden = !attemptState.isInvalid
|
|
needHelpLink.isHidden = !hasEverGuessedWrongPIN
|
|
|
|
switch attemptState {
|
|
case .exhausted:
|
|
validationWarningLabel.text = NSLocalizedString("ONBOARDING_2FA_ATTEMPTS_EXHAUSTED",
|
|
comment: "Label indicating that the 2fa pin is exhausted in the 'onboarding 2fa' view.")
|
|
case .invalid(let remainingAttempts):
|
|
guard let remaining = remainingAttempts, remaining <= 5 else {
|
|
validationWarningLabel.text = NSLocalizedString("ONBOARDING_2FA_INVALID_PIN",
|
|
comment: "Label indicating that the 2fa pin is invalid in the 'onboarding 2fa' view.")
|
|
break
|
|
}
|
|
|
|
// If there are less than the threshold attempts remaining, also show an alert with more detail.
|
|
if remaining < attemptsAlertThreshold {
|
|
let formatMessage: String
|
|
if remaining == 1 {
|
|
formatMessage = hasPendingRestoration
|
|
? NSLocalizedString("REGISTER_2FA_INVALID_PIN_ALERT_MESSAGE_SINGLE",
|
|
comment: "Alert message explaining what happens if you get your pin wrong and have one attempt remaining 'two-factor auth pin' with reglock disabled.")
|
|
: NSLocalizedString("REGISTER_2FA_INVALID_PIN_ALERT_MESSAGE_REGLOCK_SINGLE",
|
|
comment: "Alert message explaining what happens if you get your pin wrong and have one attempt remaining 'two-factor auth pin' with reglock enabled.")
|
|
} else {
|
|
formatMessage = hasPendingRestoration
|
|
? NSLocalizedString("REGISTER_2FA_INVALID_PIN_ALERT_MESSAGE_PLURAL_FORMAT",
|
|
comment: "Alert message explaining what happens if you get your pin wrong and have multiple attempts remaining 'two-factor auth pin' with reglock disabled.")
|
|
: NSLocalizedString("REGISTER_2FA_INVALID_PIN_ALERT_MESSAGE_REGLOCK_PLURAL_FORMAT",
|
|
comment: "Alert message explaining what happens if you get your pin wrong and have multiple attempts remaining 'two-factor auth pin' with reglock enabled.")
|
|
}
|
|
|
|
OWSActionSheets.showActionSheet(
|
|
title: NSLocalizedString("REGISTER_2FA_INVALID_PIN_ALERT_TITLE",
|
|
comment: "Alert title explaining what happens if you forget your 'two-factor auth pin'."),
|
|
message: String(format: formatMessage, remaining)
|
|
)
|
|
}
|
|
|
|
let formatMessage: String
|
|
if remaining == 1 {
|
|
formatMessage = NSLocalizedString("ONBOARDING_2FA_INVALID_PIN_SINGLE",
|
|
comment: "Label indicating that the 2fa pin is invalid with a retry count of one in the 'onboarding 2fa' view.")
|
|
} else {
|
|
formatMessage = NSLocalizedString("ONBOARDING_2FA_INVALID_PIN_PLURAL_FORMAT",
|
|
comment: "Label indicating that the 2fa pin is invalid with a retry count other than one in the 'onboarding 2fa' view.")
|
|
}
|
|
|
|
validationWarningLabel.text = String(format: formatMessage, remaining)
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func updatePinType() {
|
|
AssertIsOnMainThread()
|
|
|
|
pinTextField.text = nil
|
|
attemptState = .unattempted
|
|
|
|
pinTypeToggle.isHidden = !isUsingKBS
|
|
|
|
switch pinType {
|
|
case .numeric:
|
|
pinTypeToggle.setTitle(NSLocalizedString("ONBOARDING_2FA_ENTER_ALPHANUMERIC",
|
|
comment: "Button asking if the user would like to enter an alphanumeric PIN"), for: .normal)
|
|
pinTextField.keyboardType = .asciiCapableNumberPad
|
|
case .alphanumeric:
|
|
pinTypeToggle.setTitle(NSLocalizedString("ONBOARDING_2FA_ENTER_NUMERIC",
|
|
comment: "Button asking if the user would like to enter an numeric PIN"), for: .normal)
|
|
pinTextField.keyboardType = .default
|
|
}
|
|
|
|
pinTextField.reloadInputViews()
|
|
}
|
|
|
|
@objc func togglePinType() {
|
|
guard isUsingKBS else {
|
|
return owsFailDebug("unexpectedly tried to toggle PIN type when not using KBS")
|
|
}
|
|
|
|
switch pinType {
|
|
case .numeric:
|
|
pinType = .alphanumeric
|
|
case .alphanumeric:
|
|
pinType = .numeric
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension Onboarding2FAViewController: UITextFieldDelegate {
|
|
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
|
let hasPendingChanges: Bool
|
|
if pinType == .numeric {
|
|
ViewControllerUtils.ows2FAPINTextField(textField, shouldChangeCharactersIn: range, replacementString: string)
|
|
hasPendingChanges = false
|
|
} else {
|
|
hasPendingChanges = true
|
|
}
|
|
|
|
// Reset the attempt state to clear errors, since the user is trying again
|
|
attemptState = .unattempted
|
|
|
|
return hasPendingChanges
|
|
}
|
|
|
|
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
tryToVerify()
|
|
return false
|
|
}
|
|
}
|