Skip to content

Commit 02db23c

Browse files
wpmobilebotcrazytonylijkmassel
authored
Merge release/25.9 into trunk (#24530)
* Bump version number * Update draft release notes for 25.9 * Update draft release notes for 25.9 * Release Notes: add new section for next version (26.0) * Application passwords reauthentication flow (#24522) * Update wordpress-rs * Do not overwrite account passwords with application passwords * Prompt for re-authentication when the application password becomes invalid * Fix unit tests compiling issues * Delete `ApplicationPasswordUpdatedViewModifier` * Update an unit test Blog.password was overwritten to the new application password. We no longer do that anymore. * Add/more error logging (#24527) * Improve error when loading notification settings It previously displayed an unhelpful error message * Log failures to fetch dashboard cards * Use error message, if available * Fix comment * Update strings for localization --------- Co-authored-by: Tony Li <[email protected]> Co-authored-by: Jeremy Massel <[email protected]>
1 parent 961b6a9 commit 02db23c

File tree

20 files changed

+491
-839
lines changed

20 files changed

+491
-839
lines changed

Modules/Package.swift

+2-3
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,8 @@ let package = Package(
5151
.package(url: "https://github.com/wordpress-mobile/WordPressKit-iOS", branch: "wpios-edition"),
5252
.package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"),
5353
// We can't use wordpress-rs branches nor commits here. Only tags work.
54-
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250411"),
55-
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from:
56-
"0.2.0"),
54+
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250505"),
55+
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.2.0"),
5756
.package(url: "https://github.com/Automattic/color-studio", branch: "trunk"),
5857
.package(url: "https://github.com/wordpress-mobile/AztecEditor-iOS", from: "1.20.0"),
5958
],

RELEASE-NOTES.txt

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
26.0
2+
-----
3+
4+
15
25.9
26
-----
37

Sources/WordPressData/Swift/Blog+SelfHosted.swift

+5-4
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,18 @@ public extension Blog {
2424
using keychainImplementation: KeychainAccessible = KeychainUtils()
2525
) async throws -> TaggedManagedObjectID<Blog> {
2626
try await contextManager.performAndSave { context in
27-
let blog = Blog.createBlankBlog(in: context)
27+
let blog = Blog.lookup(username: details.userLogin, xmlrpc: xmlrpcEndpointURL.absoluteString, in: context)
28+
?? Blog.createBlankBlog(in: context)
2829
blog.url = details.siteUrl
2930
blog.username = details.userLogin
3031
blog.restApiRootURL = restApiRootURL.absoluteString
3132
blog.setXMLRPCEndpoint(to: xmlrpcEndpointURL)
3233
blog.setSiteIdentifier(details.derivedSiteId)
3334

34-
// `url` and `xmlrpc` need to be set before setting passwords.
35+
// `url` and `xmlrpc` need to be set before setting the application password.
3536
try blog.setApplicationToken(details.password, using: keychainImplementation)
36-
// Application token can also be used in XMLRPC.
37-
try blog.setPassword(to: details.password, using: keychainImplementation)
37+
// We don't overwrite the `Blog.password` with the application password (`details.password`), because we want
38+
// the application continues to function when the application password is revoked.
3839

3940
return TaggedManagedObjectID(blog)
4041
}

Tests/KeystoneTests/Tests/Models/Blog+RestAPITests.swift

+3-2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ final class Blog_RestAPITests: CoreDataTestCase {
2323
using: testKeychain
2424
)
2525
self.blog = try XCTUnwrap(contextManager.mainContext.fetch(NSFetchRequest<Blog>(entityName: "Blog")).first)
26+
try blog.setPassword(to: "account-password", using: testKeychain)
2627
}
2728

2829
func testThatCreateRestApiBlogStoresUrl() throws {
@@ -33,8 +34,8 @@ final class Blog_RestAPITests: CoreDataTestCase {
3334
XCTAssertEqual(try blog.getUsername(), loginDetails.userLogin)
3435
}
3536

36-
func testThatCreateRestApiBlogStoresPassword() throws {
37-
XCTAssertEqual(try blog.getPassword(using: testKeychain), loginDetails.password)
37+
func testThatCreateRestApiBlogDoesNotStorePassword() throws {
38+
XCTAssertNotEqual(try blog.getPassword(using: testKeychain), loginDetails.password)
3839
}
3940

4041
func testThatCreateRestApiBlogStoresSiteIdentifier() throws {

Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class UserListViewModelTests: XCTestCase {
1616
override func setUp() async throws {
1717
try await super.setUp()
1818

19-
let client = try WordPressClient(api: .init(urlSession: .shared, apiRootUrl: .parse(input: "https://example.com/wp-json"), authenticationStategy: .none), rootUrl: .parse(input: "https://example.com"))
19+
let client = try WordPressClient(api: .init(urlSession: .shared, apiRootUrl: .parse(input: "https://example.com/wp-json"), authentication: .none), rootUrl: .parse(input: "https://example.com"))
2020
service = UserService(client: client)
2121
viewModel = await UserListViewModel(userService: service, currentUserId: 0)
2222
}

WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved

+2-2
Original file line numberDiff line numberDiff line change
@@ -383,8 +383,8 @@
383383
"kind" : "remoteSourceControl",
384384
"location" : "https://github.com/Automattic/wordpress-rs",
385385
"state" : {
386-
"branch" : "alpha-20250411",
387-
"revision" : "0de58e944c075cf509d7e13a9b4625dae430d84f"
386+
"branch" : "alpha-20250505",
387+
"revision" : "645c8a22303e93267d4f1e645f8e01d228fd7e7c"
388388
}
389389
},
390390
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import Foundation
2+
import UIKit
3+
import SwiftUI
4+
import DesignSystem
5+
6+
struct ApplicationPasswordReAuthenticationView: View {
7+
let blog: Blog
8+
let presenter: UIViewController
9+
10+
@Environment(\.dismiss) private var dismiss
11+
@State private var error: String?
12+
13+
var body: some View {
14+
NavigationView {
15+
VStack(spacing: 20) {
16+
Image(systemName: "key.slash")
17+
.font(.system(size: 50))
18+
.foregroundColor(.gray)
19+
20+
Text(Strings.title)
21+
.font(.headline)
22+
23+
Text(Strings.description)
24+
.font(.body)
25+
.frame(maxWidth: .infinity)
26+
27+
DSButton(
28+
title: Strings.signInButton,
29+
style: DSButtonStyle(emphasis: .primary, size: .large),
30+
isLoading: .constant(false)
31+
) {
32+
self.error = nil
33+
34+
Task { @MainActor in
35+
do {
36+
let _ = try await SelfHostedSiteAuthenticator()
37+
.signIn(
38+
site: blog.getUrl().absoluteString,
39+
from: presenter,
40+
context: .reauthentication(username: blog.getUsername())
41+
)
42+
43+
// Automatically dismiss this view upon a successful re-authentication.
44+
dismiss()
45+
} catch {
46+
self.error = error.localizedDescription
47+
}
48+
}
49+
}
50+
51+
if let error {
52+
Text(error)
53+
.font(.body)
54+
.foregroundStyle(.red)
55+
.padding(.horizontal)
56+
}
57+
58+
Spacer()
59+
}
60+
.padding()
61+
.navigationBarTitleDisplayMode(.inline)
62+
.toolbar {
63+
ToolbarItem(placement: .navigationBarLeading) {
64+
Button(Strings.cancelButton) {
65+
dismiss()
66+
}
67+
}
68+
}
69+
}
70+
}
71+
}
72+
73+
private enum Strings {
74+
static let title: String = NSLocalizedString("login.appPasswordReAuth.title", value: "Invalid application password", comment: "Title shown when the application password is invalid")
75+
static let description: String = NSLocalizedString("login.appPasswordReAuth.description", value: "The application password assigned to the app no longer exists in your profile.\nPlease sign in again to create a new application password for the app to use.", comment: "Description explaining why the user needs to re-authenticate")
76+
static let signInButton: String = NSLocalizedString("login.appPasswordReAuth.signInButton", value: "Sign In", comment: "Button to start the re-authentication process")
77+
static let cancelButton: String = NSLocalizedString("login.appPasswordReAuth.cancelButton", value: "Cancel", comment: "Button to dismiss the re-authentication view")
78+
}

WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift

+13-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import SVProgressHUD
1010

1111
struct SelfHostedSiteAuthenticator {
1212

13+
static let applicationPasswordUpdated = Foundation.Notification.Name(rawValue: "SelfHostedSiteAuthenticator.applicationPasswordUpdated")
14+
1315
enum SignInContext: Equatable {
1416
// Sign in to a self-hosted site. Using this context results in automatically reloading the app to display the site dashboard.
1517
case `default`
@@ -168,9 +170,15 @@ struct SelfHostedSiteAuthenticator {
168170
throw .savingSiteFailure
169171
}
170172

173+
let accountPassword = try? await ContextManager.shared.performQuery {
174+
try $0.existingObject(with: blog).password
175+
}
171176
let wporg = WordPressOrgCredentials(
172177
username: credentials.userLogin,
173-
password: credentials.password,
178+
// The `sync` call below updates `Blog.password` with the password value here.
179+
// In order to separate `Blog.password` and `Blog.applicationPassword`, we pass the account password here
180+
// if it exists.
181+
password: accountPassword ?? credentials.password,
174182
xmlrpc: xmlrpc.absoluteString,
175183
options: blogOptions
176184
)
@@ -181,8 +189,11 @@ struct SelfHostedSiteAuthenticator {
181189
}
182190
}
183191

184-
if context == .default {
192+
switch context {
193+
case .default:
185194
NotificationCenter.default.post(name: Foundation.Notification.Name(rawValue: WordPressAuthenticator.WPSigninDidFinishNotification), object: nil)
195+
case .reauthentication:
196+
NotificationCenter.default.post(name: Self.applicationPasswordUpdated, object: nil)
186197
}
187198

188199
return blog

WordPress/Classes/Networking/WordPressClient.swift

+63-5
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import Foundation
2+
import Combine
23
import WordPressAPI
4+
import WordPressAPIInternal
35
import WordPressCore
46
import WordPressShared
57

68
enum WordPressSite {
79
case dotCom(authToken: String)
8-
case selfHosted(apiRootURL: ParsedUrl, username: String, authToken: String)
10+
case selfHosted(blogId: TaggedManagedObjectID<Blog>, apiRootURL: ParsedUrl, username: String, authToken: String)
911

1012
init(blog: Blog) throws {
1113
if let account = blog.account {
@@ -14,12 +16,15 @@ enum WordPressSite {
1416
} else {
1517
let url = try blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString
1618
let apiRootURL = try ParsedUrl.parse(input: url)
17-
self = .selfHosted(apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken())
19+
self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken())
1820
}
1921
}
2022
}
2123

2224
extension WordPressClient {
25+
static var requestedWithInvalidAuthenticationNotification: Foundation.Notification.Name {
26+
.init("WordPressClient.requestedWithInvalidAuthenticationNotification")
27+
}
2328

2429
init(site: WordPressSite) {
2530
// Currently, the app supports both account passwords and application passwords.
@@ -35,10 +40,17 @@ extension WordPressClient {
3540
switch site {
3641
case let .dotCom(authToken):
3742
let apiRootURL = try! ParsedUrl.parse(input: "https://public-api.wordpress.com")
38-
let api = WordPressAPI(urlSession: session, apiRootUrl: apiRootURL, authenticationStategy: .authorizationHeader(token: authToken))
43+
let api = WordPressAPI(urlSession: session, apiRootUrl: apiRootURL, authentication: .bearer(token: authToken))
3944
self.init(api: api, rootUrl: apiRootURL)
40-
case let .selfHosted(apiRootURL, username, authToken):
41-
let api = WordPressAPI(urlSession: session, apiRootUrl: apiRootURL, authenticationStategy: .init(username: username, password: authToken))
45+
case let .selfHosted(blogId, apiRootURL, username, authToken):
46+
let provider = AutoUpdateAuthenticationProvider(
47+
authentication: .init(username: username, password: authToken),
48+
blogId: blogId,
49+
coreDataStack: ContextManager.shared
50+
)
51+
let notifier = AppNotifier()
52+
let api = WordPressAPI(urlSession: session, apiRootUrl: apiRootURL, authenticationProvider: .dynamic(dynamicAuthenticationProvider: provider), appNotifier: notifier)
53+
notifier.api = api
4254
self.init(api: api, rootUrl: apiRootURL)
4355
}
4456
}
@@ -58,3 +70,49 @@ extension PluginWpOrgDirectorySlug: @retroactive ExpressibleByStringLiteral {
5870
self.init(slug: stringLiteral)
5971
}
6072
}
73+
74+
private final class AutoUpdateAuthenticationProvider: @unchecked Sendable, WpDynamicAuthenticationProvider {
75+
private let lock = NSLock()
76+
private var authentication: WpAuthentication
77+
private var cancellable: AnyCancellable?
78+
79+
init(authentication: WpAuthentication, blogId: TaggedManagedObjectID<Blog>, coreDataStack: CoreDataStack) {
80+
self.authentication = authentication
81+
self.cancellable = NotificationCenter.default.publisher(for: SelfHostedSiteAuthenticator.applicationPasswordUpdated).sink { [weak self] _ in
82+
guard let self else { return }
83+
84+
self.lock.lock()
85+
defer {
86+
self.lock.unlock()
87+
}
88+
89+
self.authentication = coreDataStack.performQuery { context in
90+
guard let blog = try? context.existingObject(with: blogId),
91+
let username = try? blog.getUsername(),
92+
let password = try? blog.getApplicationToken()
93+
else {
94+
return WpAuthentication.none
95+
}
96+
97+
return WpAuthentication(username: username, password: password)
98+
}
99+
}
100+
}
101+
102+
func auth() -> WordPressAPIInternal.WpAuthentication {
103+
lock.lock()
104+
defer {
105+
lock.unlock()
106+
}
107+
108+
return self.authentication
109+
}
110+
}
111+
112+
private class AppNotifier: @unchecked Sendable, WpAppNotifier {
113+
weak var api: WordPressAPI?
114+
115+
func requestedWithInvalidAuthentication() async {
116+
NotificationCenter.default.post(name: WordPressClient.requestedWithInvalidAuthenticationNotification, object: api)
117+
}
118+
}

WordPress/Classes/Plugins/Views/InstalledPluginsListView.swift

+12
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ struct InstalledPluginsListView: View {
5454
AddNewPluginView(service: viewModel.service)
5555
}
5656
}
57+
.onApplicationPasswordUpdate {
58+
Task { await viewModel.refreshItems() }
59+
}
5760
.task {
5861
await viewModel.onAppear()
5962
}
@@ -217,6 +220,7 @@ private final class InstalledPluginsListViewModel: ObservableObject {
217220
isRefreshing = true
218221
defer { isRefreshing = false }
219222

223+
self.error = nil
220224
self.showNoPluginsView = false
221225

222226
do {
@@ -281,3 +285,11 @@ private final class InstalledPluginsListViewModel: ObservableObject {
281285

282286
}
283287
}
288+
289+
extension View {
290+
func onApplicationPasswordUpdate(action: @escaping () -> Void) -> some View {
291+
onReceive(NotificationCenter.default.publisher(for: SelfHostedSiteAuthenticator.applicationPasswordUpdated)) { _ in
292+
action()
293+
}
294+
}
295+
}

WordPress/Classes/Users/ViewModel/UserListViewModel.swift

+1
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ class UserListViewModel: ObservableObject {
113113
refreshItemsTask?.cancel()
114114
refreshItemsTask = Task {
115115
isRefreshing = true
116+
self.error = nil
116117
defer { isRefreshing = false }
117118
do {
118119
try await userService.fetchUsers()

WordPress/Classes/Users/Views/UserListView.swift

+3
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ public struct UserListView: View {
5959
}
6060
}
6161
}
62+
.onApplicationPasswordUpdate {
63+
Task { await viewModel.refreshItems() }
64+
}
6265
.task(id: viewModel.mode) {
6366
await viewModel.performQuery()
6467
}

WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardService.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ final class BlogDashboardService {
7070
failure?([])
7171
}
7272

73-
}, failure: { [weak self] _ in
73+
}, failure: { [weak self] error in
74+
DDLogError("Failed to fetch Dashboard Cards: \(error.localizedDescription)")
7475
blog.dashboardState.failedToLoad = true
7576
let items = self?.fetchLocal(blog: blog)
7677
failure?(items ?? [])

WordPress/Classes/ViewRelated/Jetpack/Login/JetpackConnectionViewModel.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ class JetpackConnectionService {
172172
}
173173

174174
guard let site = try? WordPressSite(blog: blog),
175-
case let .selfHosted(apiRootURL, username, password) = site
175+
case let .selfHosted(_, apiRootURL, username, password) = site
176176
else {
177177
return nil
178178
}

0 commit comments

Comments
 (0)