Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,7 @@
ConfigurationTransfer/AppSettingsSnapshot.swift,
ConfigurationTransfer/ImportExportConfigurationView.swift,
ConfigurationTransfer/ImportExportConfigurationViewModel.swift,
Connection/ClientCertificateImportFormSheet.swift,
Connection/ClientCertificateView.swift,
Connection/ConnectionHardwareAddressesView.swift,
Connection/ConnectionSettingsView.swift,
Expand All @@ -1494,6 +1495,8 @@
Connection/ServerDetailsRowView.swift,
Connection/ServerEditMenu.swift,
Connection/ServerHowItWorksSheet.swift,
Connection/ServerReauthenticationModel.swift,
Connection/ServerReauthenticationModifier.swift,
Connection/ServerSettingsSheet.swift,
Connection/ServerURLEditorSheet.swift,
Connection/Webhook/WebhookDetailView.swift,
Expand Down Expand Up @@ -1720,6 +1723,7 @@
ConfigurationTransfer/AppSettingsSnapshot.swift,
ConfigurationTransfer/ImportExportConfigurationView.swift,
ConfigurationTransfer/ImportExportConfigurationViewModel.swift,
Connection/ClientCertificateImportFormSheet.swift,
Connection/ClientCertificateView.swift,
Connection/ConnectionHardwareAddressesView.swift,
Connection/ConnectionSettingsView.swift,
Expand All @@ -1732,6 +1736,8 @@
Connection/ServerDetailsRowView.swift,
Connection/ServerEditMenu.swift,
Connection/ServerHowItWorksSheet.swift,
Connection/ServerReauthenticationModel.swift,
Connection/ServerReauthenticationModifier.swift,
Connection/ServerSettingsSheet.swift,
Connection/ServerURLEditorSheet.swift,
Connection/Webhook/WebhookDetailView.swift,
Expand Down
83 changes: 14 additions & 69 deletions Sources/App/Container/OnboardingStateObservable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,18 +118,7 @@ final class OnboardingStateObservable: ObservableObject {
server.info.connection.availableAuthenticationURLTypes
}

var clientCertificateRequirement: (URL) -> Promise<Bool> =
OnboardingAuthStepClientCertificate.requiresClientCertificate(at:)

var fetchToken: (String, URL, SecurityExceptions, ClientCertificate?) -> Promise<TokenInfo> = {
code, baseURL, exceptions, clientCertificate in
AuthenticationAPI.fetchToken(
authorizationCode: code,
baseURL: baseURL,
exceptions: exceptions,
clientCertificate: clientCertificate
)
}
var reauthenticator = ServerReauthenticator()

/// Re-authenticates a server recovered from a keychain-mirror restore, then shows its web view.
/// Moved from `WebViewWindowController`; `login` is provided by the re-auth screen, which presents
Expand All @@ -141,68 +130,24 @@ final class OnboardingStateObservable: ObservableObject {
importClientCertificate: @escaping () -> Promise<ClientCertificate>,
completion: @escaping (Swift.Result<Void, Error>) -> Void
) {
let connectionInfo = server.info.connection
guard let baseURL = connectionInfo.address(for: urlType) else {
completion(.failure(ServerConnectionError.noActiveURL(server.info.name)))
return
}
do {
let authDetails = try OnboardingAuthDetails(baseURL: baseURL)
authDetails.exceptions = connectionInfo.securityExceptions
authDetails.clientCertificate = connectionInfo.clientCertificate
firstly { [self] in
ensureClientCertificate(for: authDetails, importClientCertificate: importClientCertificate)
}.then {
login(authDetails)
}.then { [self] result -> Promise<(URL?, TokenInfo)> in
let correctedURL = result.resolvedURL?.sameHostRedirectBaseURL(from: baseURL)
return fetchToken(
result.code,
correctedURL ?? baseURL,
authDetails.exceptions,
authDetails.clientCertificate
).map { (correctedURL, $0) }
}.done { [weak self] correctedURL, tokenInfo in
server.update { serverInfo in
serverInfo.token = tokenInfo
if let certificate = authDetails.clientCertificate {
serverInfo.connection.clientCertificate = certificate
}
if let correctedURL {
Current.Log.info("Updating \(urlType) URL to redirect \(correctedURL) during re-auth")
serverInfo.connection.set(address: correctedURL, for: urlType)
}
}
completion(.success(()))
self?.showWebView(for: server)
}.catch(policy: .allErrors) { error in
if let pmkError = error as? PMKError, pmkError.isCancelled {
completion(.failure(RecoveredServerReauthenticationError.cancelled))
return
}
Current.Log.error("Recovered server re-authentication failed: \(error)")
completion(.failure(error))
reauthenticator.reauthenticate(
server,
using: urlType,
login: login,
importClientCertificate: importClientCertificate
).done { [weak self] in
completion(.success(()))
self?.showWebView(for: server)
}.catch(policy: .allErrors) { error in
if let pmkError = error as? PMKError, pmkError.isCancelled {
completion(.failure(RecoveredServerReauthenticationError.cancelled))
return
}
} catch {
Current.Log.error("Failed to create auth details for recovered server re-authentication: \(error)")
Current.Log.error("Recovered server re-authentication failed: \(error)")
completion(.failure(error))
}
}

private func ensureClientCertificate(
for authDetails: OnboardingAuthDetails,
importClientCertificate: @escaping () -> Promise<ClientCertificate>
) -> Promise<Void> {
guard authDetails.clientCertificate == nil else { return .value(()) }
return clientCertificateRequirement(authDetails.url).then { required -> Promise<Void> in
guard required else { return .value(()) }
Current.Log.info("[mTLS] Recovered server requires a client certificate, asking for one")
return importClientCertificate().done { certificate in
authDetails.clientCertificate = certificate
}
}
}

/// Mirrors `WebViewWindowController.setup()`: onboarding when required, otherwise the last viewed
/// server's web view (restoring its last path).
private static func initialScreen() -> Screen {
Expand Down
84 changes: 84 additions & 0 deletions Sources/App/Container/ServerReauthenticator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Foundation
import PromiseKit
import Shared

/// Logs an already-registered server in again and stores the new token, leaving everything else about
/// the server (its URLs, webhook and device registration) as it is. Shared by the recovered-server
/// re-auth screen and the Re-authenticate button in a server's details; the caller presents the login
/// web view through `login` and decides what happens once the token is stored.
struct ServerReauthenticator {
/// Posted with the `Server` as object once a re-authentication stored its new token, so a web view
/// showing that server's logged-out state can load the frontend again.
static let didReauthenticateNotification = Notification.Name("ServerReauthenticatorDidReauthenticate")

var clientCertificateRequirement: (URL) -> Promise<Bool> =
OnboardingAuthStepClientCertificate.requiresClientCertificate(at:)

var fetchToken: (String, URL, SecurityExceptions, ClientCertificate?) -> Promise<TokenInfo> = {
code, baseURL, exceptions, clientCertificate in
AuthenticationAPI.fetchToken(
authorizationCode: code,
baseURL: baseURL,
exceptions: exceptions,
clientCertificate: clientCertificate
)
}

/// Runs the login flow against the server's `urlType` address. A login page redirected to another
/// port or scheme on the same host also moves the stored address there. Rejects with
/// `PMKError.cancelled` when the user cancels the login.
func reauthenticate(
_ server: Server,
using urlType: ConnectionInfo.URLType,
login: @escaping (OnboardingAuthDetails) -> Promise<OnboardingAuthLoginResult>,
importClientCertificate: @escaping () -> Promise<ClientCertificate>
) -> Promise<Void> {
let connectionInfo = server.info.connection
guard let baseURL = connectionInfo.address(for: urlType) else {
return Promise(error: ServerConnectionError.noActiveURL(server.info.name))
}

return firstly { () -> Promise<OnboardingAuthDetails> in
let authDetails = try OnboardingAuthDetails(baseURL: baseURL)
authDetails.exceptions = connectionInfo.securityExceptions
authDetails.clientCertificate = connectionInfo.clientCertificate
return ensureClientCertificate(for: authDetails, importClientCertificate: importClientCertificate)
.map { authDetails }
}.then { authDetails in
login(authDetails).map { (authDetails, $0) }
}.then { authDetails, result -> Promise<(OnboardingAuthDetails, URL?, TokenInfo)> in
let correctedURL = result.resolvedURL?.sameHostRedirectBaseURL(from: baseURL)
return fetchToken(
result.code,
correctedURL ?? baseURL,
authDetails.exceptions,
authDetails.clientCertificate
).map { (authDetails, correctedURL, $0) }
}.done { authDetails, correctedURL, tokenInfo in
server.update { serverInfo in
serverInfo.token = tokenInfo
Comment on lines +58 to +59
if let certificate = authDetails.clientCertificate {
serverInfo.connection.clientCertificate = certificate
}
if let correctedURL {
Current.Log.info("Updating \(urlType) URL to redirect \(correctedURL) during re-auth")
serverInfo.connection.set(address: correctedURL, for: urlType)
}
}
}
}

private func ensureClientCertificate(
for authDetails: OnboardingAuthDetails,
importClientCertificate: @escaping () -> Promise<ClientCertificate>
) -> Promise<Void> {
guard authDetails.clientCertificate == nil else { return .value(()) }
return clientCertificateRequirement(authDetails.url).then { required -> Promise<Void> in
guard required else { return .value(()) }
Current.Log.info("[mTLS] Server requires a client certificate for re-authentication, asking for one")
return importClientCertificate().done { certificate in
authDetails.clientCertificate = certificate
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ extension WebViewController {
Current.Log.info("Re-authentication successful, tokens updated and WebView refreshing")
}

/// The server was re-authenticated somewhere else (its settings), so leave a logged-out or
/// auth-invalid state behind and reload the frontend, which may now belong to a different user.
@objc func serverDidReauthenticate(_ notification: Notification) {
guard let reauthenticatedServer = notification.object as? Server,
reauthenticatedServer.identifier == server.identifier else { return }
DispatchQueue.main.async { [self] in
Current.Log.info("Server \(server.identifier.rawValue) re-authenticated, reloading its web view")
didLogOut = false
connectionState = .unknown
hideEmptyState()
refresh()
}
}

private func showReauthFailureAlert(error: Error) {
let alert = UIAlertController(
title: L10n.Alerts.AuthRequired.title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ extension WebViewController {
object: nil
)

NotificationCenter.default.addObserver(
self,
selector: #selector(serverDidReauthenticate(_:)),
name: ServerReauthenticator.didReauthenticateNotification,
object: nil
)

tokens.append(server.observe { [weak self] _ in
self?.connectionInfoDidChange()
})
Expand Down
5 changes: 5 additions & 0 deletions Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1684,6 +1684,11 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho
"settings.connection_section.open_server" = "Open";
"settings.connection_section.refresh_server" = "Update server information";
"settings.connection_section.remote_ui_url.title" = "Remote UI URL";
"settings.connection_section.reauthenticate.error_title" = "Couldn't re-authenticate";
"settings.connection_section.reauthenticate.footer" = "Sign in to this server again, for example after changing your password or to use a different user. Your server's URLs and settings stay as they are.";
"settings.connection_section.reauthenticate.progress" = "Re-authenticating…";
"settings.connection_section.reauthenticate.title" = "Re-authenticate";
"settings.connection_section.reauthenticate.url_picker_title" = "Sign in using";
"settings.connection_section.sensor_send_type.setting.all" = "All";
"settings.connection_section.sensor_send_type.setting.none" = "None";
"settings.connection_section.sensor_send_type.title" = "Sensors Sent";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Shared
import SwiftUI
import UIKit

/// Presents the client certificate import as a UIKit form sheet while `request` is set, and dismisses
/// it once the request is answered. Used on Mac Catalyst, where SwiftUI sheet content doesn't receive
/// mouse events reliably; the web view presents the same import this way.
struct ClientCertificateImportFormSheet: UIViewControllerRepresentable {
let request: OnboardingClientCertificateRequest?

final class Coordinator {
/// Only this sheet is dismissed: the presenting controller may be showing something else.
weak var presented: UIViewController?
}

func makeCoordinator() -> Coordinator {
Coordinator()
}

func makeUIViewController(context: Context) -> UIViewController {
UIViewController()
}

func updateUIViewController(_ controller: UIViewController, context: Context) {
let coordinator = context.coordinator
if let request, coordinator.presented == nil {
let sheet = UIHostingController(rootView: NavigationView {
ClientCertificateOnboardingView(onImport: request.complete(with:), onCancel: request.cancel)
}.navigationViewStyle(.stack))
sheet.modalPresentationStyle = .formSheet
sheet.isModalInPresentation = true
controller.present(sheet, animated: true)
coordinator.presented = sheet
} else if request == nil, let presented = coordinator.presented {
presented.dismiss(animated: true)
coordinator.presented = nil
}
}
}

#Preview {
ClientCertificateImportFormSheet(request: nil)
}
34 changes: 33 additions & 1 deletion Sources/App/Settings/Connection/ConnectionSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import SwiftUI
/// SwiftUI view for managing server connection settings
struct ConnectionSettingsView: View {
@StateObject private var viewModel: ConnectionSettingsViewModel
@StateObject private var reauthentication: ServerReauthenticationModel
@Environment(\.dismiss) private var dismiss
@Environment(\.appSettingsPresenter) private var appSettingsPresenter
@State private var shareWrapper: ShareWrapper?
Expand All @@ -17,8 +18,13 @@ struct ConnectionSettingsView: View {

let onDismiss: (() -> Void)?

init(server: Server, onDismiss: (() -> Void)? = nil) {
init(
server: Server,
reauthentication: ServerReauthenticationModel? = nil,
onDismiss: (() -> Void)? = nil
) {
self._viewModel = StateObject(wrappedValue: ConnectionSettingsViewModel(server: server))
self._reauthentication = StateObject(wrappedValue: reauthentication ?? ServerReauthenticationModel())
self.onDismiss = onDismiss
}

Expand All @@ -28,6 +34,7 @@ struct ConnectionSettingsView: View {
clientCertificateSection
privacySection
statusSection
reauthenticateSection
deleteSection
}
.navigationTitle(L10n.Settings.ConnectionSection.serverDetailsTitle)
Expand Down Expand Up @@ -301,6 +308,30 @@ struct ConnectionSettingsView: View {
appSettingsPresenter?.isSheetPresented = false
}

// MARK: - Re-authenticate Section

private var reauthenticateSection: some View {
Section {
Button { reauthentication.start(for: viewModel.server) } label: {
if reauthentication.isReauthenticating(viewModel.server) {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
Text(L10n.Settings.ConnectionSection.Reauthenticate.progress)
}
} else {
Text(L10n.Settings.ConnectionSection.Reauthenticate.title)
}
}
.disabled(
reauthentication.isReauthenticating || reauthentication.urlTypes(for: viewModel.server).isEmpty
)
.serverReauthentication(reauthentication, for: viewModel.server)
} footer: {
Text(L10n.Settings.ConnectionSection.Reauthenticate.footer)
}
}

// MARK: - Delete Section

private var deleteSection: some View {
Expand Down Expand Up @@ -359,6 +390,7 @@ extension ConnectionSettingsView: SettingsScreenSearchable {
SettingsSearchEntry(L10n.Settings.ConnectionSection.Websocket.title),
SettingsSearchEntry(L10n.SettingsDetails.Notifications.LocalPush.title),
SettingsSearchEntry(L10n.Settings.ConnectionSection.Webhook.title),
SettingsSearchEntry(L10n.Settings.ConnectionSection.Reauthenticate.title),
SettingsSearchEntry(L10n.Settings.ConnectionSection.DeleteServer.title),
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,6 @@ final class ConnectionSettingsViewModel: ObservableObject {
private func loadInitialData() {
updateFromServerInfo(server.info)
updateURLs()
clientCertificate = server.info.connection.clientCertificate
guard let api = Current.api(for: server) else { return }
api.cachedProfilePicture { [weak self] image in
Task { @MainActor [weak self] in
Expand All @@ -226,6 +225,8 @@ final class ConnectionSettingsViewModel: ObservableObject {

private func updateFromServerInfo(_ info: ServerInfo) {
serverName = info.name
// Follows the server, so a certificate stored by Re-authenticate shows up without reopening the screen.
clientCertificate = info.connection.clientCertificate
// The stored copy re-evaluates the active URL on save, but observers get the value as written,
// so evaluate a copy here to keep the highlighted pill right after an edit.
var connection = server.info.connection
Expand Down
Loading
Loading