From 6eed966fc9258ec65954d1851950389f08d2ee17 Mon Sep 17 00:00:00 2001 From: cheykrym Date: Fri, 24 Oct 2025 11:03:27 +0300 Subject: [PATCH] add 2fa --- yobble/Resources/Localizable.xcstrings | 88 ++++++- yobble/ViewModels/LoginViewModel.swift | 6 + yobble/Views/Tab/MainView.swift | 29 +++ yobble/Views/Tab/Settings/SettingsView.swift | 21 +- .../Tab/Settings/TwoFactorAuthView.swift | 226 ++++++++++++++++++ 5 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 yobble/Views/Tab/Settings/TwoFactorAuthView.swift diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index be3c9da..49d92e8 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -61,6 +61,12 @@ } } }, + "2FA включена" : { + "comment" : "Заголовок уведомления об успешной активации 2FA" + }, + "2FA отключена" : { + "comment" : "Заголовок уведомления об отключении 2FA" + }, "Companion ID" : { "comment" : "Search placeholder companion title" }, @@ -247,6 +253,9 @@ } } }, + "Введите код из приложения" : { + "comment" : "Поле ввода кода 2FA" + }, "Веб" : { "comment" : "Тип сессии — веб" }, @@ -285,6 +294,12 @@ }, "Включено" : { + }, + "Включить" : { + "comment" : "Кнопка подтверждения включения 2FA" + }, + "Включить 2FA" : { + "comment" : "Тоггл активации 2FA" }, "Включить автоудаление аккаунта" : { "localizations" : { @@ -296,6 +311,9 @@ } } }, + "Включить двухфакторную аутентификацию?" : { + "comment" : "Заголовок подтверждения включения 2FA" + }, "Вложение" : { }, @@ -345,12 +363,18 @@ } } }, + "Вы всегда можете отключить двухфакторную защиту, но мы рекомендуем оставлять её включённой для безопасности." : { + "comment" : "Рекомендация оставить 2FA включенной" + }, "Вы выйдете из выбранной сессии." : { "comment" : "Описание подтверждения завершения конкретной сессии" }, "Вы выйдете со всех устройств, кроме текущего." : { "comment" : "Описание подтверждения завершения сессий" }, + "Вы можете включить защиту снова в любой момент." : { + "comment" : "Сообщение после отключения 2FA" + }, "Выберите оценку — это поможет нам понять настроение." : { "comment" : "feedback: rating hint", "localizations" : { @@ -403,6 +427,7 @@ } }, "Двухфакторная аутентификация" : { + "comment" : "Заголовок экрана 2FA", "localizations" : { "en" : { "stringUnit" : { @@ -412,6 +437,9 @@ } } }, + "Двухфакторная аутентификация настроена." : { + "comment" : "Сообщение после успешного подтверждения кода 2FA" + }, "Десктоп" : { "comment" : "Тип сессии — десктоп" }, @@ -432,6 +460,9 @@ "Добавьте контакты, чтобы быстрее выходить на связь." : { "comment" : "Contacts empty state subtitle" }, + "Добавьте новый аккаунт в приложении аутентификации и введите следующий ключ:" : { + "comment" : "Инструкция по добавлению ключа 2FA" + }, "Другие устройства (%d)" : { "comment" : "Заголовок секции других устройств с количеством" }, @@ -474,9 +505,6 @@ }, "Заглушка: Push-уведомления" : { - }, - "Заглушка: Двухфакторная аутентификация" : { - }, "Заглушка: Другие настройки" : { "extractionState" : "stale", @@ -583,6 +611,9 @@ "Здесь появится информация о собеседнике и существующих чатах." : { "comment" : "Search placeholder description" }, + "Значение сохранено в буфере обмена." : { + "comment" : "Сообщение после копирования" + }, "Идея" : { "comment" : "feedback category: idea", "localizations" : { @@ -663,6 +694,12 @@ "Код дружбы" : { "comment" : "Friend code badge" }, + "Код принят" : { + "comment" : "Заголовок успешного подтверждения кода 2FA" + }, + "Коды восстановления" : { + "comment" : "Раздел кодов восстановления 2FA" + }, "Контактов пока нет" : { "comment" : "Contacts empty state title" }, @@ -816,6 +853,9 @@ } } } + }, + "Мессенджер-режим сейчас проработан примерно на 50%." : { + }, "Мессенджер-режим сейчас проработан примерно на 60%." : { @@ -958,6 +998,9 @@ } } }, + "Настройка приложения" : { + "comment" : "Раздел инструкций подключения" + }, "Настройки" : { "comment" : "Settings", "localizations" : { @@ -1112,6 +1155,9 @@ } } }, + "Неверный код" : { + "comment" : "Заголовок ошибки неправильного кода 2FA" + }, "Неверный код приглашения." : { "localizations" : { "en" : { @@ -1363,6 +1409,12 @@ }, "Основной режим находится в ранней разработке (около 10%)." : { + }, + "Отключить" : { + "comment" : "Кнопка подтверждения отключения 2FA" + }, + "Отключить двухфакторную аутентификацию?" : { + "comment" : "Заголовок подтверждения отключения 2FA" }, "Открыть правила" : { @@ -1630,6 +1682,9 @@ }, "Подключение" : { + }, + "Подтвердить" : { + "comment" : "Кнопка подтверждения кода 2FA" }, "Подтверждение пароля" : { "comment" : "Подтверждение пароля", @@ -1885,6 +1940,9 @@ } } }, + "Проверочный код" : { + "comment" : "Раздел верификации 2FA" + }, "Проверьте данные и повторите попытку." : { "localizations" : { "en" : { @@ -1895,6 +1953,9 @@ } } }, + "Проверьте цифры и попробуйте снова." : { + "comment" : "Описание ошибки неверного кода 2FA" + }, "Произошла неизвестная ошибка." : { "comment" : "Search unknown error" }, @@ -2092,6 +2153,9 @@ "Связаться с разработчиками" : { "comment" : "FAQ: contact developers link" }, + "Сгенерируйте резервные коды и сохраните их в надежном месте." : { + "comment" : "Подсказка о необходимости генерации кодов" + }, "Сервер вернул ошибку (%d)." : { "comment" : "Search error server status" }, @@ -2140,9 +2204,18 @@ } } }, + "Скопировано" : { + "comment" : "Заголовок уведомления о копировании" + }, "Скопировать" : { "comment" : "Search placeholder copy" }, + "Скопировать ключ" : { + "comment" : "Кнопка копирования секретного ключа" + }, + "Скопировать код" : { + "comment" : "Кнопка копирования кода восстановления" + }, "Скоро" : { "comment" : "Add blocked user placeholder title\nContacts placeholder title" }, @@ -2187,6 +2260,9 @@ }, "Согласиться с правилами" : { + }, + "Создать новые коды" : { + "comment" : "Кнопка генерации резервных кодов" }, "Сообщение" : { @@ -2205,6 +2281,9 @@ } } }, + "Сохраните секретный ключ и введите код из приложения, чтобы завершить настройку." : { + "comment" : "Сообщение после активации 2FA" + }, "Сохранить изменения" : { "localizations" : { "en" : { @@ -2258,6 +2337,9 @@ } } }, + "Статус защиты" : { + "comment" : "Раздел состояния 2FA" + }, "Стикеры" : { }, diff --git a/yobble/ViewModels/LoginViewModel.swift b/yobble/ViewModels/LoginViewModel.swift index 4df145f..f17e45a 100644 --- a/yobble/ViewModels/LoginViewModel.swift +++ b/yobble/ViewModels/LoginViewModel.swift @@ -22,6 +22,7 @@ class LoginViewModel: ObservableObject { @Published var isLoadingTerms: Bool = false @Published var termsContent: String = "" @Published var termsErrorMessage: String? + @Published var onboardingDestination: OnboardingDestination? private let authService = AuthService() private let socketService = SocketService.shared @@ -32,6 +33,10 @@ class LoginViewModel: ObservableObject { case loading } + enum OnboardingDestination: Equatable { + case twoFactor + } + private enum DefaultsKeys { static let currentUser = "currentUser" static let userId = "userId" @@ -127,6 +132,7 @@ class LoginViewModel: ObservableObject { self?.isLoggedIn = true // 👈 переключаем на главный экран после автологина self?.loadStoredUser() self?.socketService.connectForCurrentUser() + self?.onboardingDestination = .twoFactor } else { self?.socketService.disconnect() } diff --git a/yobble/Views/Tab/MainView.swift b/yobble/Views/Tab/MainView.swift index bdde7df..c9a414f 100644 --- a/yobble/Views/Tab/MainView.swift +++ b/yobble/Views/Tab/MainView.swift @@ -21,6 +21,7 @@ struct MainView: View { @State private var isQrPresented = false @State private var deepLinkChatItem: PrivateChatListItem? @State private var isDeepLinkChatActive = false + @State private var hasTriggeredTwoFactorOnboarding = false private var tabTitle: String { switch selectedTab { @@ -172,9 +173,14 @@ struct MainView: View { } .onAppear { enforceTabSelectionForMessengerMode() + handleTwoFactorOnboardingIfNeeded() } .onChange(of: isMessengerModeEnabled) { _ in enforceTabSelectionForMessengerMode() + handleTwoFactorOnboardingIfNeeded() + } + .onChange(of: viewModel.onboardingDestination) { _ in + handleTwoFactorOnboardingIfNeeded() } .onChange(of: messageCenter.pendingNavigation?.id) { _ in guard !AppConfig.PRESENT_CHAT_AS_SHEET, @@ -217,6 +223,29 @@ private extension MainView { } } + func handleTwoFactorOnboardingIfNeeded() { + guard viewModel.onboardingDestination == .twoFactor else { + hasTriggeredTwoFactorOnboarding = false + return + } + + guard !hasTriggeredTwoFactorOnboarding else { return } + hasTriggeredTwoFactorOnboarding = true + + if isMessengerModeEnabled { + if selectedTab != 5 { + selectedTab = 5 + } + } else { + if selectedTab != 3 { + selectedTab = 3 + } + DispatchQueue.main.async { + isSettingsPresented = true + } + } + } + var deepLinkNavigationLink: some View { NavigationLink( destination: deepLinkChatDestination, diff --git a/yobble/Views/Tab/Settings/SettingsView.swift b/yobble/Views/Tab/Settings/SettingsView.swift index 3f4efec..2fc9f23 100644 --- a/yobble/Views/Tab/Settings/SettingsView.swift +++ b/yobble/Views/Tab/Settings/SettingsView.swift @@ -4,6 +4,7 @@ struct SettingsView: View { @ObservedObject var viewModel: LoginViewModel @EnvironmentObject private var themeManager: ThemeManager @State private var isThemeExpanded = false + @State private var isTwoFactorActive = false private let themeOptions = ThemeOption.ordered private var selectedThemeOption: ThemeOption { @@ -32,8 +33,10 @@ struct SettingsView: View { NavigationLink(destination: ChangePasswordView()) { Label(NSLocalizedString("Сменить пароль", comment: ""), systemImage: "key") } - NavigationLink(destination: Text("Заглушка: Двухфакторная аутентификация")) { - Label("Двухфакторная аутентификация", systemImage: "lock.shield") + NavigationLink(isActive: $isTwoFactorActive) { + TwoFactorAuthView() + } label: { + Label(NSLocalizedString("Двухфакторная аутентификация", comment: ""), systemImage: "lock.shield") } NavigationLink(destination: ActiveSessionsView()) { Label(NSLocalizedString("Активные сессии", comment: ""), systemImage: "iphone") @@ -125,6 +128,12 @@ struct SettingsView: View { } } .navigationTitle("Настройки") + .onAppear { + handleTwoFactorOnboardingIfNeeded() + } + .onChange(of: viewModel.onboardingDestination) { _ in + handleTwoFactorOnboardingIfNeeded() + } } private func openLanguageSettings() { @@ -165,3 +174,11 @@ struct SettingsView: View { } } + +private extension SettingsView { + func handleTwoFactorOnboardingIfNeeded() { + guard viewModel.onboardingDestination == .twoFactor else { return } + isTwoFactorActive = true + viewModel.onboardingDestination = nil + } +} diff --git a/yobble/Views/Tab/Settings/TwoFactorAuthView.swift b/yobble/Views/Tab/Settings/TwoFactorAuthView.swift new file mode 100644 index 0000000..6b1f70c --- /dev/null +++ b/yobble/Views/Tab/Settings/TwoFactorAuthView.swift @@ -0,0 +1,226 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +struct TwoFactorAuthView: View { + @State private var isTwoFactorEnabled = false + @State private var showEnableConfirmation = false + @State private var showDisableConfirmation = false + @State private var secretKey: String = TwoFactorAuthView.generateSecret() + @State private var verificationCode: String = "" + @State private var backupCodes: [String] = [] + @State private var activeAlert: TwoFactorAlert? + @FocusState private var isCodeFieldFocused: Bool + + var body: some View { + List { + Section(header: Text(NSLocalizedString("Статус защиты", comment: "Раздел состояния 2FA"))) { + Toggle(isOn: Binding( + get: { isTwoFactorEnabled }, + set: { handleToggleChange($0) } + )) { + Label(NSLocalizedString("Включить 2FA", comment: "Тоггл активации 2FA"), systemImage: "lock.shield") + } + } + + if isTwoFactorEnabled { + Section(header: Text(NSLocalizedString("Настройка приложения", comment: "Раздел инструкций подключения"))) { + Text(NSLocalizedString("Добавьте новый аккаунт в приложении аутентификации и введите следующий ключ:", comment: "Инструкция по добавлению ключа 2FA")) + .font(.callout) + keyRow + } + + Section(header: Text(NSLocalizedString("Проверочный код", comment: "Раздел верификации 2FA"))) { + VStack(alignment: .leading, spacing: 12) { + TextField(NSLocalizedString("Введите код из приложения", comment: "Поле ввода кода 2FA"), text: $verificationCode) + .keyboardType(.numberPad) + .focused($isCodeFieldFocused) + .onChange(of: verificationCode) { newValue in + verificationCode = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + } + + Button(action: verifyCode) { + Text(NSLocalizedString("Подтвердить", comment: "Кнопка подтверждения кода 2FA")) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(verificationCode.isEmpty) + } + .padding(.vertical, 4) + } + + Section(header: Text(NSLocalizedString("Коды восстановления", comment: "Раздел кодов восстановления 2FA"))) { + if backupCodes.isEmpty { + Text(NSLocalizedString("Сгенерируйте резервные коды и сохраните их в надежном месте.", comment: "Подсказка о необходимости генерации кодов")) + .font(.callout) + .foregroundColor(.secondary) + } else { + ForEach(backupCodes, id: \.self) { code in + HStack { + Text(code) + .font(.system(.body, design: .monospaced)) + Spacer() + Button(action: { copyToPasteboard(code) }) { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString("Скопировать код", comment: "Кнопка копирования кода восстановления")) + } + } + } + + Button(action: generateBackupCodes) { + Label(NSLocalizedString("Создать новые коды", comment: "Кнопка генерации резервных кодов"), systemImage: "arrow.clockwise") + } + } + + Section(footer: Text(NSLocalizedString("Вы всегда можете отключить двухфакторную защиту, но мы рекомендуем оставлять её включённой для безопасности.", comment: "Рекомендация оставить 2FA включенной"))) { + EmptyView() + } + } + } + .listStyle(.insetGrouped) + .navigationTitle(NSLocalizedString("Двухфакторная аутентификация", comment: "Заголовок экрана 2FA")) + .navigationBarTitleDisplayMode(.inline) + .alert(item: $activeAlert) { alert in + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text(NSLocalizedString("OK", comment: "Общий текст кнопки OK"))) + ) + } + .confirmationDialog( + NSLocalizedString("Включить двухфакторную аутентификацию?", comment: "Заголовок подтверждения включения 2FA"), + isPresented: $showEnableConfirmation, + titleVisibility: .visible + ) { + Button(NSLocalizedString("Включить", comment: "Кнопка подтверждения включения 2FA"), role: .destructive) { + enableTwoFactor() + } + Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {} + } + .confirmationDialog( + NSLocalizedString("Отключить двухфакторную аутентификацию?", comment: "Заголовок подтверждения отключения 2FA"), + isPresented: $showDisableConfirmation, + titleVisibility: .visible + ) { + Button(NSLocalizedString("Отключить", comment: "Кнопка подтверждения отключения 2FA"), role: .destructive) { + disableTwoFactor() + } + Button(NSLocalizedString("Отмена", comment: "Общий текст кнопки отмены"), role: .cancel) {} + } + } +} + +private extension TwoFactorAuthView { + var keyRow: some View { + HStack(alignment: .center, spacing: 12) { + Text(secretKey) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + Spacer() + Button(action: { copyToPasteboard(secretKey) }) { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString("Скопировать ключ", comment: "Кнопка копирования секретного ключа")) + } + .padding(8) + .background(Color(UIColor.secondarySystemBackground)) + .cornerRadius(10) + } + + func handleToggleChange(_ newValue: Bool) { + if newValue { + showEnableConfirmation = true + } else { + showDisableConfirmation = true + } + } + + func enableTwoFactor() { + isTwoFactorEnabled = true + showEnableConfirmation = false + secretKey = Self.generateSecret() + verificationCode = "" + generateBackupCodes() + activeAlert = TwoFactorAlert( + title: NSLocalizedString("2FA включена", comment: "Заголовок уведомления об успешной активации 2FA"), + message: NSLocalizedString("Сохраните секретный ключ и введите код из приложения, чтобы завершить настройку.", comment: "Сообщение после активации 2FA") + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + isCodeFieldFocused = true + } + } + + func disableTwoFactor() { + isTwoFactorEnabled = false + showDisableConfirmation = false + verificationCode = "" + backupCodes.removeAll() + activeAlert = TwoFactorAlert( + title: NSLocalizedString("2FA отключена", comment: "Заголовок уведомления об отключении 2FA"), + message: NSLocalizedString("Вы можете включить защиту снова в любой момент.", comment: "Сообщение после отключения 2FA") + ) + } + + func verifyCode() { + let normalized = verificationCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.count == 6, normalized.allSatisfy(\.isNumber) else { + activeAlert = TwoFactorAlert( + title: NSLocalizedString("Неверный код", comment: "Заголовок ошибки неправильного кода 2FA"), + message: NSLocalizedString("Проверьте цифры и попробуйте снова.", comment: "Описание ошибки неверного кода 2FA") + ) + return + } + + verificationCode = "" + activeAlert = TwoFactorAlert( + title: NSLocalizedString("Код принят", comment: "Заголовок успешного подтверждения кода 2FA"), + message: NSLocalizedString("Двухфакторная аутентификация настроена.", comment: "Сообщение после успешного подтверждения кода 2FA") + ) + } + + func generateBackupCodes() { + backupCodes = Self.generateBackupCodes() + } + + func copyToPasteboard(_ value: String) { +#if canImport(UIKit) + UIPasteboard.general.string = value +#endif + activeAlert = TwoFactorAlert( + title: NSLocalizedString("Скопировано", comment: "Заголовок уведомления о копировании"), + message: NSLocalizedString("Значение сохранено в буфере обмена.", comment: "Сообщение после копирования") + ) + } + + static func generateSecret() -> String { + let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") + return String((0..<16).compactMap { _ in alphabet.randomElement() }) + } + + static func generateBackupCodes(count: Int = 8) -> [String] { + let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") + return (0..